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

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

View File

@ -1,5 +1,60 @@
# Changelog
## [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
### Исправление критических проблем админ-панели
@ -478,7 +533,7 @@
- **Оптимизированный скролл**: Эффективная синхронизация между элементами
- **Уменьшенные перерисовки**: Минимизация DOM манипуляций
- **ACCESSIBILITY И СОВРЕМЕННЫЕ СТАНДАРЫ**:
- **ACCESSIBILITY И СОВРЕМЕННЫЕ СТАНДАРТЫ**:
- **ARIA атрибуты**: Правильная семантическая разметка
- **Клавиатурная навигация**: Полная поддержка навигации с клавиатуры
- **Читаемые фокусные состояния**: Четкие индикаторы фокуса

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,11 @@
import { createEffect, createSignal, For, Show } from 'solid-js'
import { createEffect, createSignal, For, Show, on } from 'solid-js'
import { Topic, useData } from '../context/data'
import { query } from '../graphql'
import { ADMIN_UPDATE_TOPIC_MUTATION } from '../graphql/mutations'
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 TopicPillsCloud, { type TopicPill } from '../ui/TopicPillsCloud'
import HTMLEditor from '../ui/HTMLEditor'
interface TopicEditModalProps {
topic: Topic
@ -28,35 +30,36 @@ export default function TopicEditModal(props: TopicEditModalProps) {
// Состояние для выбора родителей
const [availableParents, setAvailableParents] = createSignal<Topic[]>([])
const [parentSearch, setParentSearch] = createSignal('')
// Состояние для редактирования body
const [showBodyEditor, setShowBodyEditor] = createSignal(false)
const [bodyContent, setBodyContent] = createSignal('')
const [saving, setSaving] = createSignal(false)
// Инициализация формы при открытии
createEffect(() => {
if (props.isOpen && props.topic) {
console.log('[TopicEditModal] Initializing with topic:', props.topic)
const topicCommunity = props.topic.community || selectedCommunity() || 0
setFormData({
id: props.topic.id,
title: props.topic.title || '',
slug: props.topic.slug || '',
body: props.topic.body || '',
community: selectedCommunity() || 0,
community: topicCommunity,
parent_ids: props.topic.parent_ids || []
})
setBodyContent(props.topic.body || '')
updateAvailableParents(selectedCommunity() || 0)
updateAvailableParents(topicCommunity, props.topic.id)
}
})
// Обновление доступных родителей при изменении сообщества в форме
createEffect(on(() => formData().community, (communityId) => {
if (communityId > 0) {
updateAvailableParents(communityId)
}
}))
// Обновление доступных родителей при смене сообщества
const updateAvailableParents = (communityId: number) => {
const updateAvailableParents = (communityId: number, excludeTopicId?: number) => {
const allTopics = topics()
const currentTopicId = formData().id
const currentTopicId = excludeTopicId || formData().id
// Фильтруем топики того же сообщества, исключая текущий топик
const filteredTopics = allTopics.filter(
@ -66,40 +69,32 @@ export default function TopicEditModal(props: TopicEditModalProps) {
setAvailableParents(filteredTopics)
}
// Фильтрация родителей по поиску
const filteredParents = () => {
const search = parentSearch().toLowerCase()
if (!search) return availableParents()
return availableParents().filter(
(topic) => topic.title?.toLowerCase().includes(search) || topic.slug?.toLowerCase().includes(search)
)
}
// Обработка изменения сообщества
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]
/**
* Преобразование Topic в TopicPill для компонента TopicPillsCloud
*/
const convertTopicsToTopicPills = (topics: Topic[]): TopicPill[] => {
return topics.map(topic => ({
id: topic.id.toString(),
title: topic.title || '',
slug: topic.slug || '',
community: getCommunityName(topic.community),
parent_ids: (topic.parent_ids || []).map(id => id.toString()),
}))
}
/**
* Обработка изменения выбора родительских топиков из таблеточек
*/
const handleParentSelectionChange = (selectedIds: string[]) => {
const parentIds = selectedIds.map(id => Number.parseInt(id))
setFormData((prev) => ({
...prev,
parent_ids: parentIds
}))
}
// Сообщество топика изменить нельзя, поэтому обработчик не нужен
// Обработка изменения полей формы
const handleFieldChange = (field: string, value: string) => {
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 () => {
try {
setSaving(true)
const updatedTopic = {
...props.topic,
...formData()
const topicData = 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
}
})
if (result.adminUpdateTopic.success && result.adminUpdateTopic.topic) {
console.log('[TopicEditModal] Topic saved successfully:', result.adminUpdateTopic.topic)
props.onSave(result.adminUpdateTopic.topic)
props.onClose()
} else {
const errorMessage = result.adminUpdateTopic.error || 'Неизвестная ошибка'
throw new Error(errorMessage)
}
console.log('[TopicEditModal] Saving topic:', updatedTopic)
// TODO: Здесь должен быть вызов API для сохранения
// await updateTopic(updatedTopic)
props.onSave(updatedTopic)
props.onClose()
} catch (error) {
console.error('[TopicEditModal] Error saving topic:', error)
props.onError?.(error instanceof Error ? error.message : 'Ошибка сохранения топика')
@ -161,16 +147,34 @@ export default function TopicEditModal(props: TopicEditModalProps) {
return (
<>
<Modal
isOpen={props.isOpen && !showBodyEditor()}
isOpen={props.isOpen}
onClose={props.onClose}
title="Редактирование топика"
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.section}>
<h3>Основная информация</h3>
<div class={styles.field}>
<label class={styles.label}>
Название:
@ -200,147 +204,50 @@ export default function TopicEditModal(props: TopicEditModalProps) {
<div class={styles.field}>
<label class={styles.label}>
Сообщество:
<select class={styles.select} value={formData().community} onChange={handleCommunityChange}>
<option value={0}>Выберите сообщество</option>
<For each={communities()}>
{(community) => <option value={community.id}>{community.name}</option>}
</For>
</select>
<div class={`${styles.input} ${styles.disabled} ${styles.communityDisplay}`}>
{getCommunityName(formData().community) || 'Сообщество не выбрано'}
</div>
</label>
<div class={`${styles.hint} ${styles.warningHint}`}>
📍 Сообщество топика нельзя изменить после создания
</div>
</div>
</div>
{/* Содержимое */}
<div class={styles.section}>
<h3>Содержимое</h3>
<div class={styles.field}>
<label class={styles.label}>Body:</label>
<div class={styles.bodyPreview} onClick={handleOpenBodyEditor}>
<Show when={formData().body}>
<div class={styles.bodyContent}>
{formData().body.length > 200
? `${formData().body.substring(0, 200)}...`
: formData().body}
</div>
</Show>
<Show when={!formData().body}>
<div class={styles.bodyPlaceholder}>Нет содержимого. Нажмите для редактирования.</div>
</Show>
<div class={styles.bodyHint}> Кликните для редактирования в полноэкранном редакторе</div>
</div>
<label class={styles.label}>
Описание:
<HTMLEditor
value={formData().body}
onInput={(value) => handleFieldChange('body', value)}
/>
</label>
</div>
</div>
{/* Родительские топики */}
<Show when={formData().community > 0}>
<div class={styles.section}>
<h3>Родительские топики</h3>
{/* Компонент с таблеточками для выбора родителей */}
<div class={styles.field}>
<label class={styles.label}>
Поиск родителей:
<input
type="text"
class={styles.input}
value={parentSearch()}
onInput={(e) => setParentSearch(e.currentTarget.value)}
placeholder="Введите название для поиска..."
<TopicPillsCloud
topics={convertTopicsToTopicPills(availableParents())}
selectedTopics={formData().parent_ids.map(id => id.toString())}
onSelectionChange={handleParentSelectionChange}
excludeTopics={[formData().id.toString()]}
showSearch={true}
searchPlaceholder="Задайте родительские темы..."
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>
</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>
</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>
</>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -14,7 +14,7 @@ interface TopicsProps {
}
export const Topics = (props: TopicsProps) => {
const { selectedCommunity, loadTopicsByCommunity, topics: contextTopics } = useData()
const { selectedCommunity, loadTopicsByCommunity, topics: contextTopics, getTopicTitle } = useData()
// Состояние поиска
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] 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?.('Топик успешно обновлён')
// Обновляем локальные данные (пока что просто перезагружаем)
void loadTopicsForCommunity()
// Ждем большее время чтобы сервер точно обработал изменения и инвалидировал кеш
console.log('[TopicsRoute] Scheduling reload in 500ms...')
setTimeout(() => {
console.log('[TopicsRoute] Reloading topics from server...')
void loadTopicsForCommunity()
}, 500)
}
/**
@ -152,6 +168,40 @@ export const Topics = (props: TopicsProps) => {
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}>
{truncateText(topic.slug, 30)}
</td>
<td class={styles.tableCell}>
{renderParentTopics(topic.parent_ids)}
</td>
<td class={styles.tableCell}>
{topic.body ? (
<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}>
Slug
</SortableHeader>
<th class={styles.tableHeaderCell}>Родительские темы</th>
<th class={styles.tableHeaderCell}>Body</th>
</tr>
</thead>
<tbody>
<Show when={loading()}>
<tr>
<td colspan="4" class={styles.loadingCell}>
<td colspan="5" class={styles.loadingCell}>
Загрузка...
</td>
</tr>
</Show>
<Show when={!loading() && sortedTopics().length === 0}>
<tr>
<td colspan="4" class={styles.emptyCell}>
<td colspan="5" class={styles.emptyCell}>
Нет топиков
</td>
</tr>

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

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

View File

@ -1,6 +1,6 @@
{
"shout": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"],
"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"],
"bookmark": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"],
"invite": ["create", "read", "update_own", "update_any", "delete_own", "delete_any"],

View File

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

View File

@ -189,13 +189,277 @@ async def admin_get_topics(_: None, _info: GraphQLResolveInfo, community_id: int
for topic in topics
]
logger.info("Загружено топиков для сообщества", len(result))
logger.info(f"Загружено топиков для сообщества: {len(result)}")
return result
except Exception as 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:
return await admin_service.get_env_variables()
except Exception as e:
logger.error("Ошибка получения переменных окружения", e)
raise GraphQLError("Не удалось получить переменные окружения", e) from e
logger.error(f"Ошибка получения переменных окружения: {e}")
raise GraphQLError("Не удалось получить переменные окружения") from e
@mutation.field("updateEnvVariable")
@ -234,8 +498,8 @@ async def admin_get_roles(_: None, _info: GraphQLResolveInfo, community: int = N
try:
return admin_service.get_roles(community)
except Exception as e:
logger.error("Ошибка получения ролей", e)
raise GraphQLError("Не удалось получить роли", e) from e
logger.error(f"Ошибка получения ролей: {e}")
raise GraphQLError("Не удалось получить роли") from e
# === ЗАГЛУШКИ ДЛЯ ОСТАЛЬНЫХ РЕЗОЛВЕРОВ ===

View File

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

View File

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

View File

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