core/panel/modals/TopicMergeModal.tsx

494 lines
17 KiB
TypeScript
Raw Normal View History

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
2025-07-03 09:15:10 +00:00
body?: string
2025-06-30 22:20:48 +00:00
community: number
2025-07-03 09:15:10 +00:00
parent_ids?: number[]
2025-06-30 22:20:48 +00:00
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
}
2025-07-03 09:15:10 +00:00
interface ValidationErrors {
target?: string
sources?: string
general?: string
}
2025-06-30 22:20:48 +00:00
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)
2025-07-03 09:15:10 +00:00
const [errors, setErrors] = createSignal<ValidationErrors>({})
const [searchQuery, setSearchQuery] = createSignal('')
2025-06-30 22:20:48 +00:00
/**
* Получает токен авторизации из localStorage или cookie
*/
2025-07-03 09:15:10 +00:00
const getAuthToken = () => {
return localStorage.getItem('auth_token') ||
document.cookie
.split('; ')
.find((row) => row.startsWith('auth_token='))
?.split('=')[1] || ''
}
/**
* Валидация данных для слияния
*/
const validateMergeData = (): ValidationErrors => {
const newErrors: ValidationErrors = {}
const target = targetTopicId()
const sources = sourceTopicIds()
if (!target) {
newErrors.target = 'Необходимо выбрать целевую тему'
}
if (sources.length === 0) {
newErrors.sources = 'Необходимо выбрать хотя бы одну исходную тему'
} else if (sources.length > 10) {
newErrors.sources = 'Нельзя объединять более 10 тем за раз'
}
// Проверяем что целевая тема не выбрана как исходная
if (target && sources.includes(target)) {
newErrors.general = 'Целевая тема не может быть в списке исходных тем'
}
// Проверяем что все темы принадлежат одному сообществу
if (target && sources.length > 0) {
const targetTopic = props.topics.find((t) => t.id === target)
const sourcesTopics = props.topics.filter((t) => sources.includes(t.id))
if (targetTopic) {
const targetCommunity = targetTopic.community
const invalidSources = sourcesTopics.filter(topic => topic.community !== targetCommunity)
if (invalidSources.length > 0) {
newErrors.general = `Все темы должны принадлежать одному сообществу. Темы ${invalidSources.map(t => `"${t.title}"`).join(', ')} принадлежат другому сообществу`
}
}
}
return newErrors
}
/**
* Получает название сообщества по ID
*/
const getCommunityName = (communityId: number): string => {
// Заглушка - можно добавить запрос к API или кеш сообществ
return `Сообщество ${communityId}`
}
/**
* Получить отфильтрованный список топиков для поиска
*/
const getFilteredTopics = (topicsList: Topic[]) => {
const query = searchQuery().toLowerCase().trim()
if (!query) return topicsList
return topicsList.filter(topic =>
topic.title?.toLowerCase().includes(query) ||
topic.slug?.toLowerCase().includes(query)
2025-07-01 06:32:22 +00:00
)
2025-06-30 22:20:48 +00:00
}
2025-07-03 09:15:10 +00:00
/**
* Обработчик выбора целевой темы
*/
const handleTargetTopicChange = (e: Event) => {
const target = e.target as HTMLSelectElement
const topicId = target.value ? Number.parseInt(target.value) : null
setTargetTopicId(topicId)
// Убираем выбранную целевую тему из исходных тем
if (topicId) {
setSourceTopicIds(prev => prev.filter(id => id !== topicId))
}
// Перевалидация
const newErrors = validateMergeData()
setErrors(newErrors)
}
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
}
2025-07-03 09:15:10 +00:00
// Перевалидация
const newErrors = validateMergeData()
setErrors(newErrors)
2025-06-30 22:20:48 +00:00
}
/**
* Проверяет можно ли выполнить слияние
*/
const canMerge = () => {
2025-07-03 09:15:10 +00:00
const validationErrors = validateMergeData()
return Object.keys(validationErrors).length === 0
}
/**
* Получить статистику для предварительного просмотра
*/
const getMergePreview = () => {
2025-06-30 22:20:48 +00:00
const target = targetTopicId()
const sources = sourceTopicIds()
2025-07-03 09:15:10 +00:00
if (!target || sources.length === 0) return null
2025-06-30 22:20:48 +00:00
2025-07-03 09:15:10 +00:00
const targetTopic = props.topics.find(t => t.id === target)
const sourceTopics = props.topics.filter(t => sources.includes(t.id))
2025-06-30 22:20:48 +00:00
2025-07-03 09:15:10 +00:00
const totalShouts = sourceTopics.reduce((sum, topic) => sum + (topic.stat?.shouts || 0), 0)
const totalFollowers = sourceTopics.reduce((sum, topic) => sum + (topic.stat?.followers || 0), 0)
const totalAuthors = sourceTopics.reduce((sum, topic) => sum + (topic.stat?.authors || 0), 0)
2025-06-30 22:20:48 +00:00
2025-07-03 09:15:10 +00:00
return {
targetTopic,
sourceTopics,
totalShouts,
totalFollowers,
totalAuthors,
sourcesCount: sources.length
}
2025-06-30 22:20:48 +00:00
}
/**
* Выполняет слияние топиков
*/
const handleMerge = async () => {
if (!canMerge()) {
2025-07-03 09:15:10 +00:00
const validationErrors = validateMergeData()
setErrors(validationErrors)
2025-06-30 22:20:48 +00:00
return
}
setLoading(true)
2025-07-03 09:15:10 +00:00
setErrors({})
2025-06-30 22:20:48 +00:00
try {
2025-07-03 09:15:10 +00:00
const authToken = getAuthToken()
2025-06-30 22:20:48 +00:00
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
2025-07-03 09:15:10 +00:00
setErrors({ general: errorMessage })
2025-06-30 22:20:48 +00:00
props.onError(`Ошибка слияния тем: ${errorMessage}`)
} finally {
setLoading(false)
}
}
/**
* Закрывает модалку и сбрасывает состояние
*/
const handleClose = () => {
setTargetTopicId(null)
setSourceTopicIds([])
setPreserveTarget(true)
2025-07-03 09:15:10 +00:00
setErrors({})
2025-06-30 22:20:48 +00:00
setLoading(false)
2025-07-03 09:15:10 +00:00
setSearchQuery('')
2025-06-30 22:20:48 +00:00
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
}
2025-07-03 09:15:10 +00:00
const preview = getMergePreview()
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}>
2025-07-03 09:15:10 +00:00
{/* Общие ошибки */}
<Show when={errors().general}>
<div class={styles.formError}>
{errors().general}
</div>
</Show>
{/* Выбор целевой темы */}
2025-06-30 22:20:48 +00:00
<div class={styles.section}>
2025-07-03 09:15:10 +00:00
<h3 class={styles.sectionTitle}>🎯 Целевая тема</h3>
<p class={styles.sectionDescription}>
Выберите тему, в которую будут слиты остальные темы. Все подписчики и публикации
будут перенесены в эту тему, а исходные темы будут удалены.
2025-06-30 22:20:48 +00:00
</p>
2025-07-03 09:15:10 +00:00
<div class={styles.field}>
<label class={styles.label}>
Целевая тема:
<select
class={`${styles.select} ${errors().target ? styles.inputError : ''}`}
value={targetTopicId() || ''}
onChange={handleTargetTopicChange}
required
>
<option value="">Выберите целевую тему...</option>
<For each={getFilteredTopics(getAvailableTargetTopics())}>
{(topic) => (
<option value={topic.id}>
{topic.title} ({topic.slug})
{topic.stat ? `${topic.stat.shouts} публикаций` : ''}
</option>
)}
</For>
</select>
<Show when={errors().target}>
<div class={styles.errorMessage}>{errors().target}</div>
</Show>
</label>
</div>
2025-06-30 22:20:48 +00:00
</div>
2025-07-03 09:15:10 +00:00
{/* Поиск и выбор исходных тем */}
2025-06-30 22:20:48 +00:00
<div class={styles.section}>
2025-07-03 09:15:10 +00:00
<h3 class={styles.sectionTitle}>📥 Исходные темы</h3>
<p class={styles.sectionDescription}>
Выберите темы, которые будут слиты в целевую тему. Все их данные будут перенесены,
а сами темы будут удалены.
2025-06-30 22:20:48 +00:00
</p>
2025-07-03 09:15:10 +00:00
<div class={styles.field}>
<label class={styles.label}>
Поиск тем:
<input
type="text"
class={styles.input}
value={searchQuery()}
onInput={(e) => setSearchQuery(e.currentTarget.value)}
placeholder="Введите название или slug для поиска..."
/>
</label>
</div>
<Show when={errors().sources}>
<div class={styles.errorMessage}>{errors().sources}</div>
</Show>
<div class={styles.availableParents}>
<div class={styles.sectionHeader}>
<strong>Доступные темы для слияния:</strong>
<span class={styles.hint}>
Выбрано: {sourceTopicIds().length}
</span>
</div>
<div class={styles.parentsGrid}>
<For each={getFilteredTopics(getAvailableSourceTopics())}>
2025-06-30 22:20:48 +00:00
{(topic) => {
const isChecked = () => sourceTopicIds().includes(topic.id)
2025-07-03 09:15:10 +00:00
const isDisabled = () => targetTopicId() && topic.community !== props.topics.find(t => t.id === targetTopicId())?.community
2025-06-30 22:20:48 +00:00
return (
2025-07-03 09:15:10 +00:00
<label
class={`${styles.parentCheckbox} ${isDisabled() ? styles.disabled : ''}`}
title={isDisabled() ? 'Тема принадлежит другому сообществу' : ''}
>
2025-06-30 22:20:48 +00:00
<input
type="checkbox"
checked={isChecked()}
2025-07-03 09:15:10 +00:00
disabled={isDisabled() || false}
onChange={(e) => handleSourceTopicToggle(topic.id, e.currentTarget.checked)}
2025-06-30 22:20:48 +00:00
/>
2025-07-03 09:15:10 +00:00
<div class={styles.parentLabel}>
<div class={styles.parentTitle}>
{topic.title}
</div>
<div class={styles.parentSlug}>{topic.slug}</div>
<div class={styles.parentStats}>
{getCommunityName(topic.community)}
2025-06-30 22:20:48 +00:00
{topic.stat && (
2025-07-03 09:15:10 +00:00
<>
<span> {topic.stat.shouts} публикаций</span>
<span> {topic.stat.followers} подписчиков</span>
</>
2025-06-30 22:20:48 +00:00
)}
</div>
</div>
</label>
)
}}
</For>
</div>
2025-07-03 09:15:10 +00:00
<Show when={getFilteredTopics(getAvailableSourceTopics()).length === 0}>
<div class={styles.noParents}>
<Show when={searchQuery()}>
Не найдено тем по запросу "{searchQuery()}"
</Show>
<Show when={!searchQuery()}>
Нет доступных тем для слияния
</Show>
2025-06-30 22:20:48 +00:00
</div>
2025-07-03 09:15:10 +00:00
</Show>
</div>
2025-06-30 22:20:48 +00:00
</div>
2025-07-03 09:15:10 +00:00
{/* Предварительный просмотр слияния */}
<Show when={preview}>
<div class={styles.section}>
<h3 class={styles.sectionTitle}>📊 Предварительный просмотр</h3>
<div class={styles.hierarchyPath}>
<div><strong>Целевая тема:</strong> {preview!.targetTopic!.title}</div>
<div class={styles.pathDisplay}>
<span>Слияние {preview!.sourcesCount} тем:</span>
<For each={preview!.sourceTopics}>
{(topic) => (
<span class={styles.pathItem}>{topic.title}</span>
)}
</For>
</div>
2025-06-30 22:20:48 +00:00
2025-07-03 09:15:10 +00:00
<div style="margin-top: 1rem;">
<strong>Ожидаемые результаты:</strong>
<ul style="margin: 0.5rem 0; padding-left: 1.5rem;">
<li>Будет перенесено ~{preview!.totalShouts} публикаций</li>
<li>Будет перенесено ~{preview!.totalFollowers} подписчиков</li>
<li>Будет объединено ~{preview!.totalAuthors} авторов</li>
<li>Будет удалено {preview!.sourcesCount} исходных тем</li>
2025-06-30 22:20:48 +00:00
</ul>
2025-07-03 09:15:10 +00:00
</div>
</div>
2025-06-30 22:20:48 +00:00
</div>
</Show>
2025-07-03 09:15:10 +00:00
{/* Настройки слияния */}
<div class={styles.section}>
<h3 class={styles.sectionTitle}> Настройки слияния</h3>
<div class={styles.field}>
<label class={styles.parentCheckbox}>
<input
type="checkbox"
checked={preserveTarget()}
onChange={(e) => setPreserveTarget(e.currentTarget.checked)}
/>
<div class={styles.parentLabel}>
<div class={styles.parentTitle}>
Сохранить свойства целевой темы
</div>
<div class={styles.parentStats}>
Если включено, описание и другие свойства целевой темы не будут изменены.
Если выключено, свойства могут быть объединены с исходными темами.
</div>
</div>
</label>
</div>
</div>
{/* Кнопки */}
<div class={styles.actions}>
<Button
type="button"
variant="secondary"
onClick={handleClose}
disabled={loading()}
>
2025-06-30 22:20:48 +00:00
Отмена
</Button>
2025-07-03 09:15:10 +00:00
<Button
type="button"
variant="primary"
onClick={handleMerge}
disabled={!canMerge() || loading()}
loading={loading()}
>
{loading() ? 'Выполняется слияние...' : `Слить ${sourceTopicIds().length} тем`}
2025-06-30 22:20:48 +00:00
</Button>
</div>
</div>
</Modal>
)
}
export default TopicMergeModal