/** * Компонент управления топиками * @module TopicsRoute */ import { Component, createEffect, createSignal, For, JSX, on, onMount, Show, untrack } from 'solid-js' import { query } from '../graphql' import type { Query } from '../graphql/generated/schema' import { CREATE_TOPIC_MUTATION, DELETE_TOPIC_MUTATION, UPDATE_TOPIC_MUTATION } from '../graphql/mutations' import { GET_TOPICS_QUERY } from '../graphql/queries' import TopicEditModal from '../modals/TopicEditModal' import styles from '../styles/Table.module.css' import Button from '../ui/Button' import Modal from '../ui/Modal' /** * Интерфейс топика */ interface Topic { id: number slug: string title: string body?: string pic?: string community: number parent_ids?: number[] children?: Topic[] level?: number } /** * Интерфейс свойств компонента */ interface TopicsRouteProps { onError: (error: string) => void onSuccess: (message: string) => void } /** * Компонент управления топиками */ const TopicsRoute: Component = (props) => { const [rawTopics, setRawTopics] = createSignal([]) const [topics, setTopics] = createSignal([]) const [loading, setLoading] = createSignal(false) const [sortBy, setSortBy] = createSignal<'id' | 'title'>('id') const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc') const [deleteModal, setDeleteModal] = createSignal<{ show: boolean; topic: Topic | null }>({ show: false, topic: null }) const [editModal, setEditModal] = createSignal<{ show: boolean; topic: Topic | null }>({ show: false, topic: null }) const [createModal, setCreateModal] = createSignal<{ show: boolean }>({ show: false }) /** * Загружает список всех топиков */ const loadTopics = async () => { setLoading(true) try { const data = await query<{ get_topics_all: Query['get_topics_all'] }>( `${location.origin}/graphql`, GET_TOPICS_QUERY ) if (data?.get_topics_all) { // Строим иерархическую структуру const validTopics = data.get_topics_all.filter((topic): topic is Topic => topic !== null) setRawTopics(validTopics) } } catch (error) { props.onError(`Ошибка загрузки топиков: ${(error as Error).message}`) } finally { setLoading(false) } } // Пересортировка при изменении rawTopics или параметров сортировки createEffect( on([rawTopics, sortBy, sortDirection], () => { const rawData = rawTopics() const sort = sortBy() const direction = sortDirection() if (rawData.length > 0) { // Используем untrack для чтения buildHierarchy без дополнительных зависимостей const hierarchicalTopics = untrack(() => buildHierarchy(rawData, sort, direction)) setTopics(hierarchicalTopics) } else { setTopics([]) } }) ) // Загружаем топики при монтировании компонента onMount(() => { void loadTopics() }) /** * Строит иерархическую структуру топиков */ const buildHierarchy = ( flatTopics: Topic[], sortField?: 'id' | 'title', sortDir?: 'asc' | 'desc' ): Topic[] => { const topicMap = new Map() const rootTopics: Topic[] = [] // Создаем карту всех топиков flatTopics.forEach((topic) => { topicMap.set(topic.id, { ...topic, children: [], level: 0 }) }) // Строим иерархию flatTopics.forEach((topic) => { const currentTopic = topicMap.get(topic.id)! if (!topic.parent_ids || topic.parent_ids.length === 0) { // Корневой топик rootTopics.push(currentTopic) } else { // Находим родителя и добавляем как дочерний const parentId = topic.parent_ids[topic.parent_ids.length - 1] const parent = topicMap.get(parentId) if (parent) { currentTopic.level = (parent.level || 0) + 1 parent.children!.push(currentTopic) } else { // Если родитель не найден, добавляем как корневой rootTopics.push(currentTopic) } } }) return sortTopics(rootTopics, sortField, sortDir) } /** * Сортирует топики рекурсивно */ const sortTopics = (topics: Topic[], sortField?: 'id' | 'title', sortDir?: 'asc' | 'desc'): Topic[] => { const field = sortField || sortBy() const direction = sortDir || sortDirection() const sortedTopics = topics.sort((a, b) => { let comparison = 0 if (field === 'title') { comparison = (a.title || '').localeCompare(b.title || '', 'ru') } else { comparison = a.id - b.id } return direction === 'desc' ? -comparison : comparison }) // Рекурсивно сортируем дочерние элементы sortedTopics.forEach((topic) => { if (topic.children && topic.children.length > 0) { topic.children = sortTopics(topic.children, field, direction) } }) return sortedTopics } /** * Обрезает текст до указанной длины */ const truncateText = (text: string, maxLength = 100): string => { if (!text) return '—' return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text } /** * Рекурсивно отображает топики с отступами для иерархии */ const renderTopics = (topics: Topic[]): JSX.Element[] => { const result: JSX.Element[] = [] topics.forEach((topic) => { result.push( setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }} class={styles['clickable-row']} > {topic.id} {topic.level! > 0 && '└─ '} {topic.title} {topic.slug}
{truncateText(topic.body?.replace(/<[^>]*>/g, '') || '', 100)}
{topic.community} {topic.parent_ids?.join(', ') || '—'} e.stopPropagation()}> ) if (topic.children && topic.children.length > 0) { result.push(...renderTopics(topic.children)) } }) return result } /** * Обновляет топик */ const updateTopic = async (updatedTopic: Topic) => { try { const response = await fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: UPDATE_TOPIC_MUTATION, variables: { topic_input: updatedTopic } }) }) const result = await response.json() if (result.errors) { throw new Error(result.errors[0].message) } if (result.data.update_topic.success) { props.onSuccess('Топик успешно обновлен') setEditModal({ show: false, topic: null }) await loadTopics() // Перезагружаем список } else { throw new Error(result.data.update_topic.message || 'Ошибка обновления топика') } } catch (error) { props.onError(`Ошибка обновления топика: ${(error as Error).message}`) } } /** * Создает новый топик */ const createTopic = async (newTopic: Topic) => { try { const response = await fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: CREATE_TOPIC_MUTATION, variables: { topic_input: newTopic } }) }) const result = await response.json() if (result.errors) { throw new Error(result.errors[0].message) } if (result.data.create_topic.error) { throw new Error(result.data.create_topic.error) } props.onSuccess('Топик успешно создан') setCreateModal({ show: false }) await loadTopics() // Перезагружаем список } catch (error) { props.onError(`Ошибка создания топика: ${(error as Error).message}`) } } /** * Удаляет топик */ const deleteTopic = async (topicId: number) => { try { const response = await fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: DELETE_TOPIC_MUTATION, variables: { id: topicId } }) }) const result = await response.json() if (result.errors) { throw new Error(result.errors[0].message) } if (result.data.delete_topic_by_id.success) { props.onSuccess('Топик успешно удален') setDeleteModal({ show: false, topic: null }) await loadTopics() // Перезагружаем список } else { throw new Error(result.data.delete_topic_by_id.message || 'Ошибка удаления топика') } } catch (error) { props.onError(`Ошибка удаления топика: ${(error as Error).message}`) } } return (
Загрузка топиков...
} > {(row) => row}
ID Название Slug Описание Сообщество Родители Действия
{/* Модальное окно создания */} setCreateModal({ show: false })} onSave={createTopic} /> {/* Модальное окно редактирования */} setEditModal({ show: false, topic: null })} onSave={updateTopic} /> {/* Модальное окно подтверждения удаления */} setDeleteModal({ show: false, topic: null })} title="Подтверждение удаления" >

Вы уверены, что хотите удалить топик "{deleteModal().topic?.title}"?

Это действие нельзя отменить. Все дочерние топики также будут удалены.

) } export default TopicsRoute