import { Component, createSignal, For, Show } from 'solid-js' import { MERGE_TOPICS_MUTATION } from '../graphql/mutations' import styles from '../styles/Form.module.css' import Button from '../ui/Button' import Modal from '../ui/Modal' // Типы для топиков interface Topic { id: number title: string slug: string body?: string community: number parent_ids?: 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 } interface ValidationErrors { target?: string sources?: string general?: string } const TopicMergeModal: Component = (props) => { const [targetTopicId, setTargetTopicId] = createSignal(null) const [sourceTopicIds, setSourceTopicIds] = createSignal([]) const [preserveTarget, setPreserveTarget] = createSignal(true) const [loading, setLoading] = createSignal(false) const [errors, setErrors] = createSignal({}) const [searchQuery, setSearchQuery] = createSignal('') /** * Получает токен авторизации из localStorage или cookie */ 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) ) } /** * Обработчик выбора целевой темы */ 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) } /** * Обработчик выбора/снятия выбора исходной темы */ const handleSourceTopicToggle = (topicId: number, checked: boolean) => { if (checked) { setSourceTopicIds((prev) => [...prev, topicId]) } else { setSourceTopicIds((prev) => prev.filter((id) => id !== topicId)) } // Перевалидация const newErrors = validateMergeData() setErrors(newErrors) } /** * Проверяет можно ли выполнить слияние */ const canMerge = () => { const validationErrors = validateMergeData() return Object.keys(validationErrors).length === 0 } /** * Получить статистику для предварительного просмотра */ const getMergePreview = () => { const target = targetTopicId() const sources = sourceTopicIds() if (!target || sources.length === 0) return null const targetTopic = props.topics.find(t => t.id === target) const sourceTopics = props.topics.filter(t => sources.includes(t.id)) 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) return { targetTopic, sourceTopics, totalShouts, totalFollowers, totalAuthors, sourcesCount: sources.length } } /** * Выполняет слияние топиков */ const handleMerge = async () => { if (!canMerge()) { const validationErrors = validateMergeData() setErrors(validationErrors) return } setLoading(true) setErrors({}) try { const authToken = getAuthToken() 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 setErrors({ general: errorMessage }) props.onError(`Ошибка слияния тем: ${errorMessage}`) } finally { setLoading(false) } } /** * Закрывает модалку и сбрасывает состояние */ const handleClose = () => { setTargetTopicId(null) setSourceTopicIds([]) setPreserveTarget(true) setErrors({}) setLoading(false) setSearchQuery('') 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) } const preview = getMergePreview() return (
{/* Общие ошибки */}
{errors().general}
{/* Выбор целевой темы */}

🎯 Целевая тема

Выберите тему, в которую будут слиты остальные темы. Все подписчики и публикации будут перенесены в эту тему, а исходные темы будут удалены.

{/* Поиск и выбор исходных тем */}

📥 Исходные темы

Выберите темы, которые будут слиты в целевую тему. Все их данные будут перенесены, а сами темы будут удалены.

{errors().sources}
Доступные темы для слияния: Выбрано: {sourceTopicIds().length}
{(topic) => { const isChecked = () => sourceTopicIds().includes(topic.id) const isDisabled = () => targetTopicId() && topic.community !== props.topics.find(t => t.id === targetTopicId())?.community return ( ) }}
Не найдено тем по запросу "{searchQuery()}" Нет доступных тем для слияния
{/* Предварительный просмотр слияния */}

📊 Предварительный просмотр

Целевая тема: {preview!.targetTopic!.title}
Слияние {preview!.sourcesCount} тем: {(topic) => ( {topic.title} )}
Ожидаемые результаты:
  • Будет перенесено ~{preview!.totalShouts} публикаций
  • Будет перенесено ~{preview!.totalFollowers} подписчиков
  • Будет объединено ~{preview!.totalAuthors} авторов
  • Будет удалено {preview!.sourcesCount} исходных тем
{/* Настройки слияния */}

⚙️ Настройки слияния

{/* Кнопки */}
) } export default TopicMergeModal