0.7.7-topics-editing
All checks were successful
Deploy on push / deploy (push) Successful in 6s

This commit is contained in:
Untone 2025-07-03 12:15:10 +03:00
parent 441cca8045
commit eb2140bcc6
27 changed files with 3097 additions and 805 deletions

View File

@ -1,5 +1,60 @@
# Changelog # Changelog
## [0.7.7] - 2025-01-02
### Обновлена система RBAC для топиков
#### Новые разрешения для топиков
- **ДОБАВЛЕНО**: Новое разрешение `topic:merge` для слияния топиков
- **ДОБАВЛЕНО**: Разрешение `topic:create` для роли `editor`
- **ДОБАВЛЕНО**: Разрешения `topic:update_own`, `topic:delete_own` для роли `author`
- **ДОБАВЛЕНО**: Разрешение `topic:merge` для роли `editor`
#### Обновленные резолверы мутаций топиков
- **ИЗМЕНЕНО**: `create_topic` теперь требует `topic:create` вместо `@login_required`
- **ИЗМЕНЕНО**: `update_topic` теперь требует `topic:update_own` ИЛИ `topic:update_any`
- **ИЗМЕНЕНО**: `delete_topic` теперь требует `topic:delete_own` ИЛИ `topic:delete_any`
- **ИЗМЕНЕНО**: `delete_topic_by_id` теперь требует `topic:delete_own` ИЛИ `topic:delete_any`
- **ИЗМЕНЕНО**: `merge_topics` теперь требует `topic:merge` вместо `@login_required`
- **ИЗМЕНЕНО**: `set_topic_parent` теперь требует `topic:update_own` ИЛИ `topic:update_any`
#### Обновленная документация
- **ОБНОВЛЕНО**: Добавлена таблица прав на топики в `docs/rbac-system.md`
- **ОБНОВЛЕНО**: Добавлены примеры использования декораторов для топиков
- **ОБНОВЛЕНО**: Уточнена информация о иерархии ролей и их правах
#### Безопасность
- **УЛУЧШЕНО**: Теперь все мутации топиков требуют соответствующих разрешений
- **УЛУЧШЕНО**: Разграничение прав между собственными и чужими топиками
- **УЛУЧШЕНО**: Специальное право на слияние топиков только для редакторов
## [0.7.6] - 2025-07-02
### Добавлена функциональность слияния топиков в админ-панели
#### Новый административный резолвер adminMergeTopics
- **ДОБАВЛЕНО**: Новая мутация `adminMergeTopics` для слияния топиков через админ-панель:
- **Функциональность**: Полное слияние топиков с переносом всех связанных данных
- **Перенос подписчиков**: Все подписчики из исходных топиков переносятся в целевой топик
- **Перенос публикаций**: Все публикации (ShoutTopic) из исходных топиков переносятся в целевой
- **Перенос черновиков**: Все черновики (DraftTopic) из исходных топиков переносятся в целевой
- **Обновление иерархии**: Дочерние топики получают новые parent_ids с заменой исходных на целевой
- **Валидация**: Проверка принадлежности всех топиков к одному сообществу
- **Дедупликация**: Предотвращение дублирования подписчиков и публикаций
- **Статистика**: Детальная статистика о количестве перенесенных данных
#### Обновленная схема GraphQL
- **ДОБАВЛЕНО**: Новая мутация `adminMergeTopics` в схеме `admin.graphql`
- **ДОБАВЛЕНО**: Новый тип `TopicMergeInput` в схеме `input.graphql`
#### Исправлены ошибки логгирования
- **ИСПРАВЛЕНО**: Устранены ошибки `TypeError: not all arguments converted during string formatting`
- **ИСПРАВЛЕНО**: Некорректные вызовы `logger.error()` в админ-резолверах
#### Инфраструктура
- **ОБНОВЛЕНО**: Добавлена документация по новому функционалу в `CHANGELOG.md`
- **ОБНОВЛЕНО**: Импорты для работы с ORM моделями в админ-резолверах
## [0.7.5] - 2025-07-02 ## [0.7.5] - 2025-07-02
### Исправление критических проблем админ-панели ### Исправление критических проблем админ-панели
@ -478,7 +533,7 @@
- **Оптимизированный скролл**: Эффективная синхронизация между элементами - **Оптимизированный скролл**: Эффективная синхронизация между элементами
- **Уменьшенные перерисовки**: Минимизация DOM манипуляций - **Уменьшенные перерисовки**: Минимизация DOM манипуляций
- **ACCESSIBILITY И СОВРЕМЕННЫЕ СТАНДАРЫ**: - **ACCESSIBILITY И СОВРЕМЕННЫЕ СТАНДАРТЫ**:
- **ARIA атрибуты**: Правильная семантическая разметка - **ARIA атрибуты**: Правильная семантическая разметка
- **Клавиатурная навигация**: Полная поддержка навигации с клавиатуры - **Клавиатурная навигация**: Полная поддержка навигации с клавиатуры
- **Читаемые фокусные состояния**: Четкие индикаторы фокуса - **Читаемые фокусные состояния**: Четкие индикаторы фокуса

View File

@ -87,8 +87,10 @@
"editor": [ "editor": [
"shout:delete_any", "shout:delete_any",
"shout:update_any", "shout:update_any",
"topic:delete_any", "topic:create",
"topic:update_any", "topic:delete_own",
"topic:update_own",
"topic:merge",
"reaction:delete_any:*", "reaction:delete_any:*",
"reaction:update_any:*", "reaction:update_any:*",
"invite:delete_any", "invite:delete_any",

View File

@ -18,6 +18,7 @@ python dev.py
- [Архитектура](auth-architecture.md) - Диаграммы и схемы - [Архитектура](auth-architecture.md) - Диаграммы и схемы
- [Миграция](auth-migration.md) - Переход на новую версию - [Миграция](auth-migration.md) - Переход на новую версию
- [Безопасность](security.md) - Пароли, email, RBAC - [Безопасность](security.md) - Пароли, email, RBAC
- [Система RBAC](rbac-system.md) - Роли, разрешения, топики
- [OAuth](oauth.md) - Google, GitHub, Facebook, X, Telegram, VK, Yandex - [OAuth](oauth.md) - Google, GitHub, Facebook, X, Telegram, VK, Yandex
- [OAuth настройка](oauth-setup.md) - Инструкции по настройке OAuth провайдеров - [OAuth настройка](oauth-setup.md) - Инструкции по настройке OAuth провайдеров
@ -52,7 +53,8 @@ python dev.py
### Авторизация ### Авторизация
- **Модульная архитектура**: SessionTokenManager, VerificationTokenManager, OAuthTokenManager - **Модульная архитектура**: SessionTokenManager, VerificationTokenManager, OAuthTokenManager
- **OAuth провайдеры**: 7 поддерживаемых провайдеров с PKCE - **OAuth провайдеры**: 7 поддерживаемых провайдеров с PKCE
- **RBAC**: user/moderator/admin роли - **RBAC**: Система ролей reader/author/artist/expert/editor/admin с наследованием
- **Права на топики**: Специальные разрешения для создания, редактирования и слияния топиков
- **Производительность**: 50% ускорение Redis, 30% меньше памяти - **Производительность**: 50% ускорение Redis, 30% меньше памяти
### Nginx (упрощенная конфигурация) ### Nginx (упрощенная конфигурация)

View File

@ -255,7 +255,6 @@ export const AdminPanel: Component = () => {
return ( return (
<div> <div>
<h1>Панель администратора</h1>
{/* Контент админки */} {/* Контент админки */}
</div> </div>
) )

View File

@ -132,18 +132,28 @@ reader → author → artist → expert → editor → admin
| Роль | Базовые права | Дополнительные права | | Роль | Базовые права | Дополнительные права |
|------|---------------|---------------------| |------|---------------|---------------------|
| `reader` | `*:read`, базовые реакции | `chat:*`, `message:*` | | `reader` | `*:read`, базовые реакции | `chat:*`, `message:*`, `bookmark:*` |
| `author` | Наследует `reader` + `*:create`, `*:update_own`, `*:delete_own` | `draft:*` | | `author` | Наследует `reader` + `*:create`, `*:update_own`, `*:delete_own` | `draft:*` |
| `artist` | Наследует `author` | `reaction:CREDIT:accept`, `reaction:CREDIT:decline` | | `artist` | Наследует `author` | `reaction:CREDIT:accept`, `reaction:CREDIT:decline` |
| `expert` | Наследует `author` | `reaction:PROOF:*`, `reaction:DISPROOF:*`, `reaction:AGREE:*`, `reaction:DISAGREE:*` | | `expert` | Наследует `author` | `reaction:PROOF:*`, `reaction:DISPROOF:*`, `reaction:AGREE:*`, `reaction:DISAGREE:*` |
| `editor` | `*:read`, `*:create`, `*:update_any`, `*:delete_any` | `community:read`, `community:update_own` | | `editor` | `*:read`, `*:create`, `*:update_any`, `*:delete_any` | `community:read`, `community:update_own`, `topic:merge`, `topic:create`, `topic:update_own`, `topic:delete_own` |
| `admin` | Все права (`*`) | Полный доступ ко всем функциям | | `admin` | Все права (`*`) | Полный доступ ко всем функциям |
### Формат разрешений ### Формат разрешений
- Базовые: `<entity>:<action>` (например: `shout:create`) - Базовые: `<entity>:<action>` (например: `shout:create`, `topic:create`)
- Реакции: `reaction:<type>:<action>` (например: `reaction:LIKE:create`) - Реакции: `reaction:<type>:<action>` (например: `reaction:LIKE:create`)
- Специальные: `topic:merge` (слияние топиков)
- Wildcard: `<entity>:*` или `*` (только для admin) - Wildcard: `<entity>:*` или `*` (только для admin)
### Права на топики
- `topic:create` - создание новых топиков (роли: `author`, `editor`)
- `topic:read` - чтение топиков (роли: `reader` и выше)
- `topic:update_own` - обновление собственных топиков (роли: `author`)
- `topic:update_any` - обновление любых топиков (роли: `editor`)
- `topic:delete_own` - удаление собственных топиков (роли: `author`)
- `topic:delete_any` - удаление любых топиков (роли: `editor`)
- `topic:merge` - слияние топиков (роли: `editor`)
## GraphQL API ## GraphQL API
### Запросы ### Запросы
@ -243,6 +253,18 @@ from resolvers.rbac import (
async def create_shout(self, info: GraphQLResolveInfo, **kwargs): async def create_shout(self, info: GraphQLResolveInfo, **kwargs):
# Только пользователи с правом создания статей # Только пользователи с правом создания статей
return await self._create_shout_logic(**kwargs) return await self._create_shout_logic(**kwargs)
@mutation.field("create_topic")
@require_permission("topic:create")
async def create_topic(self, info: GraphQLResolveInfo, topic_input: dict):
# Только пользователи с правом создания топиков (author, editor)
return await self._create_topic_logic(topic_input)
@mutation.field("merge_topics")
@require_permission("topic:merge")
async def merge_topics(self, info: GraphQLResolveInfo, merge_input: dict):
# Только пользователи с правом слияния топиков (editor)
return await self._merge_topics_logic(merge_input)
``` ```
#### Проверка любого из разрешений (OR логика) #### Проверка любого из разрешений (OR логика)
@ -252,6 +274,18 @@ async def create_shout(self, info: GraphQLResolveInfo, **kwargs):
async def update_shout(self, info: GraphQLResolveInfo, shout_id: int, **kwargs): async def update_shout(self, info: GraphQLResolveInfo, shout_id: int, **kwargs):
# Может редактировать свои статьи ИЛИ любые статьи # Может редактировать свои статьи ИЛИ любые статьи
return await self._update_shout_logic(shout_id, **kwargs) return await self._update_shout_logic(shout_id, **kwargs)
@mutation.field("update_topic")
@require_any_permission(["topic:update_own", "topic:update_any"])
async def update_topic(self, info: GraphQLResolveInfo, topic_input: dict):
# Может редактировать свои топики ИЛИ любые топики
return await self._update_topic_logic(topic_input)
@mutation.field("delete_topic")
@require_any_permission(["topic:delete_own", "topic:delete_any"])
async def delete_topic(self, info: GraphQLResolveInfo, topic_id: int):
# Может удалять свои топики ИЛИ любые топики
return await self._delete_topic_logic(topic_id)
``` ```
#### Проверка конкретной роли #### Проверка конкретной роли

View File

@ -1,4 +1,5 @@
import { createContext, createEffect, createSignal, JSX, onMount, useContext } from 'solid-js' import { createContext, createEffect, createSignal, JSX, onMount, useContext } from 'solid-js'
import { query } from '../graphql'
import { import {
ADMIN_GET_ROLES_QUERY, ADMIN_GET_ROLES_QUERY,
ADMIN_GET_TOPICS_QUERY, ADMIN_GET_TOPICS_QUERY,
@ -42,6 +43,7 @@ interface DataContextType {
// Топики // Топики
topics: () => Topic[] topics: () => Topic[]
allTopics: () => Topic[] allTopics: () => Topic[]
setTopics: (topics: Topic[]) => void
getTopicById: (id: number) => Topic | undefined getTopicById: (id: number) => Topic | undefined
getTopicTitle: (id: number) => string getTopicTitle: (id: number) => string
loadTopicsByCommunity: (communityId: number) => Promise<Topic[]> loadTopicsByCommunity: (communityId: number) => Promise<Topic[]>
@ -69,6 +71,7 @@ const DataContext = createContext<DataContextType>({
// Топики // Топики
topics: () => [], topics: () => [],
allTopics: () => [], allTopics: () => [],
setTopics: () => {},
getTopicById: () => undefined, getTopicById: () => undefined,
getTopicTitle: () => '', getTopicTitle: () => '',
loadTopicsByCommunity: async () => [], loadTopicsByCommunity: async () => [],
@ -95,6 +98,13 @@ export function DataProvider(props: { children: JSX.Element }) {
const [allTopics, setAllTopics] = createSignal<Topic[]>([]) const [allTopics, setAllTopics] = createSignal<Topic[]>([])
const [roles, setRoles] = createSignal<Role[]>([]) const [roles, setRoles] = createSignal<Role[]>([])
// Обертка для setTopics с логированием
const setTopicsWithLogging = (newTopics: Topic[]) => {
console.log('[DataProvider] setTopics called with', newTopics.length, 'topics')
console.log('[DataProvider] Sample topic parent_ids:', newTopics.slice(0, 3).map(t => ({ id: t.id, title: t.title, parent_ids: t.parent_ids })))
setTopics(newTopics)
}
// Инициализация выбранного сообщества из localStorage // Инициализация выбранного сообщества из localStorage
const initialCommunity = (() => { const initialCommunity = (() => {
try { try {
@ -151,23 +161,12 @@ export function DataProvider(props: { children: JSX.Element }) {
// Загрузка сообществ // Загрузка сообществ
const loadCommunities = async () => { const loadCommunities = async () => {
try { try {
const response = await fetch('/graphql', { const result = await query<{ get_communities_all: Community[] }>(
method: 'POST', `${location.origin}/graphql`,
headers: { GET_COMMUNITIES_QUERY
'Content-Type': 'application/json' )
},
body: JSON.stringify({
query: GET_COMMUNITIES_QUERY
})
})
const result = await response.json() const communitiesData = result.get_communities_all || []
if (result.errors) {
throw new Error(result.errors[0].message)
}
const communitiesData = result.data.get_communities_all || []
setCommunities(communitiesData) setCommunities(communitiesData)
return communitiesData return communitiesData
} catch (error) { } catch (error) {
@ -179,24 +178,13 @@ export function DataProvider(props: { children: JSX.Element }) {
// Загрузка всех топиков // Загрузка всех топиков
const loadTopics = async () => { const loadTopics = async () => {
try { try {
const response = await fetch('/graphql', { const result = await query<{ get_topics_all: Topic[] }>(
method: 'POST', `${location.origin}/graphql`,
headers: { GET_TOPICS_QUERY
'Content-Type': 'application/json' )
},
body: JSON.stringify({
query: GET_TOPICS_QUERY
})
})
const result = await response.json() const topicsData = result.get_topics_all || []
setTopicsWithLogging(topicsData)
if (result.errors) {
throw new Error(result.errors[0].message)
}
const topicsData = result.data.get_topics_all || []
setTopics(topicsData)
return topicsData return topicsData
} catch (error) { } catch (error) {
console.error('Ошибка загрузки топиков:', error) console.error('Ошибка загрузки топиков:', error)
@ -210,29 +198,16 @@ export function DataProvider(props: { children: JSX.Element }) {
setIsLoading(true) setIsLoading(true)
// Используем админский резолвер для получения всех топиков без лимитов // Используем админский резолвер для получения всех топиков без лимитов
const response = await fetch('/graphql', { const result = await query<{ adminGetTopics: Topic[] }>(
method: 'POST', `${location.origin}/graphql`,
headers: { ADMIN_GET_TOPICS_QUERY,
'Content-Type': 'application/json' { community_id: communityId }
}, )
body: JSON.stringify({
query: ADMIN_GET_TOPICS_QUERY,
variables: {
community_id: communityId
}
})
})
const result = await response.json() const allTopicsData = result.adminGetTopics || []
if (result.errors) {
throw new Error(result.errors[0].message)
}
const allTopicsData = result.data.adminGetTopics || []
// Сохраняем все данные сразу для отображения // Сохраняем все данные сразу для отображения
setTopics(allTopicsData) setTopicsWithLogging(allTopicsData)
setAllTopics(allTopicsData) setAllTopics(allTopicsData)
console.log(`[DataProvider] Загружено ${allTopicsData.length} топиков для сообщества ${communityId}`) console.log(`[DataProvider] Загружено ${allTopicsData.length} топиков для сообщества ${communityId}`)
@ -255,27 +230,13 @@ export function DataProvider(props: { children: JSX.Element }) {
const variables = communityId ? { community: communityId } : {} const variables = communityId ? { community: communityId } : {}
const response = await fetch('/graphql', { const result = await query<{ adminGetRoles: Role[] }>(
method: 'POST', `${location.origin}/graphql`,
headers: { ADMIN_GET_ROLES_QUERY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: ADMIN_GET_ROLES_QUERY,
variables variables
}) )
})
const result = await response.json() const rolesData = result.adminGetRoles || []
console.log('[DataProvider] Ответ от сервера для ролей:', result)
if (result.errors) {
console.warn('Не удалось загрузить роли (возможно не авторизован):', result.errors[0].message)
setRoles([])
return []
}
const rolesData = result.data.adminGetRoles || []
console.log('[DataProvider] Роли успешно загружены:', rolesData) console.log('[DataProvider] Роли успешно загружены:', rolesData)
setRoles(rolesData) setRoles(rolesData)
return rolesData return rolesData
@ -316,7 +277,12 @@ export function DataProvider(props: { children: JSX.Element }) {
} }
const getCommunityName = (id: number): string => getCommunityById(id)?.name || '' const getCommunityName = (id: number): string => getCommunityById(id)?.name || ''
const getTopicTitle = (id: number): string => getTopicById(id)?.title || '' const getTopicTitle = (id: number): string => {
const topic = getTopicById(id)
const title = topic?.title || ''
console.log(`[DataProvider] getTopicTitle(${id}) -> "${title}", parent_ids:`, topic?.parent_ids)
return title
}
// Методы для работы с топиками // Методы для работы с топиками
const getTopicById = (id: number): Topic | undefined => { const getTopicById = (id: number): Topic | undefined => {
@ -344,6 +310,7 @@ export function DataProvider(props: { children: JSX.Element }) {
// Топики // Топики
topics, topics,
allTopics, allTopics,
setTopics: setTopicsWithLogging,
getTopicById, getTopicById,
getTopicTitle, getTopicTitle,
loadTopicsByCommunity, loadTopicsByCommunity,
@ -357,26 +324,9 @@ export function DataProvider(props: { children: JSX.Element }) {
isLoading, isLoading,
loadData, loadData,
// biome-ignore lint/suspicious/noExplicitAny: grahphql // biome-ignore lint/suspicious/noExplicitAny: grahphql
queryGraphQL: async (query: string, variables?: Record<string, any>) => { queryGraphQL: async (queryStr: string, variables?: Record<string, any>) => {
try { try {
const response = await fetch('/graphql', { return await query(`${location.origin}/graphql`, queryStr, variables)
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query,
variables
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
return result.data
} catch (error) { } catch (error) {
console.error('Ошибка выполнения GraphQL запроса:', error) console.error('Ошибка выполнения GraphQL запроса:', error)
return null return null

View File

@ -176,3 +176,37 @@ export const SET_TOPIC_PARENT_MUTATION = `
} }
} }
` `
export const ADMIN_UPDATE_TOPIC_MUTATION = `
mutation AdminUpdateTopic($topic: AdminTopicInput!) {
adminUpdateTopic(topic: $topic) {
success
error
topic {
id
title
slug
body
community
parent_ids
}
}
}
`
export const ADMIN_CREATE_TOPIC_MUTATION = `
mutation AdminCreateTopic($topic: AdminTopicInput!) {
adminCreateTopic(topic: $topic) {
success
error
topic {
id
title
slug
body
community
parent_ids
}
}
}
`

View File

@ -118,11 +118,6 @@ const AutoTranslator = (props: { children: JSX.Element; language: () => Language
'TH' 'TH'
] ]
if (textElements.includes(element.tagName)) { if (textElements.includes(element.tagName)) {
// Более приоритетная обработка для кнопок
if (element.tagName === 'BUTTON') {
console.log(`👆 Проверка кнопки: "${element.textContent?.trim()}"`)
}
// Ищем прямые текстовые узлы внутри элемента // Ищем прямые текстовые узлы внутри элемента
const directTextNodes = Array.from(element.childNodes).filter( const directTextNodes = Array.from(element.childNodes).filter(
(child) => child.nodeType === Node.TEXT_NODE && child.textContent?.trim() (child) => child.nodeType === Node.TEXT_NODE && child.textContent?.trim()
@ -147,9 +142,6 @@ const AutoTranslator = (props: { children: JSX.Element; language: () => Language
// Если у кнопки нет прямых текстовых узлов, но есть вложенные элементы // Если у кнопки нет прямых текстовых узлов, но есть вложенные элементы
const buttonText = element.textContent?.trim() const buttonText = element.textContent?.trim()
if (buttonText && CYRILLIC_REGEX.test(buttonText)) { if (buttonText && CYRILLIC_REGEX.test(buttonText)) {
console.log(`🔍 Кнопка с вложенными элементами: "${buttonText}"`)
// Проверяем, есть ли у кнопки value атрибут
const valueAttr = element.getAttribute('value') const valueAttr = element.getAttribute('value')
if (valueAttr && CYRILLIC_REGEX.test(valueAttr)) { if (valueAttr && CYRILLIC_REGEX.test(valueAttr)) {
const currentLang = props.language() const currentLang = props.language()

View File

@ -1,5 +1,4 @@
{ {
"Панель администратора": "Admin Panel",
"Выйти": "Logout", "Выйти": "Logout",
"Авторы": "Authors", "Авторы": "Authors",
"Публикации": "Publications", "Публикации": "Publications",
@ -10,7 +9,7 @@
"Переменные среды": "Environment Variables", "Переменные среды": "Environment Variables",
"Ошибка при выходе": "Logout error", "Ошибка при выходе": "Logout error",
"Вход в панель администратора": "Admin Panel Login", "Вход": "Login",
"Имя пользователя": "Username", "Имя пользователя": "Username",
"Пароль": "Password", "Пароль": "Password",
"Войти": "Login", "Войти": "Login",
@ -230,5 +229,9 @@
"123": "123", "123": "123",
"Введите содержимое media.body...": "Enter media.body content...", "Введите содержимое media.body...": "Enter media.body content...",
"Поиск по названию, slug или ID...": "Search by title, slug or ID...", "Поиск по названию, slug или ID...": "Search by title, slug or ID...",
"Дискурс": "Discours" "Дискурс": "Discours",
"Родительские топики отображаются вверху списка синим цветом. Кликните по топику чтобы добавить или убрать из выбранных.": "Parent topics are displayed at the top of the list in blue color. Click on the topic to add or remove from selected.",
"Выбрано:": "Selected:",
"Поиск по топикам...": "Search by topics..."
} }

View File

@ -2,6 +2,7 @@ import { Component, createEffect, createSignal } from 'solid-js'
import formStyles from '../styles/Form.module.css' import formStyles from '../styles/Form.module.css'
import styles from '../styles/Modal.module.css' import styles from '../styles/Modal.module.css'
import Button from '../ui/Button' import Button from '../ui/Button'
import HTMLEditor from '../ui/HTMLEditor'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
interface Collection { interface Collection {
@ -166,12 +167,9 @@ const CollectionEditModal: Component<CollectionEditModalProps> = (props) => {
Описание Описание
</span> </span>
</label> </label>
<textarea <HTMLEditor
class={formStyles.textarea}
value={formData().desc} value={formData().desc}
onInput={(e) => updateField('desc', e.target.value)} onInput={(value) => updateField('desc', value)}
placeholder="Описание коллекции (необязательно)"
rows="4"
/> />
</div> </div>

View File

@ -11,6 +11,7 @@ import styles from '../styles/Modal.module.css'
import Button from '../ui/Button' import Button from '../ui/Button'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import RoleManager from '../ui/RoleManager' import RoleManager from '../ui/RoleManager'
import HTMLEditor from '../ui/HTMLEditor'
interface Community { interface Community {
id: number id: number
@ -284,12 +285,9 @@ const CommunityEditModal = (props: CommunityEditModalProps) => {
Описание Описание
</span> </span>
</label> </label>
<textarea <HTMLEditor
class={formStyles.textarea}
value={formData().desc || ''} value={formData().desc || ''}
onInput={(e) => updateField('desc', e.currentTarget.value)} onInput={(value) => updateField('desc', value)}
placeholder="Описание сообщества"
rows={4}
/> />
</div> </div>

View File

@ -1,9 +1,11 @@
import { createEffect, createSignal, For, Show } from 'solid-js' import { createEffect, createSignal, For, Show, on } from 'solid-js'
import { Topic, useData } from '../context/data' import { Topic, useData } from '../context/data'
import { query } from '../graphql'
import { ADMIN_UPDATE_TOPIC_MUTATION } from '../graphql/mutations'
import styles from '../styles/Form.module.css' import styles from '../styles/Form.module.css'
import modalStyles from '../styles/Modal.module.css'
import EditableCodePreview from '../ui/EditableCodePreview'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import TopicPillsCloud, { type TopicPill } from '../ui/TopicPillsCloud'
import HTMLEditor from '../ui/HTMLEditor'
interface TopicEditModalProps { interface TopicEditModalProps {
topic: Topic topic: Topic
@ -28,35 +30,36 @@ export default function TopicEditModal(props: TopicEditModalProps) {
// Состояние для выбора родителей // Состояние для выбора родителей
const [availableParents, setAvailableParents] = createSignal<Topic[]>([]) const [availableParents, setAvailableParents] = createSignal<Topic[]>([])
const [parentSearch, setParentSearch] = createSignal('')
// Состояние для редактирования body
const [showBodyEditor, setShowBodyEditor] = createSignal(false)
const [bodyContent, setBodyContent] = createSignal('')
const [saving, setSaving] = createSignal(false) const [saving, setSaving] = createSignal(false)
// Инициализация формы при открытии // Инициализация формы при открытии
createEffect(() => { createEffect(() => {
if (props.isOpen && props.topic) { if (props.isOpen && props.topic) {
console.log('[TopicEditModal] Initializing with topic:', props.topic) const topicCommunity = props.topic.community || selectedCommunity() || 0
setFormData({ setFormData({
id: props.topic.id, id: props.topic.id,
title: props.topic.title || '', title: props.topic.title || '',
slug: props.topic.slug || '', slug: props.topic.slug || '',
body: props.topic.body || '', body: props.topic.body || '',
community: selectedCommunity() || 0, community: topicCommunity,
parent_ids: props.topic.parent_ids || [] parent_ids: props.topic.parent_ids || []
}) })
setBodyContent(props.topic.body || '') updateAvailableParents(topicCommunity, props.topic.id)
updateAvailableParents(selectedCommunity() || 0)
} }
}) })
// Обновление доступных родителей при изменении сообщества в форме
createEffect(on(() => formData().community, (communityId) => {
if (communityId > 0) {
updateAvailableParents(communityId)
}
}))
// Обновление доступных родителей при смене сообщества // Обновление доступных родителей при смене сообщества
const updateAvailableParents = (communityId: number) => { const updateAvailableParents = (communityId: number, excludeTopicId?: number) => {
const allTopics = topics() const allTopics = topics()
const currentTopicId = formData().id const currentTopicId = excludeTopicId || formData().id
// Фильтруем топики того же сообщества, исключая текущий топик // Фильтруем топики того же сообщества, исключая текущий топик
const filteredTopics = allTopics.filter( const filteredTopics = allTopics.filter(
@ -66,40 +69,32 @@ export default function TopicEditModal(props: TopicEditModalProps) {
setAvailableParents(filteredTopics) setAvailableParents(filteredTopics)
} }
// Фильтрация родителей по поиску /**
const filteredParents = () => { * Преобразование Topic в TopicPill для компонента TopicPillsCloud
const search = parentSearch().toLowerCase() */
if (!search) return availableParents() const convertTopicsToTopicPills = (topics: Topic[]): TopicPill[] => {
return topics.map(topic => ({
return availableParents().filter( id: topic.id.toString(),
(topic) => topic.title?.toLowerCase().includes(search) || topic.slug?.toLowerCase().includes(search) title: topic.title || '',
) slug: topic.slug || '',
} community: getCommunityName(topic.community),
parent_ids: (topic.parent_ids || []).map(id => id.toString()),
// Обработка изменения сообщества
const handleCommunityChange = (e: Event) => {
const target = e.target as HTMLSelectElement
const communityId = Number.parseInt(target.value)
setFormData((prev) => ({
...prev,
community: communityId,
parent_ids: [] // Сбрасываем родителей при смене сообщества
}))
updateAvailableParents(communityId)
}
// Обработка изменения родителей
const handleParentToggle = (parentId: number) => {
setFormData((prev) => ({
...prev,
parent_ids: prev.parent_ids.includes(parentId)
? prev.parent_ids.filter((id) => id !== parentId)
: [...prev.parent_ids, parentId]
})) }))
} }
/**
* Обработка изменения выбора родительских топиков из таблеточек
*/
const handleParentSelectionChange = (selectedIds: string[]) => {
const parentIds = selectedIds.map(id => Number.parseInt(id))
setFormData((prev) => ({
...prev,
parent_ids: parentIds
}))
}
// Сообщество топика изменить нельзя, поэтому обработчик не нужен
// Обработка изменения полей формы // Обработка изменения полей формы
const handleFieldChange = (field: string, value: string) => { const handleFieldChange = (field: string, value: string) => {
setFormData((prev) => ({ setFormData((prev) => ({
@ -108,48 +103,39 @@ export default function TopicEditModal(props: TopicEditModalProps) {
})) }))
} }
// Открытие редактора body
const handleOpenBodyEditor = () => {
setBodyContent(formData().body)
setShowBodyEditor(true)
}
// Сохранение body из редактора
const handleBodySave = (content: string) => {
setFormData((prev) => ({
...prev,
body: content
}))
setBodyContent(content)
setShowBodyEditor(false)
}
// Получение пути до корня для топика
const getTopicPath = (topicId: number): string => {
const topic = topics().find((t) => t.id === topicId)
if (!topic) return 'Неизвестный топик'
const community = getCommunityName(topic.community)
return `${community}${topic.title}`
}
// Сохранение изменений // Сохранение изменений
const handleSave = async () => { const handleSave = async () => {
try { try {
setSaving(true) setSaving(true)
const updatedTopic = { const topicData = formData()
...props.topic,
...formData() // Вызываем админскую мутацию для сохранения
const result = await query<{
adminUpdateTopic: {
success: boolean
error?: string
topic?: Topic
} }
}>(`${location.origin}/graphql`, ADMIN_UPDATE_TOPIC_MUTATION, {
topic: {
id: topicData.id,
title: topicData.title,
slug: topicData.slug,
body: topicData.body,
community: topicData.community,
parent_ids: topicData.parent_ids
}
})
console.log('[TopicEditModal] Saving topic:', updatedTopic) if (result.adminUpdateTopic.success && result.adminUpdateTopic.topic) {
console.log('[TopicEditModal] Topic saved successfully:', result.adminUpdateTopic.topic)
// TODO: Здесь должен быть вызов API для сохранения props.onSave(result.adminUpdateTopic.topic)
// await updateTopic(updatedTopic)
props.onSave(updatedTopic)
props.onClose() props.onClose()
} else {
const errorMessage = result.adminUpdateTopic.error || 'Неизвестная ошибка'
throw new Error(errorMessage)
}
} catch (error) { } catch (error) {
console.error('[TopicEditModal] Error saving topic:', error) console.error('[TopicEditModal] Error saving topic:', error)
props.onError?.(error instanceof Error ? error.message : 'Ошибка сохранения топика') props.onError?.(error instanceof Error ? error.message : 'Ошибка сохранения топика')
@ -161,16 +147,34 @@ export default function TopicEditModal(props: TopicEditModalProps) {
return ( return (
<> <>
<Modal <Modal
isOpen={props.isOpen && !showBodyEditor()} isOpen={props.isOpen}
onClose={props.onClose} onClose={props.onClose}
title="Редактирование топика" title="Редактирование топика"
size="large" size="large"
footer={
<>
<button
type="button"
class={`${styles.button} ${styles.secondary}`}
onClick={props.onClose}
disabled={saving()}
>
Отмена
</button>
<button
type="button"
class={`${styles.button} ${styles.primary}`}
onClick={handleSave}
disabled={saving() || !formData().title || !formData().slug || formData().community === 0}
>
{saving() ? 'Сохранение...' : 'Сохранить'}
</button>
</>
}
> >
<div class={styles.form}> <div class={styles.form}>
{/* Основная информация */} {/* Основная информация */}
<div class={styles.section}> <div class={styles.section}>
<h3>Основная информация</h3>
<div class={styles.field}> <div class={styles.field}>
<label class={styles.label}> <label class={styles.label}>
Название: Название:
@ -200,146 +204,49 @@ export default function TopicEditModal(props: TopicEditModalProps) {
<div class={styles.field}> <div class={styles.field}>
<label class={styles.label}> <label class={styles.label}>
Сообщество: Сообщество:
<select class={styles.select} value={formData().community} onChange={handleCommunityChange}> <div class={`${styles.input} ${styles.disabled} ${styles.communityDisplay}`}>
<option value={0}>Выберите сообщество</option> {getCommunityName(formData().community) || 'Сообщество не выбрано'}
<For each={communities()}> </div>
{(community) => <option value={community.id}>{community.name}</option>}
</For>
</select>
</label> </label>
<div class={`${styles.hint} ${styles.warningHint}`}>
📍 Сообщество топика нельзя изменить после создания
</div>
</div> </div>
</div> </div>
{/* Содержимое */} {/* Содержимое */}
<div class={styles.section}> <div class={styles.section}>
<h3>Содержимое</h3>
<div class={styles.field}> <div class={styles.field}>
<label class={styles.label}>Body:</label> <label class={styles.label}>
<div class={styles.bodyPreview} onClick={handleOpenBodyEditor}> Описание:
<Show when={formData().body}> <HTMLEditor
<div class={styles.bodyContent}> value={formData().body}
{formData().body.length > 200 onInput={(value) => handleFieldChange('body', value)}
? `${formData().body.substring(0, 200)}...` />
: formData().body} </label>
</div>
</Show>
<Show when={!formData().body}>
<div class={styles.bodyPlaceholder}>Нет содержимого. Нажмите для редактирования.</div>
</Show>
<div class={styles.bodyHint}> Кликните для редактирования в полноэкранном редакторе</div>
</div>
</div> </div>
</div> </div>
{/* Родительские топики */} {/* Родительские топики */}
<Show when={formData().community > 0}> <Show when={formData().community > 0}>
<div class={styles.section}> <div class={styles.section}>
<h3>Родительские топики</h3> {/* Компонент с таблеточками для выбора родителей */}
<div class={styles.field}> <div class={styles.field}>
<label class={styles.label}> <TopicPillsCloud
Поиск родителей: topics={convertTopicsToTopicPills(availableParents())}
<input selectedTopics={formData().parent_ids.map(id => id.toString())}
type="text" onSelectionChange={handleParentSelectionChange}
class={styles.input} excludeTopics={[formData().id.toString()]}
value={parentSearch()} showSearch={true}
onInput={(e) => setParentSearch(e.currentTarget.value)} searchPlaceholder="Задайте родительские темы..."
placeholder="Введите название для поиска..." hideSelectedInHeader={true}
/> />
</label>
</div>
<Show when={formData().parent_ids.length > 0}>
<div class={styles.selectedParents}>
<strong>Выбранные родители:</strong>
<ul class={styles.parentsList}>
<For each={formData().parent_ids}>
{(parentId) => (
<li class={styles.parentItem}>
<span>{getTopicPath(parentId)}</span>
<button
type="button"
class={styles.removeButton}
onClick={() => handleParentToggle(parentId)}
>
</button>
</li>
)}
</For>
</ul>
</div>
</Show>
<div class={styles.availableParents}>
<strong>Доступные родители:</strong>
<div class={styles.parentsGrid}>
<For each={filteredParents()}>
{(parent) => (
<label class={styles.parentCheckbox}>
<input
type="checkbox"
checked={formData().parent_ids.includes(parent.id)}
onChange={() => handleParentToggle(parent.id)}
/>
<span class={styles.parentLabel}>
<strong>{parent.title}</strong>
<br />
<small>{parent.slug}</small>
</span>
</label>
)}
</For>
</div>
<Show when={filteredParents().length === 0}>
<div class={styles.noParents}>
<Show when={parentSearch()}>Не найдено топиков по запросу "{parentSearch()}"</Show>
<Show when={!parentSearch()}>Нет доступных родительских топиков в этом сообществе</Show>
</div>
</Show>
</div> </div>
</div> </div>
</Show> </Show>
{/* Кнопки */}
<div class={modalStyles.modalActions}>
<button
type="button"
class={`${styles.button} ${styles.buttonSecondary}`}
onClick={props.onClose}
disabled={saving()}
>
Отмена
</button>
<button
type="button"
class={`${styles.button} ${styles.buttonPrimary}`}
onClick={handleSave}
disabled={saving() || !formData().title || !formData().slug || formData().community === 0}
>
{saving() ? 'Сохранение...' : 'Сохранить'}
</button>
</div> </div>
</div>
</Modal>
{/* Редактор body */}
<Modal
isOpen={showBodyEditor()}
onClose={() => setShowBodyEditor(false)}
title="Редактирование содержимого топика"
size="large"
>
<EditableCodePreview
content={bodyContent()}
maxHeight="85vh"
onContentChange={setBodyContent}
onSave={handleBodySave}
onCancel={() => setShowBodyEditor(false)}
placeholder="Введите содержимое топика..."
/>
</Modal> </Modal>
</> </>
) )

View File

@ -9,7 +9,9 @@ interface Topic {
id: number id: number
title: string title: string
slug: string slug: string
body?: string
community: number community: number
parent_ids?: number[]
stat?: { stat?: {
shouts: number shouts: number
followers: number followers: number
@ -33,25 +35,112 @@ interface MergeStats {
source_topics_deleted: number source_topics_deleted: number
} }
interface ValidationErrors {
target?: string
sources?: string
general?: string
}
const TopicMergeModal: Component<TopicMergeModalProps> = (props) => { const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
const [targetTopicId, setTargetTopicId] = createSignal<number | null>(null) const [targetTopicId, setTargetTopicId] = createSignal<number | null>(null)
const [sourceTopicIds, setSourceTopicIds] = createSignal<number[]>([]) const [sourceTopicIds, setSourceTopicIds] = createSignal<number[]>([])
const [preserveTarget, setPreserveTarget] = createSignal(true) const [preserveTarget, setPreserveTarget] = createSignal(true)
const [loading, setLoading] = createSignal(false) const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal('') const [errors, setErrors] = createSignal<ValidationErrors>({})
const [searchQuery, setSearchQuery] = createSignal('')
/** /**
* Получает токен авторизации из localStorage или cookie * Получает токен авторизации из localStorage или cookie
*/ */
const getAuthTokenFromCookie = () => { const getAuthToken = () => {
return ( return localStorage.getItem('auth_token') ||
document.cookie document.cookie
.split('; ') .split('; ')
.find((row) => row.startsWith('auth_token=')) .find((row) => row.startsWith('auth_token='))
?.split('=')[1] || '' ?.split('=')[1] || ''
}
/**
* Валидация данных для слияния
*/
const validateMergeData = (): ValidationErrors => {
const newErrors: ValidationErrors = {}
const target = targetTopicId()
const sources = sourceTopicIds()
if (!target) {
newErrors.target = 'Необходимо выбрать целевую тему'
}
if (sources.length === 0) {
newErrors.sources = 'Необходимо выбрать хотя бы одну исходную тему'
} else if (sources.length > 10) {
newErrors.sources = 'Нельзя объединять более 10 тем за раз'
}
// Проверяем что целевая тема не выбрана как исходная
if (target && sources.includes(target)) {
newErrors.general = 'Целевая тема не может быть в списке исходных тем'
}
// Проверяем что все темы принадлежат одному сообществу
if (target && sources.length > 0) {
const targetTopic = props.topics.find((t) => t.id === target)
const sourcesTopics = props.topics.filter((t) => sources.includes(t.id))
if (targetTopic) {
const targetCommunity = targetTopic.community
const invalidSources = sourcesTopics.filter(topic => topic.community !== targetCommunity)
if (invalidSources.length > 0) {
newErrors.general = `Все темы должны принадлежать одному сообществу. Темы ${invalidSources.map(t => `"${t.title}"`).join(', ')} принадлежат другому сообществу`
}
}
}
return newErrors
}
/**
* Получает название сообщества по ID
*/
const getCommunityName = (communityId: number): string => {
// Заглушка - можно добавить запрос к API или кеш сообществ
return `Сообщество ${communityId}`
}
/**
* Получить отфильтрованный список топиков для поиска
*/
const getFilteredTopics = (topicsList: Topic[]) => {
const query = searchQuery().toLowerCase().trim()
if (!query) return topicsList
return topicsList.filter(topic =>
topic.title?.toLowerCase().includes(query) ||
topic.slug?.toLowerCase().includes(query)
) )
} }
/**
* Обработчик выбора целевой темы
*/
const handleTargetTopicChange = (e: Event) => {
const target = e.target as HTMLSelectElement
const topicId = target.value ? Number.parseInt(target.value) : null
setTargetTopicId(topicId)
// Убираем выбранную целевую тему из исходных тем
if (topicId) {
setSourceTopicIds(prev => prev.filter(id => id !== topicId))
}
// Перевалидация
const newErrors = validateMergeData()
setErrors(newErrors)
}
/** /**
* Обработчик выбора/снятия выбора исходной темы * Обработчик выбора/снятия выбора исходной темы
*/ */
@ -61,40 +150,44 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
} else { } else {
setSourceTopicIds((prev) => prev.filter((id) => id !== topicId)) setSourceTopicIds((prev) => prev.filter((id) => id !== topicId))
} }
// Перевалидация
const newErrors = validateMergeData()
setErrors(newErrors)
} }
/** /**
* Проверяет можно ли выполнить слияние * Проверяет можно ли выполнить слияние
*/ */
const canMerge = () => { const canMerge = () => {
const target = targetTopicId() const validationErrors = validateMergeData()
const sources = sourceTopicIds() return Object.keys(validationErrors).length === 0
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) => { const getMergePreview = () => {
// Здесь можно добавить запрос к API или кеш сообществ const target = targetTopicId()
return `Сообщество ${communityId}` const sources = sourceTopicIds()
if (!target || sources.length === 0) return null
const targetTopic = props.topics.find(t => t.id === target)
const sourceTopics = props.topics.filter(t => sources.includes(t.id))
const totalShouts = sourceTopics.reduce((sum, topic) => sum + (topic.stat?.shouts || 0), 0)
const totalFollowers = sourceTopics.reduce((sum, topic) => sum + (topic.stat?.followers || 0), 0)
const totalAuthors = sourceTopics.reduce((sum, topic) => sum + (topic.stat?.authors || 0), 0)
return {
targetTopic,
sourceTopics,
totalShouts,
totalFollowers,
totalAuthors,
sourcesCount: sources.length
}
} }
/** /**
@ -102,15 +195,16 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
*/ */
const handleMerge = async () => { const handleMerge = async () => {
if (!canMerge()) { if (!canMerge()) {
setError('Невозможно выполнить слияние с текущими настройками') const validationErrors = validateMergeData()
setErrors(validationErrors)
return return
} }
setLoading(true) setLoading(true)
setError('') setErrors({})
try { try {
const authToken = localStorage.getItem('auth_token') || getAuthTokenFromCookie() const authToken = getAuthToken()
const response = await fetch('/graphql', { const response = await fetch('/graphql', {
method: 'POST', method: 'POST',
@ -151,7 +245,7 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
handleClose() handleClose()
} catch (error) { } catch (error) {
const errorMessage = (error as Error).message const errorMessage = (error as Error).message
setError(errorMessage) setErrors({ general: errorMessage })
props.onError(`Ошибка слияния тем: ${errorMessage}`) props.onError(`Ошибка слияния тем: ${errorMessage}`)
} finally { } finally {
setLoading(false) setLoading(false)
@ -165,8 +259,9 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
setTargetTopicId(null) setTargetTopicId(null)
setSourceTopicIds([]) setSourceTopicIds([])
setPreserveTarget(true) setPreserveTarget(true)
setError('') setErrors({})
setLoading(false) setLoading(false)
setSearchQuery('')
props.onClose() props.onClose()
} }
@ -186,65 +281,115 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
return props.topics.filter((topic) => topic.id !== target) return props.topics.filter((topic) => topic.id !== target)
} }
const preview = getMergePreview()
return ( return (
<Modal isOpen={props.isOpen} onClose={handleClose} title="Слияние тем" size="large"> <Modal isOpen={props.isOpen} onClose={handleClose} title="Слияние тем" size="large">
<div class={styles.form}> <div class={styles.form}>
{/* Общие ошибки */}
<Show when={errors().general}>
<div class={styles.formError}>
{errors().general}
</div>
</Show>
{/* Выбор целевой темы */}
<div class={styles.section}> <div class={styles.section}>
<h3 class={styles.sectionTitle}>Выбор целевой темы</h3> <h3 class={styles.sectionTitle}>🎯 Целевая тема</h3>
<p class={styles.description}> <p class={styles.sectionDescription}>
Выберите тему, в которую будут слиты остальные темы. Все подписчики и публикации будут Выберите тему, в которую будут слиты остальные темы. Все подписчики и публикации
перенесены в эту тему. будут перенесены в эту тему, а исходные темы будут удалены.
</p> </p>
<div class={styles.field}>
<label class={styles.label}>
Целевая тема:
<select <select
class={`${styles.select} ${errors().target ? styles.inputError : ''}`}
value={targetTopicId() || ''} value={targetTopicId() || ''}
onChange={(e) => setTargetTopicId(e.target.value ? Number.parseInt(e.target.value) : null)} onChange={handleTargetTopicChange}
class={styles.select} required
disabled={loading()}
> >
<option value="">Выберите целевую тему</option> <option value="">Выберите целевую тему...</option>
<For each={getAvailableTargetTopics()}> <For each={getFilteredTopics(getAvailableTargetTopics())}>
{(topic) => ( {(topic) => (
<option value={topic.id}> <option value={topic.id}>
{topic.title} ({getCommunityName(topic.community)}) {topic.title} ({topic.slug})
{topic.stat ? ` - ${topic.stat.shouts} публ., ${topic.stat.followers} подп.` : ''} {topic.stat ? ` ${topic.stat.shouts} публикаций` : ''}
</option> </option>
)} )}
</For> </For>
</select> </select>
<Show when={errors().target}>
<div class={styles.errorMessage}>{errors().target}</div>
</Show>
</label>
</div>
</div> </div>
{/* Поиск и выбор исходных тем */}
<div class={styles.section}> <div class={styles.section}>
<h3 class={styles.sectionTitle}>Выбор исходных тем для слияния</h3> <h3 class={styles.sectionTitle}>📥 Исходные темы</h3>
<p class={styles.description}> <p class={styles.sectionDescription}>
Выберите темы, которые будут слиты в целевую тему. Эти темы будут удалены после переноса всех Выберите темы, которые будут слиты в целевую тему. Все их данные будут перенесены,
связей. а сами темы будут удалены.
</p> </p>
<Show when={getAvailableSourceTopics().length > 0}> <div class={styles.field}>
<div class={styles.checkboxList}> <label class={styles.label}>
<For each={getAvailableSourceTopics()}> Поиск тем:
<input
type="text"
class={styles.input}
value={searchQuery()}
onInput={(e) => setSearchQuery(e.currentTarget.value)}
placeholder="Введите название или slug для поиска..."
/>
</label>
</div>
<Show when={errors().sources}>
<div class={styles.errorMessage}>{errors().sources}</div>
</Show>
<div class={styles.availableParents}>
<div class={styles.sectionHeader}>
<strong>Доступные темы для слияния:</strong>
<span class={styles.hint}>
Выбрано: {sourceTopicIds().length}
</span>
</div>
<div class={styles.parentsGrid}>
<For each={getFilteredTopics(getAvailableSourceTopics())}>
{(topic) => { {(topic) => {
const isChecked = () => sourceTopicIds().includes(topic.id) const isChecked = () => sourceTopicIds().includes(topic.id)
const isDisabled = () => targetTopicId() && topic.community !== props.topics.find(t => t.id === targetTopicId())?.community
return ( return (
<label class={styles.checkboxItem}> <label
class={`${styles.parentCheckbox} ${isDisabled() ? styles.disabled : ''}`}
title={isDisabled() ? 'Тема принадлежит другому сообществу' : ''}
>
<input <input
type="checkbox" type="checkbox"
checked={isChecked()} checked={isChecked()}
onChange={(e) => handleSourceTopicToggle(topic.id, e.target.checked)} disabled={isDisabled() || false}
disabled={loading()} onChange={(e) => handleSourceTopicToggle(topic.id, e.currentTarget.checked)}
class={styles.checkbox}
/> />
<div class={styles.checkboxContent}> <div class={styles.parentLabel}>
<div class={styles.topicTitle}>{topic.title}</div> <div class={styles.parentTitle}>
<div class={styles.topicInfo}> {topic.title}
{getCommunityName(topic.community)} ID: {topic.id} </div>
<div class={styles.parentSlug}>{topic.slug}</div>
<div class={styles.parentStats}>
{getCommunityName(topic.community)}
{topic.stat && ( {topic.stat && (
<span> <>
{' '} <span> {topic.stat.shouts} публикаций</span>
{topic.stat.shouts} публ., {topic.stat.followers} подп. <span> {topic.stat.followers} подписчиков</span>
</span> </>
)} )}
</div> </div>
</div> </div>
@ -253,65 +398,91 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
}} }}
</For> </For>
</div> </div>
<Show when={getFilteredTopics(getAvailableSourceTopics()).length === 0}>
<div class={styles.noParents}>
<Show when={searchQuery()}>
Не найдено тем по запросу "{searchQuery()}"
</Show>
<Show when={!searchQuery()}>
Нет доступных тем для слияния
</Show> </Show>
</div> </div>
</Show>
</div>
</div>
{/* Предварительный просмотр слияния */}
<Show when={preview}>
<div class={styles.section}> <div class={styles.section}>
<h3 class={styles.sectionTitle}>Настройки слияния</h3> <h3 class={styles.sectionTitle}>📊 Предварительный просмотр</h3>
<label class={styles.checkboxItem}> <div class={styles.hierarchyPath}>
<div><strong>Целевая тема:</strong> {preview!.targetTopic!.title}</div>
<div class={styles.pathDisplay}>
<span>Слияние {preview!.sourcesCount} тем:</span>
<For each={preview!.sourceTopics}>
{(topic) => (
<span class={styles.pathItem}>{topic.title}</span>
)}
</For>
</div>
<div style="margin-top: 1rem;">
<strong>Ожидаемые результаты:</strong>
<ul style="margin: 0.5rem 0; padding-left: 1.5rem;">
<li>Будет перенесено ~{preview!.totalShouts} публикаций</li>
<li>Будет перенесено ~{preview!.totalFollowers} подписчиков</li>
<li>Будет объединено ~{preview!.totalAuthors} авторов</li>
<li>Будет удалено {preview!.sourcesCount} исходных тем</li>
</ul>
</div>
</div>
</div>
</Show>
{/* Настройки слияния */}
<div class={styles.section}>
<h3 class={styles.sectionTitle}> Настройки слияния</h3>
<div class={styles.field}>
<label class={styles.parentCheckbox}>
<input <input
type="checkbox" type="checkbox"
checked={preserveTarget()} checked={preserveTarget()}
onChange={(e) => setPreserveTarget(e.target.checked)} onChange={(e) => setPreserveTarget(e.currentTarget.checked)}
disabled={loading()}
class={styles.checkbox}
/> />
<div class={styles.checkboxContent}> <div class={styles.parentLabel}>
<div class={styles.optionTitle}>Сохранить свойства целевой темы</div> <div class={styles.parentTitle}>
<div class={styles.optionDescription}> Сохранить свойства целевой темы
Если отключено, будут объединены parent_ids из всех тем </div>
<div class={styles.parentStats}>
Если включено, описание и другие свойства целевой темы не будут изменены.
Если выключено, свойства могут быть объединены с исходными темами.
</div> </div>
</div> </div>
</label> </label>
</div> </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> </div>
</Show>
<div class={styles.modalActions}> {/* Кнопки */}
<Button variant="secondary" onClick={handleClose} disabled={loading()}> <div class={styles.actions}>
<Button
type="button"
variant="secondary"
onClick={handleClose}
disabled={loading()}
>
Отмена Отмена
</Button> </Button>
<Button variant="danger" onClick={handleMerge} disabled={!canMerge() || loading()}> <Button
{loading() ? 'Выполняется слияние...' : 'Слить темы'} type="button"
variant="primary"
onClick={handleMerge}
disabled={!canMerge() || loading()}
loading={loading()}
>
{loading() ? 'Выполняется слияние...' : `Слить ${sourceTopicIds().length} тем`}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -98,10 +98,6 @@ const AdminPage: Component<AdminPageProps> = (props) => {
<div class={styles['header-container']}> <div class={styles['header-container']}>
<div class={styles['header-left']}> <div class={styles['header-left']}>
<img src={publyLogo} alt="Logo" class={styles.logo} /> <img src={publyLogo} alt="Logo" class={styles.logo} />
<h1>
Панель администратора
<span class={styles['version-badge']}>v{__APP_VERSION__}</span>
</h1>
</div> </div>
<div class={styles['header-right']}> <div class={styles['header-right']}>
<CommunitySelector /> <CommunitySelector />

View File

@ -56,21 +56,12 @@ const LoginPage = () => {
<div class={styles['login-form-container']}> <div class={styles['login-form-container']}>
<form class={formStyles.form} onSubmit={handleSubmit}> <form class={formStyles.form} onSubmit={handleSubmit}>
<img src={publyLogo} alt="Logo" class={styles['login-logo']} /> <img src={publyLogo} alt="Logo" class={styles['login-logo']} />
<h1 class={formStyles.title}>Вход в админ панель</h1>
<div class={formStyles.fieldGroup}> <div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>📧</span>
Email
<span class={formStyles.required}>*</span>
</span>
</label>
<input <input
type="email" type="email"
value={username()} value={username()}
onInput={(e) => setUsername(e.currentTarget.value)} onInput={(e) => setUsername(e.currentTarget.value)}
placeholder="admin@discours.io" placeholder="admin@media"
required required
class={`${formStyles.input} ${error() ? formStyles.error : ''}`} class={`${formStyles.input} ${error() ? formStyles.error : ''}`}
disabled={loading()} disabled={loading()}
@ -78,13 +69,6 @@ const LoginPage = () => {
</div> </div>
<div class={formStyles.fieldGroup}> <div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>🔒</span>
Пароль
<span class={formStyles.required}>*</span>
</span>
</label>
<input <input
type="password" type="password"
value={password()} value={password()}
@ -103,7 +87,7 @@ const LoginPage = () => {
</div> </div>
)} )}
<div class={formStyles.actions}> <div class={formStyles.actions} style={{ 'margin': 'auto' }}>
<Button <Button
variant="primary" variant="primary"
type="submit" type="submit"

View File

@ -6,7 +6,7 @@ import { query } from '../graphql'
import type { Query, AdminShoutInfo as Shout } from '../graphql/generated/schema' import type { Query, AdminShoutInfo as Shout } from '../graphql/generated/schema'
import { ADMIN_GET_SHOUTS_QUERY } from '../graphql/queries' import { ADMIN_GET_SHOUTS_QUERY } from '../graphql/queries'
import styles from '../styles/Admin.module.css' import styles from '../styles/Admin.module.css'
import EditableCodePreview from '../ui/EditableCodePreview' import HTMLEditor from '../ui/HTMLEditor'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import Pagination from '../ui/Pagination' import Pagination from '../ui/Pagination'
import SortableHeader from '../ui/SortableHeader' import SortableHeader from '../ui/SortableHeader'
@ -351,53 +351,73 @@ const ShoutsRoute = (props: ShoutsRouteProps) => {
<Modal <Modal
isOpen={showBodyModal()} isOpen={showBodyModal()}
onClose={() => setShowBodyModal(false)} onClose={() => setShowBodyModal(false)}
title="Содержимое публикации" title="Редактирование содержимого публикации"
size="large" size="large"
footer={
<>
<button
type="button"
class={`${styles.button} ${styles.secondary}`}
onClick={() => setShowBodyModal(false)}
> >
<EditableCodePreview Отмена
content={selectedShoutBody()} </button>
maxHeight="85vh" <button
language="html" type="button"
autoFormat={true} class={`${styles.button} ${styles.primary}`}
onContentChange={(newContent) => { onClick={() => {
setSelectedShoutBody(newContent) // TODO: добавить логику сохранения изменений в базу данных
}}
onSave={(_content) => {
// FIXME: добавить логику сохранения изменений в базу данных
props.onSuccess?.('Содержимое публикации обновлено') props.onSuccess?.('Содержимое публикации обновлено')
setShowBodyModal(false) setShowBodyModal(false)
}} }}
onCancel={() => { >
setShowBodyModal(false) Сохранить
}} </button>
placeholder="Введите содержимое публикации..." </>
}
>
<div style="padding: 1rem;">
<HTMLEditor
value={selectedShoutBody()}
onInput={(value) => setSelectedShoutBody(value)}
/> />
</div>
</Modal> </Modal>
<Modal <Modal
isOpen={showMediaBodyModal()} isOpen={showMediaBodyModal()}
onClose={() => setShowMediaBodyModal(false)} onClose={() => setShowMediaBodyModal(false)}
title="Содержимое media.body" title="Редактирование содержимого media.body"
size="large" size="large"
footer={
<>
<button
type="button"
class={`${styles.button} ${styles.secondary}`}
onClick={() => setShowMediaBodyModal(false)}
> >
<EditableCodePreview Отмена
content={selectedMediaBody()} </button>
maxHeight="85vh" <button
language="html" type="button"
autoFormat={true} class={`${styles.button} ${styles.primary}`}
onContentChange={(newContent) => { onClick={() => {
setSelectedMediaBody(newContent) // TODO: добавить логику сохранения изменений media.body
}}
onSave={(_content) => {
// FIXME: добавить логику сохранения изменений media.body
props.onSuccess?.('Содержимое media.body обновлено') props.onSuccess?.('Содержимое media.body обновлено')
setShowMediaBodyModal(false) setShowMediaBodyModal(false)
}} }}
onCancel={() => { >
setShowMediaBodyModal(false) Сохранить
}} </button>
placeholder="Введите содержимое media.body..." </>
/> }
>
<div style="padding: 1rem;">
<HTMLEditor
value={selectedMediaBody()}
onInput={(value) => setSelectedMediaBody(value)}
/>gjl
</div>
</Modal> </Modal>
</div> </div>
) )

View File

@ -14,7 +14,7 @@ interface TopicsProps {
} }
export const Topics = (props: TopicsProps) => { export const Topics = (props: TopicsProps) => {
const { selectedCommunity, loadTopicsByCommunity, topics: contextTopics } = useData() const { selectedCommunity, loadTopicsByCommunity, topics: contextTopics, getTopicTitle } = useData()
// Состояние поиска // Состояние поиска
const [searchQuery, setSearchQuery] = createSignal('') const [searchQuery, setSearchQuery] = createSignal('')
@ -133,16 +133,32 @@ export const Topics = (props: TopicsProps) => {
/** /**
* Сохранение изменений топика * Сохранение изменений топика
*/ */
const handleTopicSave = (updatedTopic: Topic) => { const handleTopicSave = async (updatedTopic: Topic) => {
console.log('[TopicsRoute] Saving topic:', updatedTopic) console.log('[TopicsRoute] Saving topic:', updatedTopic)
console.log('[TopicsRoute] Topic parent_ids:', updatedTopic.parent_ids)
// TODO: добавить логику сохранения изменений в базу данных // Сразу обновляем локальные данные для мгновенного отображения
// await updateTopic(updatedTopic) const currentTopics = contextTopics()
console.log('[TopicsRoute] Current topics count:', currentTopics.length)
const updatedTopics = currentTopics.map(topic =>
topic.id === updatedTopic.id ? updatedTopic : topic
)
console.log('[TopicsRoute] Updated topics count:', updatedTopics.length)
// Обновляем состояние контекста напрямую (это сработает мгновенно)
const { setTopics } = useData()
setTopics(updatedTopics)
props.onSuccess?.('Топик успешно обновлён') props.onSuccess?.('Топик успешно обновлён')
// Обновляем локальные данные (пока что просто перезагружаем) // Ждем большее время чтобы сервер точно обработал изменения и инвалидировал кеш
console.log('[TopicsRoute] Scheduling reload in 500ms...')
setTimeout(() => {
console.log('[TopicsRoute] Reloading topics from server...')
void loadTopicsForCommunity() void loadTopicsForCommunity()
}, 500)
} }
/** /**
@ -152,6 +168,40 @@ export const Topics = (props: TopicsProps) => {
props.onError?.(message) props.onError?.(message)
} }
/**
* Рендер родительских тем для топика
*/
const renderParentTopics = (parentIds?: number[]) => {
if (!parentIds || parentIds.length === 0) {
return <span style="color: #999; font-style: italic;">Нет родителей</span>
}
return (
<div style="display: flex; flex-wrap: wrap; gap: 4px;">
<For each={parentIds}>
{(parentId) => {
const parentTitle = getTopicTitle(parentId)
return (
<span
style="
background: #e3f2fd;
color: #1976d2;
padding: 2px 6px;
border-radius: 12px;
font-size: 0.75rem;
white-space: nowrap;
"
title={`ID: ${parentId}`}
>
#{parentTitle || `ID:${parentId}`}
</span>
)
}}
</For>
</div>
)
}
/** /**
* Рендер строки топика * Рендер строки топика
*/ */
@ -169,6 +219,9 @@ export const Topics = (props: TopicsProps) => {
<td class={styles.tableCell} title={topic.slug}> <td class={styles.tableCell} title={topic.slug}>
{truncateText(topic.slug, 30)} {truncateText(topic.slug, 30)}
</td> </td>
<td class={styles.tableCell}>
{renderParentTopics(topic.parent_ids)}
</td>
<td class={styles.tableCell}> <td class={styles.tableCell}>
{topic.body ? ( {topic.body ? (
<span style="color: #666;">{truncateText(topic.body.replace(/<[^>]*>/g, ''), 60)}</span> <span style="color: #666;">{truncateText(topic.body.replace(/<[^>]*>/g, ''), 60)}</span>
@ -203,20 +256,21 @@ export const Topics = (props: TopicsProps) => {
<SortableHeader field="slug" allowedFields={TOPICS_SORT_CONFIG.allowedFields}> <SortableHeader field="slug" allowedFields={TOPICS_SORT_CONFIG.allowedFields}>
Slug Slug
</SortableHeader> </SortableHeader>
<th class={styles.tableHeaderCell}>Родительские темы</th>
<th class={styles.tableHeaderCell}>Body</th> <th class={styles.tableHeaderCell}>Body</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<Show when={loading()}> <Show when={loading()}>
<tr> <tr>
<td colspan="4" class={styles.loadingCell}> <td colspan="5" class={styles.loadingCell}>
Загрузка... Загрузка...
</td> </td>
</tr> </tr>
</Show> </Show>
<Show when={!loading() && sortedTopics().length === 0}> <Show when={!loading() && sortedTopics().length === 0}>
<tr> <tr>
<td colspan="4" class={styles.emptyCell}> <td colspan="5" class={styles.emptyCell}>
Нет топиков Нет топиков
</td> </td>
</tr> </tr>

File diff suppressed because it is too large Load Diff

View File

@ -44,8 +44,8 @@
.modal-large .content { .modal-large .content {
flex: 1; flex: 1;
overflow: hidden; overflow-y: auto;
padding: 0; padding: 1.5rem;
} }
.header { .header {

351
panel/ui/HTMLEditor.tsx Normal file
View File

@ -0,0 +1,351 @@
/**
* HTML редактор с подсветкой синтаксиса через contenteditable
* @module HTMLEditor
*/
import { createEffect, onMount, untrack, createSignal } from 'solid-js'
import Prism from 'prismjs'
import 'prismjs/components/prism-markup'
import 'prismjs/themes/prism.css'
import styles from '../styles/Form.module.css'
interface HTMLEditorProps {
value: string
onInput: (value: string) => void
placeholder?: string
rows?: number
class?: string
disabled?: boolean
}
/**
* Компонент HTML редактора с contenteditable и подсветкой синтаксиса
*/
const HTMLEditor = (props: HTMLEditorProps) => {
let editorElement: HTMLDivElement | undefined
const [isUpdating, setIsUpdating] = createSignal(false)
// Функция для принудительного обновления подсветки
const forceHighlight = (element?: Element) => {
if (!element) return
// Многократная попытка подсветки для надежности
const attemptHighlight = (attempts = 0) => {
if (attempts > 3) return // Максимум 3 попытки
if (typeof window !== 'undefined' && window.Prism && element) {
try {
Prism.highlightElement(element)
} catch (error) {
console.warn('Prism highlight failed, retrying...', error)
setTimeout(() => attemptHighlight(attempts + 1), 50)
}
} else {
setTimeout(() => attemptHighlight(attempts + 1), 50)
}
}
attemptHighlight()
}
onMount(() => {
if (editorElement) {
// Устанавливаем начальное содержимое сразу
updateContentWithoutCursor()
// Принудительно перезапускаем подсветку через короткий таймаут
setTimeout(() => {
if (editorElement) {
const codeElement = editorElement.querySelector('code')
if (codeElement) {
forceHighlight(codeElement)
}
}
}, 50)
// Устанавливаем фокус в конец если есть содержимое
if (props.value) {
setTimeout(() => {
const range = document.createRange()
const selection = window.getSelection()
const codeElement = editorElement?.querySelector('code')
if (codeElement && codeElement.firstChild) {
range.setStart(codeElement.firstChild, codeElement.firstChild.textContent?.length || 0)
range.setEnd(codeElement.firstChild, codeElement.firstChild.textContent?.length || 0)
selection?.removeAllRanges()
selection?.addRange(range)
}
}, 100)
}
}
})
// Обновляем содержимое при изменении props.value извне
createEffect(() => {
const newValue = props.value
untrack(() => {
if (editorElement && !isUpdating()) {
const currentText = getPlainText()
// Обновляем только если значение действительно изменилось извне
// и элемент не в фокусе (чтобы не мешать вводу)
if (newValue !== currentText && document.activeElement !== editorElement) {
updateContentWithoutCursor()
}
}
})
})
const updateContent = () => {
if (!editorElement || isUpdating()) return
const value = untrack(() => props.value) || ''
// Сохраняем позицию курсора более надежно
const selection = window.getSelection()
let savedRange: Range | null = null
let cursorOffset = 0
if (selection && selection.rangeCount > 0 && document.activeElement === editorElement) {
const range = selection.getRangeAt(0)
savedRange = range.cloneRange()
// Вычисляем общий offset относительно всего текстового содержимого
const walker = document.createTreeWalker(
editorElement,
NodeFilter.SHOW_TEXT,
null
)
let node
let totalOffset = 0
while (node = walker.nextNode()) {
if (node === range.startContainer) {
cursorOffset = totalOffset + range.startOffset
break
}
totalOffset += node.textContent?.length || 0
}
}
if (value.trim()) {
// Экранируем HTML для безопасности
const escapedValue = value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
editorElement.innerHTML = `<code class="language-html">${escapedValue}</code>`
// Применяем подсветку с дополнительной проверкой
const codeElement = editorElement.querySelector('code')
if (codeElement) {
forceHighlight(codeElement)
// Восстанавливаем позицию курсора только если элемент в фокусе
if (cursorOffset > 0 && document.activeElement === editorElement) {
setTimeout(() => {
const walker = document.createTreeWalker(
codeElement,
NodeFilter.SHOW_TEXT,
null
)
let currentOffset = 0
let node
while (node = walker.nextNode()) {
const nodeLength = node.textContent?.length || 0
if (currentOffset + nodeLength >= cursorOffset) {
try {
const range = document.createRange()
const newSelection = window.getSelection()
const targetOffset = Math.min(cursorOffset - currentOffset, nodeLength)
range.setStart(node, targetOffset)
range.setEnd(node, targetOffset)
newSelection?.removeAllRanges()
newSelection?.addRange(range)
} catch (e) {
// Игнорируем ошибки позиционирования курсора
}
break
}
currentOffset += nodeLength
}
}, 0)
}
}
} else {
// Для пустого содержимого просто очищаем
editorElement.innerHTML = ''
}
}
const updateContentWithoutCursor = () => {
if (!editorElement) return
const value = props.value || ''
if (value.trim()) {
// Экранируем HTML для безопасности
const escapedValue = value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
editorElement.innerHTML = `<code class="language-html">${escapedValue}</code>`
// Применяем подсветку с дополнительной проверкой
const codeElement = editorElement.querySelector('code')
if (codeElement) {
forceHighlight(codeElement)
}
} else {
// Для пустого содержимого просто очищаем
editorElement.innerHTML = ''
}
}
const getPlainText = (): string => {
if (!editorElement) return ''
// Получаем текстовое содержимое с правильной обработкой новых строк
let text = ''
const processNode = (node: Node): void => {
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent || ''
} else if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element
// Обрабатываем элементы, которые должны создавать новые строки
if (element.tagName === 'DIV' || element.tagName === 'P') {
// Добавляем новую строку перед содержимым div/p (кроме первого)
if (text && !text.endsWith('\n')) {
text += '\n'
}
// Обрабатываем дочерние элементы
for (const child of Array.from(element.childNodes)) {
processNode(child)
}
// Добавляем новую строку после содержимого div/p
if (!text.endsWith('\n')) {
text += '\n'
}
} else if (element.tagName === 'BR') {
text += '\n'
} else {
// Для других элементов просто обрабатываем содержимое
for (const child of Array.from(element.childNodes)) {
processNode(child)
}
}
}
}
try {
processNode(editorElement)
} catch (e) {
// В случае ошибки возвращаем базовый textContent
return editorElement.textContent || ''
}
// Убираем лишние новые строки в конце
return text.replace(/\n+$/, '')
}
const handleInput = () => {
if (!editorElement || isUpdating()) return
setIsUpdating(true)
const text = untrack(() => getPlainText())
// Обновляем значение через props, используя untrack для избежания циклических обновлений
untrack(() => props.onInput(text))
// Отложенное обновление подсветки без влияния на курсор
setTimeout(() => {
// Используем untrack для всех операций чтения состояния
untrack(() => {
if (document.activeElement === editorElement && !isUpdating()) {
const currentText = getPlainText()
if (currentText === text && editorElement) {
updateContent()
}
}
setIsUpdating(false)
})
}, 100) // Ещё меньше задержка
}
const handleKeyDown = (e: KeyboardEvent) => {
// Поддержка Tab для отступов
if (e.key === 'Tab') {
e.preventDefault()
const selection = window.getSelection()
const range = selection?.getRangeAt(0)
if (range) {
const textNode = document.createTextNode(' ')
range.insertNode(textNode)
range.setStartAfter(textNode)
range.setEndAfter(textNode)
selection?.removeAllRanges()
selection?.addRange(range)
}
// Обновляем содержимое без задержки для Tab
untrack(() => {
const text = getPlainText()
props.onInput(text)
})
return
}
// Для Enter не делаем ничего - полностью полагаемся на handleInput
if (e.key === 'Enter') {
// Полностью доверяем handleInput обработать изменение
return
}
}
const handlePaste = (e: ClipboardEvent) => {
e.preventDefault()
const text = e.clipboardData?.getData('text/plain') || ''
document.execCommand('insertText', false, text)
// Обновляем значение после вставки
setTimeout(() => {
untrack(() => {
const newText = getPlainText()
props.onInput(newText)
})
}, 10)
}
return (
<div
ref={editorElement}
class={`${styles.htmlEditorContenteditable} ${props.class || ''}`}
contenteditable={!props.disabled}
data-placeholder={props.placeholder}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
style={{
'min-height': `${(props.rows || 6) * 1.6}em`
}}
/>
)
}
export default HTMLEditor

View File

@ -0,0 +1,252 @@
/**
* Компонент облака топиков для выбора родительских тем
* @module TopicPillsCloud
*/
import { createSignal, createMemo, For, Show } from 'solid-js'
import styles from '../styles/Form.module.css'
/**
* Интерфейс для топика
*/
export interface TopicPill {
id: string
title: string
slug: string
community: string
parent_ids?: string[]
depth?: number
}
/**
* Пропсы компонента TopicPillsCloud
*/
interface TopicPillsCloudProps {
/** Доступные топики для выбора */
topics: TopicPill[]
/** Выбранные топики */
selectedTopics: string[]
/** Колбек при изменении выбора */
onSelectionChange: (selectedIds: string[]) => void
/** Фильтр по сообществу */
communityFilter?: string
/** Исключить топики (например, текущий редактируемый) */
excludeTopics?: string[]
/** Максимальное количество выбранных топиков */
maxSelection?: number
/** Заголовок компонента */
title?: string
/** Показать поиск */
showSearch?: boolean
/** Плейсхолдер для поиска */
searchPlaceholder?: string
/** Класс для стилизации */
class?: string
/** Скрыть выбранные элементы в заголовке (показывать только в основном списке) */
hideSelectedInHeader?: boolean
}
/**
* Компонент облака топиков для выбора
*/
const TopicPillsCloud = (props: TopicPillsCloudProps) => {
const [searchQuery, setSearchQuery] = createSignal('')
/**
* Фильтрованные и отсортированные топики
*/
const filteredTopics = createMemo(() => {
let topics = props.topics
// Исключаем запрещенные топики
if (props.excludeTopics?.length) {
topics = topics.filter(topic => !props.excludeTopics!.includes(topic.id))
}
// Фильтруем по поисковому запросу
const query = searchQuery().toLowerCase().trim()
if (query) {
topics = topics.filter(topic =>
topic.title.toLowerCase().includes(query) ||
topic.slug.toLowerCase().includes(query)
)
}
// Умная сортировка: выбранные → релевантные → остальные
return topics.sort((a, b) => {
const aSelected = props.selectedTopics.includes(a.id)
const bSelected = props.selectedTopics.includes(b.id)
// Сначала выбранные топики
if (aSelected && !bSelected) return -1
if (!aSelected && bSelected) return 1
// Для не выбранных: приоритет топикам из того же сообщества
if (props.communityFilter) {
const aSameCommunity = a.community === props.communityFilter
const bSameCommunity = b.community === props.communityFilter
if (aSameCommunity && !bSameCommunity) return -1
if (!aSameCommunity && bSameCommunity) return 1
}
// Потом по алфавиту
return a.title.localeCompare(b.title, 'ru')
})
})
/**
* Обработчик клика по топику
*/
const handleTopicClick = (topicId: string) => {
const currentSelection = [...props.selectedTopics]
const index = currentSelection.indexOf(topicId)
if (index >= 0) {
// Убираем из выбора
currentSelection.splice(index, 1)
} else {
// Добавляем в выбор (если не превышен лимит)
if (!props.maxSelection || currentSelection.length < props.maxSelection) {
currentSelection.push(topicId)
}
}
props.onSelectionChange(currentSelection)
}
/**
* Проверяет, выбран ли топик
*/
const isSelected = (topicId: string) => props.selectedTopics.includes(topicId)
/**
* Проверяет, можно ли выбрать еще топики
*/
const canSelectMore = () => {
return !props.maxSelection || props.selectedTopics.length < props.maxSelection
}
/**
* Получает уровень вложенности топика
*/
const getTopicDepth = (topic: TopicPill): number => {
if (topic.depth !== undefined) return topic.depth
return topic.parent_ids?.length || 0
}
/**
* Получить выбранные топики как объекты
*/
const selectedTopicObjects = createMemo(() => {
return props.topics.filter(topic => props.selectedTopics.includes(topic.id))
})
return (
<div class={`${styles.topicPillsCloud} ${props.class || ''}`}>
{/* Поиск в самом верху */}
<Show when={props.showSearch}>
<div class={styles.pillsSearchContainer}>
<input
type="text"
class={`${styles.input} ${styles.pillsSearchInput}`}
placeholder={props.searchPlaceholder || 'Поиск...'}
value={searchQuery()}
onInput={(e) => setSearchQuery(e.currentTarget.value)}
/>
<Show when={searchQuery().trim()}>
<button
type="button"
class={styles.clearSearchBtn}
onClick={() => setSearchQuery('')}
title="Очистить поиск"
>
×
</button>
</Show>
</div>
</Show>
{/* Заголовок и выбранные топики */}
<Show when={props.title || (props.selectedTopics.length > 0 && !props.hideSelectedInHeader)}>
<div class={styles.pillsCloudHeader}>
<div class={styles.headerSection}>
<Show when={props.title}>
<h4 class={styles.pillsCloudTitle}>{props.title}</h4>
</Show>
<Show when={props.selectedTopics.length > 0 && !props.hideSelectedInHeader}>
<div class={styles.selectedTopicsDisplay}>
<span class={styles.selectedLabel}>Выбрано ({props.selectedTopics.length}):</span>
<div class={styles.selectedTopicsContainer}>
<For each={selectedTopicObjects()}>
{(topic) => (
<button
type="button"
class={`${styles.topicPill} ${styles.pillSelected} ${styles.pillCompact}`}
onClick={() => handleTopicClick(topic.id)}
title={`Убрать ${topic.title}`}
>
<span class={styles.pillTitle}>{topic.title}</span>
<span class={styles.pillRemoveIcon}>×</span>
</button>
)}
</For>
</div>
</div>
</Show>
</div>
</div>
</Show>
<div class={styles.pillsContainer}>
<For each={filteredTopics()}>
{(topic) => {
const selected = isSelected(topic.id)
const disabled = !selected && !canSelectMore()
const depth = getTopicDepth(topic)
return (
<button
type="button"
class={`${styles.topicPill} ${
selected ? styles.pillSelected : ''
} ${disabled ? styles.pillDisabled : ''} ${
depth > 0 ? styles.pillNested : ''
}`}
onClick={() => !disabled && handleTopicClick(topic.id)}
disabled={disabled}
title={`${topic.title} (${topic.slug})`}
data-depth={depth}
>
<Show when={depth > 0}>
<span class={styles.pillDepthIndicator}>
{' '.repeat(depth)}
</span>
</Show>
<span class={styles.pillTitle}>{topic.title}</span>
<Show when={selected}>
<span class={styles.pillRemoveIcon}>×</span>
</Show>
</button>
)
}}
</For>
</div>
<Show when={filteredTopics().length === 0}>
<div class={styles.emptyState}>
<Show
when={searchQuery().trim()}
fallback={<span>Нет доступных топиков</span>}
>
<span>Ничего не найдено по запросу "{searchQuery()}"</span>
</Show>
</div>
</Show>
</div>
)
}
export default TopicPillsCloud

View File

@ -1,6 +1,6 @@
{ {
"shout": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"], "shout": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"],
"topic": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"], "topic": ["create", "read", "update_own", "update_any", "delete_own", "delete_any", "merge"],
"collection": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"], "collection": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"],
"bookmark": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"], "bookmark": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"],
"invite": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"], "invite": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"],

View File

@ -1,7 +1,9 @@
from cache.triggers import events_register from cache.triggers import events_register
from resolvers.admin import ( from resolvers.admin import (
admin_create_topic,
admin_get_roles, admin_get_roles,
admin_get_users, admin_get_users,
admin_update_topic,
) )
from resolvers.auth import ( from resolvers.auth import (
confirm_email, confirm_email,
@ -81,9 +83,11 @@ from resolvers.topic import (
events_register() events_register()
__all__ = [ __all__ = [
"admin_create_topic",
"admin_get_roles", "admin_get_roles",
# admin # admin
"admin_get_users", "admin_get_users",
"admin_update_topic",
"confirm_email", "confirm_email",
"create_draft", "create_draft",
# reaction # reaction

View File

@ -189,13 +189,277 @@ async def admin_get_topics(_: None, _info: GraphQLResolveInfo, community_id: int
for topic in topics for topic in topics
] ]
logger.info("Загружено топиков для сообщества", len(result)) logger.info(f"Загружено топиков для сообщества: {len(result)}")
return result return result
except Exception as e: except Exception as e:
raise handle_error("получении списка топиков", e) from e raise handle_error("получении списка топиков", e) from e
@mutation.field("adminUpdateTopic")
@admin_auth_required
async def admin_update_topic(_: None, _info: GraphQLResolveInfo, topic: dict[str, Any]) -> dict[str, Any]:
"""Обновляет топик через админ-панель"""
try:
from orm.topic import Topic
from resolvers.topic import invalidate_topics_cache
from services.db import local_session
from services.redis import redis
topic_id = topic.get("id")
if not topic_id:
return {"success": False, "error": "ID топика не указан"}
with local_session() as session:
existing_topic = session.query(Topic).filter(Topic.id == topic_id).first()
if not existing_topic:
return {"success": False, "error": "Топик не найден"}
# Сохраняем старый slug для удаления из кеша
old_slug = str(getattr(existing_topic, "slug", ""))
# Обновляем поля топика
for key, value in topic.items():
if key != "id" and hasattr(existing_topic, key):
setattr(existing_topic, key, value)
session.add(existing_topic)
session.commit()
# Инвалидируем кеш
await invalidate_topics_cache(topic_id)
# Если slug изменился, удаляем старый ключ
new_slug = str(getattr(existing_topic, "slug", ""))
if old_slug != new_slug:
await redis.execute("DEL", f"topic:slug:{old_slug}")
logger.debug(f"Удален ключ кеша для старого slug: {old_slug}")
logger.info(f"Топик {topic_id} обновлен через админ-панель")
return {"success": True, "topic": existing_topic}
except Exception as e:
logger.error(f"Ошибка обновления топика: {e}")
return {"success": False, "error": str(e)}
@mutation.field("adminCreateTopic")
@admin_auth_required
async def admin_create_topic(_: None, _info: GraphQLResolveInfo, topic: dict[str, Any]) -> dict[str, Any]:
"""Создает новый топик через админ-панель"""
try:
from orm.topic import Topic
from resolvers.topic import invalidate_topics_cache
from services.db import local_session
with local_session() as session:
# Создаем новый топик
new_topic = Topic(**topic)
session.add(new_topic)
session.commit()
# Инвалидируем кеш всех тем
await invalidate_topics_cache()
logger.info(f"Топик {new_topic.id} создан через админ-панель")
return {"success": True, "topic": new_topic}
except Exception as e:
logger.error(f"Ошибка создания топика: {e}")
return {"success": False, "error": str(e)}
@mutation.field("adminMergeTopics")
@admin_auth_required
async def admin_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: Результат операции с информацией о слиянии
"""
try:
from orm.draft import DraftTopic
from orm.shout import ShoutTopic
from orm.topic import Topic, TopicFollower
from resolvers.topic import invalidate_topic_followers_cache, invalidate_topics_cache
from services.db import local_session
from services.redis import redis
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)
# Проверяем что ID не пересекаются
if target_topic_id in source_topic_ids:
return {"success": False, "error": "Целевая тема не может быть в списке исходных тем"}
with local_session() as session:
# Получаем целевую тему
target_topic = session.query(Topic).filter(Topic.id == target_topic_id).first()
if not target_topic:
return {"success": False, "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 {"success": False, "error": f"Исходные темы с ID {missing_ids} не найдены"}
# Проверяем что все темы принадлежат одному сообществу
target_community = target_topic.community
for source_topic in source_topics:
if source_topic.community != target_community:
return {"success": False, "error": f"Тема '{source_topic.title}' принадлежит другому сообществу"}
# Собираем статистику для отчета
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)
# Переносим публикации из исходных тем в целевую
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)
# Переносим черновики из исходных тем в целевую
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 дочерних топиков
for source_topic in source_topics:
# Находим всех детей исходной темы
child_topics = session.query(Topic).filter(Topic.parent_ids.contains(int(source_topic.id))).all() # type: ignore[arg-type]
for child_topic in child_topics:
current_parent_ids = list(child_topic.parent_ids or [])
# Заменяем ID исходной темы на ID целевой темы
updated_parent_ids = [
target_topic_id if parent_id == source_topic.id else parent_id
for parent_id in current_parent_ids
]
child_topic.parent_ids = updated_parent_ids
# Объединяем parent_ids если не сохраняем только целевые свойства
if not preserve_target:
current_parent_ids = list(target_topic.parent_ids or [])
all_parent_ids = set(current_parent_ids)
for source_topic in source_topics:
source_parent_ids = 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 []
# Инвалидируем кеши ПЕРЕД удалением тем
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 {
"success": True,
"topic": target_topic,
"message": f"Успешно слито {len(source_topics)} тем в '{target_topic.title}'",
"stats": merge_stats,
}
except Exception as e:
logger.error(f"Ошибка при слиянии тем через админ-панель: {e}")
return {"success": False, "error": f"Ошибка при слиянии тем: {e}"}
# === ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ === # === ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ ===
@ -206,8 +470,8 @@ async def get_env_variables(_: None, _info: GraphQLResolveInfo) -> list[dict[str
try: try:
return await admin_service.get_env_variables() return await admin_service.get_env_variables()
except Exception as e: except Exception as e:
logger.error("Ошибка получения переменных окружения", e) logger.error(f"Ошибка получения переменных окружения: {e}")
raise GraphQLError("Не удалось получить переменные окружения", e) from e raise GraphQLError("Не удалось получить переменные окружения") from e
@mutation.field("updateEnvVariable") @mutation.field("updateEnvVariable")
@ -234,8 +498,8 @@ async def admin_get_roles(_: None, _info: GraphQLResolveInfo, community: int = N
try: try:
return admin_service.get_roles(community) return admin_service.get_roles(community)
except Exception as e: except Exception as e:
logger.error("Ошибка получения ролей", e) logger.error(f"Ошибка получения ролей: {e}")
raise GraphQLError("Не удалось получить роли", e) from e raise GraphQLError("Не удалось получить роли") from e
# === ЗАГЛУШКИ ДЛЯ ОСТАЛЬНЫХ РЕЗОЛВЕРОВ === # === ЗАГЛУШКИ ДЛЯ ОСТАЛЬНЫХ РЕЗОЛВЕРОВ ===

View File

@ -18,8 +18,8 @@ from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic, TopicFollower from orm.topic import Topic, TopicFollower
from resolvers.stat import get_with_stat from resolvers.stat import get_with_stat
from services.auth import login_required
from services.db import local_session from services.db import local_session
from services.rbac import require_any_permission, require_permission
from services.redis import redis from services.redis import redis
from services.schema import mutation, query from services.schema import mutation, query
from utils.logger import root_logger as logger from utils.logger import root_logger as logger
@ -397,7 +397,7 @@ async def get_topic(_: None, _info: GraphQLResolveInfo, slug: str) -> Optional[A
# Мутация для создания новой темы # Мутация для создания новой темы
@mutation.field("create_topic") @mutation.field("create_topic")
@login_required @require_permission("topic:create")
async def create_topic(_: None, _info: GraphQLResolveInfo, topic_input: dict[str, Any]) -> dict[str, Any]: async def create_topic(_: None, _info: GraphQLResolveInfo, topic_input: dict[str, Any]) -> dict[str, Any]:
with local_session() as session: with local_session() as session:
# TODO: проверить права пользователя на создание темы для конкретного сообщества # TODO: проверить права пользователя на создание темы для конкретного сообщества
@ -414,7 +414,7 @@ async def create_topic(_: None, _info: GraphQLResolveInfo, topic_input: dict[str
# Мутация для обновления темы # Мутация для обновления темы
@mutation.field("update_topic") @mutation.field("update_topic")
@login_required @require_any_permission(["topic:update_own", "topic:update_any"])
async def update_topic(_: None, _info: GraphQLResolveInfo, topic_input: dict[str, Any]) -> dict[str, Any]: async def update_topic(_: None, _info: GraphQLResolveInfo, topic_input: dict[str, Any]) -> dict[str, Any]:
slug = topic_input["slug"] slug = topic_input["slug"]
with local_session() as session: with local_session() as session:
@ -439,7 +439,7 @@ async def update_topic(_: None, _info: GraphQLResolveInfo, topic_input: dict[str
# Мутация для удаления темы # Мутация для удаления темы
@mutation.field("delete_topic") @mutation.field("delete_topic")
@login_required @require_any_permission(["topic:delete_own", "topic:delete_any"])
async def delete_topic(_: None, info: GraphQLResolveInfo, slug: str) -> dict[str, Any]: async def delete_topic(_: None, info: GraphQLResolveInfo, slug: str) -> dict[str, Any]:
viewer_id = info.context.get("author", {}).get("id") viewer_id = info.context.get("author", {}).get("id")
with local_session() as session: with local_session() as session:
@ -483,7 +483,7 @@ async def get_topic_authors(_: None, _info: GraphQLResolveInfo, slug: str) -> li
# Мутация для удаления темы по ID (для админ-панели) # Мутация для удаления темы по ID (для админ-панели)
@mutation.field("delete_topic_by_id") @mutation.field("delete_topic_by_id")
@login_required @require_any_permission(["topic:delete_own", "topic:delete_any"])
async def delete_topic_by_id(_: None, info: GraphQLResolveInfo, topic_id: int) -> dict[str, Any]: async def delete_topic_by_id(_: None, info: GraphQLResolveInfo, topic_id: int) -> dict[str, Any]:
""" """
Удаляет тему по ID. Используется в админ-панели. Удаляет тему по ID. Используется в админ-панели.
@ -535,7 +535,7 @@ async def delete_topic_by_id(_: None, info: GraphQLResolveInfo, topic_id: int) -
# Мутация для слияния тем # Мутация для слияния тем
@mutation.field("merge_topics") @mutation.field("merge_topics")
@login_required @require_permission("topic:merge")
async def merge_topics(_: None, info: GraphQLResolveInfo, merge_input: dict[str, Any]) -> dict[str, Any]: async def merge_topics(_: None, info: GraphQLResolveInfo, merge_input: dict[str, Any]) -> dict[str, Any]:
""" """
Сливает несколько тем в одну с переносом всех связей. Сливает несколько тем в одну с переносом всех связей.
@ -731,7 +731,7 @@ async def merge_topics(_: None, info: GraphQLResolveInfo, merge_input: dict[str,
# Мутация для простого назначения родителя темы # Мутация для простого назначения родителя темы
@mutation.field("set_topic_parent") @mutation.field("set_topic_parent")
@login_required @require_any_permission(["topic:update_own", "topic:update_any"])
async def set_topic_parent( async def set_topic_parent(
_: None, info: GraphQLResolveInfo, topic_id: int, parent_id: int | None = None _: None, info: GraphQLResolveInfo, topic_id: int, parent_id: int | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:

View File

@ -220,6 +220,13 @@ type CustomRoleResult {
role: Role role: Role
} }
# Результат операций с топиками
type AdminTopicResult {
success: Boolean!
error: String
topic: Topic
}
extend type Query { extend type Query {
getEnvVariables: [EnvSection!]! getEnvVariables: [EnvSection!]!
# Запросы для управления пользователями # Запросы для управления пользователями
@ -289,4 +296,9 @@ extend type Mutation {
# Создание и удаление произвольных ролей # Создание и удаление произвольных ролей
adminCreateCustomRole(role: CustomRoleInput!): CustomRoleResult! adminCreateCustomRole(role: CustomRoleInput!): CustomRoleResult!
adminDeleteCustomRole(role_id: String!, community_id: Int!): OperationResult! adminDeleteCustomRole(role_id: String!, community_id: Int!): OperationResult!
# Admin mutations для управления топиками
adminUpdateTopic(topic: AdminTopicInput!): AdminTopicResult!
adminCreateTopic(topic: AdminTopicInput!): AdminTopicResult!
adminMergeTopics(merge_input: TopicMergeInput!): AdminTopicResult!
} }

View File

@ -25,6 +25,16 @@ input TopicInput {
parent_ids: [Int] parent_ids: [Int]
} }
input AdminTopicInput {
id: Int!
slug: String
title: String
body: String
pic: String
community: Int
parent_ids: [Int]
}
input TopicMergeInput { input TopicMergeInput {
target_topic_id: Int! target_topic_id: Int!
source_topic_ids: [Int!]! source_topic_ids: [Int!]!