Add metadata management for shows: implement metadata retrieval, application, and updates in the API and UI. Enhance Show and ShowDto models to include metadata fields, and update the database schema accordingly. Introduce new endpoints for metadata operations and integrate metadata display in the ShowDetail component.

This commit is contained in:
Leonid Pershin
2026-07-25 09:07:55 +03:00
parent ba023bc416
commit 0d2dee815e
42 changed files with 2271 additions and 4 deletions
@@ -0,0 +1,237 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import type { MetadataCandidate, ShowDto } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import {
applyMetadata,
clearMetadata,
getMetadataProviders,
searchMetadata,
showPosterUrl,
updateMetadata,
uploadPoster,
} from './api'
export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged: () => void }) {
const { t } = useTranslation()
const posterInput = useRef<HTMLInputElement>(null)
const [bust, setBust] = useState(0)
const [provider, setProvider] = useState('')
const [query, setQuery] = useState(show.name)
const [results, setResults] = useState<MetadataCandidate[]>([])
const [description, setDescription] = useState(show.description ?? '')
const [year, setYear] = useState(show.year != null ? String(show.year) : '')
const { data: providers } = useQuery({
queryKey: ['admin', 'metadata', 'providers'],
queryFn: getMetadataProviders,
})
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const changed = () => {
setBust(Date.now())
onChanged()
}
const effectiveProvider = provider || providers?.[0] || ''
const search = useMutation({
mutationFn: () => searchMetadata(effectiveProvider, query.trim()),
onSuccess: setResults,
onError,
})
const apply = useMutation({
mutationFn: (externalId: string) => applyMetadata(show.id, effectiveProvider, externalId),
onSuccess: () => {
setResults([])
toast.success(t('admin.metadata.applied'))
changed()
},
onError,
})
const saveManual = useMutation({
mutationFn: () =>
updateMetadata(show.id, {
description: description.trim() || null,
year: year.trim() ? Number(year) : null,
}),
onSuccess: () => {
toast.success(t('settings.saved'))
changed()
},
onError,
})
const clear = useMutation({
mutationFn: () => clearMetadata(show.id),
onSuccess: () => {
setDescription('')
setYear('')
changed()
},
onError,
})
const posterUpload = useMutation({
mutationFn: (file: File) => uploadPoster(show.id, file),
onSuccess: changed,
onError,
})
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.hasPoster ? (
<img
src={showPosterUrl(show.id, String(bust))}
alt=""
className="h-full w-full object-cover"
/>
) : (
<span className="text-xs text-muted-foreground">{t('admin.metadata.noPoster')}</span>
)}
</div>
<input
ref={posterInput}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) posterUpload.mutate(file)
e.target.value = ''
}}
/>
<Button
size="sm"
variant="outline"
disabled={posterUpload.isPending}
onClick={() => posterInput.current?.click()}
>
{t('admin.metadata.uploadPoster')}
</Button>
</div>
{/* Поиск + ручная правка */}
<div className="flex min-w-0 flex-1 flex-col gap-4">
{providers && providers.length > 0 && (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.metadata.source')}</Label>
<Select value={effectiveProvider} onValueChange={setProvider}>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{providers.map((p) => (
<SelectItem key={p} value={p}>
{p.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Input
className="min-w-40 flex-1"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('admin.metadata.searchPlaceholder')}
/>
<Button size="sm" disabled={search.isPending || !query.trim()} onClick={() => search.mutate()}>
{t('admin.metadata.searchBtn')}
</Button>
</div>
{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="text-sm font-medium">
{r.title}
{r.year != null && (
<span className="text-muted-foreground"> ({r.year})</span>
)}
</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 items-center gap-2">
<Button size="sm" disabled={saveManual.isPending} onClick={() => saveManual.mutate()}>
{t('common.save')}
</Button>
{(show.metadataProvider || show.hasPoster) && (
<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>
</CardContent>
</Card>
)
}