Форматтера у фронтенда не было: стиль держался вручную и успел разъехаться в 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>
282 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|