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>
This commit is contained in:
Leonid Pershin
2026-07-27 01:38:12 +03:00
co-authored by Claude Opus 5
parent 0442056367
commit 0606ea3e6e
58 changed files with 5158 additions and 4903 deletions
@@ -240,30 +240,36 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
>
{t('admin.shows.deselectAll')}
</Button>
<Button size="sm" disabled={selected.length === 0 || adding != null} onClick={() => void bulkAdd()}>
<Button
size="sm"
disabled={selected.length === 0 || adding != null}
onClick={() => void bulkAdd()}
>
{addButtonLabel(adding, isSingle ? Math.min(1, selected.length) : selected.length, t)}
</Button>
</div>
<div className="crt-panel max-h-72 overflow-y-auto rounded-md">
{candidates.length === 0 ? (
<p className="px-4 py-3 text-sm text-muted-foreground">{t('admin.shows.noMatches')}</p>
<p className="px-4 py-3 text-sm text-muted-foreground">
{t('admin.shows.noMatches')}
</p>
) : (
<ul className="divide-y divide-border text-sm">
{candItems.map(({ asset, parsed }) => {
const label = formatSeasonEpisode(parsed)
return (
<li key={asset.id}>
<label className="flex cursor-pointer items-center gap-3 px-4 py-2 hover:bg-muted">
<input
type="checkbox"
checked={!deselected.has(asset.id)}
onChange={() => toggle(asset.id)}
/>
{label ? <Badge>{label}</Badge> : <Badge variant="muted"></Badge>}
<span className="truncate">{asset.originalFileName}</span>
</label>
</li>
<li key={asset.id}>
<label className="flex cursor-pointer items-center gap-3 px-4 py-2 hover:bg-muted">
<input
type="checkbox"
checked={!deselected.has(asset.id)}
onChange={() => toggle(asset.id)}
/>
{label ? <Badge>{label}</Badge> : <Badge variant="muted"></Badge>}
<span className="truncate">{asset.originalFileName}</span>
</label>
</li>
)
})}
</ul>
@@ -295,48 +301,48 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
: parseEpisodeName(episode.assetName ?? '')
const label = formatSeasonEpisode(parsed)
return (
<tr key={episode.id} className="border-b border-border last:border-0">
<td className="px-4 py-2 text-muted-foreground">{epOffset + index + 1}</td>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
{episode.stillImageId && (
<img
src={imageUrl(episode.stillImageId)}
alt=""
className="h-9 w-16 shrink-0 rounded object-cover"
/>
)}
{label && <Badge>{label}</Badge>}
<div className="min-w-0">
<div className="truncate">{episode.title ?? episode.assetName ?? '—'}</div>
{episode.title && (
<div className="truncate text-xs text-muted-foreground">
{episode.assetName}
</div>
<tr key={episode.id} className="border-b border-border last:border-0">
<td className="px-4 py-2 text-muted-foreground">{epOffset + index + 1}</td>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
{episode.stillImageId && (
<img
src={imageUrl(episode.stillImageId)}
alt=""
className="h-9 w-16 shrink-0 rounded object-cover"
/>
)}
{label && <Badge>{label}</Badge>}
<div className="min-w-0">
<div className="truncate">{episode.title ?? episode.assetName ?? '—'}</div>
{episode.title && (
<div className="truncate text-xs text-muted-foreground">
{episode.assetName}
</div>
)}
</div>
</div>
</div>
</td>
<td className="px-4 py-2 text-muted-foreground">
{formatDuration(episode.durationSeconds)}
</td>
<td className="px-4 py-2">
{episode.assetStatus && (
<Badge variant={episode.assetStatus === 'Ready' ? 'default' : 'muted'}>
{t(`admin.media.statuses.${episode.assetStatus}`)}
</Badge>
)}
</td>
<td className="px-4 py-2">
<Button
size="sm"
variant="destructive"
onClick={() => removeMutation.mutate(episode.id)}
>
{t('common.delete')}
</Button>
</td>
</tr>
</td>
<td className="px-4 py-2 text-muted-foreground">
{formatDuration(episode.durationSeconds)}
</td>
<td className="px-4 py-2">
{episode.assetStatus && (
<Badge variant={episode.assetStatus === 'Ready' ? 'default' : 'muted'}>
{t(`admin.media.statuses.${episode.assetStatus}`)}
</Badge>
)}
</td>
<td className="px-4 py-2">
<Button
size="sm"
variant="destructive"
onClick={() => removeMutation.mutate(episode.id)}
>
{t('common.delete')}
</Button>
</td>
</tr>
)
})}
{show.episodes.length === 0 && (
@@ -95,7 +95,8 @@ export function ShowMetadataCard({
mutationFn: async () => {
const yearNum = year.trim() ? Number(year) : null
const infoChanged =
(description.trim() || null) !== (show.description ?? null) || yearNum !== (show.year ?? null)
(description.trim() || null) !== (show.description ?? null) ||
yearNum !== (show.year ?? null)
if (name.trim() && name.trim() !== show.name) await renameShow(show.id, name.trim())
if (originalName.trim() !== (show.originalName ?? ''))
await setShowOriginalName(show.id, originalName.trim() || null)
@@ -137,226 +138,238 @@ export function ShowMetadataCard({
return (
<>
<Card>
<CardHeader>
<CardTitle>{t('admin.metadata.title')}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row">
{/* Постер */}
<div className="flex w-40 shrink-0 flex-col gap-2">
<div className="flex aspect-[2/3] items-center justify-center overflow-hidden rounded-md border border-border bg-muted/30">
{show.posterImageId ? (
<img
src={imageUrl(show.posterImageId)}
alt=""
className="h-full w-full object-cover"
/>
) : (
<span className="text-xs text-muted-foreground">{t('admin.metadata.noPoster')}</span>
)}
</div>
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
{t('admin.metadata.pickPoster')}
</Button>
<ImageGallery
open={galleryOpen}
onOpenChange={setGalleryOpen}
category="ShowPoster"
onSelect={(img) => setPoster.mutate(img.id)}
/>
</div>
{/* Название + поиск + ручная правка */}
<div className="flex min-w-0 flex-1 flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.metadata.name')}</Label>
<Input value={name} maxLength={256} onChange={(e) => setName(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.metadata.originalName')}</Label>
<Input
value={originalName}
maxLength={256}
placeholder={t('admin.metadata.originalNamePlaceholder')}
onChange={(e) => {
setOriginalName(e.target.value)
setSearched(false)
}}
/>
<p className="text-xs text-muted-foreground">{t('admin.metadata.originalNameHint')}</p>
</div>
{providers && providers.length > 0 && (searched || results.length > 0) && (
<div className="flex flex-col gap-2">
{searched && results.length === 0 && (
<p className="text-xs text-muted-foreground">{t('admin.metadata.nothingFound')}</p>
)}
{results.length > 0 && (
<ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md">
{results.map((r) => (
<li key={r.externalId} className="flex items-start gap-3 p-2">
{r.posterUrl ? (
<img src={r.posterUrl} alt="" className="h-16 w-11 shrink-0 rounded object-cover" />
) : (
<div className="h-16 w-11 shrink-0 rounded bg-muted/40" />
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-sm font-medium">
<span>
{r.title}
{r.year != null && (
<span className="text-muted-foreground"> ({r.year})</span>
)}
</span>
{/* В выдаче OMDb сериалы и полнометражки идут вперемешку — без метки
одноимённые фильм и сериал не различить. */}
{r.kind && (
<Badge variant="muted">{t(`admin.shows.kinds.${r.kind}`)}</Badge>
)}
</div>
{r.overview && (
<p className="line-clamp-2 text-xs text-muted-foreground">{r.overview}</p>
)}
</div>
<Button
size="sm"
variant="outline"
disabled={apply.isPending}
onClick={() => apply.mutate(r.externalId)}
>
{t('admin.metadata.apply')}
</Button>
</li>
))}
</ul>
)}
</div>
)}
<div className="flex flex-col gap-2">
<div className="flex items-end gap-2">
<div className="flex flex-1 flex-col gap-1.5">
<Label>{t('admin.metadata.overview')}</Label>
<textarea
className="min-h-20 w-full rounded-sm border border-border bg-transparent px-3 py-2 text-sm"
value={description}
onChange={(e) => setDescription(e.target.value)}
<Card>
<CardHeader>
<CardTitle>{t('admin.metadata.title')}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row">
{/* Постер */}
<div className="flex w-40 shrink-0 flex-col gap-2">
<div className="flex aspect-[2/3] items-center justify-center overflow-hidden rounded-md border border-border bg-muted/30">
{show.posterImageId ? (
<img
src={imageUrl(show.posterImageId)}
alt=""
className="h-full w-full object-cover"
/>
</div>
<div className="flex w-24 flex-col gap-1.5">
<Label>{t('admin.metadata.year')}</Label>
<Input
type="number"
value={year}
onChange={(e) => setYear(e.target.value)}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{providers && providers.length > 0 && (
<>
<Select value={effectiveProvider} onValueChange={setProvider}>
<SelectTrigger className="h-9 w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{providers.map((p) => (
<SelectItem key={p} value={p}>
{p.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
disabled={search.isPending || !searchTerm}
onClick={() => search.mutate()}
>
{t('admin.metadata.searchBtn')}
</Button>
</>
)}
<Button size="sm" disabled={save.isPending || !name.trim()} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
{linked && (
<Button
size="sm"
variant="outline"
disabled={refreshEpisodes.isPending}
onClick={() => refreshEpisodes.mutate()}
>
{refreshEpisodes.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{refreshEpisodes.isPending
? t('admin.metadata.refreshing')
: t('admin.metadata.refreshEpisodes')}
</Button>
)}
{linked && (
<Button
size="sm"
variant="outline"
disabled={findMissing.isPending}
onClick={() => findMissing.mutate()}
>
{findMissing.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{t('admin.metadata.findMissing')}
</Button>
)}
{(show.metadataProvider || show.posterImageId) && (
<Button
size="sm"
variant="ghost"
disabled={clear.isPending}
onClick={() => clear.mutate()}
>
{t('admin.metadata.clear')}
</Button>
)}
{show.metadataProvider && (
) : (
<span className="text-xs text-muted-foreground">
{t('admin.metadata.sourceLabel')}: {show.metadataProvider}
{t('admin.metadata.noPoster')}
</span>
)}
</div>
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
{t('admin.metadata.pickPoster')}
</Button>
<ImageGallery
open={galleryOpen}
onOpenChange={setGalleryOpen}
category="ShowPoster"
onSelect={(img) => setPoster.mutate(img.id)}
/>
</div>
</div>
</CardContent>
</Card>
<Dialog open={missing != null} onOpenChange={(open) => !open && setMissing(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('admin.metadata.missingTitle')}</DialogTitle>
</DialogHeader>
{missing && missing.seasons.length === 0 && (
<p className="text-sm text-muted-foreground">{t('admin.metadata.missingNoSeasons')}</p>
)}
{missing && missing.seasons.length > 0 && (
<div className="flex max-h-[60vh] flex-col gap-3 overflow-y-auto">
{missing.seasons.map((s) => (
<div key={s.season} className="rounded-md border border-border p-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">
{t('admin.metadata.seasonN', { n: s.season })}
</span>
<span className="text-xs text-muted-foreground">
{t('admin.metadata.loadedOf', {
loaded: s.loaded,
total: s.expected ?? '?',
})}
</span>
</div>
<SeasonGapNote gap={s} />
{/* Название + поиск + ручная правка */}
<div className="flex min-w-0 flex-1 flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.metadata.name')}</Label>
<Input value={name} maxLength={256} onChange={(e) => setName(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.metadata.originalName')}</Label>
<Input
value={originalName}
maxLength={256}
placeholder={t('admin.metadata.originalNamePlaceholder')}
onChange={(e) => {
setOriginalName(e.target.value)
setSearched(false)
}}
/>
<p className="text-xs text-muted-foreground">
{t('admin.metadata.originalNameHint')}
</p>
</div>
{providers && providers.length > 0 && (searched || results.length > 0) && (
<div className="flex flex-col gap-2">
{searched && results.length === 0 && (
<p className="text-xs text-muted-foreground">
{t('admin.metadata.nothingFound')}
</p>
)}
{results.length > 0 && (
<ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md">
{results.map((r) => (
<li key={r.externalId} className="flex items-start gap-3 p-2">
{r.posterUrl ? (
<img
src={r.posterUrl}
alt=""
className="h-16 w-11 shrink-0 rounded object-cover"
/>
) : (
<div className="h-16 w-11 shrink-0 rounded bg-muted/40" />
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-sm font-medium">
<span>
{r.title}
{r.year != null && (
<span className="text-muted-foreground"> ({r.year})</span>
)}
</span>
{/* В выдаче OMDb сериалы и полнометражки идут вперемешку — без метки
одноимённые фильм и сериал не различить. */}
{r.kind && (
<Badge variant="muted">{t(`admin.shows.kinds.${r.kind}`)}</Badge>
)}
</div>
{r.overview && (
<p className="line-clamp-2 text-xs text-muted-foreground">
{r.overview}
</p>
)}
</div>
<Button
size="sm"
variant="outline"
disabled={apply.isPending}
onClick={() => apply.mutate(r.externalId)}
>
{t('admin.metadata.apply')}
</Button>
</li>
))}
</ul>
)}
</div>
))}
)}
<div className="flex flex-col gap-2">
<div className="flex items-end gap-2">
<div className="flex flex-1 flex-col gap-1.5">
<Label>{t('admin.metadata.overview')}</Label>
<textarea
className="min-h-20 w-full rounded-sm border border-border bg-transparent px-3 py-2 text-sm"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="flex w-24 flex-col gap-1.5">
<Label>{t('admin.metadata.year')}</Label>
<Input type="number" value={year} onChange={(e) => setYear(e.target.value)} />
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{providers && providers.length > 0 && (
<>
<Select value={effectiveProvider} onValueChange={setProvider}>
<SelectTrigger className="h-9 w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{providers.map((p) => (
<SelectItem key={p} value={p}>
{p.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
disabled={search.isPending || !searchTerm}
onClick={() => search.mutate()}
>
{t('admin.metadata.searchBtn')}
</Button>
</>
)}
<Button
size="sm"
disabled={save.isPending || !name.trim()}
onClick={() => save.mutate()}
>
{t('common.save')}
</Button>
{linked && (
<Button
size="sm"
variant="outline"
disabled={refreshEpisodes.isPending}
onClick={() => refreshEpisodes.mutate()}
>
{refreshEpisodes.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{refreshEpisodes.isPending
? t('admin.metadata.refreshing')
: t('admin.metadata.refreshEpisodes')}
</Button>
)}
{linked && (
<Button
size="sm"
variant="outline"
disabled={findMissing.isPending}
onClick={() => findMissing.mutate()}
>
{findMissing.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{t('admin.metadata.findMissing')}
</Button>
)}
{(show.metadataProvider || show.posterImageId) && (
<Button
size="sm"
variant="ghost"
disabled={clear.isPending}
onClick={() => clear.mutate()}
>
{t('admin.metadata.clear')}
</Button>
)}
{show.metadataProvider && (
<span className="text-xs text-muted-foreground">
{t('admin.metadata.sourceLabel')}: {show.metadataProvider}
</span>
)}
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
</CardContent>
</Card>
<Dialog open={missing != null} onOpenChange={(open) => !open && setMissing(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('admin.metadata.missingTitle')}</DialogTitle>
</DialogHeader>
{missing && missing.seasons.length === 0 && (
<p className="text-sm text-muted-foreground">{t('admin.metadata.missingNoSeasons')}</p>
)}
{missing && missing.seasons.length > 0 && (
<div className="flex max-h-[60vh] flex-col gap-3 overflow-y-auto">
{missing.seasons.map((s) => (
<div key={s.season} className="rounded-md border border-border p-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">
{t('admin.metadata.seasonN', { n: s.season })}
</span>
<span className="text-xs text-muted-foreground">
{t('admin.metadata.loadedOf', {
loaded: s.loaded,
total: s.expected ?? '?',
})}
</span>
</div>
<SeasonGapNote gap={s} />
</div>
))}
</div>
)}
</DialogContent>
</Dialog>
</>
)
}
+4 -1
View File
@@ -86,7 +86,10 @@ export function applyMetadata(showId: string, provider: string, externalId: stri
})
}
export function updateMetadata(showId: string, body: { description: string | null; year: number | null }) {
export function updateMetadata(
showId: string,
body: { description: string | null; year: number | null },
) {
return apiRequest<void>(`/admin/metadata/shows/${showId}`, { method: 'PUT', body })
}