This commit is contained in:
parent
441cca8045
commit
eb2140bcc6
57
CHANGELOG.md
57
CHANGELOG.md
|
@ -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 атрибуты**: Правильная семантическая разметка
|
||||||
- **Клавиатурная навигация**: Полная поддержка навигации с клавиатуры
|
- **Клавиатурная навигация**: Полная поддержка навигации с клавиатуры
|
||||||
- **Читаемые фокусные состояния**: Четкие индикаторы фокуса
|
- **Читаемые фокусные состояния**: Четкие индикаторы фокуса
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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 (упрощенная конфигурация)
|
||||||
|
|
|
@ -255,7 +255,6 @@ export const AdminPanel: Component = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Панель администратора</h1>
|
|
||||||
{/* Контент админки */}
|
{/* Контент админки */}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Проверка конкретной роли
|
#### Проверка конкретной роли
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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..."
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
@ -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
351
panel/ui/HTMLEditor.tsx
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
|
||||||
|
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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
|
||||||
|
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
|
252
panel/ui/TopicPillsCloud.tsx
Normal file
252
panel/ui/TopicPillsCloud.tsx
Normal 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
|
|
@ -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"],
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
# === ЗАГЛУШКИ ДЛЯ ОСТАЛЬНЫХ РЕЗОЛВЕРОВ ===
|
# === ЗАГЛУШКИ ДЛЯ ОСТАЛЬНЫХ РЕЗОЛВЕРОВ ===
|
||||||
|
|
|
@ -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]:
|
||||||
|
|
|
@ -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!
|
||||||
}
|
}
|
||||||
|
|
|
@ -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!]!
|
||||||
|
|
Loading…
Reference in New Issue
Block a user