Files
TeleWave/frontend/src/features/admin/genres/GenresPanel.tsx
T
Leonid PershinandClaude Opus 5 0606ea3e6e Add Prettier to the frontend and gate formatting in CI
Форматтера у фронтенда не было: стиль держался вручную и успел разъехаться в 50 файлах.
Ставим Prettier с настройками под уже сложившийся стиль (без точек с запятой, одинарные кавычки,
ширина 100 — подобрана замером: при 100 расходится меньше файлов, чем при 96 или 110) и
прогоняем его по коду. `src/routeTree.gen.ts` исключён — его переписывает плагин роутера.

Чтобы форматирование больше не расходилось незаметно, добавлены проверки в CI: `csharpier check`
для бэкенда (его отсутствие и позволило накопиться 79 неотформатированным файлам) и
`prettier --check` для фронтенда. Версии форматтеров прибиты точно, без кареток: минорка меняет
вывод и красит CI на файлах, которых никто не трогал.

`.editorconfig` задаёт редакторам те же отступы и LF ещё до форматтера; значения совпадают с
настройками csharpier и Prettier намеренно — оба его читают.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:38:12 +03:00

282 lines
10 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import { qk } from '@/shared/api/query-keys'
import type { GenreDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { sortRows, useTableSort } from '@/shared/lib/table-sort'
import { SortHeader } from '@/shared/ui/sortable'
import { createGenre, deleteGenre, listGenres, updateGenre } from './api'
const createSchema = z.object({
name: z.string().min(1).max(128),
slug: z
.string()
.min(1)
.max(64)
.regex(/^[a-z0-9-]+$/),
aliases: z.string().max(1024).optional(),
})
const editSchema = z.object({
name: z.string().min(1).max(128),
sortOrder: z.number().int().min(0),
aliases: z.string().max(1024).optional(),
})
type CreateValues = z.infer<typeof createSchema>
type EditValues = z.infer<typeof editSchema>
/** Псевдонимы вводятся одной строкой через запятую — их редко больше десятка. */
function parseAliases(value: string | undefined): string[] {
return (value ?? '')
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0)
}
export function GenresPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data: genres, isLoading } = useQuery({
queryKey: qk.genres.all,
queryFn: listGenres,
})
const { sort, toggle } = useTableSort('sortOrder', false)
const sortedGenres = sortRows(genres ?? [], sort, {
sortOrder: (g) => g.sortOrder,
name: (g) => g.name.toLowerCase(),
slug: (g) => g.slug,
showCount: (g) => g.showCount,
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.genres.all })
const reportError = useApiError()
const createMutation = useMutation({ mutationFn: createGenre, onSuccess: invalidate })
const updateMutation = useMutation({
mutationFn: ({
id,
...body
}: Readonly<{
id: string
name: string
sortOrder: number
aliases: string[]
}>) => updateGenre(id, body),
onSuccess: invalidate,
})
const deleteMutation = useMutation({
mutationFn: deleteGenre,
onSuccess: invalidate,
onError: reportError,
})
const [createOpen, setCreateOpen] = useState(false)
const [editing, setEditing] = useState<GenreDto | null>(null)
const createForm = useForm<CreateValues>({ resolver: zodResolver(createSchema) })
const editForm = useForm<EditValues>({ resolver: zodResolver(editSchema) })
const openEdit = (genre: GenreDto) => {
setEditing(genre)
editForm.reset({
name: genre.name,
sortOrder: genre.sortOrder,
aliases: genre.aliases.join(', '),
})
}
const onCreate = async (values: CreateValues) => {
try {
await createMutation.mutateAsync({
name: values.name,
slug: values.slug,
aliases: parseAliases(values.aliases),
})
createForm.reset()
setCreateOpen(false)
} catch (error) {
reportError(error)
}
}
const onEdit = async (values: EditValues) => {
if (!editing) return
try {
await updateMutation.mutateAsync({
id: editing.id,
name: values.name,
sortOrder: values.sortOrder,
aliases: parseAliases(values.aliases),
})
setEditing(null)
} catch (error) {
reportError(error)
}
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-1">
<h2 className="crt-glow text-xl font-semibold">{t('admin.genres.title')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.genres.hint')}</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" /> {t('admin.genres.create')}
</Button>
</div>
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<SortHeader
label={t('admin.genres.order')}
sortKey="sortOrder"
sort={sort}
onToggle={toggle}
/>
<SortHeader
label={t('admin.genres.name')}
sortKey="name"
sort={sort}
onToggle={toggle}
/>
<SortHeader
label={t('admin.genres.slug')}
sortKey="slug"
sort={sort}
onToggle={toggle}
/>
<th className="px-4 py-2 font-medium">{t('admin.genres.aliases')}</th>
<SortHeader
label={t('admin.genres.usage')}
sortKey="showCount"
sort={sort}
onToggle={toggle}
/>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
{t('common.loading')}
</td>
</tr>
)}
{sortedGenres.map((genre) => (
<tr key={genre.id} className="border-b border-border last:border-0">
<td className="px-4 py-2 text-muted-foreground">{genre.sortOrder}</td>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
{genre.name}
{genre.isSystem && <Badge variant="muted">{t('admin.genres.system')}</Badge>}
</div>
</td>
<td className="px-4 py-2 font-mono text-xs text-muted-foreground">{genre.slug}</td>
<td className="max-w-md px-4 py-2 text-xs text-muted-foreground">
{genre.aliases.join(', ')}
</td>
<td className="px-4 py-2">{genre.showCount}</td>
<td className="px-4 py-2">
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => openEdit(genre)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="destructive"
disabled={genre.isSystem || genre.showCount > 0}
onClick={() => deleteMutation.mutate(genre.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.genres.create')}</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={createForm.handleSubmit(onCreate)}>
<div className="flex flex-col gap-1.5">
<Label htmlFor="genreName">{t('admin.genres.name')}</Label>
<Input id="genreName" {...createForm.register('name')} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="genreSlug">{t('admin.genres.slug')}</Label>
<Input id="genreSlug" placeholder="action" {...createForm.register('slug')} />
<p className="text-xs text-muted-foreground">{t('admin.genres.slugHint')}</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="genreAliases">{t('admin.genres.aliases')}</Label>
<Input
id="genreAliases"
placeholder="tmdb:28, action"
{...createForm.register('aliases')}
/>
<p className="text-xs text-muted-foreground">{t('admin.genres.aliasesHint')}</p>
</div>
<DialogFooter>
<Button type="submit" disabled={createMutation.isPending}>
{t('common.create')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog open={editing !== null} onOpenChange={(open) => !open && setEditing(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.genres.edit')}</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={editForm.handleSubmit(onEdit)}>
<div className="flex flex-col gap-1.5">
<Label htmlFor="editGenreName">{t('admin.genres.name')}</Label>
<Input id="editGenreName" {...editForm.register('name')} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="editGenreOrder">{t('admin.genres.order')}</Label>
<Input
id="editGenreOrder"
type="number"
{...editForm.register('sortOrder', { valueAsNumber: true })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="editGenreAliases">{t('admin.genres.aliases')}</Label>
<Input id="editGenreAliases" {...editForm.register('aliases')} />
<p className="text-xs text-muted-foreground">{t('admin.genres.aliasesHint')}</p>
</div>
<DialogFooter>
<Button type="submit" disabled={updateMutation.isPending}>
{t('common.save')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}