This commit is contained in:
parent
9f70654fb5
commit
d03336174f
26
CHANGELOG.md
26
CHANGELOG.md
|
@ -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**: Более быстрый и интуитивный интерфейс без лишних кнопок и полей
|
||||||
|
|
||||||
#### Новая функциональность
|
#### Новая функциональность
|
||||||
- **Вкладка "Реакции"** в навигации админ-панели с эмоджи-индикаторами
|
- **Вкладка "Реакции"** в навигации админ-панели с эмоджи-индикаторами
|
||||||
- **Просмотр всех реакций** с детальной информацией о типе, авторе, публикации и статистике
|
- **Просмотр всех реакций** с детальной информацией о типе, авторе, публикации и статистике
|
||||||
|
|
|
@ -34,18 +34,21 @@ export const ADMIN_GET_SHOUTS_QUERY: string =
|
||||||
name
|
name
|
||||||
email
|
email
|
||||||
slug
|
slug
|
||||||
|
created_at
|
||||||
}
|
}
|
||||||
updated_by {
|
updated_by {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
email
|
email
|
||||||
slug
|
slug
|
||||||
|
created_at
|
||||||
}
|
}
|
||||||
deleted_by {
|
deleted_by {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
email
|
email
|
||||||
slug
|
slug
|
||||||
|
created_at
|
||||||
}
|
}
|
||||||
community {
|
community {
|
||||||
id
|
id
|
||||||
|
@ -57,6 +60,7 @@ export const ADMIN_GET_SHOUTS_QUERY: string =
|
||||||
name
|
name
|
||||||
email
|
email
|
||||||
slug
|
slug
|
||||||
|
created_at
|
||||||
}
|
}
|
||||||
topics {
|
topics {
|
||||||
id
|
id
|
||||||
|
@ -210,6 +214,7 @@ export const ADMIN_GET_REACTIONS_QUERY: string =
|
||||||
name
|
name
|
||||||
email
|
email
|
||||||
slug
|
slug
|
||||||
|
created_at
|
||||||
}
|
}
|
||||||
shout {
|
shout {
|
||||||
id
|
id
|
||||||
|
|
|
@ -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 { query } from '../graphql'
|
||||||
import type { Query } from '../graphql/generated/schema'
|
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_DELETE_REACTION_MUTATION, ADMIN_RESTORE_REACTION_MUTATION, ADMIN_UPDATE_REACTION_MUTATION } from '../graphql/mutations'
|
||||||
|
@ -31,6 +31,7 @@ interface AdminReaction {
|
||||||
name: string
|
name: string
|
||||||
email: string
|
email: string
|
||||||
slug: string
|
slug: string
|
||||||
|
created_at: number
|
||||||
}
|
}
|
||||||
shout: {
|
shout: {
|
||||||
id: number
|
id: number
|
||||||
|
@ -70,8 +71,7 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
|
||||||
// Фильтры
|
// Фильтры
|
||||||
const [searchQuery, setSearchQuery] = createSignal('')
|
const [searchQuery, setSearchQuery] = createSignal('')
|
||||||
const [kindFilter, setKindFilter] = createSignal('')
|
const [kindFilter, setKindFilter] = createSignal('')
|
||||||
const [shoutIdFilter, setShoutIdFilter] = createSignal('')
|
const [showDeletedOnly, setShowDeletedOnly] = createSignal(false)
|
||||||
const [statusFilter, setStatusFilter] = createSignal('all')
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Загрузка списка реакций
|
* Загрузка списка реакций
|
||||||
|
@ -80,6 +80,11 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
|
||||||
console.log('[ReactionsRoute] Loading reactions...')
|
console.log('[ReactionsRoute] Loading reactions...')
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
|
// Определяем, является ли поисковый запрос ID публикации
|
||||||
|
const query_value = searchQuery().trim()
|
||||||
|
const isShoutId = /^\d+$/.test(query_value) // Проверяем, состоит ли запрос только из цифр
|
||||||
|
|
||||||
const data = await query<{ adminGetReactions: {
|
const data = await query<{ adminGetReactions: {
|
||||||
reactions: AdminReaction[]
|
reactions: AdminReaction[]
|
||||||
total: number
|
total: number
|
||||||
|
@ -90,10 +95,10 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
|
||||||
`${location.origin}/graphql`,
|
`${location.origin}/graphql`,
|
||||||
ADMIN_GET_REACTIONS_QUERY,
|
ADMIN_GET_REACTIONS_QUERY,
|
||||||
{
|
{
|
||||||
search: searchQuery(),
|
search: isShoutId ? '' : query_value, // Если это ID, не передаем в обычный поиск
|
||||||
kind: kindFilter() || undefined,
|
kind: kindFilter() || undefined,
|
||||||
shout_id: shoutIdFilter() ? parseInt(shoutIdFilter()) : undefined,
|
shout_id: isShoutId ? parseInt(query_value) : undefined, // Если это ID, передаем в shout_id
|
||||||
status: statusFilter(),
|
status: showDeletedOnly() ? 'deleted' : 'all',
|
||||||
limit: pagination().limit,
|
limit: pagination().limit,
|
||||||
offset: (pagination().page - 1) * pagination().limit
|
offset: (pagination().page - 1) * pagination().limit
|
||||||
}
|
}
|
||||||
|
@ -187,9 +192,28 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
|
||||||
void loadReactions()
|
void loadReactions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Флаг для пропуска первого вызова createEffect при монтировании
|
||||||
|
let isInitialized = false
|
||||||
|
|
||||||
// Load reactions on mount
|
// Load reactions on mount
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
console.log('[ReactionsRoute] Component mounted, loading reactions...')
|
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()
|
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 (
|
return (
|
||||||
<div class={styles['reactions-container']}>
|
<div class={styles['reactions-container']}>
|
||||||
<Show when={loading()}>
|
<Show when={loading()}>
|
||||||
|
@ -281,7 +334,7 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
|
||||||
searchValue={searchQuery()}
|
searchValue={searchQuery()}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
searchPlaceholder="Поиск по тексту, автору или публикации..."
|
searchPlaceholder="Поиск по тексту, автору, публикации или ID публикации..."
|
||||||
isLoading={loading()}
|
isLoading={loading()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -308,27 +361,14 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
|
||||||
<option value="SILENT">Причастность</option>
|
<option value="SILENT">Причастность</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select
|
<label class={styles['filter-checkbox']}>
|
||||||
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
|
<input
|
||||||
type="text"
|
type="checkbox"
|
||||||
placeholder="ID публикации"
|
checked={showDeletedOnly()}
|
||||||
value={shoutIdFilter()}
|
onChange={(e) => setShowDeletedOnly(e.target.checked)}
|
||||||
onInput={(e) => setShoutIdFilter(e.target.value)}
|
|
||||||
class={styles['filter-input']}
|
|
||||||
/>
|
/>
|
||||||
|
Только удаленные
|
||||||
<Button variant="primary" onClick={() => void loadReactions()}>
|
</label>
|
||||||
Применить фильтры
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -347,7 +387,6 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
|
||||||
<th>Автор</th>
|
<th>Автор</th>
|
||||||
<th>Публикация</th>
|
<th>Публикация</th>
|
||||||
<th>Создано</th>
|
<th>Создано</th>
|
||||||
<th>Статус</th>
|
|
||||||
<th>Действия</th>
|
<th>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
@ -361,7 +400,16 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
|
||||||
setShowEditModal(true)
|
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>
|
<td>
|
||||||
<span title={getReactionName(reaction.kind)} class={styles['reaction-icon']}>
|
<span title={getReactionName(reaction.kind)} class={styles['reaction-icon']}>
|
||||||
{getReactionIcon(reaction.kind)}
|
{getReactionIcon(reaction.kind)}
|
||||||
|
@ -373,7 +421,7 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class={styles['author-cell']}>
|
<div class={styles['author-cell']} title={formatAuthorTooltip(reaction.created_by)}>
|
||||||
<div>{reaction.created_by.name || 'Без имени'}</div>
|
<div>{reaction.created_by.name || 'Без имени'}</div>
|
||||||
<div class={styles['author-email']}>{reaction.created_by.email}</div>
|
<div class={styles['author-email']}>{reaction.created_by.email}</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -390,11 +438,6 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{formatDateRelative(reaction.created_at)()}</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>
|
<td>
|
||||||
<div class={styles['actions-cell']} onClick={(e) => e.stopPropagation()}>
|
<div class={styles['actions-cell']} onClick={(e) => e.stopPropagation()}>
|
||||||
<Show when={reaction.deleted_at}>
|
<Show when={reaction.deleted_at}>
|
||||||
|
|
|
@ -193,6 +193,21 @@ const ShoutsRoute = (props: ShoutsRouteProps) => {
|
||||||
return `${text.substring(0, maxLength)}...`
|
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 (
|
return (
|
||||||
<div class={styles['shouts-container']}>
|
<div class={styles['shouts-container']}>
|
||||||
<Show when={loading()}>
|
<Show when={loading()}>
|
||||||
|
@ -258,7 +273,7 @@ const ShoutsRoute = (props: ShoutsRouteProps) => {
|
||||||
{(author) => (
|
{(author) => (
|
||||||
<Show when={author}>
|
<Show when={author}>
|
||||||
{(safeAuthor) => (
|
{(safeAuthor) => (
|
||||||
<span class={styles['author-badge']} title={safeAuthor()?.email || ''}>
|
<span class={styles['author-badge']} title={formatAuthorTooltip(safeAuthor()!)}>
|
||||||
{safeAuthor()?.name || safeAuthor()?.email || `ID:${safeAuthor()?.id}`}
|
{safeAuthor()?.name || safeAuthor()?.email || `ID:${safeAuthor()?.id}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -707,17 +707,17 @@ td {
|
||||||
|
|
||||||
.reactions-list th:nth-child(3), /* ТЕКСТ */
|
.reactions-list th:nth-child(3), /* ТЕКСТ */
|
||||||
.reactions-list td:nth-child(3) {
|
.reactions-list td:nth-child(3) {
|
||||||
width: 25%;
|
width: 28%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactions-list th:nth-child(4), /* АВТОР */
|
.reactions-list th:nth-child(4), /* АВТОР */
|
||||||
.reactions-list td:nth-child(4) {
|
.reactions-list td:nth-child(4) {
|
||||||
width: 18%;
|
width: 20%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactions-list th:nth-child(5), /* ПУБЛИКАЦИЯ */
|
.reactions-list th:nth-child(5), /* ПУБЛИКАЦИЯ */
|
||||||
.reactions-list td:nth-child(5) {
|
.reactions-list td:nth-child(5) {
|
||||||
width: 22%;
|
width: 25%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactions-list th:nth-child(6), /* СОЗДАНО */
|
.reactions-list th:nth-child(6), /* СОЗДАНО */
|
||||||
|
@ -725,14 +725,8 @@ td {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactions-list th:nth-child(7), /* СТАТУС */
|
.reactions-list th:nth-child(7), /* ДЕЙСТВИЯ */
|
||||||
.reactions-list td: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;
|
width: 120px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
@ -847,6 +841,29 @@ td {
|
||||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
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 {
|
.stat-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|
Loading…
Reference in New Issue
Block a user