core/panel/routes/shouts.tsx

442 lines
16 KiB
TypeScript
Raw Normal View History

2025-07-02 19:30:21 +00:00
import { createEffect, createSignal, For, on, onMount, Show, untrack } from 'solid-js'
import { useData } from '../context/data'
import { useTableSort } from '../context/sort'
import { SHOUTS_SORT_CONFIG } from '../context/sortConfig'
2025-06-30 18:25:26 +00:00
import { query } from '../graphql'
import type { Query, AdminShoutInfo as Shout } from '../graphql/generated/schema'
import { ADMIN_GET_SHOUTS_QUERY } from '../graphql/queries'
import styles from '../styles/Admin.module.css'
2025-07-03 09:15:10 +00:00
import HTMLEditor from '../ui/HTMLEditor'
2025-06-30 18:25:26 +00:00
import Modal from '../ui/Modal'
import Pagination from '../ui/Pagination'
2025-07-02 19:30:21 +00:00
import SortableHeader from '../ui/SortableHeader'
import TableControls from '../ui/TableControls'
2025-06-30 18:25:26 +00:00
import { formatDateRelative } from '../utils/date'
export interface ShoutsRouteProps {
onError?: (error: string) => void
onSuccess?: (message: string) => void
}
2025-07-02 19:30:21 +00:00
const ShoutsRoute = (props: ShoutsRouteProps) => {
2025-06-30 18:25:26 +00:00
const [shouts, setShouts] = createSignal<Shout[]>([])
const [loading, setLoading] = createSignal(true)
const [showBodyModal, setShowBodyModal] = createSignal(false)
const [selectedShoutBody, setSelectedShoutBody] = createSignal<string>('')
const [showMediaBodyModal, setShowMediaBodyModal] = createSignal(false)
const [selectedMediaBody, setSelectedMediaBody] = createSignal<string>('')
2025-07-02 19:30:21 +00:00
const { sortState } = useTableSort()
const { selectedCommunity } = useData()
2025-06-30 18:25:26 +00:00
// Pagination state
const [pagination, setPagination] = createSignal<{
page: number
limit: number
total: number
totalPages: number
}>({
page: 1,
limit: 20,
total: 0,
totalPages: 0
})
// Filter state
const [searchQuery, setSearchQuery] = createSignal('')
/**
* Загрузка списка публикаций
*/
async function loadShouts() {
try {
setLoading(true)
2025-07-02 19:30:21 +00:00
// Подготавливаем параметры запроса
const variables: {
limit: number
offset: number
search?: string
community?: number
} = {
limit: pagination().limit,
offset: (pagination().page - 1) * pagination().limit
}
// Добавляем поиск если есть
if (searchQuery().trim()) {
variables.search = searchQuery().trim()
}
// Добавляем фильтр по сообществу если выбрано
const communityFilter = selectedCommunity()
if (communityFilter !== null) {
variables.community = communityFilter
}
2025-06-30 18:25:26 +00:00
const result = await query<{ adminGetShouts: Query['adminGetShouts'] }>(
`${location.origin}/graphql`,
ADMIN_GET_SHOUTS_QUERY,
2025-07-02 19:30:21 +00:00
variables
2025-06-30 18:25:26 +00:00
)
if (result?.adminGetShouts?.shouts) {
2025-07-02 19:30:21 +00:00
// Применяем сортировку на клиенте
const sortedShouts = sortShouts(result.adminGetShouts.shouts)
setShouts(sortedShouts)
2025-06-30 18:25:26 +00:00
setPagination((prev) => ({
...prev,
total: result.adminGetShouts.total || 0,
totalPages: result.adminGetShouts.totalPages || 1
}))
}
} catch (error) {
console.error('Failed to load shouts:', error)
props.onError?.(error instanceof Error ? error.message : 'Failed to load shouts')
} finally {
setLoading(false)
}
}
// Load shouts on mount
onMount(() => {
void loadShouts()
})
// Pagination handlers
function handlePageChange(page: number) {
setPagination((prev) => ({ ...prev, page }))
void loadShouts()
}
function handlePerPageChange(limit: number) {
setPagination((prev) => ({ ...prev, page: 1, limit }))
void loadShouts()
}
2025-07-02 19:30:21 +00:00
/**
* Сортирует публикации на клиенте
*/
function sortShouts(shoutsData: Shout[]): Shout[] {
const { field, direction } = sortState()
return [...shoutsData].sort((a, b) => {
let comparison = 0
switch (field) {
case 'id':
comparison = Number(a.id) - Number(b.id)
break
case 'title':
comparison = (a.title || '').localeCompare(b.title || '', 'ru')
break
case 'slug':
comparison = (a.slug || '').localeCompare(b.slug || '', 'ru')
break
case 'created_at':
comparison = (a.created_at || 0) - (b.created_at || 0)
break
case 'published_at':
comparison = (a.published_at || 0) - (b.published_at || 0)
break
case 'updated_at':
comparison = (a.updated_at || 0) - (b.updated_at || 0)
break
default:
comparison = Number(a.id) - Number(b.id)
}
return direction === 'desc' ? -comparison : comparison
})
2025-06-30 18:25:26 +00:00
}
2025-07-02 19:30:21 +00:00
// Пересортировка при изменении состояния сортировки
createEffect(
on([sortState], () => {
if (shouts().length > 0) {
// Используем untrack для предотвращения бесконечной рекурсии
const currentShouts = untrack(() => shouts())
const sortedShouts = sortShouts(currentShouts)
// Сравниваем текущий порядок с отсортированным, чтобы избежать лишних обновлений
const needsUpdate =
JSON.stringify(currentShouts.map((s: Shout) => s.id)) !==
JSON.stringify(sortedShouts.map((s: Shout) => s.id))
if (needsUpdate) {
setShouts(sortedShouts)
}
}
})
)
// Перезагрузка при изменении выбранного сообщества
createEffect(
on([selectedCommunity], () => {
void loadShouts()
})
)
// Helper functions
2025-06-30 18:25:26 +00:00
function getShoutStatusTitle(shout: Shout): string {
if (shout.deleted_at) return 'Удалена'
if (shout.published_at) return 'Опубликована'
return 'Черновик'
}
2025-07-02 19:30:21 +00:00
function getShoutStatusBackgroundColor(shout: Shout): string {
if (shout.deleted_at) return '#fee2e2' // Пастельный красный
if (shout.published_at) return '#d1fae5' // Пастельный зеленый
return '#fef3c7' // Пастельный желтый для черновиков
2025-06-30 18:25:26 +00:00
}
function truncateText(text: string, maxLength = 100): string {
if (!text || text.length <= maxLength) return text
return `${text.substring(0, maxLength)}...`
}
2025-07-07 14:51:48 +00:00
/**
* Форматирует 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}`
}
2025-06-30 18:25:26 +00:00
return (
<div class={styles['shouts-container']}>
<Show when={loading()}>
<div class={styles['loading']}>Загрузка публикаций...</div>
</Show>
<Show when={!loading() && shouts().length === 0}>
<div class={styles['empty-state']}>Нет публикаций для отображения</div>
</Show>
<Show when={!loading() && shouts().length > 0}>
2025-07-02 19:30:21 +00:00
<TableControls
onRefresh={loadShouts}
isLoading={loading()}
searchValue={searchQuery()}
onSearchChange={(value) => setSearchQuery(value)}
onSearch={() => void loadShouts()}
/>
2025-06-30 18:25:26 +00:00
<div class={styles['shouts-list']}>
<table>
<thead>
<tr>
2025-07-02 19:30:21 +00:00
<SortableHeader field="id" allowedFields={SHOUTS_SORT_CONFIG.allowedFields}>
ID
</SortableHeader>
<SortableHeader field="title" allowedFields={SHOUTS_SORT_CONFIG.allowedFields}>
Заголовок
</SortableHeader>
<SortableHeader field="slug" allowedFields={SHOUTS_SORT_CONFIG.allowedFields}>
Slug
</SortableHeader>
2025-06-30 18:25:26 +00:00
<th>Авторы</th>
<th>Темы</th>
2025-07-02 19:30:21 +00:00
<SortableHeader field="created_at" allowedFields={SHOUTS_SORT_CONFIG.allowedFields}>
Создан
</SortableHeader>
2025-06-30 18:25:26 +00:00
<th>Содержимое</th>
<th>Media</th>
</tr>
</thead>
<tbody>
<For each={shouts()}>
{(shout) => (
<tr>
2025-07-02 19:30:21 +00:00
<td
style={{
'background-color': getShoutStatusBackgroundColor(shout),
padding: '8px 12px',
'border-radius': '4px'
}}
title={getShoutStatusTitle(shout)}
>
{shout.id}
</td>
2025-06-30 18:25:26 +00:00
<td title={shout.title}>{truncateText(shout.title, 50)}</td>
<td title={shout.slug}>{truncateText(shout.slug, 30)}</td>
<td>
<Show when={shout.authors?.length}>
<div class={styles['authors-list']}>
<For each={shout.authors}>
{(author) => (
<Show when={author}>
{(safeAuthor) => (
2025-07-07 14:51:48 +00:00
<span class={styles['author-badge']} title={formatAuthorTooltip(safeAuthor()!)}>
2025-06-30 18:25:26 +00:00
{safeAuthor()?.name || safeAuthor()?.email || `ID:${safeAuthor()?.id}`}
</span>
)}
</Show>
)}
</For>
</div>
</Show>
<Show when={!shout.authors?.length}>
<span class={styles['no-data']}>-</span>
</Show>
</td>
<td>
<Show when={shout.topics?.length}>
<div class={styles['topics-list']}>
<For each={shout.topics}>
{(topic) => (
<Show when={topic}>
{(safeTopic) => (
<span class={styles['topic-badge']} title={safeTopic()?.slug || ''}>
{safeTopic()?.title || safeTopic()?.slug}
</span>
)}
</Show>
)}
</For>
</div>
</Show>
<Show when={!shout.topics?.length}>
<span class={styles['no-data']}>-</span>
</Show>
</td>
2025-07-02 19:30:21 +00:00
<td>{formatDateRelative(shout.created_at)()}</td>
2025-06-30 18:25:26 +00:00
<td
class={styles['body-cell']}
onClick={() => {
setSelectedShoutBody(shout.body)
setShowBodyModal(true)
}}
style="cursor: pointer; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
>
{truncateText(shout.body.replace(/<[^>]*>/g, ''), 100)}
</td>
<td>
<Show when={shout.media && shout.media.length > 0}>
<div style="display: flex; flex-direction: column; gap: 4px;">
<For each={shout.media}>
{(mediaItem, idx) => (
<div style="display: flex; align-items: center; gap: 6px;">
<Show when={mediaItem?.body}>
<button
class={styles['edit-button']}
2025-07-02 19:30:21 +00:00
style="padding: 4px; font-size: 14px; min-width: 24px; border-radius: 4px;"
2025-06-30 18:25:26 +00:00
onClick={() => {
setSelectedMediaBody(mediaItem?.body || '')
setShowMediaBodyModal(true)
}}
2025-07-02 19:30:21 +00:00
title={mediaItem?.title || idx().toString()}
2025-06-30 18:25:26 +00:00
>
2025-07-02 19:30:21 +00:00
👁
2025-06-30 18:25:26 +00:00
</button>
</Show>
</div>
)}
</For>
</div>
</Show>
<Show when={!shout.media || shout.media.length === 0}>
<span class={styles['no-data']}>-</span>
</Show>
</td>
</tr>
)}
</For>
</tbody>
</table>
<Pagination
currentPage={pagination().page}
totalPages={pagination().totalPages}
total={pagination().total}
limit={pagination().limit}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
/>
</div>
</Show>
2025-07-01 06:10:32 +00:00
<Modal
isOpen={showBodyModal()}
onClose={() => setShowBodyModal(false)}
2025-07-03 09:15:10 +00:00
title="Редактирование содержимого публикации"
2025-07-01 06:10:32 +00:00
size="large"
2025-07-03 09:15:10 +00:00
footer={
<>
<button
type="button"
class={`${styles.button} ${styles.secondary}`}
onClick={() => setShowBodyModal(false)}
>
Отмена
</button>
<button
type="button"
class={`${styles.button} ${styles.primary}`}
onClick={() => {
// TODO: добавить логику сохранения изменений в базу данных
props.onSuccess?.('Содержимое публикации обновлено')
setShowBodyModal(false)
}}
>
Сохранить
</button>
</>
}
2025-07-01 06:10:32 +00:00
>
2025-07-03 09:15:10 +00:00
<div style="padding: 1rem;">
<HTMLEditor
value={selectedShoutBody()}
onInput={(value) => setSelectedShoutBody(value)}
/>
</div>
2025-06-30 18:25:26 +00:00
</Modal>
<Modal
isOpen={showMediaBodyModal()}
onClose={() => setShowMediaBodyModal(false)}
2025-07-03 09:15:10 +00:00
title="Редактирование содержимого media.body"
2025-07-01 06:10:32 +00:00
size="large"
2025-07-03 09:15:10 +00:00
footer={
<>
<button
type="button"
class={`${styles.button} ${styles.secondary}`}
onClick={() => setShowMediaBodyModal(false)}
>
Отмена
</button>
<button
type="button"
class={`${styles.button} ${styles.primary}`}
onClick={() => {
// TODO: добавить логику сохранения изменений media.body
props.onSuccess?.('Содержимое media.body обновлено')
setShowMediaBodyModal(false)
}}
>
Сохранить
</button>
</>
}
2025-06-30 18:25:26 +00:00
>
2025-07-03 09:15:10 +00:00
<div style="padding: 1rem;">
<HTMLEditor
value={selectedMediaBody()}
onInput={(value) => setSelectedMediaBody(value)}
/>gjl
</div>
2025-06-30 18:25:26 +00:00
</Modal>
</div>
)
}
export default ShoutsRoute