2025-06-30 22:20:48 +00:00
|
|
|
|
import { Component, createSignal, For, Show } from 'solid-js'
|
2025-07-01 06:32:22 +00:00
|
|
|
|
import { MERGE_TOPICS_MUTATION } from '../graphql/mutations'
|
|
|
|
|
import styles from '../styles/Form.module.css'
|
2025-06-30 22:20:48 +00:00
|
|
|
|
import Button from '../ui/Button'
|
|
|
|
|
import Modal from '../ui/Modal'
|
|
|
|
|
|
|
|
|
|
// Типы для топиков
|
|
|
|
|
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 = () => {
|
2025-07-01 06:32:22 +00:00
|
|
|
|
return (
|
|
|
|
|
document.cookie
|
|
|
|
|
.split('; ')
|
|
|
|
|
.find((row) => row.startsWith('auth_token='))
|
|
|
|
|
?.split('=')[1] || ''
|
|
|
|
|
)
|
2025-06-30 22:20:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Обработчик выбора/снятия выбора исходной темы
|
|
|
|
|
*/
|
|
|
|
|
const handleSourceTopicToggle = (topicId: number, checked: boolean) => {
|
|
|
|
|
if (checked) {
|
2025-07-01 06:32:22 +00:00
|
|
|
|
setSourceTopicIds((prev) => [...prev, topicId])
|
2025-06-30 22:20:48 +00:00
|
|
|
|
} else {
|
2025-07-01 06:32:22 +00:00
|
|
|
|
setSourceTopicIds((prev) => prev.filter((id) => id !== topicId))
|
2025-06-30 22:20:48 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Проверяет можно ли выполнить слияние
|
|
|
|
|
*/
|
|
|
|
|
const canMerge = () => {
|
|
|
|
|
const target = targetTopicId()
|
|
|
|
|
const sources = sourceTopicIds()
|
|
|
|
|
|
|
|
|
|
if (!target || sources.length === 0) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Проверяем что целевая тема не выбрана как исходная
|
|
|
|
|
if (sources.includes(target)) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Проверяем что все темы принадлежат одному сообществу
|
2025-07-01 06:32:22 +00:00
|
|
|
|
const targetTopic = props.topics.find((t) => t.id === target)
|
2025-06-30 22:20:48 +00:00
|
|
|
|
if (!targetTopic) return false
|
|
|
|
|
|
|
|
|
|
const targetCommunity = targetTopic.community
|
2025-07-01 06:32:22 +00:00
|
|
|
|
const sourcesTopics = props.topics.filter((t) => sources.includes(t.id))
|
2025-06-30 22:20:48 +00:00
|
|
|
|
|
2025-07-01 06:32:22 +00:00
|
|
|
|
return sourcesTopics.every((topic) => topic.community === targetCommunity)
|
2025-06-30 22:20:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Получает название сообщества по 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
|
2025-07-01 06:32:22 +00:00
|
|
|
|
const statsText = stats
|
|
|
|
|
? ` (перенесено ${stats.followers_moved} подписчиков, ${stats.publications_moved} публикаций, ${stats.drafts_moved} черновиков, удалено ${stats.source_topics_deleted} тем)`
|
|
|
|
|
: ''
|
2025-06-30 22:20:48 +00:00
|
|
|
|
|
|
|
|
|
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()
|
2025-07-01 06:32:22 +00:00
|
|
|
|
return props.topics.filter((topic) => !sources.includes(topic.id))
|
2025-06-30 22:20:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Получает отфильтрованный список топиков (исключая целевую тему)
|
|
|
|
|
*/
|
|
|
|
|
const getAvailableSourceTopics = () => {
|
|
|
|
|
const target = targetTopicId()
|
2025-07-01 06:32:22 +00:00
|
|
|
|
return props.topics.filter((topic) => topic.id !== target)
|
2025-06-30 22:20:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2025-07-01 06:32:22 +00:00
|
|
|
|
<Modal isOpen={props.isOpen} onClose={handleClose} title="Слияние тем" size="large">
|
2025-06-30 22:20:48 +00:00
|
|
|
|
<div class={styles.form}>
|
|
|
|
|
<div class={styles.section}>
|
|
|
|
|
<h3 class={styles.sectionTitle}>Выбор целевой темы</h3>
|
|
|
|
|
<p class={styles.description}>
|
2025-07-01 06:32:22 +00:00
|
|
|
|
Выберите тему, в которую будут слиты остальные темы. Все подписчики и публикации будут
|
|
|
|
|
перенесены в эту тему.
|
2025-06-30 22:20:48 +00:00
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<select
|
|
|
|
|
value={targetTopicId() || ''}
|
2025-07-01 06:32:22 +00:00
|
|
|
|
onChange={(e) => setTargetTopicId(e.target.value ? Number.parseInt(e.target.value) : null)}
|
2025-06-30 22:20:48 +00:00
|
|
|
|
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}>
|
2025-07-01 06:32:22 +00:00
|
|
|
|
Выберите темы, которые будут слиты в целевую тему. Эти темы будут удалены после переноса всех
|
|
|
|
|
связей.
|
2025-06-30 22:20:48 +00:00
|
|
|
|
</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 && (
|
2025-07-01 06:32:22 +00:00
|
|
|
|
<span>
|
|
|
|
|
{' '}
|
|
|
|
|
• {topic.stat.shouts} публ., {topic.stat.followers} подп.
|
|
|
|
|
</span>
|
2025-06-30 22:20:48 +00:00
|
|
|
|
)}
|
|
|
|
|
</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>
|
2025-07-01 06:32:22 +00:00
|
|
|
|
<strong>Целевая тема:</strong> {props.topics.find((t) => t.id === targetTopicId())?.title}
|
2025-06-30 22:20:48 +00:00
|
|
|
|
</li>
|
|
|
|
|
<li>
|
|
|
|
|
<strong>Исходные темы:</strong> {sourceTopicIds().length} шт.
|
|
|
|
|
<ul>
|
|
|
|
|
<For each={sourceTopicIds()}>
|
|
|
|
|
{(id) => {
|
2025-07-01 06:32:22 +00:00
|
|
|
|
const topic = props.topics.find((t) => t.id === id)
|
2025-06-30 22:20:48 +00:00
|
|
|
|
return topic ? <li>{topic.title}</li> : null
|
|
|
|
|
}}
|
|
|
|
|
</For>
|
|
|
|
|
</ul>
|
|
|
|
|
</li>
|
|
|
|
|
<li>
|
2025-07-01 06:32:22 +00:00
|
|
|
|
<strong>Действие:</strong> Все подписчики, публикации и черновики будут перенесены в целевую
|
|
|
|
|
тему, исходные темы будут удалены
|
2025-06-30 22:20:48 +00:00
|
|
|
|
</li>
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
</Show>
|
|
|
|
|
|
|
|
|
|
<div class={styles.modalActions}>
|
2025-07-01 06:32:22 +00:00
|
|
|
|
<Button variant="secondary" onClick={handleClose} disabled={loading()}>
|
2025-06-30 22:20:48 +00:00
|
|
|
|
Отмена
|
|
|
|
|
</Button>
|
2025-07-01 06:32:22 +00:00
|
|
|
|
<Button variant="danger" onClick={handleMerge} disabled={!canMerge() || loading()}>
|
2025-06-30 22:20:48 +00:00
|
|
|
|
{loading() ? 'Выполняется слияние...' : 'Слить темы'}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Modal>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default TopicMergeModal
|