simpler-parent-select
This commit is contained in:
parent
2683982180
commit
bb41c02d62
100
CHANGELOG.md
100
CHANGELOG.md
|
@ -1,5 +1,97 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.6.0] - 2025-07-01
|
||||||
|
|
||||||
|
### Исправления авторизации
|
||||||
|
|
||||||
|
- **КРИТИЧНО**: Исправлена ошибка "Сессия не найдена в Redis" в админ-панели:
|
||||||
|
- **Проблема**: Несоответствие полей в JWT токенах - при создании использовалось поле `id`, а при декодировании ожидалось `user_id`
|
||||||
|
- **Исправления**:
|
||||||
|
- В `SessionTokenManager.create_session_token` изменено создание JWT с поля `id` на `user_id`
|
||||||
|
- В `JWTCodec.encode` добавлена поддержка обоих полей (`user_id` и `id`) для обратной совместимости
|
||||||
|
- Обновлена обработка словарей в `JWTCodec.encode` для корректной работы с новым форматом
|
||||||
|
- **Результат**: Авторизация в админ-панели работает корректно, токены правильно верифицируются в Redis
|
||||||
|
|
||||||
|
### Исправления типизации
|
||||||
|
|
||||||
|
- **ИСПРАВЛЕНО**: Ошибки mypy в `resolvers/topic.py`:
|
||||||
|
- Добавлены аннотации типов для переменных `current_parent_ids`, `source_parent_ids`, `old_parent_ids`, `parent_parent_ids`
|
||||||
|
- Исправлена типизация при работе с `parent_ids` как `list[int]` с использованием `list()` для явного преобразования
|
||||||
|
- Заменен метод `contains()` на `op("@>")` для корректной работы с PostgreSQL JSON массивами
|
||||||
|
- Добавлено явное приведение типов для `invalidate_topic_followers_cache(int(source_topic.id))`
|
||||||
|
- Добавлены `# type: ignore[assignment]` комментарии для присваивания значений SQLAlchemy Column полям
|
||||||
|
- **Результат**: Код проходит проверку mypy без ошибок
|
||||||
|
|
||||||
|
### Новые интерфейсы управления иерархией топиков
|
||||||
|
|
||||||
|
- **НОВОЕ**: Три варианта интерфейса для управления иерархией тем в админ-панели:
|
||||||
|
|
||||||
|
#### Простой интерфейс назначения родителей
|
||||||
|
- **TopicSimpleParentModal**: Простое и понятное назначение родительских тем
|
||||||
|
- **Возможности**:
|
||||||
|
- 🔍 **Поиск родителя**: Быстрый поиск подходящих родительских тем по названию
|
||||||
|
- 🏠 **Опция корневой темы**: Возможность сделать тему корневой одним кликом
|
||||||
|
- 📍 **Отображение текущего расположения**: Показ полного пути темы в иерархии
|
||||||
|
- 📋 **Предварительный просмотр**: Показ нового расположения перед применением
|
||||||
|
- ✅ **Валидация**: Автоматическая проверка циклических зависимостей
|
||||||
|
- 🏘️ **Фильтрация по сообществу**: Показ только тем из того же сообщества
|
||||||
|
- **UX особенности**:
|
||||||
|
- Radio buttons для четкого выбора одного варианта
|
||||||
|
- Отображение полных путей до корня для каждой темы
|
||||||
|
- Информационные панели с детальным описанием каждой опции
|
||||||
|
- Блокировка некорректных действий (циклы, разные сообщества)
|
||||||
|
- Простой и интуитивный интерфейс без сложных элементов
|
||||||
|
|
||||||
|
#### Вариант 2: Простой селектор родителей
|
||||||
|
- **TopicParentModal**: Быстрый выбор родительской темы для одного топика
|
||||||
|
- **Возможности**:
|
||||||
|
- Поиск по названию для быстрого нахождения родителя
|
||||||
|
- Отображение текущего и нового местоположения в иерархии
|
||||||
|
- Опция "Сделать корневой темой" (🏠)
|
||||||
|
- Показ полного пути до корня для каждой темы
|
||||||
|
- Фильтрация только совместимых родителей (то же сообщество, без циклов)
|
||||||
|
- Предотвращение выбора потомков как родителей
|
||||||
|
- **UX особенности**:
|
||||||
|
- Radio buttons для четкого выбора
|
||||||
|
- Отображение slug и ID для точной идентификации
|
||||||
|
- Информационные панели с текущим состоянием
|
||||||
|
- Валидация с блокировкой некорректных действий
|
||||||
|
|
||||||
|
#### Вариант 3: Массовый редактор иерархии
|
||||||
|
- **TopicBulkParentModal**: Одновременное изменение родителя для множества тем
|
||||||
|
- **Возможности**:
|
||||||
|
- Два режима: "Установить родителя" и "Сделать корневыми"
|
||||||
|
- Проверка совместимости (только темы одного сообщества)
|
||||||
|
- Предварительный просмотр изменений "Было → Станет"
|
||||||
|
- Поиск по названию среди доступных родителей
|
||||||
|
- Валидация для предотвращения циклов и ошибок
|
||||||
|
- Отображение количества затрагиваемых тем
|
||||||
|
- **UX особенности**:
|
||||||
|
- Список выбранных тем с их текущими путями
|
||||||
|
- Цветовая индикация состояний (до/после изменения)
|
||||||
|
- Предупреждения о несовместимых действиях
|
||||||
|
- Массовое применение с подтверждением
|
||||||
|
|
||||||
|
### Техническая архитектура
|
||||||
|
|
||||||
|
- **НОВАЯ мутация `set_topic_parent`**: Простое API для назначения родительской темы
|
||||||
|
- **Исправления GraphQL схемы**: Добавлены поля `message` и `stats` в `CommonResult`
|
||||||
|
- **Унифицированная валидация**: Проверка циклических зависимостей и принадлежности к сообществу
|
||||||
|
- **Простой интерфейс**: Radio buttons вместо сложного drag & drop для лучшего UX
|
||||||
|
- **Поиск и фильтрация**: Быстрый поиск подходящих родительских тем
|
||||||
|
- **Переиспользование компонентов**: Единый стиль с существующими модальными окнами
|
||||||
|
- **Автоматическая инвалидация кешей**: Обновление кешей при изменении иерархии
|
||||||
|
- **Детальное логирование**: Отслеживание всех операций с иерархией для отладки
|
||||||
|
|
||||||
|
### Интеграция с существующей системой
|
||||||
|
|
||||||
|
- **Кнопка "🏠 Назначить родителя"**: Простая кнопка для назначения родительской темы
|
||||||
|
- **Требует выбора одной темы**: Работает только с одной выбранной темой за раз
|
||||||
|
- **Совместимость**: Работает с существующей системой `parent_ids` в JSON формате
|
||||||
|
- **Обновление кешей**: Автоматическая инвалидация при изменении иерархии
|
||||||
|
- **Логирование**: Детальное отслеживание всех операций с иерархией
|
||||||
|
- **Отладка слияния**: Исправлена ошибка GraphQL `Cannot query field 'message'` в системе слияния тем
|
||||||
|
|
||||||
## [0.5.10] - 2025-06-30
|
## [0.5.10] - 2025-06-30
|
||||||
|
|
||||||
### auth/internal fix
|
### auth/internal fix
|
||||||
|
@ -9,6 +101,14 @@
|
||||||
- Исправлена функция `admin_delete_invites_batch` - завершена реализация для корректной обработки пакетного удаления приглашений
|
- Исправлена функция `admin_delete_invites_batch` - завершена реализация для корректной обработки пакетного удаления приглашений
|
||||||
- Исправлена ошибка в функции `get_shouts_with_links` в файле `resolvers/reader.py` - добавлено значение по умолчанию для поля `slug` у авторов публикаций в полях `authors` и `created_by`, чтобы избежать ошибки "Cannot return null for non-nullable field Author.slug"
|
- Исправлена ошибка в функции `get_shouts_with_links` в файле `resolvers/reader.py` - добавлено значение по умолчанию для поля `slug` у авторов публикаций в полях `authors` и `created_by`, чтобы избежать ошибки "Cannot return null for non-nullable field Author.slug"
|
||||||
- Исправлена ошибка в функции `admin_get_shouts` в файле `resolvers/admin.py` - добавлена полная загрузка информации об авторах для полей `created_by`, `updated_by` и `deleted_by` с корректной обработкой поля `slug` и значениями по умолчанию, чтобы избежать ошибки "Cannot return null for non-nullable field Author.slug"
|
- Исправлена ошибка в функции `admin_get_shouts` в файле `resolvers/admin.py` - добавлена полная загрузка информации об авторах для полей `created_by`, `updated_by` и `deleted_by` с корректной обработкой поля `slug` и значениями по умолчанию, чтобы избежать ошибки "Cannot return null for non-nullable field Author.slug"
|
||||||
|
- Исправлена ошибка базы данных "relation invite does not exist" - раскомментирована таблица `invite.Invite` в функции `create_all_tables()` в файле `services/schema.py` для создания необходимой таблицы приглашений
|
||||||
|
- **УЛУЧШЕНО**: Верстка админ-панели приглашений:
|
||||||
|
- **Поиск на всю ширину**: Поле поиска теперь занимает всю ширину в отдельной строке для удобства ввода длинных запросов
|
||||||
|
- **Сортировка в заголовках**: Добавлены кликабельные иконки сортировки (↑↓) прямо в заголовки колонок таблицы
|
||||||
|
- **Компактная панель фильтров**: Фильтр статуса и кнопки управления размещены в отдельной строке под поиском
|
||||||
|
- **Улучшенный UX**: Hover эффекты для сортируемых колонок, визуальные индикаторы активной сортировки
|
||||||
|
- **Адаптивный дизайн**: Корректное отображение на мобильных устройствах с переносом элементов
|
||||||
|
- **Современный стиль**: Обновленная цветовая схема и типографика для лучшей читаемости
|
||||||
|
|
||||||
### Улучшения админ-панели для приглашений
|
### Улучшения админ-панели для приглашений
|
||||||
|
|
||||||
|
|
|
@ -21,9 +21,9 @@ class JWTCodec:
|
||||||
def encode(user: Union[dict[str, Any], Any], exp: Optional[datetime] = None) -> str:
|
def encode(user: Union[dict[str, Any], Any], exp: Optional[datetime] = None) -> str:
|
||||||
# Поддержка как объектов, так и словарей
|
# Поддержка как объектов, так и словарей
|
||||||
if isinstance(user, dict):
|
if isinstance(user, dict):
|
||||||
# В TokenStorage.create_session передается словарь {"id": user_id, "email": username}
|
# В TokenStorage.create_session передается словарь {"user_id": user_id, "username": username}
|
||||||
user_id = str(user.get("id", ""))
|
user_id = str(user.get("user_id", "") or user.get("id", ""))
|
||||||
username = user.get("email", "") or user.get("username", "")
|
username = user.get("username", "") or user.get("email", "")
|
||||||
else:
|
else:
|
||||||
# Для объектов с атрибутами
|
# Для объектов с атрибутами
|
||||||
user_id = str(getattr(user, "id", ""))
|
user_id = str(getattr(user, "id", ""))
|
||||||
|
|
|
@ -45,7 +45,7 @@ class SessionTokenManager(BaseTokenManager):
|
||||||
# Создаем JWT токен
|
# Создаем JWT токен
|
||||||
jwt_token = JWTCodec.encode(
|
jwt_token = JWTCodec.encode(
|
||||||
{
|
{
|
||||||
"id": user_id,
|
"user_id": user_id,
|
||||||
"username": username,
|
"username": username,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -137,3 +137,33 @@ export const ADMIN_DELETE_INVITES_BATCH_MUTATION = `
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export const MERGE_TOPICS_MUTATION = `
|
||||||
|
mutation MergeTopics($merge_input: TopicMergeInput!) {
|
||||||
|
merge_topics(merge_input: $merge_input) {
|
||||||
|
error
|
||||||
|
message
|
||||||
|
topic {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const SET_TOPIC_PARENT_MUTATION = `
|
||||||
|
mutation SetTopicParent($topic_id: Int!, $parent_id: Int) {
|
||||||
|
set_topic_parent(topic_id: $topic_id, parent_id: $parent_id) {
|
||||||
|
error
|
||||||
|
message
|
||||||
|
topic {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
slug
|
||||||
|
parent_ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
326
panel/modals/TopicBulkParentModal.tsx
Normal file
326
panel/modals/TopicBulkParentModal.tsx
Normal file
|
@ -0,0 +1,326 @@
|
||||||
|
import { Component, createSignal, For, Show } from 'solid-js'
|
||||||
|
import Button from '../ui/Button'
|
||||||
|
import Modal from '../ui/Modal'
|
||||||
|
import styles from '../styles/Form.module.css'
|
||||||
|
|
||||||
|
interface Topic {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
parent_ids?: number[]
|
||||||
|
community: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopicBulkParentModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
selectedTopicIds: number[]
|
||||||
|
allTopics: Topic[]
|
||||||
|
onSave: (changes: BulkParentChange[]) => void
|
||||||
|
onError: (error: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BulkParentChange {
|
||||||
|
topicId: number
|
||||||
|
newParentIds: number[]
|
||||||
|
oldParentIds: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const TopicBulkParentModal: Component<TopicBulkParentModalProps> = (props) => {
|
||||||
|
const [newParentId, setNewParentId] = createSignal<number | null>(null)
|
||||||
|
const [searchQuery, setSearchQuery] = createSignal('')
|
||||||
|
const [actionType, setActionType] = createSignal<'set' | 'makeRoot'>('set')
|
||||||
|
|
||||||
|
// Получаем выбранные топики
|
||||||
|
const getSelectedTopics = () => {
|
||||||
|
return props.allTopics.filter(topic =>
|
||||||
|
props.selectedTopicIds.includes(topic.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтрация доступных родителей
|
||||||
|
const getAvailableParents = () => {
|
||||||
|
const selectedIds = new Set(props.selectedTopicIds)
|
||||||
|
|
||||||
|
return props.allTopics.filter(topic => {
|
||||||
|
// Исключаем выбранные топики
|
||||||
|
if (selectedIds.has(topic.id)) return false
|
||||||
|
|
||||||
|
// Исключаем топики, которые являются детьми выбранных
|
||||||
|
const isChildOfSelected = props.selectedTopicIds.some(selectedId =>
|
||||||
|
isDescendant(selectedId, topic.id)
|
||||||
|
)
|
||||||
|
if (isChildOfSelected) return false
|
||||||
|
|
||||||
|
// Фильтр по поисковому запросу
|
||||||
|
const query = searchQuery().toLowerCase()
|
||||||
|
if (query && !topic.title.toLowerCase().includes(query)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка, является ли топик потомком другого
|
||||||
|
const isDescendant = (ancestorId: number, descendantId: number): boolean => {
|
||||||
|
const descendant = props.allTopics.find(t => t.id === descendantId)
|
||||||
|
if (!descendant || !descendant.parent_ids) return false
|
||||||
|
|
||||||
|
return descendant.parent_ids.includes(ancestorId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение пути к корню
|
||||||
|
const getTopicPath = (topicId: number): string => {
|
||||||
|
const topic = props.allTopics.find(t => t.id === topicId)
|
||||||
|
if (!topic) return ''
|
||||||
|
|
||||||
|
if (!topic.parent_ids || topic.parent_ids.length === 0) {
|
||||||
|
return topic.title
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = getTopicPath(topic.parent_ids[topic.parent_ids.length - 1])
|
||||||
|
return `${parentPath} → ${topic.title}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группировка топиков по сообществам
|
||||||
|
const getTopicsByCommunity = () => {
|
||||||
|
const selectedTopics = getSelectedTopics()
|
||||||
|
const communities = new Map<number, Topic[]>()
|
||||||
|
|
||||||
|
selectedTopics.forEach(topic => {
|
||||||
|
if (!communities.has(topic.community)) {
|
||||||
|
communities.set(topic.community, [])
|
||||||
|
}
|
||||||
|
communities.get(topic.community)!.push(topic)
|
||||||
|
})
|
||||||
|
|
||||||
|
return communities
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка совместимости действия
|
||||||
|
const validateAction = (): string | null => {
|
||||||
|
const communities = getTopicsByCommunity()
|
||||||
|
|
||||||
|
if (communities.size > 1) {
|
||||||
|
return 'Нельзя изменять иерархию тем из разных сообществ одновременно'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionType() === 'set' && !newParentId()) {
|
||||||
|
return 'Выберите родительскую тему'
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedParent = props.allTopics.find(t => t.id === newParentId())
|
||||||
|
if (selectedParent) {
|
||||||
|
const selectedCommunity = Array.from(communities.keys())[0]
|
||||||
|
if (selectedParent.community !== selectedCommunity) {
|
||||||
|
return 'Родительская тема должна быть из того же сообщества'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение изменений
|
||||||
|
const handleSave = () => {
|
||||||
|
const validationError = validateAction()
|
||||||
|
if (validationError) {
|
||||||
|
props.onError(validationError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes: BulkParentChange[] = []
|
||||||
|
const selectedTopics = getSelectedTopics()
|
||||||
|
|
||||||
|
selectedTopics.forEach(topic => {
|
||||||
|
let newParentIds: number[] = []
|
||||||
|
|
||||||
|
if (actionType() === 'set' && newParentId()) {
|
||||||
|
const parentTopic = props.allTopics.find(t => t.id === newParentId())
|
||||||
|
if (parentTopic) {
|
||||||
|
newParentIds = [...(parentTopic.parent_ids || []), newParentId()!]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changes.push({
|
||||||
|
topicId: topic.id,
|
||||||
|
newParentIds,
|
||||||
|
oldParentIds: topic.parent_ids || []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
props.onSave(changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={props.isOpen}
|
||||||
|
onClose={props.onClose}
|
||||||
|
title={`Массовое изменение иерархии (${props.selectedTopicIds.length} тем)`}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<div class={styles.bulkParentContainer}>
|
||||||
|
{/* Проверка совместимости */}
|
||||||
|
<Show when={getTopicsByCommunity().size > 1}>
|
||||||
|
<div class={styles.errorMessage}>
|
||||||
|
⚠️ Выбраны темы из разных сообществ. Массовое изменение иерархии возможно только для тем одного сообщества.
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Список выбранных тем */}
|
||||||
|
<div class={styles.selectedTopicsPreview}>
|
||||||
|
<h4>Выбранные темы ({props.selectedTopicIds.length}):</h4>
|
||||||
|
<div class={styles.topicsList}>
|
||||||
|
<For each={getSelectedTopics()}>
|
||||||
|
{(topic) => (
|
||||||
|
<div class={styles.topicPreviewItem}>
|
||||||
|
<span class={styles.topicTitle}>{topic.title}</span>
|
||||||
|
<span class={styles.topicId}>#{topic.id}</span>
|
||||||
|
<Show when={topic.parent_ids && topic.parent_ids.length > 0}>
|
||||||
|
<div class={styles.currentPath}>
|
||||||
|
Текущий путь: {getTopicPath(topic.id)}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Выбор действия */}
|
||||||
|
<div class={styles.actionSelection}>
|
||||||
|
<h4>Выберите действие:</h4>
|
||||||
|
<div class={styles.actionOptions}>
|
||||||
|
<div class={styles.actionOption}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="action-set"
|
||||||
|
name="action"
|
||||||
|
checked={actionType() === 'set'}
|
||||||
|
onChange={() => setActionType('set')}
|
||||||
|
/>
|
||||||
|
<label for="action-set" class={styles.actionLabel}>
|
||||||
|
<strong>Установить нового родителя</strong>
|
||||||
|
<div class={styles.actionDescription}>
|
||||||
|
Переместить все выбранные темы под одного родителя
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.actionOption}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="action-root"
|
||||||
|
name="action"
|
||||||
|
checked={actionType() === 'makeRoot'}
|
||||||
|
onChange={() => setActionType('makeRoot')}
|
||||||
|
/>
|
||||||
|
<label for="action-root" class={styles.actionLabel}>
|
||||||
|
<strong>🏠 Сделать корневыми</strong>
|
||||||
|
<div class={styles.actionDescription}>
|
||||||
|
Переместить все выбранные темы на верхний уровень
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Выбор родителя */}
|
||||||
|
<Show when={actionType() === 'set'}>
|
||||||
|
<div class={styles.parentSelection}>
|
||||||
|
<h4>Выбор родительской темы:</h4>
|
||||||
|
|
||||||
|
<div class={styles.searchSection}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery()}
|
||||||
|
onInput={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Поиск родительской темы..."
|
||||||
|
class={styles.searchInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.parentsList}>
|
||||||
|
<For each={getAvailableParents()}>
|
||||||
|
{(topic) => (
|
||||||
|
<div class={styles.parentOption}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id={`bulk-parent-${topic.id}`}
|
||||||
|
name="bulk-parent"
|
||||||
|
checked={newParentId() === topic.id}
|
||||||
|
onChange={() => setNewParentId(topic.id)}
|
||||||
|
/>
|
||||||
|
<label for={`bulk-parent-${topic.id}`} class={styles.parentOptionLabel}>
|
||||||
|
<strong>{topic.title}</strong>
|
||||||
|
<div class={styles.parentDescription}>
|
||||||
|
<span class={styles.topicId}>#{topic.id}</span>
|
||||||
|
<span class={styles.topicSlug}>{topic.slug}</span>
|
||||||
|
</div>
|
||||||
|
<Show when={topic.parent_ids && topic.parent_ids.length > 0}>
|
||||||
|
<div class={styles.parentPath}>
|
||||||
|
Текущий путь: {getTopicPath(topic.id)}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={getAvailableParents().length === 0}>
|
||||||
|
<div class={styles.noResults}>
|
||||||
|
{searchQuery()
|
||||||
|
? `Нет доступных тем для поиска "${searchQuery()}"`
|
||||||
|
: 'Нет доступных родительских тем'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Предварительный просмотр изменений */}
|
||||||
|
<Show when={actionType() === 'makeRoot' || (actionType() === 'set' && newParentId())}>
|
||||||
|
<div class={styles.previewSection}>
|
||||||
|
<h4>Предварительный просмотр:</h4>
|
||||||
|
<div class={styles.previewChanges}>
|
||||||
|
<For each={getSelectedTopics()}>
|
||||||
|
{(topic) => (
|
||||||
|
<div class={styles.previewItem}>
|
||||||
|
<strong>{topic.title}</strong>
|
||||||
|
<div class={styles.previewChange}>
|
||||||
|
<span class={styles.beforeState}>
|
||||||
|
Было: {topic.parent_ids?.length ? getTopicPath(topic.id) : 'Корневая тема'}
|
||||||
|
</span>
|
||||||
|
<span class={styles.arrow}>→</span>
|
||||||
|
<span class={styles.afterState}>
|
||||||
|
Станет: {
|
||||||
|
actionType() === 'makeRoot'
|
||||||
|
? 'Корневая тема'
|
||||||
|
: newParentId() ? `${getTopicPath(newParentId()!)} → ${topic.title}` : ''
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class={styles.modalActions}>
|
||||||
|
<Button variant="secondary" onClick={props.onClose}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!!validateAction() || getTopicsByCommunity().size > 1}
|
||||||
|
>
|
||||||
|
Применить к {props.selectedTopicIds.length} темам
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TopicBulkParentModal
|
458
panel/modals/TopicHierarchyModal.tsx
Normal file
458
panel/modals/TopicHierarchyModal.tsx
Normal file
|
@ -0,0 +1,458 @@
|
||||||
|
import { Component, createSignal, For, Show } from 'solid-js'
|
||||||
|
import Button from '../ui/Button'
|
||||||
|
import Modal from '../ui/Modal'
|
||||||
|
import styles from '../styles/Form.module.css'
|
||||||
|
|
||||||
|
// Типы для топиков
|
||||||
|
interface Topic {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
parent_ids?: number[]
|
||||||
|
children?: Topic[]
|
||||||
|
level?: number
|
||||||
|
community: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopicHierarchyModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
topics: Topic[]
|
||||||
|
onSave: (hierarchyChanges: HierarchyChange[]) => void
|
||||||
|
onError: (error: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HierarchyChange {
|
||||||
|
topicId: number
|
||||||
|
newParentIds: number[]
|
||||||
|
oldParentIds: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const TopicHierarchyModal: Component<TopicHierarchyModalProps> = (props) => {
|
||||||
|
const [localTopics, setLocalTopics] = createSignal<Topic[]>([])
|
||||||
|
const [changes, setChanges] = createSignal<HierarchyChange[]>([])
|
||||||
|
const [expandedNodes, setExpandedNodes] = createSignal<Set<number>>(new Set())
|
||||||
|
const [searchQuery, setSearchQuery] = createSignal('')
|
||||||
|
const [selectedForMove, setSelectedForMove] = createSignal<number | null>(null)
|
||||||
|
|
||||||
|
// Инициализация локального состояния
|
||||||
|
const initializeTopics = () => {
|
||||||
|
const hierarchicalTopics = buildHierarchy(props.topics)
|
||||||
|
setLocalTopics(hierarchicalTopics)
|
||||||
|
setChanges([])
|
||||||
|
setSearchQuery('')
|
||||||
|
setSelectedForMove(null)
|
||||||
|
// Раскрываем все узлы по умолчанию
|
||||||
|
const allIds = new Set(props.topics.map(t => t.id))
|
||||||
|
setExpandedNodes(allIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Построение иерархической структуры
|
||||||
|
const buildHierarchy = (flatTopics: Topic[]): Topic[] => {
|
||||||
|
const topicMap = new Map<number, Topic>()
|
||||||
|
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 rootTopics
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция удалена - используем кликабельный интерфейс вместо drag & drop
|
||||||
|
|
||||||
|
// Проверка, является ли топик потомком другого
|
||||||
|
const isDescendant = (parentId: number, childId: number): boolean => {
|
||||||
|
const checkChildren = (topics: Topic[]): boolean => {
|
||||||
|
for (const topic of topics) {
|
||||||
|
if (topic.id === childId) return true
|
||||||
|
if (topic.children && checkChildren(topic.children)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentTopic = findTopicById(parentId)
|
||||||
|
return parentTopic ? checkChildren(parentTopic.children || []) : false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск топика по ID
|
||||||
|
const findTopicById = (id: number): Topic | null => {
|
||||||
|
const search = (topics: Topic[]): Topic | null => {
|
||||||
|
for (const topic of topics) {
|
||||||
|
if (topic.id === id) return topic
|
||||||
|
if (topic.children) {
|
||||||
|
const found = search(topic.children)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return search(localTopics())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление родителя топика
|
||||||
|
const updateTopicParent = (topicId: number, newParentIds: number[]) => {
|
||||||
|
const flatTopics = flattenTopics(localTopics())
|
||||||
|
const updatedTopics = flatTopics.map(topic =>
|
||||||
|
topic.id === topicId
|
||||||
|
? { ...topic, parent_ids: newParentIds }
|
||||||
|
: topic
|
||||||
|
)
|
||||||
|
const newHierarchy = buildHierarchy(updatedTopics)
|
||||||
|
setLocalTopics(newHierarchy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Преобразование дерева в плоский список
|
||||||
|
const flattenTopics = (topics: Topic[]): Topic[] => {
|
||||||
|
const result: Topic[] = []
|
||||||
|
const flatten = (topicList: Topic[]) => {
|
||||||
|
topicList.forEach(topic => {
|
||||||
|
result.push(topic)
|
||||||
|
if (topic.children) {
|
||||||
|
flatten(topic.children)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
flatten(topics)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переключение раскрытия узла
|
||||||
|
const toggleExpanded = (topicId: number) => {
|
||||||
|
const expanded = expandedNodes()
|
||||||
|
if (expanded.has(topicId)) {
|
||||||
|
expanded.delete(topicId)
|
||||||
|
} else {
|
||||||
|
expanded.add(topicId)
|
||||||
|
}
|
||||||
|
setExpandedNodes(new Set(expanded))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск темы по названию
|
||||||
|
const findTopicByTitle = (title: string): Topic | null => {
|
||||||
|
const query = title.toLowerCase().trim()
|
||||||
|
if (!query) return null
|
||||||
|
|
||||||
|
const search = (topics: Topic[]): Topic | null => {
|
||||||
|
for (const topic of topics) {
|
||||||
|
if (topic.title.toLowerCase().includes(query)) {
|
||||||
|
return topic
|
||||||
|
}
|
||||||
|
if (topic.children) {
|
||||||
|
const found = search(topic.children)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return search(localTopics())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выбор темы для перемещения
|
||||||
|
const selectTopicForMove = (topicId: number) => {
|
||||||
|
setSelectedForMove(topicId)
|
||||||
|
const topic = findTopicById(topicId)
|
||||||
|
if (topic) {
|
||||||
|
props.onError(`Выбрана тема "${topic.title}" для перемещения. Теперь нажмите на новую родительскую тему или используйте "Сделать корневой".`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Перемещение выбранной темы к новому родителю
|
||||||
|
const moveSelectedTopic = (newParentId: number | null) => {
|
||||||
|
const selectedId = selectedForMove()
|
||||||
|
if (!selectedId) return
|
||||||
|
|
||||||
|
const selectedTopic = findTopicById(selectedId)
|
||||||
|
if (!selectedTopic) return
|
||||||
|
|
||||||
|
// Проверяем циклы
|
||||||
|
if (newParentId && isDescendant(newParentId, selectedId)) {
|
||||||
|
props.onError('Нельзя переместить тему в своего потомка')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let newParentIds: number[] = []
|
||||||
|
if (newParentId) {
|
||||||
|
const newParent = findTopicById(newParentId)
|
||||||
|
if (newParent) {
|
||||||
|
newParentIds = [...(newParent.parent_ids || []), newParentId]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем локальное состояние
|
||||||
|
updateTopicParent(selectedId, newParentIds)
|
||||||
|
|
||||||
|
// Добавляем в список изменений
|
||||||
|
setChanges(prev => [
|
||||||
|
...prev.filter(c => c.topicId !== selectedId),
|
||||||
|
{
|
||||||
|
topicId: selectedId,
|
||||||
|
newParentIds,
|
||||||
|
oldParentIds: selectedTopic.parent_ids || []
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
setSelectedForMove(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Раскрытие пути до определенной темы
|
||||||
|
const expandPathToTopic = (topicId: number) => {
|
||||||
|
const topic = findTopicById(topicId)
|
||||||
|
if (!topic || !topic.parent_ids) return
|
||||||
|
|
||||||
|
const expanded = expandedNodes()
|
||||||
|
// Раскрываем всех родителей
|
||||||
|
topic.parent_ids.forEach(parentId => {
|
||||||
|
expanded.add(parentId)
|
||||||
|
})
|
||||||
|
setExpandedNodes(new Set(expanded))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рендеринг дерева топиков
|
||||||
|
const renderTree = (topics: Topic[]): any => {
|
||||||
|
return (
|
||||||
|
<For each={topics}>
|
||||||
|
{(topic) => {
|
||||||
|
const isExpanded = expandedNodes().has(topic.id)
|
||||||
|
const isSelected = selectedForMove() === topic.id
|
||||||
|
const isTarget = selectedForMove() && selectedForMove() !== topic.id
|
||||||
|
const hasChildren = topic.children && topic.children.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={styles.treeNode}>
|
||||||
|
<div
|
||||||
|
class={`${styles.treeItem} ${isSelected ? styles.selectedForMove : ''} ${isTarget ? styles.moveTarget : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedForMove() && selectedForMove() !== topic.id) {
|
||||||
|
// Если уже выбрана тема для перемещения, делаем текущую тему родителем
|
||||||
|
moveSelectedTopic(topic.id)
|
||||||
|
} else {
|
||||||
|
// Иначе выбираем эту тему для перемещения
|
||||||
|
selectTopicForMove(topic.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
'padding-left': `${(topic.level || 0) * 20}px`,
|
||||||
|
'cursor': 'pointer',
|
||||||
|
'border': isSelected ? '2px solid #007bff' : isTarget ? '2px dashed #28a745' : '1px solid transparent',
|
||||||
|
'background-color': isSelected ? '#e3f2fd' : isTarget ? '#d4edda' : 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', 'align-items': 'center', gap: '8px' }}>
|
||||||
|
<Show when={hasChildren}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
toggleExpanded(topic.id)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
'font-size': '12px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExpanded ? '▼' : '▶'}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<Show when={!hasChildren}>
|
||||||
|
<span style={{ width: '12px' }}></span>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={isSelected}>
|
||||||
|
<span class={styles.selectedIcon}>📦</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={isTarget}>
|
||||||
|
<span class={styles.targetIcon}>📂</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={!isSelected && !isTarget}>
|
||||||
|
<span class={styles.clickIcon}>#</span>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<span class={styles.topicTitle}>{topic.title}</span>
|
||||||
|
<span class={styles.topicId}>#{topic.id}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={isExpanded && hasChildren}>
|
||||||
|
<div class={styles.treeChildren}>
|
||||||
|
{renderTree(topic.children!)}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сброс корневого уровня (перетаскивание в корень)
|
||||||
|
const makeRootTopic = (topicId: number) => {
|
||||||
|
updateTopicParent(topicId, [])
|
||||||
|
|
||||||
|
const draggedTopic = findTopicById(topicId)
|
||||||
|
if (!draggedTopic) return
|
||||||
|
|
||||||
|
setChanges(prev => [
|
||||||
|
...prev.filter(c => c.topicId !== topicId),
|
||||||
|
{
|
||||||
|
topicId,
|
||||||
|
newParentIds: [],
|
||||||
|
oldParentIds: draggedTopic.parent_ids || []
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение изменений
|
||||||
|
const handleSave = () => {
|
||||||
|
if (changes().length === 0) {
|
||||||
|
props.onError('Нет изменений для сохранения')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
props.onSave(changes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация при открытии
|
||||||
|
if (props.isOpen && localTopics().length === 0) {
|
||||||
|
initializeTopics()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={props.isOpen}
|
||||||
|
onClose={props.onClose}
|
||||||
|
title="Управление иерархией тем"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<div class={styles.hierarchyContainer}>
|
||||||
|
<div class={styles.instructions}>
|
||||||
|
<h4>Инструкции:</h4>
|
||||||
|
<ul>
|
||||||
|
<li>🔍 Найдите тему по названию или прокрутите список</li>
|
||||||
|
<li># Нажмите на тему, чтобы выбрать её для перемещения (синяя рамка)</li>
|
||||||
|
<li>📂 Нажмите на другую тему, чтобы сделать её родителем (зеленая рамка)</li>
|
||||||
|
<li>🏠 Используйте кнопку "Сделать корневой" для перемещения на верхний уровень</li>
|
||||||
|
<li>▶/▼ Раскрывайте/сворачивайте ветки дерева</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.searchSection}>
|
||||||
|
<label class={styles.label}>Поиск темы:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery()}
|
||||||
|
onInput={(e) => {
|
||||||
|
const query = e.target.value
|
||||||
|
setSearchQuery(query)
|
||||||
|
// Автоматически находим и подсвечиваем тему
|
||||||
|
if (query.trim()) {
|
||||||
|
const foundTopic = findTopicByTitle(query)
|
||||||
|
if (foundTopic) {
|
||||||
|
// Раскрываем путь до найденной темы
|
||||||
|
expandPathToTopic(foundTopic.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Введите название темы для поиска..."
|
||||||
|
class={styles.searchInput}
|
||||||
|
/>
|
||||||
|
<Show when={searchQuery() && findTopicByTitle(searchQuery())}>
|
||||||
|
<div class={styles.searchResult}>
|
||||||
|
✅ Найдена тема: <strong>{findTopicByTitle(searchQuery())?.title}</strong>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={searchQuery() && !findTopicByTitle(searchQuery())}>
|
||||||
|
<div class={styles.searchNoResult}>
|
||||||
|
❌ Тема не найдена
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.hierarchyTree}>
|
||||||
|
{renderTree(localTopics())}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={changes().length > 0}>
|
||||||
|
<div class={styles.changesSummary}>
|
||||||
|
<h4>Планируемые изменения ({changes().length}):</h4>
|
||||||
|
<For each={changes()}>
|
||||||
|
{(change) => {
|
||||||
|
const topic = findTopicById(change.topicId)
|
||||||
|
return (
|
||||||
|
<div class={styles.changeItem}>
|
||||||
|
<strong>{topic?.title}</strong>: {
|
||||||
|
change.newParentIds.length === 0
|
||||||
|
? 'станет корневой темой'
|
||||||
|
: `переместится под тему #${change.newParentIds[change.newParentIds.length - 1]}`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={selectedForMove()}>
|
||||||
|
<div class={styles.actionZone}>
|
||||||
|
<div class={styles.selectedTopicInfo}>
|
||||||
|
<h4>Выбрана для перемещения:</h4>
|
||||||
|
<div class={styles.selectedTopicDisplay}>
|
||||||
|
📦 <strong>{findTopicById(selectedForMove()!)?.title}</strong> #{selectedForMove()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.actionButtons}>
|
||||||
|
<button
|
||||||
|
class={styles.rootButton}
|
||||||
|
onClick={() => moveSelectedTopic(null)}
|
||||||
|
>
|
||||||
|
🏠 Сделать корневой темой
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={styles.cancelButton}
|
||||||
|
onClick={() => setSelectedForMove(null)}
|
||||||
|
>
|
||||||
|
❌ Отменить выбор
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class={styles.modalActions}>
|
||||||
|
<Button variant="secondary" onClick={props.onClose}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={changes().length === 0}
|
||||||
|
>
|
||||||
|
Сохранить изменения ({changes().length})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TopicHierarchyModal
|
327
panel/modals/TopicMergeModal.tsx
Normal file
327
panel/modals/TopicMergeModal.tsx
Normal file
|
@ -0,0 +1,327 @@
|
||||||
|
import { Component, createSignal, For, Show } from 'solid-js'
|
||||||
|
import Button from '../ui/Button'
|
||||||
|
import Modal from '../ui/Modal'
|
||||||
|
import styles from '../styles/Form.module.css'
|
||||||
|
import { MERGE_TOPICS_MUTATION } from '../graphql/mutations'
|
||||||
|
|
||||||
|
// Типы для топиков
|
||||||
|
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 = () => {
|
||||||
|
return document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith('auth_token='))
|
||||||
|
?.split('=')[1] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработчик выбора/снятия выбора исходной темы
|
||||||
|
*/
|
||||||
|
const handleSourceTopicToggle = (topicId: number, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSourceTopicIds(prev => [...prev, topicId])
|
||||||
|
} else {
|
||||||
|
setSourceTopicIds(prev => prev.filter(id => id !== topicId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет можно ли выполнить слияние
|
||||||
|
*/
|
||||||
|
const canMerge = () => {
|
||||||
|
const target = targetTopicId()
|
||||||
|
const sources = sourceTopicIds()
|
||||||
|
|
||||||
|
if (!target || sources.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем что целевая тема не выбрана как исходная
|
||||||
|
if (sources.includes(target)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем что все темы принадлежат одному сообществу
|
||||||
|
const targetTopic = props.topics.find(t => t.id === target)
|
||||||
|
if (!targetTopic) return false
|
||||||
|
|
||||||
|
const targetCommunity = targetTopic.community
|
||||||
|
const sourcesTopics = props.topics.filter(t => sources.includes(t.id))
|
||||||
|
|
||||||
|
return sourcesTopics.every(topic => topic.community === targetCommunity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает название сообщества по 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
|
||||||
|
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
|
||||||
|
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()
|
||||||
|
return props.topics.filter(topic => !sources.includes(topic.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает отфильтрованный список топиков (исключая целевую тему)
|
||||||
|
*/
|
||||||
|
const getAvailableSourceTopics = () => {
|
||||||
|
const target = targetTopicId()
|
||||||
|
return props.topics.filter(topic => topic.id !== target)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={props.isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Слияние тем"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<div class={styles.form}>
|
||||||
|
<div class={styles.section}>
|
||||||
|
<h3 class={styles.sectionTitle}>Выбор целевой темы</h3>
|
||||||
|
<p class={styles.description}>
|
||||||
|
Выберите тему, в которую будут слиты остальные темы. Все подписчики и публикации будут перенесены в эту тему.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={targetTopicId() || ''}
|
||||||
|
onChange={(e) => setTargetTopicId(e.target.value ? parseInt(e.target.value) : null)}
|
||||||
|
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}>
|
||||||
|
Выберите темы, которые будут слиты в целевую тему. Эти темы будут удалены после переноса всех связей.
|
||||||
|
</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 && (
|
||||||
|
<span> • {topic.stat.shouts} публ., {topic.stat.followers} подп.</span>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
<strong>Целевая тема:</strong> {props.topics.find(t => t.id === targetTopicId())?.title}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Исходные темы:</strong> {sourceTopicIds().length} шт.
|
||||||
|
<ul>
|
||||||
|
<For each={sourceTopicIds()}>
|
||||||
|
{(id) => {
|
||||||
|
const topic = props.topics.find(t => t.id === id)
|
||||||
|
return topic ? <li>{topic.title}</li> : null
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Действие:</strong> Все подписчики, публикации и черновики будут перенесены в целевую тему, исходные темы будут удалены
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class={styles.modalActions}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={loading()}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={handleMerge}
|
||||||
|
disabled={!canMerge() || loading()}
|
||||||
|
>
|
||||||
|
{loading() ? 'Выполняется слияние...' : 'Слить темы'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TopicMergeModal
|
215
panel/modals/TopicParentModal.tsx
Normal file
215
panel/modals/TopicParentModal.tsx
Normal file
|
@ -0,0 +1,215 @@
|
||||||
|
import { Component, createSignal, For, Show } from 'solid-js'
|
||||||
|
import Button from '../ui/Button'
|
||||||
|
import Modal from '../ui/Modal'
|
||||||
|
import styles from '../styles/Form.module.css'
|
||||||
|
|
||||||
|
interface Topic {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
parent_ids?: number[]
|
||||||
|
community: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopicParentModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
topic: Topic | null
|
||||||
|
allTopics: Topic[]
|
||||||
|
onSave: (topic: Topic) => void
|
||||||
|
onError: (error: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TopicParentModal: Component<TopicParentModalProps> = (props) => {
|
||||||
|
const [selectedParentId, setSelectedParentId] = createSignal<number | null>(null)
|
||||||
|
const [searchQuery, setSearchQuery] = createSignal('')
|
||||||
|
|
||||||
|
// Получаем текущего родителя при открытии модалки
|
||||||
|
const getCurrentParentId = (): number | null => {
|
||||||
|
const topic = props.topic
|
||||||
|
if (!topic || !topic.parent_ids || topic.parent_ids.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return topic.parent_ids[topic.parent_ids.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтрация доступных родителей
|
||||||
|
const getAvailableParents = () => {
|
||||||
|
const currentTopic = props.topic
|
||||||
|
if (!currentTopic) return []
|
||||||
|
|
||||||
|
return props.allTopics.filter(topic => {
|
||||||
|
// Исключаем сам топик
|
||||||
|
if (topic.id === currentTopic.id) return false
|
||||||
|
|
||||||
|
// Исключаем топики из других сообществ
|
||||||
|
if (topic.community !== currentTopic.community) return false
|
||||||
|
|
||||||
|
// Исключаем дочерние топики (предотвращаем циклы)
|
||||||
|
if (isDescendant(currentTopic.id, topic.id)) return false
|
||||||
|
|
||||||
|
// Фильтр по поисковому запросу
|
||||||
|
const query = searchQuery().toLowerCase()
|
||||||
|
if (query && !topic.title.toLowerCase().includes(query)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка, является ли топик потомком другого
|
||||||
|
const isDescendant = (ancestorId: number, descendantId: number): boolean => {
|
||||||
|
const descendant = props.allTopics.find(t => t.id === descendantId)
|
||||||
|
if (!descendant || !descendant.parent_ids) return false
|
||||||
|
|
||||||
|
return descendant.parent_ids.includes(ancestorId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение пути к корню для отображения полного пути
|
||||||
|
const getTopicPath = (topicId: number): string => {
|
||||||
|
const topic = props.allTopics.find(t => t.id === topicId)
|
||||||
|
if (!topic) return ''
|
||||||
|
|
||||||
|
if (!topic.parent_ids || topic.parent_ids.length === 0) {
|
||||||
|
return topic.title
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = getTopicPath(topic.parent_ids[topic.parent_ids.length - 1])
|
||||||
|
return `${parentPath} → ${topic.title}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение изменений
|
||||||
|
const handleSave = () => {
|
||||||
|
const currentTopic = props.topic
|
||||||
|
if (!currentTopic) return
|
||||||
|
|
||||||
|
const newParentId = selectedParentId()
|
||||||
|
let newParentIds: number[] = []
|
||||||
|
|
||||||
|
if (newParentId) {
|
||||||
|
const parentTopic = props.allTopics.find(t => t.id === newParentId)
|
||||||
|
if (parentTopic) {
|
||||||
|
// Строим полный путь от корня до нового родителя
|
||||||
|
newParentIds = [...(parentTopic.parent_ids || []), newParentId]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedTopic: Topic = {
|
||||||
|
...currentTopic,
|
||||||
|
parent_ids: newParentIds
|
||||||
|
}
|
||||||
|
|
||||||
|
props.onSave(updatedTopic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация при открытии
|
||||||
|
if (props.isOpen && props.topic) {
|
||||||
|
setSelectedParentId(getCurrentParentId())
|
||||||
|
setSearchQuery('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={props.isOpen}
|
||||||
|
onClose={props.onClose}
|
||||||
|
title={`Выбор родительской темы для "${props.topic?.title}"`}
|
||||||
|
>
|
||||||
|
<div class={styles.parentSelectorContainer}>
|
||||||
|
<div class={styles.searchSection}>
|
||||||
|
<label class={styles.label}>Поиск родительской темы:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery()}
|
||||||
|
onInput={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Введите название темы..."
|
||||||
|
class={styles.searchInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.currentSelection}>
|
||||||
|
<label class={styles.label}>Текущий родитель:</label>
|
||||||
|
<div class={styles.currentParent}>
|
||||||
|
<Show
|
||||||
|
when={getCurrentParentId()}
|
||||||
|
fallback={<span class={styles.noParent}>Корневая тема</span>}
|
||||||
|
>
|
||||||
|
<span class={styles.parentPath}>
|
||||||
|
{getCurrentParentId() ? getTopicPath(getCurrentParentId()!) : ''}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.parentOptions}>
|
||||||
|
<label class={styles.label}>Выберите нового родителя:</label>
|
||||||
|
|
||||||
|
{/* Опция "Сделать корневой" */}
|
||||||
|
<div class={styles.parentOption}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="root-option"
|
||||||
|
name="parent"
|
||||||
|
checked={selectedParentId() === null}
|
||||||
|
onChange={() => setSelectedParentId(null)}
|
||||||
|
/>
|
||||||
|
<label for="root-option" class={styles.parentOptionLabel}>
|
||||||
|
<strong>🏠 Корневая тема</strong>
|
||||||
|
<div class={styles.parentDescription}>
|
||||||
|
Переместить на верхний уровень иерархии
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Доступные родители */}
|
||||||
|
<div class={styles.parentsList}>
|
||||||
|
<For each={getAvailableParents()}>
|
||||||
|
{(topic) => (
|
||||||
|
<div class={styles.parentOption}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id={`parent-${topic.id}`}
|
||||||
|
name="parent"
|
||||||
|
checked={selectedParentId() === topic.id}
|
||||||
|
onChange={() => setSelectedParentId(topic.id)}
|
||||||
|
/>
|
||||||
|
<label for={`parent-${topic.id}`} class={styles.parentOptionLabel}>
|
||||||
|
<strong>{topic.title}</strong>
|
||||||
|
<div class={styles.parentDescription}>
|
||||||
|
<span class={styles.topicId}>#{topic.id}</span>
|
||||||
|
<span class={styles.topicSlug}>{topic.slug}</span>
|
||||||
|
</div>
|
||||||
|
<Show when={topic.parent_ids && topic.parent_ids.length > 0}>
|
||||||
|
<div class={styles.parentPath}>
|
||||||
|
Путь: {getTopicPath(topic.id)}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={getAvailableParents().length === 0 && searchQuery()}>
|
||||||
|
<div class={styles.noResults}>
|
||||||
|
Нет тем, соответствующих поисковому запросу "{searchQuery()}"
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.modalActions}>
|
||||||
|
<Button variant="secondary" onClick={props.onClose}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={selectedParentId() === getCurrentParentId()}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TopicParentModal
|
305
panel/modals/TopicSimpleParentModal.tsx
Normal file
305
panel/modals/TopicSimpleParentModal.tsx
Normal file
|
@ -0,0 +1,305 @@
|
||||||
|
import { Component, createSignal, For, Show } from 'solid-js'
|
||||||
|
import Button from '../ui/Button'
|
||||||
|
import Modal from '../ui/Modal'
|
||||||
|
import styles from '../styles/Form.module.css'
|
||||||
|
import { SET_TOPIC_PARENT_MUTATION } from '../graphql/mutations'
|
||||||
|
|
||||||
|
// Типы для топиков
|
||||||
|
interface Topic {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
parent_ids?: number[]
|
||||||
|
community: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopicSimpleParentModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
topic: Topic | null
|
||||||
|
allTopics: Topic[]
|
||||||
|
onSuccess: (message: string) => void
|
||||||
|
onError: (error: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TopicSimpleParentModal: Component<TopicSimpleParentModalProps> = (props) => {
|
||||||
|
const [selectedParentId, setSelectedParentId] = createSignal<number | null>(null)
|
||||||
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
const [searchQuery, setSearchQuery] = createSignal('')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает токен авторизации
|
||||||
|
*/
|
||||||
|
const getAuthTokenFromCookie = () => {
|
||||||
|
return document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith('auth_token='))
|
||||||
|
?.split('=')[1] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает текущего родителя темы
|
||||||
|
*/
|
||||||
|
const getCurrentParentId = (): number | null => {
|
||||||
|
if (!props.topic?.parent_ids || props.topic.parent_ids.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return props.topic.parent_ids[props.topic.parent_ids.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает путь темы до корня
|
||||||
|
*/
|
||||||
|
const getTopicPath = (topicId: number): string => {
|
||||||
|
const topic = props.allTopics.find(t => t.id === topicId)
|
||||||
|
if (!topic) return 'Неизвестная тема'
|
||||||
|
|
||||||
|
if (!topic.parent_ids || topic.parent_ids.length === 0) {
|
||||||
|
return topic.title
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = getTopicPath(topic.parent_ids[topic.parent_ids.length - 1])
|
||||||
|
return `${parentPath} → ${topic.title}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет циклические зависимости
|
||||||
|
*/
|
||||||
|
const isDescendant = (parentId: number, childId: number): boolean => {
|
||||||
|
if (parentId === childId) return true
|
||||||
|
|
||||||
|
const checkDescendants = (currentId: number): boolean => {
|
||||||
|
const descendants = props.allTopics.filter(t =>
|
||||||
|
t.parent_ids && t.parent_ids.includes(currentId)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const descendant of descendants) {
|
||||||
|
if (descendant.id === childId || checkDescendants(descendant.id)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkDescendants(parentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает доступных родителей (исключая потомков и темы из других сообществ)
|
||||||
|
*/
|
||||||
|
const getAvailableParents = () => {
|
||||||
|
if (!props.topic) return []
|
||||||
|
|
||||||
|
const query = searchQuery().toLowerCase()
|
||||||
|
|
||||||
|
return props.allTopics.filter(topic => {
|
||||||
|
// Исключаем саму тему
|
||||||
|
if (topic.id === props.topic!.id) return false
|
||||||
|
|
||||||
|
// Только темы из того же сообщества
|
||||||
|
if (topic.community !== props.topic!.community) return false
|
||||||
|
|
||||||
|
// Исключаем потомков (предотвращаем циклы)
|
||||||
|
if (isDescendant(topic.id, props.topic!.id)) return false
|
||||||
|
|
||||||
|
// Фильтр по поиску
|
||||||
|
if (query && !topic.title.toLowerCase().includes(query)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполняет назначение родителя
|
||||||
|
*/
|
||||||
|
const handleSetParent = async () => {
|
||||||
|
if (!props.topic) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
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: SET_TOPIC_PARENT_MUTATION,
|
||||||
|
variables: {
|
||||||
|
topic_id: props.topic.id,
|
||||||
|
parent_id: selectedParentId()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.errors) {
|
||||||
|
throw new Error(result.errors[0].message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setResult = result.data.set_topic_parent
|
||||||
|
|
||||||
|
if (setResult.error) {
|
||||||
|
throw new Error(setResult.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
props.onSuccess(setResult.message)
|
||||||
|
handleClose()
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = (error as Error).message
|
||||||
|
props.onError(`Ошибка назначения родителя: ${errorMessage}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Закрывает модалку и сбрасывает состояние
|
||||||
|
*/
|
||||||
|
const handleClose = () => {
|
||||||
|
setSelectedParentId(null)
|
||||||
|
setSearchQuery('')
|
||||||
|
setLoading(false)
|
||||||
|
props.onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={props.isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Назначить родительскую тему"
|
||||||
|
size="medium"
|
||||||
|
>
|
||||||
|
<div class={styles.parentSelectorContainer}>
|
||||||
|
<Show when={props.topic}>
|
||||||
|
<div class={styles.currentSelection}>
|
||||||
|
<h4>Редактируемая тема:</h4>
|
||||||
|
<div class={styles.topicDisplay}>
|
||||||
|
<strong>{props.topic?.title}</strong> #{props.topic?.id}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.currentParent}>
|
||||||
|
<strong>Текущее расположение:</strong>
|
||||||
|
<div class={styles.parentPath}>
|
||||||
|
{getCurrentParentId() ?
|
||||||
|
getTopicPath(props.topic!.id) :
|
||||||
|
<span class={styles.noParent}>🏠 Корневая тема</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.searchSection}>
|
||||||
|
<label class={styles.label}>Поиск новой родительской темы:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery()}
|
||||||
|
onInput={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Введите название темы..."
|
||||||
|
class={styles.searchInput}
|
||||||
|
disabled={loading()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.parentOptions}>
|
||||||
|
<h4>Выберите новую родительскую тему:</h4>
|
||||||
|
|
||||||
|
{/* Опция корневой темы */}
|
||||||
|
<div class={styles.parentsList}>
|
||||||
|
<label class={styles.parentOption}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="parentSelection"
|
||||||
|
checked={selectedParentId() === null}
|
||||||
|
onChange={() => setSelectedParentId(null)}
|
||||||
|
disabled={loading()}
|
||||||
|
/>
|
||||||
|
<div class={styles.parentOptionLabel}>
|
||||||
|
<div class={styles.topicTitle}>
|
||||||
|
🏠 Сделать корневой темой
|
||||||
|
</div>
|
||||||
|
<div class={styles.parentDescription}>
|
||||||
|
Тема будет перемещена на верхний уровень иерархии
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Список доступных родителей */}
|
||||||
|
<div class={styles.parentsList}>
|
||||||
|
<Show when={getAvailableParents().length > 0}>
|
||||||
|
<For each={getAvailableParents()}>
|
||||||
|
{(topic) => (
|
||||||
|
<label class={styles.parentOption}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="parentSelection"
|
||||||
|
checked={selectedParentId() === topic.id}
|
||||||
|
onChange={() => setSelectedParentId(topic.id)}
|
||||||
|
disabled={loading()}
|
||||||
|
/>
|
||||||
|
<div class={styles.parentOptionLabel}>
|
||||||
|
<div class={styles.topicTitle}>
|
||||||
|
{topic.title}
|
||||||
|
</div>
|
||||||
|
<div class={styles.parentDescription}>
|
||||||
|
<span class={styles.topicId}>ID: {topic.id}</span>
|
||||||
|
<span class={styles.topicSlug}>• {topic.slug}</span>
|
||||||
|
<br />
|
||||||
|
<strong>Путь:</strong> {getTopicPath(topic.id)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={getAvailableParents().length === 0}>
|
||||||
|
<div class={styles.noResults}>
|
||||||
|
{searchQuery() ?
|
||||||
|
'Не найдено подходящих тем по запросу' :
|
||||||
|
'Нет доступных родительских тем'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={selectedParentId() !== null}>
|
||||||
|
<div class={styles.preview}>
|
||||||
|
<h4>Предварительный просмотр:</h4>
|
||||||
|
<div class={styles.previewPath}>
|
||||||
|
<strong>Новое расположение:</strong><br />
|
||||||
|
{getTopicPath(selectedParentId()!)} → <strong>{props.topic?.title}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class={styles.modalActions}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={loading()}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSetParent}
|
||||||
|
disabled={loading() || (selectedParentId() === getCurrentParentId())}
|
||||||
|
>
|
||||||
|
{loading() ? 'Назначение...' : 'Назначить родителя'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TopicSimpleParentModal
|
|
@ -43,6 +43,15 @@ interface InvitesRouteProps {
|
||||||
onSuccess: (message: string) => void
|
onSuccess: (message: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Добавляю типы для сортировки
|
||||||
|
type SortField = 'inviter_name' | 'author_name' | 'shout_title' | 'status' | 'created_at'
|
||||||
|
type SortDirection = 'asc' | 'desc'
|
||||||
|
|
||||||
|
interface SortState {
|
||||||
|
field: SortField | null
|
||||||
|
direction: SortDirection
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Компонент для управления приглашениями
|
* Компонент для управления приглашениями
|
||||||
*/
|
*/
|
||||||
|
@ -73,6 +82,9 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
|
||||||
show: false
|
show: false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Добавляю состояние сортировки
|
||||||
|
const [sortState, setSortState] = createSignal<SortState>({ field: null, direction: 'asc' })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Загружает список приглашений с учетом фильтров и пагинации
|
* Загружает список приглашений с учетом фильтров и пагинации
|
||||||
*/
|
*/
|
||||||
|
@ -307,6 +319,34 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
|
||||||
return Object.values(selectedInvites()).filter(Boolean).length
|
return Object.values(selectedInvites()).filter(Boolean).length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработчик клика по заголовку колонки для сортировки
|
||||||
|
*/
|
||||||
|
const handleSort = (field: SortField) => {
|
||||||
|
const current = sortState()
|
||||||
|
let newDirection: SortDirection = 'asc'
|
||||||
|
|
||||||
|
if (current.field === field) {
|
||||||
|
// Если кликнули по той же колонке, меняем направление
|
||||||
|
newDirection = current.direction === 'asc' ? 'desc' : 'asc'
|
||||||
|
}
|
||||||
|
|
||||||
|
setSortState({ field, direction: newDirection })
|
||||||
|
// Здесь можно добавить логику сортировки на сервере или клиенте
|
||||||
|
console.log(`Сортировка по ${field} в направлении ${newDirection}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает иконку сортировки для колонки
|
||||||
|
*/
|
||||||
|
const getSortIcon = (field: SortField) => {
|
||||||
|
const current = sortState()
|
||||||
|
if (current.field !== field) {
|
||||||
|
return '↕️' // Неактивная сортировка
|
||||||
|
}
|
||||||
|
return current.direction === 'asc' ? '↑' : '↓'
|
||||||
|
}
|
||||||
|
|
||||||
// Загружаем приглашения при монтировании компонента
|
// Загружаем приглашения при монтировании компонента
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void loadInvites()
|
void loadInvites()
|
||||||
|
@ -314,20 +354,20 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={styles.container}>
|
<div class={styles.container}>
|
||||||
<div class={styles.header}>
|
{/* Новая компактная панель поиска и фильтров */}
|
||||||
<div class={styles.controls}>
|
<div class={styles.searchSection}>
|
||||||
|
<div class={styles.searchRow}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Поиск приглашений..."
|
placeholder="Поиск по приглашающему, приглашаемому, публикации..."
|
||||||
value={search()}
|
value={search()}
|
||||||
onInput={(e) => setSearch(e.target.value)}
|
onInput={(e) => setSearch(e.target.value)}
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
class={styles.searchInput}
|
class={styles.fullWidthSearch}
|
||||||
/>
|
/>
|
||||||
<Button onClick={handleSearch} disabled={loading()}>
|
</div>
|
||||||
🔍
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
|
<div class={styles.filtersRow}>
|
||||||
<select
|
<select
|
||||||
value={statusFilter()}
|
value={statusFilter()}
|
||||||
onChange={(e) => handleStatusFilterChange(e.target.value)}
|
onChange={(e) => handleStatusFilterChange(e.target.value)}
|
||||||
|
@ -339,8 +379,12 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
|
||||||
<option value="rejected">Отклонено</option>
|
<option value="rejected">Отклонено</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<Button onClick={handleSearch} disabled={loading()}>
|
||||||
|
🔍 Поиск
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button onClick={() => loadInvites(pagination().page)} disabled={loading()}>
|
<Button onClick={() => loadInvites(pagination().page)} disabled={loading()}>
|
||||||
{loading() ? 'Загрузка...' : 'Обновить'}
|
{loading() ? 'Загрузка...' : '🔄 Обновить'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -391,10 +435,30 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class={styles['checkbox-column']}></th>
|
<th class={styles['checkbox-column']}></th>
|
||||||
<th>Приглашающий</th>
|
<th class={styles.sortableHeader} onClick={() => handleSort('inviter_name')}>
|
||||||
<th>Приглашаемый</th>
|
<span class={styles.headerContent}>
|
||||||
<th>Публикация</th>
|
Приглашающий
|
||||||
<th>Статус</th>
|
<span class={styles.sortIcon}>{getSortIcon('inviter_name')}</span>
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th class={styles.sortableHeader} onClick={() => handleSort('author_name')}>
|
||||||
|
<span class={styles.headerContent}>
|
||||||
|
Приглашаемый
|
||||||
|
<span class={styles.sortIcon}>{getSortIcon('author_name')}</span>
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th class={styles.sortableHeader} onClick={() => handleSort('shout_title')}>
|
||||||
|
<span class={styles.headerContent}>
|
||||||
|
Публикация
|
||||||
|
<span class={styles.sortIcon}>{getSortIcon('shout_title')}</span>
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th class={styles.sortableHeader} onClick={() => handleSort('status')}>
|
||||||
|
<span class={styles.headerContent}>
|
||||||
|
Статус
|
||||||
|
<span class={styles.sortIcon}>{getSortIcon('status')}</span>
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
<th>Действия</th>
|
<th>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
|
@ -9,6 +9,8 @@ import type { Query } from '../graphql/generated/schema'
|
||||||
import { CREATE_TOPIC_MUTATION, DELETE_TOPIC_MUTATION, UPDATE_TOPIC_MUTATION } from '../graphql/mutations'
|
import { CREATE_TOPIC_MUTATION, DELETE_TOPIC_MUTATION, UPDATE_TOPIC_MUTATION } from '../graphql/mutations'
|
||||||
import { GET_TOPICS_QUERY } from '../graphql/queries'
|
import { GET_TOPICS_QUERY } from '../graphql/queries'
|
||||||
import TopicEditModal from '../modals/TopicEditModal'
|
import TopicEditModal from '../modals/TopicEditModal'
|
||||||
|
import TopicMergeModal from '../modals/TopicMergeModal'
|
||||||
|
import TopicSimpleParentModal from '../modals/TopicSimpleParentModal'
|
||||||
import styles from '../styles/Table.module.css'
|
import styles from '../styles/Table.module.css'
|
||||||
import Button from '../ui/Button'
|
import Button from '../ui/Button'
|
||||||
import Modal from '../ui/Modal'
|
import Modal from '../ui/Modal'
|
||||||
|
@ -56,6 +58,15 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||||
const [createModal, setCreateModal] = createSignal<{ show: boolean }>({
|
const [createModal, setCreateModal] = createSignal<{ show: boolean }>({
|
||||||
show: false
|
show: false
|
||||||
})
|
})
|
||||||
|
const [selectedTopics, setSelectedTopics] = createSignal<number[]>([])
|
||||||
|
const [groupAction, setGroupAction] = createSignal<'delete' | 'merge' | ''>('')
|
||||||
|
const [mergeModal, setMergeModal] = createSignal<{ show: boolean }>({
|
||||||
|
show: false
|
||||||
|
})
|
||||||
|
const [simpleParentModal, setSimpleParentModal] = createSignal<{ show: boolean; topic: Topic | null }>({
|
||||||
|
show: false,
|
||||||
|
topic: null
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Загружает список всех топиков
|
* Загружает список всех топиков
|
||||||
|
@ -186,19 +197,22 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||||
const result: JSX.Element[] = []
|
const result: JSX.Element[] = []
|
||||||
|
|
||||||
topics.forEach((topic) => {
|
topics.forEach((topic) => {
|
||||||
|
const isSelected = selectedTopics().includes(topic.id)
|
||||||
|
|
||||||
result.push(
|
result.push(
|
||||||
<tr
|
<tr class={styles['clickable-row']}>
|
||||||
onClick={() => setEditModal({ show: true, topic })}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
class={styles['clickable-row']}
|
|
||||||
>
|
|
||||||
<td>{topic.id}</td>
|
<td>{topic.id}</td>
|
||||||
<td style={{ 'padding-left': `${(topic.level || 0) * 20}px` }}>
|
<td
|
||||||
|
style={{ 'padding-left': `${(topic.level || 0) * 20}px`, cursor: 'pointer' }}
|
||||||
|
onClick={() => setEditModal({ show: true, topic })}
|
||||||
|
>
|
||||||
{topic.level! > 0 && '└─ '}
|
{topic.level! > 0 && '└─ '}
|
||||||
{topic.title}
|
{topic.title}
|
||||||
</td>
|
</td>
|
||||||
<td>{topic.slug}</td>
|
<td onClick={() => setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }}>
|
||||||
<td>
|
{topic.slug}
|
||||||
|
</td>
|
||||||
|
<td onClick={() => setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
'max-width': '200px',
|
'max-width': '200px',
|
||||||
|
@ -211,20 +225,22 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||||
{truncateText(topic.body?.replace(/<[^>]*>/g, '') || '', 100)}
|
{truncateText(topic.body?.replace(/<[^>]*>/g, '') || '', 100)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{topic.community}</td>
|
<td onClick={() => setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }}>
|
||||||
<td>{topic.parent_ids?.join(', ') || '—'}</td>
|
{topic.community}
|
||||||
|
</td>
|
||||||
|
<td onClick={() => setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }}>
|
||||||
|
{topic.parent_ids?.join(', ') || '—'}
|
||||||
|
</td>
|
||||||
<td onClick={(e) => e.stopPropagation()}>
|
<td onClick={(e) => e.stopPropagation()}>
|
||||||
<button
|
<input
|
||||||
onClick={(e) => {
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setDeleteModal({ show: true, topic })
|
handleTopicSelect(topic.id, e.target.checked)
|
||||||
}}
|
}}
|
||||||
class={styles['delete-button']}
|
style={{ cursor: 'pointer' }}
|
||||||
title="Удалить топик"
|
/>
|
||||||
aria-label="Удалить топик"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
|
@ -305,6 +321,90 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработчик выбора/снятия выбора топика
|
||||||
|
*/
|
||||||
|
const handleTopicSelect = (topicId: number, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedTopics(prev => [...prev, topicId])
|
||||||
|
} else {
|
||||||
|
setSelectedTopics(prev => prev.filter(id => id !== topicId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработчик выбора/снятия выбора всех топиков
|
||||||
|
*/
|
||||||
|
const handleSelectAll = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
const allTopicIds = rawTopics().map(topic => topic.id)
|
||||||
|
setSelectedTopics(allTopicIds)
|
||||||
|
} else {
|
||||||
|
setSelectedTopics([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет выбраны ли все топики
|
||||||
|
*/
|
||||||
|
const isAllSelected = () => {
|
||||||
|
const allIds = rawTopics().map(topic => topic.id)
|
||||||
|
const selected = selectedTopics()
|
||||||
|
return allIds.length > 0 && allIds.every(id => selected.includes(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет выбран ли хотя бы один топик
|
||||||
|
*/
|
||||||
|
const hasSelectedTopics = () => selectedTopics().length > 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполняет групповое действие
|
||||||
|
*/
|
||||||
|
const executeGroupAction = () => {
|
||||||
|
const action = groupAction()
|
||||||
|
const selected = selectedTopics()
|
||||||
|
|
||||||
|
if (!action || selected.length === 0) {
|
||||||
|
props.onError('Выберите действие и топики')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'delete') {
|
||||||
|
// Групповое удаление
|
||||||
|
const selectedTopicsData = rawTopics().filter(t => selected.includes(t.id))
|
||||||
|
setDeleteModal({ show: true, topic: selectedTopicsData[0] }) // Используем первый для отображения
|
||||||
|
} else if (action === 'merge') {
|
||||||
|
// Слияние топиков
|
||||||
|
if (selected.length < 2) {
|
||||||
|
props.onError('Для слияния нужно выбрать минимум 2 темы')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setMergeModal({ show: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Групповое удаление выбранных топиков
|
||||||
|
*/
|
||||||
|
const deleteSelectedTopics = async () => {
|
||||||
|
const selected = selectedTopics()
|
||||||
|
if (selected.length === 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Удаляем по одному (можно оптимизировать пакетным удалением)
|
||||||
|
for (const topicId of selected) {
|
||||||
|
await deleteTopic(topicId)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedTopics([])
|
||||||
|
setGroupAction('')
|
||||||
|
props.onSuccess(`Успешно удалено ${selected.length} тем`)
|
||||||
|
} catch (error) {
|
||||||
|
props.onError(`Ошибка группового удаления: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Удаляет топик
|
* Удаляет топик
|
||||||
*/
|
*/
|
||||||
|
@ -378,6 +478,21 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||||
<Button variant="primary" onClick={() => setCreateModal({ show: true })}>
|
<Button variant="primary" onClick={() => setCreateModal({ show: true })}>
|
||||||
Создать тему
|
Создать тему
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedTopics().length === 1) {
|
||||||
|
const selectedTopic = rawTopics().find(t => t.id === selectedTopics()[0])
|
||||||
|
if (selectedTopic) {
|
||||||
|
setSimpleParentModal({ show: true, topic: selectedTopic })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
props.onError('Выберите одну тему для назначения родителя')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🏠 Назначить родителя
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -399,7 +514,53 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||||
<th>Описание</th>
|
<th>Описание</th>
|
||||||
<th>Сообщество</th>
|
<th>Сообщество</th>
|
||||||
<th>Родители</th>
|
<th>Родители</th>
|
||||||
<th>Действия</th>
|
<th>
|
||||||
|
<div style={{ display: 'flex', 'align-items': 'center', gap: '8px', 'flex-direction': 'column' }}>
|
||||||
|
<div style={{ display: 'flex', 'align-items': 'center', gap: '4px' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isAllSelected()}
|
||||||
|
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
title="Выбрать все"
|
||||||
|
/>
|
||||||
|
<span style={{ 'font-size': '12px' }}>Все</span>
|
||||||
|
</div>
|
||||||
|
<Show when={hasSelectedTopics()}>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', 'align-items': 'center' }}>
|
||||||
|
<select
|
||||||
|
value={groupAction()}
|
||||||
|
onChange={(e) => setGroupAction(e.target.value as 'delete' | 'merge' | '')}
|
||||||
|
style={{
|
||||||
|
padding: '2px 4px',
|
||||||
|
'font-size': '11px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
'border-radius': '3px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Действие</option>
|
||||||
|
<option value="delete">Удалить</option>
|
||||||
|
<option value="merge">Слить</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={executeGroupAction}
|
||||||
|
disabled={!groupAction()}
|
||||||
|
style={{
|
||||||
|
padding: '2px 6px',
|
||||||
|
'font-size': '11px',
|
||||||
|
background: groupAction() ? '#007bff' : '#ccc',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
'border-radius': '3px',
|
||||||
|
cursor: groupAction() ? 'pointer' : 'not-allowed'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
@ -431,6 +592,23 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||||
title="Подтверждение удаления"
|
title="Подтверждение удаления"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
|
<Show when={selectedTopics().length > 1}>
|
||||||
|
<p>
|
||||||
|
Вы уверены, что хотите удалить <strong>{selectedTopics().length}</strong> выбранных тем?
|
||||||
|
</p>
|
||||||
|
<p class={styles['warning-text']}>
|
||||||
|
Это действие нельзя отменить. Все дочерние топики также будут удалены.
|
||||||
|
</p>
|
||||||
|
<div class={styles['modal-actions']}>
|
||||||
|
<Button variant="secondary" onClick={() => setDeleteModal({ show: false, topic: null })}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={deleteSelectedTopics}>
|
||||||
|
Удалить {selectedTopics().length} тем
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={selectedTopics().length <= 1}>
|
||||||
<p>
|
<p>
|
||||||
Вы уверены, что хотите удалить топик "<strong>{deleteModal().topic?.title}</strong>"?
|
Вы уверены, что хотите удалить топик "<strong>{deleteModal().topic?.title}</strong>"?
|
||||||
</p>
|
</p>
|
||||||
|
@ -443,13 +621,50 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => deleteModal().topic && deleteTopic(deleteModal().topic!.id)}
|
onClick={() => {
|
||||||
|
if (deleteModal().topic) {
|
||||||
|
void deleteTopic(deleteModal().topic!.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Удалить
|
Удалить
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Модальное окно слияния тем */}
|
||||||
|
<TopicMergeModal
|
||||||
|
isOpen={mergeModal().show}
|
||||||
|
onClose={() => {
|
||||||
|
setMergeModal({ show: false })
|
||||||
|
setSelectedTopics([])
|
||||||
|
setGroupAction('')
|
||||||
|
}}
|
||||||
|
topics={rawTopics().filter(topic => selectedTopics().includes(topic.id))}
|
||||||
|
onSuccess={(message) => {
|
||||||
|
props.onSuccess(message)
|
||||||
|
setSelectedTopics([])
|
||||||
|
setGroupAction('')
|
||||||
|
void loadTopics()
|
||||||
|
}}
|
||||||
|
onError={props.onError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Модальное окно назначения родителя */}
|
||||||
|
<TopicSimpleParentModal
|
||||||
|
isOpen={simpleParentModal().show}
|
||||||
|
onClose={() => setSimpleParentModal({ show: false, topic: null })}
|
||||||
|
topic={simpleParentModal().topic}
|
||||||
|
allTopics={rawTopics()}
|
||||||
|
onSuccess={(message) => {
|
||||||
|
props.onSuccess(message)
|
||||||
|
setSimpleParentModal({ show: false, topic: null })
|
||||||
|
void loadTopics() // Перезагружаем данные
|
||||||
|
}}
|
||||||
|
onError={props.onError}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -439,3 +439,736 @@
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Стили для модального окна слияния топиков */
|
||||||
|
.checkboxList {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxItem:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxItem:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxContent {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicTitle {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicInfo {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionTitle {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionDescription {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary h4 {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary ul ul {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary ul ul li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для управления иерархией топиков */
|
||||||
|
.hierarchyContainer {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions h4 {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hierarchyTree {
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: white;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeNode {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeItem {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: grab;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeItem:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeItem:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropTarget {
|
||||||
|
background-color: #e3f2fd !important;
|
||||||
|
border: 2px dashed #2196f3 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragHandle {
|
||||||
|
color: #999;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: grab;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragHandle:hover {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicTitle {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicId {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeChildren {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changesSummary {
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changesSummary h4 {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
color: #856404;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changeItem {
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid #ffeaa7;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changeItem:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropZone {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rootDropZone {
|
||||||
|
border: 2px dashed #6c757d;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #6c757d;
|
||||||
|
background: #f8f9fa;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rootDropZone:hover {
|
||||||
|
border-color: #007bff;
|
||||||
|
color: #007bff;
|
||||||
|
background: #e3f2fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Дополнительные стили для режима большой модалки */
|
||||||
|
.modal.large {
|
||||||
|
max-width: 900px;
|
||||||
|
width: 90vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.large .modalContent {
|
||||||
|
min-height: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивность для мобильных */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hierarchyContainer {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeItem {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragHandle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rootDropZone {
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для выбора родительской темы */
|
||||||
|
.parentSelectorContainer {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchSection {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currentSelection {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currentParent {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noParent {
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentPath {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentOptions {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentsList {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentOption {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #f1f3f4;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentOption:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentOption:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentOption input[type="radio"] {
|
||||||
|
margin-right: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentOptionLabel {
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentDescription {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicId {
|
||||||
|
background: #e9ecef;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicSlug {
|
||||||
|
font-family: monospace;
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noResults {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Мобильная адаптивность */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.parentSelectorContainer {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentOption {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentDescription {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для массового редактирования родителей */
|
||||||
|
.bulkParentContainer {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorMessage {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedTopicsPreview {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedTopicsPreview h4 {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicsList {
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicPreviewItem {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicPreviewItem:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currentPath {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-top: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionSelection {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionSelection h4 {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionOptions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionOption {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: white;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionOption:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionOption input[type="radio"] {
|
||||||
|
margin-right: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionLabel {
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionDescription {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentSelection {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentSelection h4 {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewSection {
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewSection h4 {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
color: #856404;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewChanges {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewItem {
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #ffeaa7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewItem:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewChange {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beforeState {
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
color: #007bff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.afterState {
|
||||||
|
color: #28a745;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивность для мобильных */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.bulkParentContainer {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionOptions {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionOption {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicPreviewItem {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewChange {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Новые стили для кликабельного интерфейса */
|
||||||
|
.selectedForMove {
|
||||||
|
border-color: #007bff !important;
|
||||||
|
background-color: #e3f2fd !important;
|
||||||
|
box-shadow: 0 0 8px rgba(0, 123, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moveTarget {
|
||||||
|
border-color: #28a745 !important;
|
||||||
|
background-color: #d4edda !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedIcon, .targetIcon, .clickIcon {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedIcon {
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.targetIcon {
|
||||||
|
animation: bounce 0.8s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickIcon {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-2px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchSection {
|
||||||
|
margin: 20px 0;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchResult {
|
||||||
|
color: #28a745;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchNoResult {
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionZone {
|
||||||
|
margin: 20px 0;
|
||||||
|
padding: 20px;
|
||||||
|
background: #e3f2fd;
|
||||||
|
border: 2px solid #007bff;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedTopicInfo h4 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #007bff;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedTopicDisplay {
|
||||||
|
background: white;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rootButton {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rootButton:hover {
|
||||||
|
background: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelButton {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelButton:hover {
|
||||||
|
background: #5a6268;
|
||||||
|
}
|
||||||
|
|
|
@ -269,3 +269,152 @@
|
||||||
background-color: #e9a8ae;
|
background-color: #e9a8ae;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Новые стили для улучшенной панели поиска */
|
||||||
|
.searchSection {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchRow {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullWidthSearch {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: white;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullWidthSearch:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4f46e5;
|
||||||
|
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullWidthSearch::placeholder {
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filtersRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusFilter {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: white;
|
||||||
|
color: #495057;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusFilter:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для сортируемых заголовков */
|
||||||
|
.sortableHeader {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortableHeader:hover {
|
||||||
|
background-color: #e9ecef !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerContent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortIcon {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-left: auto;
|
||||||
|
min-width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortableHeader:hover .sortIcon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortableHeader[data-active="true"] .sortIcon {
|
||||||
|
color: #4f46e5;
|
||||||
|
opacity: 1;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Улучшенные адаптивные стили */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.filtersRow {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusFilter {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerContent {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortIcon {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.searchSection {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullWidthSearch {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filtersRow {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Улучшения существующих стилей */
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
min-width: 200px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
|
@ -76,6 +76,8 @@ from resolvers.topic import (
|
||||||
get_topics_all,
|
get_topics_all,
|
||||||
get_topics_by_author,
|
get_topics_by_author,
|
||||||
get_topics_by_community,
|
get_topics_by_community,
|
||||||
|
merge_topics,
|
||||||
|
set_topic_parent,
|
||||||
)
|
)
|
||||||
|
|
||||||
events_register()
|
events_register()
|
||||||
|
@ -145,6 +147,7 @@ __all__ = [
|
||||||
"load_shouts_unrated",
|
"load_shouts_unrated",
|
||||||
"load_shouts_with_topic",
|
"load_shouts_with_topic",
|
||||||
"login",
|
"login",
|
||||||
|
"merge_topics",
|
||||||
"notification_mark_seen",
|
"notification_mark_seen",
|
||||||
"notifications_seen_after",
|
"notifications_seen_after",
|
||||||
"notifications_seen_thread",
|
"notifications_seen_thread",
|
||||||
|
@ -153,6 +156,7 @@ __all__ = [
|
||||||
"rate_author",
|
"rate_author",
|
||||||
"register_by_email",
|
"register_by_email",
|
||||||
"send_link",
|
"send_link",
|
||||||
|
"set_topic_parent",
|
||||||
"unfollow",
|
"unfollow",
|
||||||
"unpublish_draft",
|
"unpublish_draft",
|
||||||
"unpublish_shout",
|
"unpublish_shout",
|
||||||
|
|
|
@ -499,3 +499,299 @@ async def delete_topic_by_id(_: None, info: GraphQLResolveInfo, topic_id: int) -
|
||||||
session.rollback()
|
session.rollback()
|
||||||
logger.error(f"Ошибка при удалении топика {topic_id}: {e}")
|
logger.error(f"Ошибка при удалении топика {topic_id}: {e}")
|
||||||
return {"success": False, "message": f"Ошибка при удалении: {e!s}"}
|
return {"success": False, "message": f"Ошибка при удалении: {e!s}"}
|
||||||
|
|
||||||
|
|
||||||
|
# Мутация для слияния тем
|
||||||
|
@mutation.field("merge_topics")
|
||||||
|
@login_required
|
||||||
|
async def merge_topics(_: None, info: GraphQLResolveInfo, merge_input: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Сливает несколько тем в одну с переносом всех связей.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
merge_input: Данные для слияния:
|
||||||
|
- target_topic_id: ID целевой темы (в которую сливаем)
|
||||||
|
- source_topic_ids: Список ID исходных тем (которые сливаем)
|
||||||
|
- preserve_target_properties: Сохранить свойства целевой темы
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Результат операции с информацией о слиянии
|
||||||
|
|
||||||
|
Функциональность:
|
||||||
|
- Переносит всех подписчиков из исходных тем в целевую
|
||||||
|
- Переносит все публикации из исходных тем в целевую
|
||||||
|
- Обновляет связи с черновиками
|
||||||
|
- Проверяет принадлежность тем к одному сообществу
|
||||||
|
- Удаляет исходные темы после переноса
|
||||||
|
- Инвалидирует соответствующие кеши
|
||||||
|
"""
|
||||||
|
viewer_id = info.context.get("author", {}).get("id")
|
||||||
|
target_topic_id = merge_input["target_topic_id"]
|
||||||
|
source_topic_ids = merge_input["source_topic_ids"]
|
||||||
|
preserve_target = merge_input.get("preserve_target_properties", True)
|
||||||
|
|
||||||
|
# Проверяем права доступа
|
||||||
|
if not viewer_id:
|
||||||
|
return {"error": "Не авторизован"}
|
||||||
|
|
||||||
|
# Проверяем что ID не пересекаются
|
||||||
|
if target_topic_id in source_topic_ids:
|
||||||
|
return {"error": "Целевая тема не может быть в списке исходных тем"}
|
||||||
|
|
||||||
|
with local_session() as session:
|
||||||
|
try:
|
||||||
|
# Получаем целевую тему
|
||||||
|
target_topic = session.query(Topic).filter(Topic.id == target_topic_id).first()
|
||||||
|
if not target_topic:
|
||||||
|
return {"error": f"Целевая тема с ID {target_topic_id} не найдена"}
|
||||||
|
|
||||||
|
# Получаем исходные темы
|
||||||
|
source_topics = session.query(Topic).filter(Topic.id.in_(source_topic_ids)).all()
|
||||||
|
if len(source_topics) != len(source_topic_ids):
|
||||||
|
found_ids = [t.id for t in source_topics]
|
||||||
|
missing_ids = [topic_id for topic_id in source_topic_ids if topic_id not in found_ids]
|
||||||
|
return {"error": f"Исходные темы с ID {missing_ids} не найдены"}
|
||||||
|
|
||||||
|
# Проверяем что все темы принадлежат одному сообществу
|
||||||
|
target_community = target_topic.community
|
||||||
|
for source_topic in source_topics:
|
||||||
|
if source_topic.community != target_community:
|
||||||
|
return {"error": f"Тема '{source_topic.title}' принадлежит другому сообществу"}
|
||||||
|
|
||||||
|
# Получаем автора для проверки прав
|
||||||
|
author = session.query(Author).filter(Author.id == viewer_id).first()
|
||||||
|
if not author:
|
||||||
|
return {"error": "Автор не найден"}
|
||||||
|
|
||||||
|
# TODO: проверить права администратора или создателя тем
|
||||||
|
# Для админ-панели допускаем слияние любых тем администратором
|
||||||
|
|
||||||
|
# Собираем статистику для отчета
|
||||||
|
merge_stats = {"followers_moved": 0, "publications_moved": 0, "drafts_moved": 0, "source_topics_deleted": 0}
|
||||||
|
|
||||||
|
# Переносим подписчиков из исходных тем в целевую
|
||||||
|
for source_topic in source_topics:
|
||||||
|
# Получаем подписчиков исходной темы
|
||||||
|
source_followers = session.query(TopicFollower).filter(TopicFollower.topic == source_topic.id).all()
|
||||||
|
|
||||||
|
for follower in source_followers:
|
||||||
|
# Проверяем, не подписан ли уже пользователь на целевую тему
|
||||||
|
existing = (
|
||||||
|
session.query(TopicFollower)
|
||||||
|
.filter(TopicFollower.topic == target_topic_id, TopicFollower.follower == follower.follower)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
# Создаем новую подписку на целевую тему
|
||||||
|
new_follower = TopicFollower(
|
||||||
|
topic=target_topic_id,
|
||||||
|
follower=follower.follower,
|
||||||
|
created_at=follower.created_at,
|
||||||
|
auto=follower.auto,
|
||||||
|
)
|
||||||
|
session.add(new_follower)
|
||||||
|
merge_stats["followers_moved"] += 1
|
||||||
|
|
||||||
|
# Удаляем старую подписку
|
||||||
|
session.delete(follower)
|
||||||
|
|
||||||
|
# Переносим публикации из исходных тем в целевую
|
||||||
|
from orm.shout import ShoutTopic
|
||||||
|
|
||||||
|
for source_topic in source_topics:
|
||||||
|
# Получаем связи публикаций с исходной темой
|
||||||
|
shout_topics = session.query(ShoutTopic).filter(ShoutTopic.topic == source_topic.id).all()
|
||||||
|
|
||||||
|
for shout_topic in shout_topics:
|
||||||
|
# Проверяем, не связана ли уже публикация с целевой темой
|
||||||
|
existing = (
|
||||||
|
session.query(ShoutTopic)
|
||||||
|
.filter(ShoutTopic.topic == target_topic_id, ShoutTopic.shout == shout_topic.shout)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
# Создаем новую связь с целевой темой
|
||||||
|
new_shout_topic = ShoutTopic(
|
||||||
|
topic=target_topic_id, shout=shout_topic.shout, main=shout_topic.main
|
||||||
|
)
|
||||||
|
session.add(new_shout_topic)
|
||||||
|
merge_stats["publications_moved"] += 1
|
||||||
|
|
||||||
|
# Удаляем старую связь
|
||||||
|
session.delete(shout_topic)
|
||||||
|
|
||||||
|
# Переносим черновики из исходных тем в целевую
|
||||||
|
from orm.draft import DraftTopic
|
||||||
|
|
||||||
|
for source_topic in source_topics:
|
||||||
|
# Получаем связи черновиков с исходной темой
|
||||||
|
draft_topics = session.query(DraftTopic).filter(DraftTopic.topic == source_topic.id).all()
|
||||||
|
|
||||||
|
for draft_topic in draft_topics:
|
||||||
|
# Проверяем, не связан ли уже черновик с целевой темой
|
||||||
|
existing = (
|
||||||
|
session.query(DraftTopic)
|
||||||
|
.filter(DraftTopic.topic == target_topic_id, DraftTopic.shout == draft_topic.shout)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
# Создаем новую связь с целевой темой
|
||||||
|
new_draft_topic = DraftTopic(
|
||||||
|
topic=target_topic_id, shout=draft_topic.shout, main=draft_topic.main
|
||||||
|
)
|
||||||
|
session.add(new_draft_topic)
|
||||||
|
merge_stats["drafts_moved"] += 1
|
||||||
|
|
||||||
|
# Удаляем старую связь
|
||||||
|
session.delete(draft_topic)
|
||||||
|
|
||||||
|
# Объединяем parent_ids если не сохраняем только целевые свойства
|
||||||
|
if not preserve_target:
|
||||||
|
current_parent_ids: list[int] = list(target_topic.parent_ids or [])
|
||||||
|
all_parent_ids = set(current_parent_ids)
|
||||||
|
for source_topic in source_topics:
|
||||||
|
source_parent_ids: list[int] = list(source_topic.parent_ids or [])
|
||||||
|
if source_parent_ids:
|
||||||
|
all_parent_ids.update(source_parent_ids)
|
||||||
|
# Убираем IDs исходных тем из parent_ids
|
||||||
|
all_parent_ids.discard(target_topic_id)
|
||||||
|
for source_id in source_topic_ids:
|
||||||
|
all_parent_ids.discard(source_id)
|
||||||
|
target_topic.parent_ids = list(all_parent_ids) if all_parent_ids else [] # type: ignore[assignment]
|
||||||
|
|
||||||
|
# Инвалидируем кеши ПЕРЕД удалением тем
|
||||||
|
for source_topic in source_topics:
|
||||||
|
await invalidate_topic_followers_cache(int(source_topic.id))
|
||||||
|
if source_topic.slug:
|
||||||
|
await redis.execute("DEL", f"topic:slug:{source_topic.slug}")
|
||||||
|
await redis.execute("DEL", f"topic:id:{source_topic.id}")
|
||||||
|
|
||||||
|
# Удаляем исходные темы
|
||||||
|
for source_topic in source_topics:
|
||||||
|
session.delete(source_topic)
|
||||||
|
merge_stats["source_topics_deleted"] += 1
|
||||||
|
logger.info(f"Удалена исходная тема: {source_topic.title} (ID: {source_topic.id})")
|
||||||
|
|
||||||
|
# Сохраняем изменения
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Инвалидируем кеши целевой темы и общие кеши
|
||||||
|
await invalidate_topics_cache(target_topic_id)
|
||||||
|
await invalidate_topic_followers_cache(target_topic_id)
|
||||||
|
|
||||||
|
logger.info(f"Успешно слиты темы {source_topic_ids} в тему {target_topic_id}")
|
||||||
|
logger.info(f"Статистика слияния: {merge_stats}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"topic": target_topic,
|
||||||
|
"message": f"Успешно слито {len(source_topics)} тем в '{target_topic.title}'",
|
||||||
|
"stats": merge_stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
logger.error(f"Ошибка при слиянии тем: {e}")
|
||||||
|
return {"error": f"Ошибка при слиянии тем: {e!s}"}
|
||||||
|
|
||||||
|
|
||||||
|
# Мутация для простого назначения родителя темы
|
||||||
|
@mutation.field("set_topic_parent")
|
||||||
|
@login_required
|
||||||
|
async def set_topic_parent(
|
||||||
|
_: None, info: GraphQLResolveInfo, topic_id: int, parent_id: int | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Простое назначение родительской темы для указанной темы.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
topic_id: ID темы, которой назначаем родителя
|
||||||
|
parent_id: ID родительской темы (None для корневой темы)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Результат операции
|
||||||
|
|
||||||
|
Функциональность:
|
||||||
|
- Устанавливает parent_ids для темы
|
||||||
|
- Проверяет циклические зависимости
|
||||||
|
- Проверяет принадлежность к одному сообществу
|
||||||
|
- Инвалидирует кеши
|
||||||
|
"""
|
||||||
|
viewer_id = info.context.get("author", {}).get("id")
|
||||||
|
|
||||||
|
# Проверяем права доступа
|
||||||
|
if not viewer_id:
|
||||||
|
return {"error": "Не авторизован"}
|
||||||
|
|
||||||
|
with local_session() as session:
|
||||||
|
try:
|
||||||
|
# Получаем тему
|
||||||
|
topic = session.query(Topic).filter(Topic.id == topic_id).first()
|
||||||
|
if not topic:
|
||||||
|
return {"error": f"Тема с ID {topic_id} не найдена"}
|
||||||
|
|
||||||
|
# Если устанавливаем корневую тему
|
||||||
|
if parent_id is None:
|
||||||
|
old_parent_ids: list[int] = list(topic.parent_ids or [])
|
||||||
|
topic.parent_ids = [] # type: ignore[assignment]
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Инвалидируем кеши
|
||||||
|
await invalidate_topics_cache(topic_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"topic": topic,
|
||||||
|
"message": f"Тема '{topic.title}' установлена как корневая",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Получаем родительскую тему
|
||||||
|
parent_topic = session.query(Topic).filter(Topic.id == parent_id).first()
|
||||||
|
if not parent_topic:
|
||||||
|
return {"error": f"Родительская тема с ID {parent_id} не найдена"}
|
||||||
|
|
||||||
|
# Проверяем принадлежность к одному сообществу
|
||||||
|
if topic.community != parent_topic.community:
|
||||||
|
return {"error": "Тема и родительская тема должны принадлежать одному сообществу"}
|
||||||
|
|
||||||
|
# Проверяем циклические зависимости
|
||||||
|
def is_descendant(potential_parent: Topic, child_id: int) -> bool:
|
||||||
|
"""Проверяет, является ли тема потомком другой темы"""
|
||||||
|
if potential_parent.id == child_id:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Ищем всех потомков parent'а
|
||||||
|
descendants = session.query(Topic).filter(Topic.parent_ids.op("@>")([potential_parent.id])).all()
|
||||||
|
|
||||||
|
for descendant in descendants:
|
||||||
|
if descendant.id == child_id or is_descendant(descendant, child_id):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
if is_descendant(topic, parent_id):
|
||||||
|
return {"error": "Нельзя установить потомка как родителя (циклическая зависимость)"}
|
||||||
|
|
||||||
|
# Устанавливаем новые parent_ids
|
||||||
|
parent_parent_ids: list[int] = list(parent_topic.parent_ids or [])
|
||||||
|
new_parent_ids = [*parent_parent_ids, parent_id]
|
||||||
|
|
||||||
|
topic.parent_ids = new_parent_ids # type: ignore[assignment]
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Инвалидируем кеши
|
||||||
|
await invalidate_topics_cache(topic_id)
|
||||||
|
await invalidate_topics_cache(parent_id)
|
||||||
|
|
||||||
|
logger.info(f"Установлен родитель для темы {topic_id}: {parent_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"topic": topic,
|
||||||
|
"message": f"Тема '{topic.title}' перемещена под '{parent_topic.title}'",
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
logger.error(f"Ошибка при назначении родителя темы: {e}")
|
||||||
|
return {"error": f"Ошибка при назначении родителя: {e!s}"}
|
||||||
|
|
|
@ -25,6 +25,12 @@ input TopicInput {
|
||||||
parent_ids: [Int]
|
parent_ids: [Int]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input TopicMergeInput {
|
||||||
|
target_topic_id: Int!
|
||||||
|
source_topic_ids: [Int!]!
|
||||||
|
preserve_target_properties: Boolean
|
||||||
|
}
|
||||||
|
|
||||||
input DraftInput {
|
input DraftInput {
|
||||||
id: Int
|
id: Int
|
||||||
# no created_at, updated_at, deleted_at, updated_by, deleted_by
|
# no created_at, updated_at, deleted_at, updated_by, deleted_by
|
||||||
|
|
|
@ -37,6 +37,8 @@ type Mutation {
|
||||||
update_topic(topic_input: TopicInput!): CommonResult!
|
update_topic(topic_input: TopicInput!): CommonResult!
|
||||||
delete_topic(slug: String!): CommonResult!
|
delete_topic(slug: String!): CommonResult!
|
||||||
delete_topic_by_id(id: Int!): CommonResult!
|
delete_topic_by_id(id: Int!): CommonResult!
|
||||||
|
merge_topics(merge_input: TopicMergeInput!): CommonResult!
|
||||||
|
set_topic_parent(topic_id: Int!, parent_id: Int): CommonResult!
|
||||||
|
|
||||||
# reaction
|
# reaction
|
||||||
create_reaction(reaction: ReactionInput!): CommonResult!
|
create_reaction(reaction: ReactionInput!): CommonResult!
|
||||||
|
|
|
@ -201,6 +201,8 @@ type Topic {
|
||||||
|
|
||||||
type CommonResult {
|
type CommonResult {
|
||||||
error: String
|
error: String
|
||||||
|
message: String
|
||||||
|
stats: String
|
||||||
drafts: [Draft]
|
drafts: [Draft]
|
||||||
draft: Draft
|
draft: Draft
|
||||||
slugs: [String]
|
slugs: [String]
|
||||||
|
|
Loading…
Reference in New Issue
Block a user