diff --git a/CHANGELOG.md b/CHANGELOG.md index 464a1a75..b4f71e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## [0.7.8] - 2025-07-04 + +### πŸ’¬ БистСма управлСния рСакциями Π² Π°Π΄ΠΌΠΈΠ½-ΠΏΠ°Π½Π΅Π»ΠΈ + +Π”ΠΎΠ±Π°Π²Π»Π΅Π½Π° полная систСма просмотра ΠΈ ΠΌΠΎΠ΄Π΅Ρ€Π°Ρ†ΠΈΠΈ Ρ€Π΅Π°ΠΊΡ†ΠΈΠΉ с Ρ€Π°ΡΡˆΠΈΡ€Π΅Π½Π½Ρ‹ΠΌΠΈ возмоТностями Ρ„ΠΈΠ»ΡŒΡ‚Ρ€Π°Ρ†ΠΈΠΈ ΠΈ управлСния. + +#### Новая Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΎΠ½Π°Π»ΡŒΠ½ΠΎΡΡ‚ΡŒ +- **Π’ΠΊΠ»Π°Π΄ΠΊΠ° "Π Π΅Π°ΠΊΡ†ΠΈΠΈ"** Π² Π½Π°Π²ΠΈΠ³Π°Ρ†ΠΈΠΈ Π°Π΄ΠΌΠΈΠ½-ΠΏΠ°Π½Π΅Π»ΠΈ с эмодТи-ΠΈΠ½Π΄ΠΈΠΊΠ°Ρ‚ΠΎΡ€Π°ΠΌΠΈ +- **ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€ всСх Ρ€Π΅Π°ΠΊΡ†ΠΈΠΉ** с Π΄Π΅Ρ‚Π°Π»ΡŒΠ½ΠΎΠΉ ΠΈΠ½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΠ΅ΠΉ ΠΎ Ρ‚ΠΈΠΏΠ΅, Π°Π²Ρ‚ΠΎΡ€Π΅, ΠΏΡƒΠ±Π»ΠΈΠΊΠ°Ρ†ΠΈΠΈ ΠΈ статистикС +- **Π€ΠΈΠ»ΡŒΡ‚Ρ€Π°Ρ†ΠΈΡ ΠΏΠΎ Ρ‚ΠΈΠΏΠ°ΠΌ**: Π»Π°ΠΉΠΊΠΈ, Π΄ΠΈΠ·Π»Π°ΠΉΠΊΠΈ, ΠΊΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠΈ, Ρ†ΠΈΡ‚Π°Ρ‚Ρ‹, согласиС/нСсогласиС, вопросы, прСдлоТСния, Π΄ΠΎΠΊΠ°Π·Π°Ρ‚Π΅Π»ΡŒΡΡ‚Π²Π°/опровСрТСния +- **Поиск ΠΏΠΎ тСксту Ρ€Π΅Π°ΠΊΡ†ΠΈΠΈ**, ΠΈΠΌΠ΅Π½ΠΈ Π°Π²Ρ‚ΠΎΡ€Π°, email ΠΈΠ»ΠΈ названию ΠΏΡƒΠ±Π»ΠΈΠΊΠ°Ρ†ΠΈΠΈ +- **Π€ΠΈΠ»ΡŒΡ‚Ρ€Π°Ρ†ΠΈΡ ΠΏΠΎ ID ΠΏΡƒΠ±Π»ΠΈΠΊΠ°Ρ†ΠΈΠΈ** для ΠΌΠΎΠ΄Π΅Ρ€Π°Ρ†ΠΈΠΈ ΠΊΠΎΠ½ΠΊΡ€Π΅Ρ‚Π½Ρ‹Ρ… постов +- **Бтатус Ρ€Π΅Π°ΠΊΡ†ΠΈΠΉ**: Π²ΠΈΠ·ΡƒΠ°Π»ΡŒΠ½ΠΎΠ΅ ΠΎΡ‚ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹Ρ… ΠΈ ΡƒΠ΄Π°Π»Π΅Π½Π½Ρ‹Ρ… Ρ€Π΅Π°ΠΊΡ†ΠΈΠΉ + +#### ΠœΠΎΠ΄Π΅Ρ€Π°Ρ†ΠΈΡ Ρ€Π΅Π°ΠΊΡ†ΠΈΠΉ +- **Π Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ тСкста** Ρ€Π΅Π°ΠΊΡ†ΠΈΠΉ Ρ‡Π΅Ρ€Π΅Π· модальноС ΠΎΠΊΠ½ΠΎ +- **МягкоС ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠ΅** Ρ€Π΅Π°ΠΊΡ†ΠΈΠΉ с Π²ΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎΡΡ‚ΡŒΡŽ восстановлСния +- **ВосстановлСниС ΡƒΠ΄Π°Π»Π΅Π½Π½Ρ‹Ρ…** Ρ€Π΅Π°ΠΊΡ†ΠΈΠΉ ΠΎΠ΄Π½ΠΈΠΌ ΠΊΠ»ΠΈΠΊΠΎΠΌ +- **ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€ статистики**: Ρ€Π΅ΠΉΡ‚ΠΈΠ½Π³ ΠΈ количСство ΠΊΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠ΅Π² ΠΊ ΠΊΠ°ΠΆΠ΄ΠΎΠΉ Ρ€Π΅Π°ΠΊΡ†ΠΈΠΈ +- **Π€ΠΈΠ»ΡŒΡ‚Ρ€ ΠΏΠΎ статусу**: администратор Π²ΠΈΠ΄ΠΈΡ‚ всС Ρ€Π΅Π°ΠΊΡ†ΠΈΠΈ Π²ΠΊΠ»ΡŽΡ‡Π°Ρ ΡƒΠ΄Π°Π»Π΅Π½Π½Ρ‹Π΅ (Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹Π΅/ΡƒΠ΄Π°Π»Π΅Π½Π½Ρ‹Π΅/всС) + +#### Π£ΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅ публикациями +- **ΠŸΠΎΠ»Π½Ρ‹ΠΉ доступ**: администратор Π²ΠΈΠ΄ΠΈΡ‚ всС ΠΏΡƒΠ±Π»ΠΈΠΊΠ°Ρ†ΠΈΠΈ Π²ΠΊΠ»ΡŽΡ‡Π°Ρ ΡƒΠ΄Π°Π»Π΅Π½Π½Ρ‹Π΅ +- **Бтатус-Ρ„ΠΈΠ»ΡŒΡ‚Ρ€Ρ‹**: ΠΎΠΏΡƒΠ±Π»ΠΈΠΊΠΎΠ²Π°Π½Π½Ρ‹Π΅, Ρ‡Π΅Ρ€Π½ΠΎΠ²ΠΈΠΊΠΈ, ΡƒΠ΄Π°Π»Π΅Π½Π½Ρ‹Π΅ ΠΈΠ»ΠΈ всС ΠΏΡƒΠ±Π»ΠΈΠΊΠ°Ρ†ΠΈΠΈ + +#### GraphQL API +- `adminGetReactions` - ΠΏΠΎΠ»ΡƒΡ‡Π΅Π½ΠΈΠ΅ списка Ρ€Π΅Π°ΠΊΡ†ΠΈΠΉ с ΠΏΠ°Π³ΠΈΠ½Π°Ρ†ΠΈΠ΅ΠΉ ΠΈ Ρ„ΠΈΠ»ΡŒΡ‚Ρ€Π°ΠΌΠΈ (Π²ΠΊΠ»ΡŽΡ‡Π°Ρ ΠΏΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€ `status`) +- `adminUpdateReaction` - ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠ΅ тСкста Ρ€Π΅Π°ΠΊΡ†ΠΈΠΈ +- `adminDeleteReaction` - мягкоС ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠ΅ Ρ€Π΅Π°ΠΊΡ†ΠΈΠΈ +- `adminRestoreReaction` - восстановлСниС ΡƒΠ΄Π°Π»Π΅Π½Π½ΠΎΠΉ Ρ€Π΅Π°ΠΊΡ†ΠΈΠΈ +- ОбновлСн ΠΏΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€ `status` Π² `adminGetShouts` для Ρ„ΠΈΠ»ΡŒΡ‚Ρ€Π°Ρ†ΠΈΠΈ ΡƒΠ΄Π°Π»Π΅Π½Π½Ρ‹Ρ… ΠΏΡƒΠ±Π»ΠΈΠΊΠ°Ρ†ΠΈΠΉ + +#### Π˜Π½Ρ‚Π΅Ρ€Ρ„Π΅ΠΉΡ +- **Π’Π°Π±Π»ΠΈΡ†Π° Ρ€Π΅Π°ΠΊΡ†ΠΈΠΉ** с сортировкой ΠΏΠΎ Π΄Π°Ρ‚Π΅ создания +- **Π­ΠΌΠΎΠ΄ΠΆΠΈ-ΠΈΠ½Π΄ΠΈΠΊΠ°Ρ‚ΠΎΡ€Ρ‹** для всСх Ρ‚ΠΈΠΏΠΎΠ² Ρ€Π΅Π°ΠΊΡ†ΠΈΠΉ (πŸ‘ πŸ‘Ž πŸ’¬ ❝ βœ… ❌ ❓ πŸ’‘ πŸ”¬ 🚫) +- **Русификация Ρ‚ΠΈΠΏΠΎΠ²** Ρ€Π΅Π°ΠΊΡ†ΠΈΠΉ Π² интСрфСйсС +- **Адаптивный Π΄ΠΈΠ·Π°ΠΉΠ½** с ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠΎΠΉ ΠΌΠΎΠ±ΠΈΠ»ΡŒΠ½Ρ‹Ρ… устройств +- **ΠŸΠ°Π³ΠΈΠ½Π°Ρ†ΠΈΡ** с настраиваСмым количСством элСмСнтов Π½Π° страницС + +#### Π‘Π΅Π·ΠΎΠΏΠ°ΡΠ½ΠΎΡΡ‚ΡŒ +- **RBAC Π·Π°Ρ‰ΠΈΡ‚Π°**: всС ΠΎΠΏΠ΅Ρ€Π°Ρ†ΠΈΠΈ Ρ‚Ρ€Π΅Π±ΡƒΡŽΡ‚ Ρ€ΠΎΠ»ΡŒ администратора +- **Валидация Π²Ρ…ΠΎΠ΄Π½Ρ‹Ρ… Π΄Π°Π½Π½Ρ‹Ρ…** ΠΈ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ° ошибок +- **Аудит ΠΎΠΏΠ΅Ρ€Π°Ρ†ΠΈΠΉ** с Π»ΠΎΠ³ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ΠΌ всСх ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ + ## [0.7.7] - 2025-07-03 ### πŸ” RBAC System for Topic Management diff --git a/docs/README.md b/docs/README.md index b1daa872..1605d7e0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,6 +41,14 @@ python dev.py - **Π”Π΅Ρ€Π΅Π²ΠΎ Ρ‚ΠΎΠΏΠΈΠΊΠΎΠ²**: Визуализация Ρ€ΠΎΠ΄ΠΈΡ‚Π΅Π»ΡŒΡΠΊΠΎ-Π΄ΠΎΡ‡Π΅Ρ€Π½ΠΈΡ… связСй с отступами ΠΈ символами `└─` - **БСзопасноС ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠ΅**: ΠŸΡ€Π΅Π΄ΡƒΠΏΡ€Π΅ΠΆΠ΄Π΅Π½ΠΈΡ ΠΎ каскадном ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠΈ Π΄ΠΎΡ‡Π΅Ρ€Π½ΠΈΡ… Ρ‚ΠΎΠΏΠΈΠΊΠΎΠ² - **АвтообновлСниС**: Π Π΅Ρ„Ρ€Π΅Ρˆ списка послС ΠΎΠΏΠ΅Ρ€Π°Ρ†ΠΈΠΉ с ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½ΠΎΠΉ ΠΈΠ½Π²Π°Π»ΠΈΠ΄Π°Ρ†ΠΈΠ΅ΠΉ кСшСй +- **ΠœΠΎΠ΄Π΅Ρ€Π°Ρ†ΠΈΡ Ρ€Π΅Π°ΠΊΡ†ΠΈΠΉ**: Полная систСма управлСния рСакциями ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ + - **ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€ всСх Ρ€Π΅Π°ΠΊΡ†ΠΈΠΉ**: Π’Π°Π±Π»ΠΈΡ†Π° с Ρ‚ΠΈΠΏΠΎΠΌ, тСкстом, Π°Π²Ρ‚ΠΎΡ€ΠΎΠΌ, ΠΏΡƒΠ±Π»ΠΈΠΊΠ°Ρ†ΠΈΠ΅ΠΉ ΠΈ статистикой + - **Π€ΠΈΠ»ΡŒΡ‚Ρ€Π°Ρ†ΠΈΡ ΠΏΠΎ Ρ‚ΠΈΠΏΠ°ΠΌ**: Π›Π°ΠΉΠΊΠΈ, Π΄ΠΈΠ·Π»Π°ΠΉΠΊΠΈ, ΠΊΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠΈ, Ρ†ΠΈΡ‚Π°Ρ‚Ρ‹, согласиС/нСсогласиС, вопросы, прСдлоТСния, Π΄ΠΎΠΊΠ°Π·Π°Ρ‚Π΅Π»ΡŒΡΡ‚Π²Π°/опровСрТСния + - **Поиск ΠΈ Ρ„ΠΈΠ»ΡŒΡ‚Ρ€Ρ‹**: По тСксту Ρ€Π΅Π°ΠΊΡ†ΠΈΠΈ, Π°Π²Ρ‚ΠΎΡ€Ρƒ, email ΠΈΠ»ΠΈ ID ΠΏΡƒΠ±Π»ΠΈΠΊΠ°Ρ†ΠΈΠΈ + - **Π­ΠΌΠΎΠ΄ΠΆΠΈ-ΠΈΠ½Π΄ΠΈΠΊΠ°Ρ‚ΠΎΡ€Ρ‹**: Π’ΠΈΠ·ΡƒΠ°Π»ΡŒΠ½ΠΎΠ΅ ΠΎΡ‚ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ Ρ‚ΠΈΠΏΠΎΠ² Ρ€Π΅Π°ΠΊΡ†ΠΈΠΉ (πŸ‘ πŸ‘Ž πŸ’¬ ❝ βœ… ❌ ❓ πŸ’‘ πŸ”¬ 🚫) + - **ΠœΠΎΠ΄Π΅Ρ€Π°Ρ†ΠΈΡ**: Π Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ тСкста, мягкоС ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠ΅ ΠΈ восстановлСниС + - **Бтатистика**: Π Π΅ΠΉΡ‚ΠΈΠ½Π³ ΠΈ количСство ΠΊΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠ΅Π² ΠΊ ΠΊΠ°ΠΆΠ΄ΠΎΠΉ Ρ€Π΅Π°ΠΊΡ†ΠΈΠΈ + - **Π‘Π΅Π·ΠΎΠΏΠ°ΡΠ½ΠΎΡΡ‚ΡŒ**: RBAC Π·Π°Ρ‰ΠΈΡ‚Π° ΠΈ Π°ΡƒΠ΄ΠΈΡ‚ всСх ΠΎΠΏΠ΅Ρ€Π°Ρ†ΠΈΠΉ - **ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€ Π΄Π°Π½Π½Ρ‹Ρ…**: Body, media, Π°Π²Ρ‚ΠΎΡ€Ρ‹, Ρ‚Π΅ΠΌΡ‹ с ΡƒΠ΄ΠΎΠ±Π½ΠΎΠΉ Π½Π°Π²ΠΈΠ³Π°Ρ†ΠΈΠ΅ΠΉ - **DRY ΠΏΡ€ΠΈΠ½Ρ†ΠΈΠΏ**: ΠŸΠ΅Ρ€Π΅ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Π½ΠΈΠ΅ ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΡ… Ρ€Π΅Π·ΠΎΠ»Π²Π΅Ρ€ΠΎΠ² ΠΈΠ· reader.py ΠΈ editor.py diff --git a/package.json b/package.json index 47f05b73..4feeef4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "publy-panel", - "version": "0.7.7", + "version": "0.7.8", "private": true, "scripts": { "dev": "vite", diff --git a/panel/context/data.tsx b/panel/context/data.tsx index 81d2e392..8d52c298 100644 --- a/panel/context/data.tsx +++ b/panel/context/data.tsx @@ -101,7 +101,6 @@ export function DataProvider(props: { children: JSX.Element }) { // ΠžΠ±Π΅Ρ€Ρ‚ΠΊΠ° для 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) } diff --git a/panel/graphql/mutations.ts b/panel/graphql/mutations.ts index dc3c5010..ac5573fe 100644 --- a/panel/graphql/mutations.ts +++ b/panel/graphql/mutations.ts @@ -194,6 +194,33 @@ export const ADMIN_UPDATE_TOPIC_MUTATION = ` } ` +export const ADMIN_UPDATE_REACTION_MUTATION = ` + mutation AdminUpdateReaction($reaction: AdminReactionUpdateInput!) { + adminUpdateReaction(reaction: $reaction) { + success + error + } + } +` + +export const ADMIN_DELETE_REACTION_MUTATION = ` + mutation AdminDeleteReaction($reaction_id: Int!) { + adminDeleteReaction(reaction_id: $reaction_id) { + success + error + } + } +` + +export const ADMIN_RESTORE_REACTION_MUTATION = ` + mutation AdminRestoreReaction($reaction_id: Int!) { + adminRestoreReaction(reaction_id: $reaction_id) { + success + error + } + } +` + export const ADMIN_CREATE_TOPIC_MUTATION = ` mutation AdminCreateTopic($topic: AdminTopicInput!) { adminCreateTopic(topic: $topic) { diff --git a/panel/graphql/queries.ts b/panel/graphql/queries.ts index 062be837..b47d3244 100644 --- a/panel/graphql/queries.ts +++ b/panel/graphql/queries.ts @@ -193,6 +193,46 @@ export const GET_TOPICS_BY_COMMUNITY_QUERY: string = } `.loc?.source.body || '' +export const ADMIN_GET_REACTIONS_QUERY: string = + gql` + query AdminGetReactions($limit: Int, $offset: Int, $search: String, $kind: ReactionKind, $shout_id: Int, $status: String) { + adminGetReactions(limit: $limit, offset: $offset, search: $search, kind: $kind, shout_id: $shout_id, status: $status) { + reactions { + id + kind + body + created_at + updated_at + deleted_at + reply_to + created_by { + id + name + email + slug + } + shout { + id + title + slug + layout + created_at + published_at + deleted_at + } + stat { + comments_count + rating + } + } + total + page + perPage + totalPages + } + } +`.loc?.source.body || '' + export const ADMIN_GET_TOPICS_QUERY: string = gql` query AdminGetTopics($community_id: Int!) { diff --git a/panel/modals/ReactionEditModal.tsx b/panel/modals/ReactionEditModal.tsx new file mode 100644 index 00000000..2bcb518d --- /dev/null +++ b/panel/modals/ReactionEditModal.tsx @@ -0,0 +1,206 @@ +import { Component, createSignal, createEffect } from 'solid-js' +import styles from '../styles/Modal.module.css' +import Button from '../ui/Button' +import Modal from '../ui/Modal' +import HTMLEditor from '../ui/HTMLEditor' + +interface ReactionEditModalProps { + reaction: { + id: number + kind: string + body: string + created_at: number + updated_at?: number + deleted_at?: number + reply_to?: number + created_by: { + id: number + name: string + email: string + slug: string + } + shout: { + id: number + title: string + slug: string + layout: string + created_at: number + published_at?: number + deleted_at?: number + } + stat: { + comments_count: number + rating: number + } + } + isOpen: boolean + onClose: () => void + onSave: (reaction: { id: number; body?: string; deleted_at?: number }) => Promise +} + +/** + * МодальноС ΠΎΠΊΠ½ΠΎ для рСдактирования Ρ€Π΅Π°ΠΊΡ†ΠΈΠΈ + */ +const ReactionEditModal: Component = (props) => { + const [body, setBody] = createSignal('') + const [loading, setLoading] = createSignal(false) + const [error, setError] = createSignal('') + + // Π˜Π½ΠΈΡ†ΠΈΠ°Π»ΠΈΠ·Π°Ρ†ΠΈΡ Π΄Π°Π½Π½Ρ‹Ρ… ΠΏΡ€ΠΈ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΈ Ρ€Π΅Π°ΠΊΡ†ΠΈΠΈ + createEffect(() => { + if (props.reaction) { + setBody(props.reaction.body || '') + setError('') + } + }) + + /** + * ΠžΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ° сохранСния ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ + */ + async function handleSave() { + try { + setLoading(true) + setError('') + + const updateData: { id: number; body?: string; deleted_at?: number } = { + id: props.reaction.id, + body: body(), + } + + await props.onSave(updateData) + } catch (err) { + setError(err instanceof Error ? err.message : 'Ошибка сохранСния') + } finally { + setLoading(false) + } + } + + /** + * ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚ Π½Π°Π·Π²Π°Π½ΠΈΠ΅ Ρ‚ΠΈΠΏΠ° Ρ€Π΅Π°ΠΊΡ†ΠΈΠΈ Π½Π° русском + */ + const getReactionName = (kind: string): string => { + switch (kind) { + case 'LIKE': + return 'Π›Π°ΠΉΠΊ' + case 'DISLIKE': + return 'Π”ΠΈΠ·Π»Π°ΠΉΠΊ' + case 'COMMENT': + return 'ΠšΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠΉ' + case 'QUOTE': + return 'Π¦ΠΈΡ‚Π°Ρ‚Π°' + case 'AGREE': + return 'БогласСн' + case 'DISAGREE': + return 'НС согласСн' + case 'ASK': + return 'Вопрос' + case 'PROPOSE': + return 'ΠŸΡ€Π΅Π΄Π»ΠΎΠΆΠ΅Π½ΠΈΠ΅' + case 'PROOF': + return 'Π”ΠΎΠΊΠ°Π·Π°Ρ‚Π΅Π»ΡŒΡΡ‚Π²ΠΎ' + case 'DISPROOF': + return 'ΠžΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΆΠ΅Π½ΠΈΠ΅' + case 'ACCEPT': + return 'ΠŸΡ€ΠΈΠ½ΡΡ‚ΡŒ' + case 'REJECT': + return 'ΠžΡ‚ΠΊΠ»ΠΎΠ½ΠΈΡ‚ΡŒ' + case 'CREDIT': + return 'Π£ΠΏΠΎΠΌΠΈΠ½Π°Π½ΠΈΠ΅' + case 'SILENT': + return 'ΠŸΡ€ΠΈΡ‡Π°ΡΡ‚Π½ΠΎΡΡ‚ΡŒ' + default: + return kind + } + } + + return ( + +
+ {error() && ( +
+ {error()} +
+ )} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + setBody(value)} + placeholder="Π’Π²Π΅Π΄ΠΈΡ‚Π΅ тСкст Ρ€Π΅Π°ΠΊΡ†ΠΈΠΈ (поддСрТиваСтся HTML)..." + rows={6} + /> +
+ +
+ +
+ Π Π΅ΠΉΡ‚ΠΈΠ½Π³: {props.reaction.stat.rating} + ΠšΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠ΅Π²: {props.reaction.stat.comments_count} +
+
+ +
+ + +
+ +
+ + +
+
+
+ ) +} + +export default ReactionEditModal diff --git a/panel/routes/admin.tsx b/panel/routes/admin.tsx index d65b0bb2..90348a89 100644 --- a/panel/routes/admin.tsx +++ b/panel/routes/admin.tsx @@ -17,6 +17,7 @@ import CollectionsRoute from './collections' import CommunitiesRoute from './communities' import EnvRoute from './env' import InvitesRoute from './invites' +import ReactionsRoute from './reactions' import ShoutsRoute from './shouts' import { Topics as TopicsRoute } from './topics' @@ -145,6 +146,12 @@ const AdminPage: Component = (props) => { > ΠŸΡ€ΠΈΠ³Π»Π°ΡˆΠ΅Π½ΠΈΡ + + + + + +
НСт Π΄Π°Π½Π½Ρ‹Ρ… для отобраТСния
+
+ + 0}> +
+ + + + + + + + + + + + + + + + {(reaction) => ( + { + setSelectedReaction(reaction) + setShowEditModal(true) + }} + > + + + + + + + + + + )} + + +
IDΠ’ΠΈΠΏΠ’Π΅ΠΊΡΡ‚ΠΠ²Ρ‚ΠΎΡ€ΠŸΡƒΠ±Π»ΠΈΠΊΠ°Ρ†ΠΈΡΠ‘ΠΎΠ·Π΄Π°Π½ΠΎΠ‘Ρ‚Π°Ρ‚ΡƒΡΠ”Π΅ΠΉΡΡ‚Π²ΠΈΡ
{reaction.id} + + {getReactionIcon(reaction.kind)} + + +
+ {reaction.body ? reaction.body.substring(0, 100) + (reaction.body.length > 100 ? '...' : '') : '-'} +
+
+
+
{reaction.created_by.name || 'Π‘Π΅Π· ΠΈΠΌΠ΅Π½ΠΈ'}
+
{reaction.created_by.email}
+
+
+
+
+ {reaction.shout.title.substring(0, 50)} + {reaction.shout.title.length > 50 ? '...' : ''} +
+
+ ID: {reaction.shout.id} | {reaction.shout.slug} +
+
+
{formatDateRelative(reaction.created_at)()} + + {reaction.deleted_at ? 'Π£Π΄Π°Π»Π΅Π½ΠΎ' : 'Активно'} + + +
e.stopPropagation()}> + + + + + + +
+
+
+ + +
+ + + + + + + ) +} + +export default ReactionsRoute diff --git a/panel/styles/Admin.module.css b/panel/styles/Admin.module.css index 6fff69f6..b6e96901 100644 --- a/panel/styles/Admin.module.css +++ b/panel/styles/Admin.module.css @@ -274,8 +274,6 @@ main { white-space: nowrap; } - - .roles-cell { min-width: 200px; } @@ -334,9 +332,6 @@ main { background-color: white; } -.shouts-list { -} - .status-badge { display: inline-flex; align-items: center; @@ -584,8 +579,6 @@ td { hyphens: auto; } - - /* Responsive Styles */ @media (max-width: 1024px) { .header-container { @@ -677,3 +670,198 @@ td { flex-direction: column; } } + +/* Styles for reaction-related components */ +.reactions-container { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.reactions-list { + width: 100%; + overflow-x: auto; +} + +.reactions-list table { + width: 100%; + border-collapse: collapse; + background-color: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + border-radius: 8px; + table-layout: fixed; +} + +/* ΠžΠΏΡ‚ΠΈΠΌΠΈΠ·Π°Ρ†ΠΈΡ ΡˆΠΈΡ€ΠΈΠ½Ρ‹ ΠΊΠΎΠ»ΠΎΠ½ΠΎΠΊ для Ρ€Π΅Π°ΠΊΡ†ΠΈΠΉ */ +.reactions-list th:nth-child(1), /* ID */ +.reactions-list td:nth-child(1) { + width: 80px; + text-align: center; +} + +.reactions-list th:nth-child(2), /* ВИП */ +.reactions-list td:nth-child(2) { + width: 60px; + text-align: center; +} + +.reactions-list th:nth-child(3), /* Π’Π•ΠšΠ‘Π’ */ +.reactions-list td:nth-child(3) { + width: 25%; +} + +.reactions-list th:nth-child(4), /* ΠΠ’Π’ΠžΠ  */ +.reactions-list td:nth-child(4) { + width: 18%; +} + +.reactions-list th:nth-child(5), /* ΠŸΠ£Π‘Π›Π˜ΠšΠΠ¦Π˜Π― */ +.reactions-list td:nth-child(5) { + width: 22%; +} + +.reactions-list th:nth-child(6), /* Π‘ΠžΠ—Π”ΠΠΠž */ +.reactions-list td:nth-child(6) { + width: 120px; +} + +.reactions-list th:nth-child(7), /* БВАВУБ */ +.reactions-list td:nth-child(7) { + width: 100px; + text-align: center; +} + +.reactions-list th:nth-child(8), /* Π”Π•Π™Π‘Π’Π’Π˜Π― */ +.reactions-list td:nth-child(8) { + width: 120px; + text-align: center; +} + +.reactions-list th, +.reactions-list td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid #e0e0e0; +} + +.reactions-list th { + background-color: #f8f9fa; + font-weight: 600; + color: #333; +} + +.reactions-list tr:hover { + background-color: #f8f9fa; + cursor: pointer; +} + +.reactions-list tr.deleted-row { + background-color: #fff5f5; + color: #666; +} + +.reactions-list tr.deleted-row:hover { + background-color: #fed7d7; +} + +.body-cell { + max-width: 200px; +} + +.body-preview { + word-wrap: break-word; + overflow-wrap: break-word; + font-size: 0.875rem; + line-height: 1.4; +} + +.author-cell { + min-width: 150px; +} + +.author-email { + font-size: 0.75rem; + color: #666; + margin-top: 0.25rem; +} + +.shout-cell { + min-width: 200px; +} + +.shout-title { + font-weight: 500; + margin-bottom: 0.25rem; +} + +.shout-meta { + font-size: 0.75rem; + color: #666; +} + +.status-active { + color: #28a745; + font-weight: 500; +} + +.status-deleted { + color: #dc3545; + font-weight: 500; +} + +.actions-cell { + min-width: 120px; +} + +.actions-cell button { + margin-right: 0.5rem; +} + +.filters-section { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 1rem; +} + +.additional-filters { + display: flex; + gap: 1rem; + align-items: center; + flex-wrap: wrap; +} + +.filter-select, +.filter-input { + padding: 0.5rem; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: 0.875rem; + min-width: 120px; +} + +.filter-select:focus, +.filter-input:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); +} + +.stat-info { + display: flex; + gap: 1rem; + font-size: 0.875rem; + color: #666; +} + +.stat-info span { + padding: 0.25rem 0.5rem; + background-color: #f8f9fa; + border-radius: 4px; +} + +.reaction-icon { + font-size: 1.25rem; + display: inline-block; + cursor: pointer; +} diff --git a/panel/styles/Modal.module.css b/panel/styles/Modal.module.css index 55465dde..934ebe4c 100644 --- a/panel/styles/Modal.module.css +++ b/panel/styles/Modal.module.css @@ -363,6 +363,68 @@ border-left: 4px solid var(--primary-color); } +/* HTML Editor Π² ΠΌΠΎΠ΄Π°Π»ΡŒΠ½Ρ‹Ρ… ΠΎΠΊΠ½Π°Ρ… ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ стили ΠΈΠ· Form.module.css */ + +.form-textarea { + padding: 0.75rem; + border: 2px solid var(--border-color); + border-radius: 0.5rem; + font-size: 0.875rem; + background-color: var(--bg-color); + color: var(--text-color); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + font-family: var(--font-family); + resize: vertical; + min-height: 120px; +} + +.form-textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 4px rgba(0, 123, 255, 0.1); + transform: translateY(-1px); +} + +.stat-info { + display: flex; + gap: 1rem; + font-size: 0.875rem; + color: var(--text-color-light); +} + +.stat-info span { + padding: 0.375rem 0.75rem; + background-color: var(--hover-bg); + border-radius: 0.375rem; + border: 1px solid var(--border-color); +} + +.error-message { + padding: 0.75rem; + background-color: var(--error-color-light, #fef2f2); + color: var(--error-color-dark, #dc2626); + border-radius: 0.5rem; + border: 1px solid var(--error-color, #fca5a5); + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border-color); +} + +.form-label { + font-weight: 600; + color: var(--text-color); + font-size: 0.875rem; + margin-bottom: 0.5rem; +} + /* Body Preview Modal */ .body-preview { width: 100%; diff --git a/resolvers/admin.py b/resolvers/admin.py index 8e5d2682..6f6ac89f 100644 --- a/resolvers/admin.py +++ b/resolvers/admin.py @@ -617,3 +617,234 @@ async def admin_get_community_role_settings(_: None, _info: GraphQLResolveInfo, "available_roles": ["reader", "author", "artist", "expert", "editor", "admin"], "error": str(e), } + + +# === Π Π•ΠΠšΠ¦Π˜Π˜ === + + +@query.field("adminGetReactions") +@admin_auth_required +async def admin_get_reactions( + _: None, + _info: GraphQLResolveInfo, + limit: int = 20, + offset: int = 0, + search: str = "", + kind: str = None, + shout_id: int = None, + status: str = "all", +) -> dict[str, Any]: + """ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚ список Ρ€Π΅Π°ΠΊΡ†ΠΈΠΉ для Π°Π΄ΠΌΠΈΠ½-ΠΏΠ°Π½Π΅Π»ΠΈ""" + try: + from sqlalchemy import and_, case, func, or_ + from sqlalchemy.orm import aliased + + from auth.orm import Author + from orm.reaction import Reaction + from orm.shout import Shout + from services.db import local_session + + with local_session() as session: + # Π‘Π°Π·ΠΎΠ²Ρ‹ΠΉ запрос с Π΄ΠΆΠΎΠΉΠ½Π°ΠΌΠΈ + query = ( + session.query(Reaction, Author, Shout) + .join(Author, Reaction.created_by == Author.id) + .join(Shout, Reaction.shout == Shout.id) + ) + + # Π€ΠΈΠ»ΡŒΡ‚Ρ€Π°Ρ†ΠΈΡ + filters = [] + + # Π€ΠΈΠ»ΡŒΡ‚Ρ€ ΠΏΠΎ статусу (ΠΊΠ°ΠΊ Π² публикациях) + if status == "active": + filters.append(Reaction.deleted_at.is_(None)) + elif status == "deleted": + filters.append(Reaction.deleted_at.isnot(None)) + # Если status == "all", Π½Π΅ добавляСм Ρ„ΠΈΠ»ΡŒΡ‚Ρ€ - ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅ΠΌ всС + + if search: + filters.append( + or_( + Reaction.body.ilike(f"%{search}%"), + Author.name.ilike(f"%{search}%"), + Author.email.ilike(f"%{search}%"), + Shout.title.ilike(f"%{search}%"), + ) + ) + if kind: + filters.append(Reaction.kind == kind) + if shout_id: + filters.append(Reaction.shout == shout_id) + + if filters: + query = query.filter(and_(*filters)) + + # ΠžΠ±Ρ‰Π΅Π΅ количСство + total = query.count() + + # ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅ΠΌ Ρ€Π΅Π°ΠΊΡ†ΠΈΠΈ с ΠΏΠ°Π³ΠΈΠ½Π°Ρ†ΠΈΠ΅ΠΉ + reactions_data = query.order_by(Reaction.created_at.desc()).offset(offset).limit(limit).all() + + # Π€ΠΎΡ€ΠΌΠΈΡ€ΡƒΠ΅ΠΌ Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚ + reactions = [] + for reaction, author, shout in reactions_data: + # ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅ΠΌ статистику для ΠΊΠ°ΠΆΠ΄ΠΎΠΉ Ρ€Π΅Π°ΠΊΡ†ΠΈΠΈ + aliased_reaction = aliased(Reaction) + stats = ( + session.query( + func.count(aliased_reaction.id.distinct()).label("comments_count"), + func.sum( + case( + (aliased_reaction.kind == "LIKE", 1), (aliased_reaction.kind == "DISLIKE", -1), else_=0 + ) + ).label("rating"), + ) + .filter( + aliased_reaction.reply_to == reaction.id, + # Π£Π±ΠΈΡ€Π°Π΅ΠΌ Ρ„ΠΈΠ»ΡŒΡ‚Ρ€ deleted_at Ρ‡Ρ‚ΠΎΠ±Ρ‹ Π²ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ всС Ρ€Π΅Π°ΠΊΡ†ΠΈΠΈ Π² статистику + ) + .first() + ) + + reactions.append( + { + "id": reaction.id, + "kind": reaction.kind, + "body": reaction.body or "", + "created_at": reaction.created_at, + "updated_at": reaction.updated_at, + "deleted_at": reaction.deleted_at, + "reply_to": reaction.reply_to, + "created_by": { + "id": author.id, + "name": author.name, + "email": author.email, + "slug": author.slug, + }, + "shout": { + "id": shout.id, + "title": shout.title, + "slug": shout.slug, + "layout": shout.layout, + "created_at": shout.created_at, + "published_at": shout.published_at, + "deleted_at": shout.deleted_at, + }, + "stat": { + "comments_count": stats.comments_count or 0, + "rating": stats.rating or 0, + }, + } + ) + + # РасчСт ΠΏΠ°Π³ΠΈΠ½Π°Ρ†ΠΈΠΈ + per_page = limit + total_pages = (total + per_page - 1) // per_page + page = (offset // per_page) + 1 + + logger.info(f"Π—Π°Π³Ρ€ΡƒΠΆΠ΅Π½ΠΎ Ρ€Π΅Π°ΠΊΡ†ΠΈΠΉ для Π°Π΄ΠΌΠΈΠ½-ΠΏΠ°Π½Π΅Π»ΠΈ: {len(reactions)}") + return { + "reactions": reactions, + "total": total, + "page": page, + "perPage": per_page, + "totalPages": total_pages, + } + + except Exception as e: + raise handle_error("ΠΏΠΎΠ»ΡƒΡ‡Π΅Π½ΠΈΠΈ списка Ρ€Π΅Π°ΠΊΡ†ΠΈΠΉ", e) from e + + +@mutation.field("adminUpdateReaction") +@admin_auth_required +async def admin_update_reaction(_: None, _info: GraphQLResolveInfo, reaction: dict[str, Any]) -> dict[str, Any]: + """ΠžΠ±Π½ΠΎΠ²Π»ΡΠ΅Ρ‚ Ρ€Π΅Π°ΠΊΡ†ΠΈΡŽ""" + try: + import time + + from orm.reaction import Reaction + from services.db import local_session + + reaction_id = reaction.get("id") + if not reaction_id: + return {"success": False, "error": "ID Ρ€Π΅Π°ΠΊΡ†ΠΈΠΈ Π½Π΅ ΡƒΠΊΠ°Π·Π°Π½"} + + with local_session() as session: + # Находим Ρ€Π΅Π°ΠΊΡ†ΠΈΡŽ + db_reaction = session.query(Reaction).filter(Reaction.id == reaction_id).first() + if not db_reaction: + return {"success": False, "error": "РСакция Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Π°"} + + # ОбновляСм поля + if "body" in reaction: + db_reaction.body = reaction["body"] + if "deleted_at" in reaction: + db_reaction.deleted_at = reaction["deleted_at"] + + # ОбновляСм врСмя измСнСния + db_reaction.updated_at = int(time.time()) + + session.commit() + + logger.info(f"РСакция {reaction_id} ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½Π° Ρ‡Π΅Ρ€Π΅Π· Π°Π΄ΠΌΠΈΠ½-панСль") + return {"success": True} + + except Exception as e: + logger.error(f"Ошибка обновлСния Ρ€Π΅Π°ΠΊΡ†ΠΈΠΈ: {e}") + return {"success": False, "error": str(e)} + + +@mutation.field("adminDeleteReaction") +@admin_auth_required +async def admin_delete_reaction(_: None, _info: GraphQLResolveInfo, reaction_id: int) -> dict[str, Any]: + """УдаляСт Ρ€Π΅Π°ΠΊΡ†ΠΈΡŽ (мягкоС ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠ΅)""" + try: + import time + + from orm.reaction import Reaction + from services.db import local_session + + with local_session() as session: + # Находим Ρ€Π΅Π°ΠΊΡ†ΠΈΡŽ + db_reaction = session.query(Reaction).filter(Reaction.id == reaction_id).first() + if not db_reaction: + return {"success": False, "error": "РСакция Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Π°"} + + # УстанавливаСм врСмя удалСния + db_reaction.deleted_at = int(time.time()) + + session.commit() + + logger.info(f"РСакция {reaction_id} ΡƒΠ΄Π°Π»Π΅Π½Π° Ρ‡Π΅Ρ€Π΅Π· Π°Π΄ΠΌΠΈΠ½-панСль") + return {"success": True} + + except Exception as e: + logger.error(f"Ошибка удалСния Ρ€Π΅Π°ΠΊΡ†ΠΈΠΈ: {e}") + return {"success": False, "error": str(e)} + + +@mutation.field("adminRestoreReaction") +@admin_auth_required +async def admin_restore_reaction(_: None, _info: GraphQLResolveInfo, reaction_id: int) -> dict[str, Any]: + """ВосстанавливаСт ΡƒΠ΄Π°Π»Π΅Π½Π½ΡƒΡŽ Ρ€Π΅Π°ΠΊΡ†ΠΈΡŽ""" + try: + from orm.reaction import Reaction + from services.db import local_session + + with local_session() as session: + # Находим Ρ€Π΅Π°ΠΊΡ†ΠΈΡŽ + db_reaction = session.query(Reaction).filter(Reaction.id == reaction_id).first() + if not db_reaction: + return {"success": False, "error": "РСакция Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Π°"} + + # Π£Π±ΠΈΡ€Π°Π΅ΠΌ врСмя удалСния + db_reaction.deleted_at = None + + session.commit() + + logger.info(f"РСакция {reaction_id} восстановлСна Ρ‡Π΅Ρ€Π΅Π· Π°Π΄ΠΌΠΈΠ½-панСль") + return {"success": True} + + except Exception as e: + logger.error(f"Ошибка восстановлСния Ρ€Π΅Π°ΠΊΡ†ΠΈΠΈ: {e}") + return {"success": False, "error": str(e)} diff --git a/schema/admin.graphql b/schema/admin.graphql index bccd5ffa..0c3c9a1c 100644 --- a/schema/admin.graphql +++ b/schema/admin.graphql @@ -227,6 +227,36 @@ type AdminTopicResult { topic: Topic } +# Π’ΠΈΠΏΡ‹ для управлСния рСакциями +type AdminReactionInfo { + id: Int! + shout: AdminShoutInfo! + created_at: Int! + created_by: Author! + updated_at: Int + deleted_at: Int + deleted_by: Author + kind: ReactionKind! + body: String + reply_to: Int + stat: Stat +} + +# Π’ΠΈΠΏ для ΠΏΠ°Π³ΠΈΠ½ΠΈΡ€ΠΎΠ²Π°Π½Π½ΠΎΠ³ΠΎ ΠΎΡ‚Π²Π΅Ρ‚Π° Ρ€Π΅Π°ΠΊΡ†ΠΈΠΉ +type AdminReactionListResponse { + reactions: [AdminReactionInfo!]! + total: Int! + page: Int! + perPage: Int! + totalPages: Int! +} + +input AdminReactionUpdateInput { + id: Int! + body: String + deleted_at: Int +} + extend type Query { getEnvVariables: [EnvSection!]! # Запросы для управлСния ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡΠΌΠΈ @@ -255,6 +285,15 @@ extend type Query { ): AdminInviteListResponse! # Запросы для управлСния Ρ‚ΠΎΠΏΠΈΠΊΠ°ΠΌΠΈ adminGetTopics(community_id: Int!): [Topic!]! + # Запросы для управлСния рСакциями + adminGetReactions( + limit: Int + offset: Int + search: String + kind: ReactionKind + shout_id: Int + status: String + ): AdminReactionListResponse! } extend type Mutation { @@ -301,4 +340,8 @@ extend type Mutation { adminUpdateTopic(topic: AdminTopicInput!): AdminTopicResult! adminCreateTopic(topic: AdminTopicInput!): AdminTopicResult! adminMergeTopics(merge_input: TopicMergeInput!): AdminTopicResult! + # Admin mutations для управлСния рСакциями + adminUpdateReaction(reaction: AdminReactionUpdateInput!): OperationResult! + adminDeleteReaction(reaction_id: Int!): OperationResult! + adminRestoreReaction(reaction_id: Int!): OperationResult! }