core/panel/routes/reactions.tsx
Untone d03336174f
All checks were successful
Deploy on push / deploy (push) Successful in 6s
admin-ui-fix
2025-07-07 17:51:48 +03:00

486 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Component, createSignal, createEffect, 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
created_at: number
}
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 [showDeletedOnly, setShowDeletedOnly] = createSignal(false)
/**
* Загрузка списка реакций
*/
async function loadReactions() {
console.log('[ReactionsRoute] Loading reactions...')
try {
setLoading(true)
// Определяем, является ли поисковый запрос ID публикации
const query_value = searchQuery().trim()
const isShoutId = /^\d+$/.test(query_value) // Проверяем, состоит ли запрос только из цифр
const data = await query<{ adminGetReactions: {
reactions: AdminReaction[]
total: number
page: number
perPage: number
totalPages: number
} }>(
`${location.origin}/graphql`,
ADMIN_GET_REACTIONS_QUERY,
{
search: isShoutId ? '' : query_value, // Если это ID, не передаем в обычный поиск
kind: kindFilter() || undefined,
shout_id: isShoutId ? parseInt(query_value) : undefined, // Если это ID, передаем в shout_id
status: showDeletedOnly() ? 'deleted' : 'all',
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()
}
// Флаг для пропуска первого вызова createEffect при монтировании
let isInitialized = false
// Load reactions on mount
onMount(() => {
console.log('[ReactionsRoute] Component mounted, loading reactions...')
isInitialized = true
void loadReactions()
})
// Автоматически применяем фильтры при изменении (но не при первом рендере)
createEffect(() => {
// Отслеживаем изменения фильтров и поиска
searchQuery()
kindFilter()
showDeletedOnly()
// Пропускаем первый вызов при инициализации
if (!isInitialized) return
// Сбрасываем страницу на первую и перезагружаем данные
setPagination((prev) => ({ ...prev, page: 1 }))
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
}
}
/**
* Получает название статуса реакции
*/
const getReactionStatusTitle = (reaction: AdminReaction): string => {
return reaction.deleted_at ? 'Удалена' : 'Активна'
}
/**
* Получает цвет фона для ID реакции в зависимости от статуса
*/
const getReactionStatusBackgroundColor = (reaction: AdminReaction): string => {
return reaction.deleted_at ? '#fee2e2' : '#d1fae5' // Пастельный красный для удаленных, зеленый для активных
}
/**
* Форматирует tooltip для автора с email и датой регистрации
*/
const formatAuthorTooltip = (author: { email?: string | null; created_at?: number | null }): string => {
if (!author.email) return ''
if (!author.created_at) return author.email
const registrationDate = new Date(author.created_at * 1000).toLocaleDateString('ru-RU', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
return `${author.email} с ${registrationDate}`
}
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="Поиск по тексту, автору, публикации или ID публикации..."
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>
<label class={styles['filter-checkbox']}>
<input
type="checkbox"
checked={showDeletedOnly()}
onChange={(e) => setShowDeletedOnly(e.target.checked)}
/>
Только удаленные
</label>
</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>
</tr>
</thead>
<tbody>
<For each={reactions()}>
{(reaction) => (
<tr
class={reaction.deleted_at ? styles['deleted-row'] : ''}
onClick={() => {
setSelectedReaction(reaction)
setShowEditModal(true)
}}
>
<td
style={{
'background-color': getReactionStatusBackgroundColor(reaction),
padding: '8px 12px',
'border-radius': '4px'
}}
title={getReactionStatusTitle(reaction)}
>
{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']} title={formatAuthorTooltip(reaction.created_by)}>
<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>
<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