Files
pipu-music/apps/web/src/pages/admin/AuditPage.tsx
T
mrleo1nidandCursor 72567acd28
release / desktop (Linux и Windows в релиз) (push) Successful in 16m22s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m23s
release / release (версия и заметки) (push) Successful in 6s
ci / ts / ts (packages + web) (push) Failing after 5m25s
release / android (APK в релиз) (push) Successful in 4m18s
feat(web): polish catalog motion, empty states, and mobile now-playing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 09:53:37 +03:00

81 lines
3.3 KiB
TypeScript

import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { formatDateTime } from '../../shared/lib/format';
import { Button, EmptyState, FormError, PageSkeleton } from '../../shared/ui/ui';
import s from '../page.module.css';
type Entry = components['schemas']['AuditEntry'];
/** Краткое описание полезной нагрузки записи: «user → moderator», «anna (user)». */
function details(e: Entry, roleName: (r: string) => string): string {
const p = e.payload ?? {};
if (e.action === 'user.role_change') return `${roleName(String(p.from))} → ${roleName(String(p.to))}`;
if (e.action === 'user.username_change') return `${String(p.from)} → ${String(p.to)}`;
const parts = [p.username, p.role && roleName(String(p.role)), p.display_name].filter(
(v): v is string => typeof v === 'string' && v !== '',
);
return parts.join(' · ');
}
export function AuditPage() {
const { t, i18n } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const list = useInfiniteQuery({
queryKey: ['admin-audit'],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) =>
unwrap(await session.client.GET('/api/v1/admin/audit', { params: { query: { cursor: pageParam, limit: 50 } } })),
getNextPageParam: (page) => page.next_cursor ?? undefined,
});
const entries = list.data?.pages.flatMap((p) => p.items) ?? [];
const tr = t as unknown as (key: string, opts?: Record<string, unknown>) => string;
const roleName = (r: string) => tr(`common:roles.${r}`, { defaultValue: r });
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('audit.title')}</h1>
<p className={s.subtitle}>{t('audit.subtitle')}</p>
</div>
</div>
<FormError>{list.error ? errorText(list.error) : null}</FormError>
<div className={s.tableWrap}>
<table className={s.table}>
<thead>
<tr>
<th>{t('audit.col_when')}</th>
<th>{t('audit.col_who')}</th>
<th>{t('audit.col_action')}</th>
<th>{t('audit.col_details')}</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.id}>
<td className={s.muted}>{formatDateTime(e.created_at, i18n.language)}</td>
<td>{e.actor ? `@${e.actor.username}` : <span className={s.muted}>{t('audit.system')}</span>}</td>
<td>{tr(`admin:audit.actions.${e.action}`, { defaultValue: e.action })}</td>
<td className={s.muted}>{details(e, roleName)}</td>
</tr>
))}
</tbody>
</table>
{list.isSuccess && entries.length === 0 && <EmptyState>{t('audit.empty')}</EmptyState>}
{list.isPending && <PageSkeleton />}
</div>
{list.hasNextPage && (
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('audit.more')}
</Button>
)}
</div>
);
}