admin-ui-fix
All checks were successful
Deploy on push / deploy (push) Successful in 6s

This commit is contained in:
Untone 2025-07-07 17:51:48 +03:00
parent 9f70654fb5
commit d03336174f
5 changed files with 153 additions and 47 deletions

View File

@ -6,6 +6,32 @@
Добавлена полная система просмотра и модерации реакций с расширенными возможностями фильтрации и управления.
#### Улучшения интерфейса фильтрации реакций
- **Упрощена фильтрация по статусу**: Заменен выпадающий список "Все статусы/Активные/Удаленные" на простую галочку "Только удаленные"
- **Цветовой индикатор статуса**: Убрана колонка "Статус", статус теперь отображается цветом фона ID реакции
- **Цветовая схема**: Зеленый фон (#d1fae5) для активных реакций, красный фон (#fee2e2) для удаленных
- **Tooltip статуса**: При наведении на ID показывается текстовое описание статуса ("Активна" / "Удалена")
- **Перераспределение колонок**: Увеличена ширина колонок "Текст" (28%), "Автор" (20%) и "Публикация" (25%) за счет убранной колонки статуса
- **Улучшенные стили**: Добавлены стили для галочки с hover эффектами и правильным позиционированием
#### Расширенная информация об авторах в tooltip'ах
- **Дата регистрации в tooltip'ах**: Во всех таблицах админ-панели (публикации и реакции) tooltip'ы авторов теперь показывают не только email, но и дату регистрации с предлогом "с"
- **Формат tooltip'а**: "email@example.com с 01.10.2023" - краткий и информативный формат
- **GraphQL обновления**: Добавлено поле `created_at` для всех полей авторов в запросах `ADMIN_GET_SHOUTS_QUERY` и `ADMIN_GET_REACTIONS_QUERY`
- **Безопасная типизация**: Функция `formatAuthorTooltip()` корректно обрабатывает отсутствующие поля и возвращает fallback значения
- **Локализация**: Дата форматируется в русском формате (ДД.ММ.ГГГГ) через `toLocaleDateString('ru-RU')`
#### Улучшенный поиск и автоматическая фильтрация
- **Умный поиск по ID публикаций**: Строка поиска теперь автоматически определяет числовые запросы как ID публикаций и ищет реакции к конкретной публикации
- **Расширенный placeholder**: "Поиск по тексту, автору, публикации или ID публикации..." - информирует о всех возможностях поиска
- **Автоматическое применение фильтров**: Убрана кнопка "Применить фильтры" - фильтры применяются мгновенно при изменении:
- Галочка "Только удаленные" срабатывает сразу при клике
- Выбор типа реакции (лайк, комментарий и т.д.) применяется автоматически
- Поиск запускается при каждом изменении строки поиска
- **Убрано отдельное поле ID**: Удалено дублирующее поле "ID публикации" - теперь поиск по ID происходит через основную строку поиска
- **Оптимизированная логика**: Использование `createEffect` для отслеживания изменений всех фильтров без дублирования запросов
- **Улучшенный UX**: Более быстрый и интуитивный интерфейс без лишних кнопок и полей
#### Новая функциональность
- **Вкладка "Реакции"** в навигации админ-панели с эмоджи-индикаторами
- **Просмотр всех реакций** с детальной информацией о типе, авторе, публикации и статистике

View File

@ -34,18 +34,21 @@ export const ADMIN_GET_SHOUTS_QUERY: string =
name
email
slug
created_at
}
updated_by {
id
name
email
slug
created_at
}
deleted_by {
id
name
email
slug
created_at
}
community {
id
@ -57,6 +60,7 @@ export const ADMIN_GET_SHOUTS_QUERY: string =
name
email
slug
created_at
}
topics {
id
@ -210,6 +214,7 @@ export const ADMIN_GET_REACTIONS_QUERY: string =
name
email
slug
created_at
}
shout {
id

View File

@ -1,4 +1,4 @@
import { Component, createSignal, For, onMount, Show } from 'solid-js'
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'
@ -31,6 +31,7 @@ interface AdminReaction {
name: string
email: string
slug: string
created_at: number
}
shout: {
id: number
@ -70,8 +71,7 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
// Фильтры
const [searchQuery, setSearchQuery] = createSignal('')
const [kindFilter, setKindFilter] = createSignal('')
const [shoutIdFilter, setShoutIdFilter] = createSignal('')
const [statusFilter, setStatusFilter] = createSignal('all')
const [showDeletedOnly, setShowDeletedOnly] = createSignal(false)
/**
* Загрузка списка реакций
@ -80,6 +80,11 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
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
@ -90,10 +95,10 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
`${location.origin}/graphql`,
ADMIN_GET_REACTIONS_QUERY,
{
search: searchQuery(),
search: isShoutId ? '' : query_value, // Если это ID, не передаем в обычный поиск
kind: kindFilter() || undefined,
shout_id: shoutIdFilter() ? parseInt(shoutIdFilter()) : undefined,
status: statusFilter(),
shout_id: isShoutId ? parseInt(query_value) : undefined, // Если это ID, передаем в shout_id
status: showDeletedOnly() ? 'deleted' : 'all',
limit: pagination().limit,
offset: (pagination().page - 1) * pagination().limit
}
@ -187,9 +192,28 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
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()
})
@ -269,6 +293,35 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
}
}
/**
* Получает название статуса реакции
*/
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()}>
@ -281,7 +334,7 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
searchValue={searchQuery()}
onSearchChange={handleSearchChange}
onSearch={handleSearch}
searchPlaceholder="Поиск по тексту, автору или публикации..."
searchPlaceholder="Поиск по тексту, автору, публикации или ID публикации..."
isLoading={loading()}
/>
@ -308,27 +361,14 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
<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>
<label class={styles['filter-checkbox']}>
<input
type="text"
placeholder="ID публикации"
value={shoutIdFilter()}
onInput={(e) => setShoutIdFilter(e.target.value)}
class={styles['filter-input']}
type="checkbox"
checked={showDeletedOnly()}
onChange={(e) => setShowDeletedOnly(e.target.checked)}
/>
<Button variant="primary" onClick={() => void loadReactions()}>
Применить фильтры
</Button>
Только удаленные
</label>
</div>
</div>
@ -347,7 +387,6 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
<th>Автор</th>
<th>Публикация</th>
<th>Создано</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
@ -361,7 +400,16 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
setShowEditModal(true)
}}
>
<td>{reaction.id}</td>
<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)}
@ -373,7 +421,7 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
</div>
</td>
<td>
<div class={styles['author-cell']}>
<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>
@ -390,11 +438,6 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
</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}>

View File

@ -193,6 +193,21 @@ const ShoutsRoute = (props: ShoutsRouteProps) => {
return `${text.substring(0, maxLength)}...`
}
/**
* Форматирует tooltip для автора с email и датой регистрации
*/
function 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['shouts-container']}>
<Show when={loading()}>
@ -258,7 +273,7 @@ const ShoutsRoute = (props: ShoutsRouteProps) => {
{(author) => (
<Show when={author}>
{(safeAuthor) => (
<span class={styles['author-badge']} title={safeAuthor()?.email || ''}>
<span class={styles['author-badge']} title={formatAuthorTooltip(safeAuthor()!)}>
{safeAuthor()?.name || safeAuthor()?.email || `ID:${safeAuthor()?.id}`}
</span>
)}

View File

@ -707,17 +707,17 @@ td {
.reactions-list th:nth-child(3), /* ТЕКСТ */
.reactions-list td:nth-child(3) {
width: 25%;
width: 28%;
}
.reactions-list th:nth-child(4), /* АВТОР */
.reactions-list td:nth-child(4) {
width: 18%;
width: 20%;
}
.reactions-list th:nth-child(5), /* ПУБЛИКАЦИЯ */
.reactions-list td:nth-child(5) {
width: 22%;
width: 25%;
}
.reactions-list th:nth-child(6), /* СОЗДАНО */
@ -725,14 +725,8 @@ td {
width: 120px;
}
.reactions-list th:nth-child(7), /* СТАТУС */
.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;
}
@ -847,6 +841,29 @@ td {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.filter-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 6px 10px;
border-radius: 4px;
transition: all 0.2s ease;
user-select: none;
font-size: 0.875rem;
}
.filter-checkbox:hover {
background-color: #f0f4f8;
}
.filter-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: #3b82f6;
}
.stat-info {
display: flex;
gap: 1rem;