328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
|
import { Component, createSignal, For, Show } from 'solid-js'
|
|||
|
import Button from '../ui/Button'
|
|||
|
import Modal from '../ui/Modal'
|
|||
|
import styles from '../styles/Form.module.css'
|
|||
|
import { MERGE_TOPICS_MUTATION } from '../graphql/mutations'
|
|||
|
|
|||
|
// Типы для топиков
|
|||
|
interface Topic {
|
|||
|
id: number
|
|||
|
title: string
|
|||
|
slug: string
|
|||
|
community: number
|
|||
|
stat?: {
|
|||
|
shouts: number
|
|||
|
followers: number
|
|||
|
authors: number
|
|||
|
comments: number
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
interface TopicMergeModalProps {
|
|||
|
isOpen: boolean
|
|||
|
onClose: () => void
|
|||
|
topics: Topic[]
|
|||
|
onSuccess: (message: string) => void
|
|||
|
onError: (error: string) => void
|
|||
|
}
|
|||
|
|
|||
|
interface MergeStats {
|
|||
|
followers_moved: number
|
|||
|
publications_moved: number
|
|||
|
drafts_moved: number
|
|||
|
source_topics_deleted: number
|
|||
|
}
|
|||
|
|
|||
|
const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
|||
|
const [targetTopicId, setTargetTopicId] = createSignal<number | null>(null)
|
|||
|
const [sourceTopicIds, setSourceTopicIds] = createSignal<number[]>([])
|
|||
|
const [preserveTarget, setPreserveTarget] = createSignal(true)
|
|||
|
const [loading, setLoading] = createSignal(false)
|
|||
|
const [error, setError] = createSignal('')
|
|||
|
|
|||
|
/**
|
|||
|
* Получает токен авторизации из localStorage или cookie
|
|||
|
*/
|
|||
|
const getAuthTokenFromCookie = () => {
|
|||
|
return document.cookie
|
|||
|
.split('; ')
|
|||
|
.find(row => row.startsWith('auth_token='))
|
|||
|
?.split('=')[1] || ''
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Обработчик выбора/снятия выбора исходной темы
|
|||
|
*/
|
|||
|
const handleSourceTopicToggle = (topicId: number, checked: boolean) => {
|
|||
|
if (checked) {
|
|||
|
setSourceTopicIds(prev => [...prev, topicId])
|
|||
|
} else {
|
|||
|
setSourceTopicIds(prev => prev.filter(id => id !== topicId))
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Проверяет можно ли выполнить слияние
|
|||
|
*/
|
|||
|
const canMerge = () => {
|
|||
|
const target = targetTopicId()
|
|||
|
const sources = sourceTopicIds()
|
|||
|
|
|||
|
if (!target || sources.length === 0) {
|
|||
|
return false
|
|||
|
}
|
|||
|
|
|||
|
// Проверяем что целевая тема не выбрана как исходная
|
|||
|
if (sources.includes(target)) {
|
|||
|
return false
|
|||
|
}
|
|||
|
|
|||
|
// Проверяем что все темы принадлежат одному сообществу
|
|||
|
const targetTopic = props.topics.find(t => t.id === target)
|
|||
|
if (!targetTopic) return false
|
|||
|
|
|||
|
const targetCommunity = targetTopic.community
|
|||
|
const sourcesTopics = props.topics.filter(t => sources.includes(t.id))
|
|||
|
|
|||
|
return sourcesTopics.every(topic => topic.community === targetCommunity)
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Получает название сообщества по ID (заглушка)
|
|||
|
*/
|
|||
|
const getCommunityName = (communityId: number) => {
|
|||
|
// Здесь можно добавить запрос к API или кеш сообществ
|
|||
|
return `Сообщество ${communityId}`
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Выполняет слияние топиков
|
|||
|
*/
|
|||
|
const handleMerge = async () => {
|
|||
|
if (!canMerge()) {
|
|||
|
setError('Невозможно выполнить слияние с текущими настройками')
|
|||
|
return
|
|||
|
}
|
|||
|
|
|||
|
setLoading(true)
|
|||
|
setError('')
|
|||
|
|
|||
|
try {
|
|||
|
const authToken = localStorage.getItem('auth_token') || getAuthTokenFromCookie()
|
|||
|
|
|||
|
const response = await fetch('/graphql', {
|
|||
|
method: 'POST',
|
|||
|
headers: {
|
|||
|
'Content-Type': 'application/json',
|
|||
|
Authorization: authToken ? `Bearer ${authToken}` : ''
|
|||
|
},
|
|||
|
body: JSON.stringify({
|
|||
|
query: MERGE_TOPICS_MUTATION,
|
|||
|
variables: {
|
|||
|
merge_input: {
|
|||
|
target_topic_id: targetTopicId(),
|
|||
|
source_topic_ids: sourceTopicIds(),
|
|||
|
preserve_target_properties: preserveTarget()
|
|||
|
}
|
|||
|
}
|
|||
|
})
|
|||
|
})
|
|||
|
|
|||
|
const result = await response.json()
|
|||
|
|
|||
|
if (result.errors) {
|
|||
|
throw new Error(result.errors[0].message)
|
|||
|
}
|
|||
|
|
|||
|
const mergeResult = result.data.merge_topics
|
|||
|
|
|||
|
if (mergeResult.error) {
|
|||
|
throw new Error(mergeResult.error)
|
|||
|
}
|
|||
|
|
|||
|
const stats = mergeResult.stats as MergeStats
|
|||
|
const statsText = stats ?
|
|||
|
` (перенесено ${stats.followers_moved} подписчиков, ${stats.publications_moved} публикаций, ${stats.drafts_moved} черновиков, удалено ${stats.source_topics_deleted} тем)` : ''
|
|||
|
|
|||
|
props.onSuccess(mergeResult.message + statsText)
|
|||
|
handleClose()
|
|||
|
|
|||
|
} catch (error) {
|
|||
|
const errorMessage = (error as Error).message
|
|||
|
setError(errorMessage)
|
|||
|
props.onError(`Ошибка слияния тем: ${errorMessage}`)
|
|||
|
} finally {
|
|||
|
setLoading(false)
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Закрывает модалку и сбрасывает состояние
|
|||
|
*/
|
|||
|
const handleClose = () => {
|
|||
|
setTargetTopicId(null)
|
|||
|
setSourceTopicIds([])
|
|||
|
setPreserveTarget(true)
|
|||
|
setError('')
|
|||
|
setLoading(false)
|
|||
|
props.onClose()
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Получает отфильтрованный список топиков (исключая выбранные как исходные)
|
|||
|
*/
|
|||
|
const getAvailableTargetTopics = () => {
|
|||
|
const sources = sourceTopicIds()
|
|||
|
return props.topics.filter(topic => !sources.includes(topic.id))
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Получает отфильтрованный список топиков (исключая целевую тему)
|
|||
|
*/
|
|||
|
const getAvailableSourceTopics = () => {
|
|||
|
const target = targetTopicId()
|
|||
|
return props.topics.filter(topic => topic.id !== target)
|
|||
|
}
|
|||
|
|
|||
|
return (
|
|||
|
<Modal
|
|||
|
isOpen={props.isOpen}
|
|||
|
onClose={handleClose}
|
|||
|
title="Слияние тем"
|
|||
|
size="large"
|
|||
|
>
|
|||
|
<div class={styles.form}>
|
|||
|
<div class={styles.section}>
|
|||
|
<h3 class={styles.sectionTitle}>Выбор целевой темы</h3>
|
|||
|
<p class={styles.description}>
|
|||
|
Выберите тему, в которую будут слиты остальные темы. Все подписчики и публикации будут перенесены в эту тему.
|
|||
|
</p>
|
|||
|
|
|||
|
<select
|
|||
|
value={targetTopicId() || ''}
|
|||
|
onChange={(e) => setTargetTopicId(e.target.value ? parseInt(e.target.value) : null)}
|
|||
|
class={styles.select}
|
|||
|
disabled={loading()}
|
|||
|
>
|
|||
|
<option value="">Выберите целевую тему</option>
|
|||
|
<For each={getAvailableTargetTopics()}>
|
|||
|
{(topic) => (
|
|||
|
<option value={topic.id}>
|
|||
|
{topic.title} ({getCommunityName(topic.community)})
|
|||
|
{topic.stat ? ` - ${topic.stat.shouts} публ., ${topic.stat.followers} подп.` : ''}
|
|||
|
</option>
|
|||
|
)}
|
|||
|
</For>
|
|||
|
</select>
|
|||
|
</div>
|
|||
|
|
|||
|
<div class={styles.section}>
|
|||
|
<h3 class={styles.sectionTitle}>Выбор исходных тем для слияния</h3>
|
|||
|
<p class={styles.description}>
|
|||
|
Выберите темы, которые будут слиты в целевую тему. Эти темы будут удалены после переноса всех связей.
|
|||
|
</p>
|
|||
|
|
|||
|
<Show when={getAvailableSourceTopics().length > 0}>
|
|||
|
<div class={styles.checkboxList}>
|
|||
|
<For each={getAvailableSourceTopics()}>
|
|||
|
{(topic) => {
|
|||
|
const isChecked = () => sourceTopicIds().includes(topic.id)
|
|||
|
|
|||
|
return (
|
|||
|
<label class={styles.checkboxItem}>
|
|||
|
<input
|
|||
|
type="checkbox"
|
|||
|
checked={isChecked()}
|
|||
|
onChange={(e) => handleSourceTopicToggle(topic.id, e.target.checked)}
|
|||
|
disabled={loading()}
|
|||
|
class={styles.checkbox}
|
|||
|
/>
|
|||
|
<div class={styles.checkboxContent}>
|
|||
|
<div class={styles.topicTitle}>{topic.title}</div>
|
|||
|
<div class={styles.topicInfo}>
|
|||
|
{getCommunityName(topic.community)} • ID: {topic.id}
|
|||
|
{topic.stat && (
|
|||
|
<span> • {topic.stat.shouts} публ., {topic.stat.followers} подп.</span>
|
|||
|
)}
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</label>
|
|||
|
)
|
|||
|
}}
|
|||
|
</For>
|
|||
|
</div>
|
|||
|
</Show>
|
|||
|
</div>
|
|||
|
|
|||
|
<div class={styles.section}>
|
|||
|
<h3 class={styles.sectionTitle}>Настройки слияния</h3>
|
|||
|
|
|||
|
<label class={styles.checkboxItem}>
|
|||
|
<input
|
|||
|
type="checkbox"
|
|||
|
checked={preserveTarget()}
|
|||
|
onChange={(e) => setPreserveTarget(e.target.checked)}
|
|||
|
disabled={loading()}
|
|||
|
class={styles.checkbox}
|
|||
|
/>
|
|||
|
<div class={styles.checkboxContent}>
|
|||
|
<div class={styles.optionTitle}>Сохранить свойства целевой темы</div>
|
|||
|
<div class={styles.optionDescription}>
|
|||
|
Если отключено, будут объединены parent_ids из всех тем
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</label>
|
|||
|
</div>
|
|||
|
|
|||
|
<Show when={error()}>
|
|||
|
<div class={styles.error}>{error()}</div>
|
|||
|
</Show>
|
|||
|
|
|||
|
<Show when={targetTopicId() && sourceTopicIds().length > 0}>
|
|||
|
<div class={styles.summary}>
|
|||
|
<h4>Предпросмотр слияния:</h4>
|
|||
|
<ul>
|
|||
|
<li>
|
|||
|
<strong>Целевая тема:</strong> {props.topics.find(t => t.id === targetTopicId())?.title}
|
|||
|
</li>
|
|||
|
<li>
|
|||
|
<strong>Исходные темы:</strong> {sourceTopicIds().length} шт.
|
|||
|
<ul>
|
|||
|
<For each={sourceTopicIds()}>
|
|||
|
{(id) => {
|
|||
|
const topic = props.topics.find(t => t.id === id)
|
|||
|
return topic ? <li>{topic.title}</li> : null
|
|||
|
}}
|
|||
|
</For>
|
|||
|
</ul>
|
|||
|
</li>
|
|||
|
<li>
|
|||
|
<strong>Действие:</strong> Все подписчики, публикации и черновики будут перенесены в целевую тему, исходные темы будут удалены
|
|||
|
</li>
|
|||
|
</ul>
|
|||
|
</div>
|
|||
|
</Show>
|
|||
|
|
|||
|
<div class={styles.modalActions}>
|
|||
|
<Button
|
|||
|
variant="secondary"
|
|||
|
onClick={handleClose}
|
|||
|
disabled={loading()}
|
|||
|
>
|
|||
|
Отмена
|
|||
|
</Button>
|
|||
|
<Button
|
|||
|
variant="danger"
|
|||
|
onClick={handleMerge}
|
|||
|
disabled={!canMerge() || loading()}
|
|||
|
>
|
|||
|
{loading() ? 'Выполняется слияние...' : 'Слить темы'}
|
|||
|
</Button>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</Modal>
|
|||
|
)
|
|||
|
}
|
|||
|
|
|||
|
export default TopicMergeModal
|