reactions-admin-tab
All checks were successful
Deploy on push / deploy (push) Successful in 7s

This commit is contained in:
Untone 2025-07-04 12:39:41 +03:00
parent db92cc6406
commit c8728540ed
13 changed files with 1310 additions and 9 deletions

View File

@ -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

View File

@ -41,6 +41,14 @@ python dev.py
- **Дерево топиков**: Визуализация родительско-дочерних связей с отступами и символами `└─` - **Дерево топиков**: Визуализация родительско-дочерних связей с отступами и символами `└─`
- **Безопасное удаление**: Предупреждения о каскадном удалении дочерних топиков - **Безопасное удаление**: Предупреждения о каскадном удалении дочерних топиков
- **Автообновление**: Рефреш списка после операций с корректной инвалидацией кешей - **Автообновление**: Рефреш списка после операций с корректной инвалидацией кешей
- **Модерация реакций**: Полная система управления реакциями пользователей
- **Просмотр всех реакций**: Таблица с типом, текстом, автором, публикацией и статистикой
- **Фильтрация по типам**: Лайки, дизлайки, комментарии, цитаты, согласие/несогласие, вопросы, предложения, доказательства/опровержения
- **Поиск и фильтры**: По тексту реакции, автору, email или ID публикации
- **Эмоджи-индикаторы**: Визуальное отображение типов реакций (👍 👎 💬 ❝ ✅ ❌ ❓ 💡 🔬 🚫)
- **Модерация**: Редактирование текста, мягкое удаление и восстановление
- **Статистика**: Рейтинг и количество комментариев к каждой реакции
- **Безопасность**: RBAC защита и аудит всех операций
- **Просмотр данных**: Body, media, авторы, темы с удобной навигацией - **Просмотр данных**: Body, media, авторы, темы с удобной навигацией
- **DRY принцип**: Переиспользование существующих резолверов из reader.py и editor.py - **DRY принцип**: Переиспользование существующих резолверов из reader.py и editor.py

View File

@ -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",

View File

@ -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)
} }

View File

@ -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) {

View File

@ -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!) {

View 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

View File

@ -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
View 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

View File

@ -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;
}

View File

@ -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%;

View File

@ -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)}

View File

@ -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!
} }