This commit is contained in:
parent
db92cc6406
commit
c8728540ed
44
CHANGELOG.md
44
CHANGELOG.md
|
@ -1,5 +1,49 @@
|
||||||
# Changelog
|
# 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
|
## [0.7.7] - 2025-07-03
|
||||||
|
|
||||||
### 🔐 RBAC System for Topic Management
|
### 🔐 RBAC System for Topic Management
|
||||||
|
|
|
@ -41,6 +41,14 @@ python dev.py
|
||||||
- **Дерево топиков**: Визуализация родительско-дочерних связей с отступами и символами `└─`
|
- **Дерево топиков**: Визуализация родительско-дочерних связей с отступами и символами `└─`
|
||||||
- **Безопасное удаление**: Предупреждения о каскадном удалении дочерних топиков
|
- **Безопасное удаление**: Предупреждения о каскадном удалении дочерних топиков
|
||||||
- **Автообновление**: Рефреш списка после операций с корректной инвалидацией кешей
|
- **Автообновление**: Рефреш списка после операций с корректной инвалидацией кешей
|
||||||
|
- **Модерация реакций**: Полная система управления реакциями пользователей
|
||||||
|
- **Просмотр всех реакций**: Таблица с типом, текстом, автором, публикацией и статистикой
|
||||||
|
- **Фильтрация по типам**: Лайки, дизлайки, комментарии, цитаты, согласие/несогласие, вопросы, предложения, доказательства/опровержения
|
||||||
|
- **Поиск и фильтры**: По тексту реакции, автору, email или ID публикации
|
||||||
|
- **Эмоджи-индикаторы**: Визуальное отображение типов реакций (👍 👎 💬 ❝ ✅ ❌ ❓ 💡 🔬 🚫)
|
||||||
|
- **Модерация**: Редактирование текста, мягкое удаление и восстановление
|
||||||
|
- **Статистика**: Рейтинг и количество комментариев к каждой реакции
|
||||||
|
- **Безопасность**: RBAC защита и аудит всех операций
|
||||||
- **Просмотр данных**: Body, media, авторы, темы с удобной навигацией
|
- **Просмотр данных**: Body, media, авторы, темы с удобной навигацией
|
||||||
- **DRY принцип**: Переиспользование существующих резолверов из reader.py и editor.py
|
- **DRY принцип**: Переиспользование существующих резолверов из reader.py и editor.py
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "publy-panel",
|
"name": "publy-panel",
|
||||||
"version": "0.7.7",
|
"version": "0.7.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|
|
@ -101,7 +101,6 @@ export function DataProvider(props: { children: JSX.Element }) {
|
||||||
// Обертка для setTopics с логированием
|
// Обертка для setTopics с логированием
|
||||||
const setTopicsWithLogging = (newTopics: Topic[]) => {
|
const setTopicsWithLogging = (newTopics: Topic[]) => {
|
||||||
console.log('[DataProvider] setTopics called with', newTopics.length, 'topics')
|
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)
|
setTopics(newTopics)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 = `
|
export const ADMIN_CREATE_TOPIC_MUTATION = `
|
||||||
mutation AdminCreateTopic($topic: AdminTopicInput!) {
|
mutation AdminCreateTopic($topic: AdminTopicInput!) {
|
||||||
adminCreateTopic(topic: $topic) {
|
adminCreateTopic(topic: $topic) {
|
||||||
|
|
|
@ -193,6 +193,46 @@ export const GET_TOPICS_BY_COMMUNITY_QUERY: string =
|
||||||
}
|
}
|
||||||
`.loc?.source.body || ''
|
`.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 =
|
export const ADMIN_GET_TOPICS_QUERY: string =
|
||||||
gql`
|
gql`
|
||||||
query AdminGetTopics($community_id: Int!) {
|
query AdminGetTopics($community_id: Int!) {
|
||||||
|
|
206
panel/modals/ReactionEditModal.tsx
Normal file
206
panel/modals/ReactionEditModal.tsx
Normal file
|
@ -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<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модальное окно для редактирования реакции
|
||||||
|
*/
|
||||||
|
const ReactionEditModal: Component<ReactionEditModalProps> = (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 (
|
||||||
|
<Modal isOpen={props.isOpen} onClose={props.onClose} title="Редактирование реакции">
|
||||||
|
<div class={styles['modal-content']}>
|
||||||
|
{error() && (
|
||||||
|
<div class={styles['error-message']}>
|
||||||
|
{error()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class={styles['form-group']}>
|
||||||
|
<label class={styles['form-label']}>ID реакции:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={props.reaction.id}
|
||||||
|
disabled
|
||||||
|
class={styles['form-input']}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles['form-group']}>
|
||||||
|
<label class={styles['form-label']}>Тип реакции:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getReactionName(props.reaction.kind)}
|
||||||
|
disabled
|
||||||
|
class={styles['form-input']}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles['form-group']}>
|
||||||
|
<label class={styles['form-label']}>Автор:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={`${props.reaction.created_by.name || 'Без имени'} (${props.reaction.created_by.email})`}
|
||||||
|
disabled
|
||||||
|
class={styles['form-input']}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles['form-group']}>
|
||||||
|
<label class={styles['form-label']}>Публикация:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={`${props.reaction.shout.title} (ID: ${props.reaction.shout.id})`}
|
||||||
|
disabled
|
||||||
|
class={styles['form-input']}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles['form-group']}>
|
||||||
|
<label class={styles['form-label']}>Текст реакции:</label>
|
||||||
|
<HTMLEditor
|
||||||
|
value={body()}
|
||||||
|
onInput={(value) => setBody(value)}
|
||||||
|
placeholder="Введите текст реакции (поддерживается HTML)..."
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles['form-group']}>
|
||||||
|
<label class={styles['form-label']}>Статистика:</label>
|
||||||
|
<div class={styles['stat-info']}>
|
||||||
|
<span>Рейтинг: {props.reaction.stat.rating}</span>
|
||||||
|
<span>Комментариев: {props.reaction.stat.comments_count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles['form-group']}>
|
||||||
|
<label class={styles['form-label']}>Статус:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={props.reaction.deleted_at ? 'Удалено' : 'Активно'}
|
||||||
|
disabled
|
||||||
|
class={styles['form-input']}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles['modal-actions']}>
|
||||||
|
<Button variant="secondary" onClick={props.onClose} disabled={loading()}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={handleSave} disabled={loading()}>
|
||||||
|
{loading() ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReactionEditModal
|
|
@ -17,6 +17,7 @@ import CollectionsRoute from './collections'
|
||||||
import CommunitiesRoute from './communities'
|
import CommunitiesRoute from './communities'
|
||||||
import EnvRoute from './env'
|
import EnvRoute from './env'
|
||||||
import InvitesRoute from './invites'
|
import InvitesRoute from './invites'
|
||||||
|
import ReactionsRoute from './reactions'
|
||||||
import ShoutsRoute from './shouts'
|
import ShoutsRoute from './shouts'
|
||||||
import { Topics as TopicsRoute } from './topics'
|
import { Topics as TopicsRoute } from './topics'
|
||||||
|
|
||||||
|
@ -145,6 +146,12 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
||||||
>
|
>
|
||||||
Приглашения
|
Приглашения
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={currentTab() === 'reactions' ? 'primary' : 'secondary'}
|
||||||
|
onClick={() => navigate('/admin/reactions')}
|
||||||
|
>
|
||||||
|
Реакции
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={currentTab() === 'env' ? 'primary' : 'secondary'}
|
variant={currentTab() === 'env' ? 'primary' : 'secondary'}
|
||||||
onClick={() => navigate('/admin/env')}
|
onClick={() => navigate('/admin/env')}
|
||||||
|
@ -188,6 +195,10 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
||||||
<InvitesRoute onError={handleError} onSuccess={handleSuccess} />
|
<InvitesRoute onError={handleError} onSuccess={handleSuccess} />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={currentTab() === 'reactions'}>
|
||||||
|
<ReactionsRoute onError={handleError} onSuccess={handleSuccess} />
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={currentTab() === 'env'}>
|
<Show when={currentTab() === 'env'}>
|
||||||
<EnvRoute onError={handleError} onSuccess={handleSuccess} />
|
<EnvRoute onError={handleError} onSuccess={handleSuccess} />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
442
panel/routes/reactions.tsx
Normal file
442
panel/routes/reactions.tsx
Normal file
|
@ -0,0 +1,442 @@
|
||||||
|
import { Component, createSignal, For, onMount, Show } from 'solid-js'
|
||||||
|
import { query } from '../graphql'
|
||||||
|
import type { Query } from '../graphql/generated/schema'
|
||||||
|
import { ADMIN_DELETE_REACTION_MUTATION, ADMIN_RESTORE_REACTION_MUTATION, ADMIN_UPDATE_REACTION_MUTATION } from '../graphql/mutations'
|
||||||
|
import { ADMIN_GET_REACTIONS_QUERY } from '../graphql/queries'
|
||||||
|
import ReactionEditModal from '../modals/ReactionEditModal'
|
||||||
|
import styles from '../styles/Admin.module.css'
|
||||||
|
import Button from '../ui/Button'
|
||||||
|
import Pagination from '../ui/Pagination'
|
||||||
|
import TableControls from '../ui/TableControls'
|
||||||
|
import { formatDateRelative } from '../utils/date'
|
||||||
|
|
||||||
|
export interface ReactionsRouteProps {
|
||||||
|
onError?: (error: string) => void
|
||||||
|
onSuccess?: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тип реакции для админки
|
||||||
|
*/
|
||||||
|
interface AdminReaction {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
|
||||||
|
console.log('[ReactionsRoute] Initializing...')
|
||||||
|
const [reactions, setReactions] = createSignal<AdminReaction[]>([])
|
||||||
|
const [loading, setLoading] = createSignal(true)
|
||||||
|
const [selectedReaction, setSelectedReaction] = createSignal<AdminReaction | null>(null)
|
||||||
|
const [showEditModal, setShowEditModal] = createSignal(false)
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const [pagination, setPagination] = createSignal<{
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
total: number
|
||||||
|
totalPages: number
|
||||||
|
}>({
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
// Фильтры
|
||||||
|
const [searchQuery, setSearchQuery] = createSignal('')
|
||||||
|
const [kindFilter, setKindFilter] = createSignal('')
|
||||||
|
const [shoutIdFilter, setShoutIdFilter] = createSignal('')
|
||||||
|
const [statusFilter, setStatusFilter] = createSignal('all')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка списка реакций
|
||||||
|
*/
|
||||||
|
async function loadReactions() {
|
||||||
|
console.log('[ReactionsRoute] Loading reactions...')
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const data = await query<{ adminGetReactions: {
|
||||||
|
reactions: AdminReaction[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
perPage: number
|
||||||
|
totalPages: number
|
||||||
|
} }>(
|
||||||
|
`${location.origin}/graphql`,
|
||||||
|
ADMIN_GET_REACTIONS_QUERY,
|
||||||
|
{
|
||||||
|
search: searchQuery(),
|
||||||
|
kind: kindFilter() || undefined,
|
||||||
|
shout_id: shoutIdFilter() ? parseInt(shoutIdFilter()) : undefined,
|
||||||
|
status: statusFilter(),
|
||||||
|
limit: pagination().limit,
|
||||||
|
offset: (pagination().page - 1) * pagination().limit
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (data?.adminGetReactions?.reactions) {
|
||||||
|
console.log('[ReactionsRoute] Reactions loaded:', data.adminGetReactions.reactions.length)
|
||||||
|
setReactions(data.adminGetReactions.reactions as AdminReaction[])
|
||||||
|
setPagination((prev) => ({
|
||||||
|
...prev,
|
||||||
|
total: data.adminGetReactions.total || 0,
|
||||||
|
totalPages: data.adminGetReactions.totalPages || 1
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ReactionsRoute] Failed to load reactions:', error)
|
||||||
|
props.onError?.(error instanceof Error ? error.message : 'Не удалось загрузить список реакций')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет реакцию
|
||||||
|
*/
|
||||||
|
async function updateReaction(reactionData: { id: number; body?: string; deleted_at?: number }) {
|
||||||
|
try {
|
||||||
|
await query(`${location.origin}/graphql`, ADMIN_UPDATE_REACTION_MUTATION, {
|
||||||
|
reaction: reactionData
|
||||||
|
})
|
||||||
|
|
||||||
|
closeEditModal()
|
||||||
|
props.onSuccess?.('Реакция успешно обновлена')
|
||||||
|
void loadReactions()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка обновления реакции:', err)
|
||||||
|
props.onError?.(err instanceof Error ? err.message : 'Ошибка обновления реакции')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет реакцию
|
||||||
|
*/
|
||||||
|
async function deleteReaction(id: number) {
|
||||||
|
try {
|
||||||
|
await query(`${location.origin}/graphql`, ADMIN_DELETE_REACTION_MUTATION, { reaction_id: id })
|
||||||
|
props.onSuccess?.('Реакция успешно удалена')
|
||||||
|
void loadReactions()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка удаления реакции:', err)
|
||||||
|
props.onError?.(err instanceof Error ? err.message : 'Ошибка удаления реакции')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Восстанавливает реакцию
|
||||||
|
*/
|
||||||
|
async function restoreReaction(id: number) {
|
||||||
|
try {
|
||||||
|
await query(`${location.origin}/graphql`, ADMIN_RESTORE_REACTION_MUTATION, { reaction_id: id })
|
||||||
|
props.onSuccess?.('Реакция успешно восстановлена')
|
||||||
|
void loadReactions()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка восстановления реакции:', err)
|
||||||
|
props.onError?.(err instanceof Error ? err.message : 'Ошибка восстановления реакции')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
setShowEditModal(false)
|
||||||
|
setSelectedReaction(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination handlers
|
||||||
|
function handlePageChange(page: number) {
|
||||||
|
setPagination((prev) => ({ ...prev, page }))
|
||||||
|
void loadReactions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePerPageChange(limit: number) {
|
||||||
|
setPagination((prev) => ({ ...prev, page: 1, limit }))
|
||||||
|
void loadReactions()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search handlers
|
||||||
|
function handleSearchChange(value: string) {
|
||||||
|
setSearchQuery(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
setPagination((prev) => ({ ...prev, page: 1 }))
|
||||||
|
void loadReactions()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reactions on mount
|
||||||
|
onMount(() => {
|
||||||
|
console.log('[ReactionsRoute] Component mounted, loading reactions...')
|
||||||
|
void loadReactions()
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает эмоджи для типа реакции
|
||||||
|
*/
|
||||||
|
const getReactionIcon = (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 '💬'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает название типа реакции на русском
|
||||||
|
*/
|
||||||
|
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 (
|
||||||
|
<div class={styles['reactions-container']}>
|
||||||
|
<Show when={loading()}>
|
||||||
|
<div class={styles['loading']}>Загрузка данных...</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!loading()}>
|
||||||
|
<div class={styles['filters-section']}>
|
||||||
|
<TableControls
|
||||||
|
searchValue={searchQuery()}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
searchPlaceholder="Поиск по тексту, автору или публикации..."
|
||||||
|
isLoading={loading()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class={styles['additional-filters']}>
|
||||||
|
<select
|
||||||
|
value={kindFilter()}
|
||||||
|
onChange={(e) => setKindFilter(e.target.value)}
|
||||||
|
class={styles['filter-select']}
|
||||||
|
>
|
||||||
|
<option value="">Все типы</option>
|
||||||
|
<option value="LIKE">Лайк</option>
|
||||||
|
<option value="DISLIKE">Дизлайк</option>
|
||||||
|
<option value="COMMENT">Комментарий</option>
|
||||||
|
<option value="QUOTE">Цитата</option>
|
||||||
|
<option value="AGREE">Согласен</option>
|
||||||
|
<option value="DISAGREE">Не согласен</option>
|
||||||
|
<option value="ASK">Вопрос</option>
|
||||||
|
<option value="PROPOSE">Предложение</option>
|
||||||
|
<option value="PROOF">Доказательство</option>
|
||||||
|
<option value="DISPROOF">Опровержение</option>
|
||||||
|
<option value="ACCEPT">Принять</option>
|
||||||
|
<option value="REJECT">Отклонить</option>
|
||||||
|
<option value="CREDIT">Упоминание</option>
|
||||||
|
<option value="SILENT">Причастность</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={statusFilter()}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
class={styles['filter-select']}
|
||||||
|
>
|
||||||
|
<option value="all">Все статусы</option>
|
||||||
|
<option value="active">Активные</option>
|
||||||
|
<option value="deleted">Удаленные</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="ID публикации"
|
||||||
|
value={shoutIdFilter()}
|
||||||
|
onInput={(e) => setShoutIdFilter(e.target.value)}
|
||||||
|
class={styles['filter-input']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button variant="primary" onClick={() => void loadReactions()}>
|
||||||
|
Применить фильтры
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={reactions().length === 0}>
|
||||||
|
<div class={styles['empty-state']}>Нет данных для отображения</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={reactions().length > 0}>
|
||||||
|
<div class={styles['reactions-list']}>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Тип</th>
|
||||||
|
<th>Текст</th>
|
||||||
|
<th>Автор</th>
|
||||||
|
<th>Публикация</th>
|
||||||
|
<th>Создано</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={reactions()}>
|
||||||
|
{(reaction) => (
|
||||||
|
<tr
|
||||||
|
class={reaction.deleted_at ? styles['deleted-row'] : ''}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedReaction(reaction)
|
||||||
|
setShowEditModal(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td>{reaction.id}</td>
|
||||||
|
<td>
|
||||||
|
<span title={getReactionName(reaction.kind)} class={styles['reaction-icon']}>
|
||||||
|
{getReactionIcon(reaction.kind)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class={styles['body-cell']}>
|
||||||
|
<div class={styles['body-preview']}>
|
||||||
|
{reaction.body ? reaction.body.substring(0, 100) + (reaction.body.length > 100 ? '...' : '') : '-'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class={styles['author-cell']}>
|
||||||
|
<div>{reaction.created_by.name || 'Без имени'}</div>
|
||||||
|
<div class={styles['author-email']}>{reaction.created_by.email}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class={styles['shout-cell']}>
|
||||||
|
<div class={styles['shout-title']}>
|
||||||
|
{reaction.shout.title.substring(0, 50)}
|
||||||
|
{reaction.shout.title.length > 50 ? '...' : ''}
|
||||||
|
</div>
|
||||||
|
<div class={styles['shout-meta']}>
|
||||||
|
ID: {reaction.shout.id} | {reaction.shout.slug}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{formatDateRelative(reaction.created_at)()}</td>
|
||||||
|
<td>
|
||||||
|
<span class={reaction.deleted_at ? styles['status-deleted'] : styles['status-active']}>
|
||||||
|
{reaction.deleted_at ? 'Удалено' : 'Активно'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class={styles['actions-cell']} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Show when={reaction.deleted_at}>
|
||||||
|
<Button variant="primary" size="small" onClick={() => restoreReaction(reaction.id)}>
|
||||||
|
Восстановить
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
|
<Show when={!reaction.deleted_at}>
|
||||||
|
<Button variant="danger" size="small" onClick={() => deleteReaction(reaction.id)}>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
currentPage={pagination().page}
|
||||||
|
totalPages={pagination().totalPages}
|
||||||
|
total={pagination().total}
|
||||||
|
limit={pagination().limit}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPerPageChange={handlePerPageChange}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={showEditModal() && selectedReaction()}>
|
||||||
|
<ReactionEditModal
|
||||||
|
reaction={selectedReaction()!}
|
||||||
|
isOpen={showEditModal()}
|
||||||
|
onClose={closeEditModal}
|
||||||
|
onSave={updateReaction}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReactionsRoute
|
|
@ -274,8 +274,6 @@ main {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.roles-cell {
|
.roles-cell {
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
@ -334,9 +332,6 @@ main {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shouts-list {
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -584,8 +579,6 @@ td {
|
||||||
hyphens: auto;
|
hyphens: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* Responsive Styles */
|
/* Responsive Styles */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.header-container {
|
.header-container {
|
||||||
|
@ -677,3 +670,198 @@ td {
|
||||||
flex-direction: column;
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -363,6 +363,68 @@
|
||||||
border-left: 4px solid var(--primary-color);
|
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 Modal */
|
||||||
.body-preview {
|
.body-preview {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
@ -617,3 +617,234 @@ async def admin_get_community_role_settings(_: None, _info: GraphQLResolveInfo,
|
||||||
"available_roles": ["reader", "author", "artist", "expert", "editor", "admin"],
|
"available_roles": ["reader", "author", "artist", "expert", "editor", "admin"],
|
||||||
"error": str(e),
|
"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)}
|
||||||
|
|
|
@ -227,6 +227,36 @@ type AdminTopicResult {
|
||||||
topic: Topic
|
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 {
|
extend type Query {
|
||||||
getEnvVariables: [EnvSection!]!
|
getEnvVariables: [EnvSection!]!
|
||||||
# Запросы для управления пользователями
|
# Запросы для управления пользователями
|
||||||
|
@ -255,6 +285,15 @@ extend type Query {
|
||||||
): AdminInviteListResponse!
|
): AdminInviteListResponse!
|
||||||
# Запросы для управления топиками
|
# Запросы для управления топиками
|
||||||
adminGetTopics(community_id: Int!): [Topic!]!
|
adminGetTopics(community_id: Int!): [Topic!]!
|
||||||
|
# Запросы для управления реакциями
|
||||||
|
adminGetReactions(
|
||||||
|
limit: Int
|
||||||
|
offset: Int
|
||||||
|
search: String
|
||||||
|
kind: ReactionKind
|
||||||
|
shout_id: Int
|
||||||
|
status: String
|
||||||
|
): AdminReactionListResponse!
|
||||||
}
|
}
|
||||||
|
|
||||||
extend type Mutation {
|
extend type Mutation {
|
||||||
|
@ -301,4 +340,8 @@ extend type Mutation {
|
||||||
adminUpdateTopic(topic: AdminTopicInput!): AdminTopicResult!
|
adminUpdateTopic(topic: AdminTopicInput!): AdminTopicResult!
|
||||||
adminCreateTopic(topic: AdminTopicInput!): AdminTopicResult!
|
adminCreateTopic(topic: AdminTopicInput!): AdminTopicResult!
|
||||||
adminMergeTopics(merge_input: TopicMergeInput!): AdminTopicResult!
|
adminMergeTopics(merge_input: TopicMergeInput!): AdminTopicResult!
|
||||||
|
# Admin mutations для управления реакциями
|
||||||
|
adminUpdateReaction(reaction: AdminReactionUpdateInput!): OperationResult!
|
||||||
|
adminDeleteReaction(reaction_id: Int!): OperationResult!
|
||||||
|
adminRestoreReaction(reaction_id: Int!): OperationResult!
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user