core/panel/modals/TopicMergeModal.tsx
Untone eb2140bcc6
All checks were successful
Deploy on push / deploy (push) Successful in 6s
0.7.7-topics-editing
2025-07-03 12:15:10 +03:00

494 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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 [errors, setErrors] = createSignal<ValidationErrors>({})
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 (
<Modal isOpen={props.isOpen} onClose={handleClose} title="Слияние тем" size="large">
<div class={styles.form}>
{/* Общие ошибки */}
<Show when={errors().general}>
<div class={styles.formError}>
{errors().general}
</div>
</Show>
{/* Выбор целевой темы */}
<div class={styles.section}>
<h3 class={styles.sectionTitle}>🎯 Целевая тема</h3>
<p class={styles.sectionDescription}>
Выберите тему, в которую будут слиты остальные темы. Все подписчики и публикации
будут перенесены в эту тему, а исходные темы будут удалены.
</p>
<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>
</div>
{/* Поиск и выбор исходных тем */}
<div class={styles.section}>
<h3 class={styles.sectionTitle}>📥 Исходные темы</h3>
<p class={styles.sectionDescription}>
Выберите темы, которые будут слиты в целевую тему. Все их данные будут перенесены,
а сами темы будут удалены.
</p>
<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())}>
{(topic) => {
const isChecked = () => sourceTopicIds().includes(topic.id)
const isDisabled = () => targetTopicId() && topic.community !== props.topics.find(t => t.id === targetTopicId())?.community
return (
<label
class={`${styles.parentCheckbox} ${isDisabled() ? styles.disabled : ''}`}
title={isDisabled() ? 'Тема принадлежит другому сообществу' : ''}
>
<input
type="checkbox"
checked={isChecked()}
disabled={isDisabled() || false}
onChange={(e) => handleSourceTopicToggle(topic.id, e.currentTarget.checked)}
/>
<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)}
{topic.stat && (
<>
<span> {topic.stat.shouts} публикаций</span>
<span> {topic.stat.followers} подписчиков</span>
</>
)}
</div>
</div>
</label>
)
}}
</For>
</div>
<Show when={getFilteredTopics(getAvailableSourceTopics()).length === 0}>
<div class={styles.noParents}>
<Show when={searchQuery()}>
Не найдено тем по запросу "{searchQuery()}"
</Show>
<Show when={!searchQuery()}>
Нет доступных тем для слияния
</Show>
</div>
</Show>
</div>
</div>
{/* Предварительный просмотр слияния */}
<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>
<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>
</ul>
</div>
</div>
</div>
</Show>
{/* Настройки слияния */}
<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()}
>
Отмена
</Button>
<Button
type="button"
variant="primary"
onClick={handleMerge}
disabled={!canMerge() || loading()}
loading={loading()}
>
{loading() ? 'Выполняется слияние...' : `Слить ${sourceTopicIds().length} тем`}
</Button>
</div>
</div>
</Modal>
)
}
export default TopicMergeModal