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
|