290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
import { useMutation, useQuery } from '@tanstack/react-query'
|
|
import { 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 { imageUrl } from '@/features/admin/images/api'
|
|
import { ImageGallery } from '@/features/admin/images/ImageGallery'
|
|
import {
|
|
applyMetadata,
|
|
clearMetadata,
|
|
getMetadataProviders,
|
|
refreshEpisodesMetadata,
|
|
renameShow,
|
|
searchMetadata,
|
|
setShowOriginalName,
|
|
setShowPoster,
|
|
updateMetadata,
|
|
} from './api'
|
|
|
|
export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged: () => void }) {
|
|
const { t } = useTranslation()
|
|
const [galleryOpen, setGalleryOpen] = useState(false)
|
|
const [provider, setProvider] = useState('')
|
|
const [name, setName] = useState(show.name)
|
|
const [originalName, setOriginalName] = useState(show.originalName ?? '')
|
|
const [results, setResults] = useState<MetadataCandidate[]>([])
|
|
const [searched, setSearched] = useState(false)
|
|
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 = () => onChanged()
|
|
|
|
const setPoster = useMutation({
|
|
mutationFn: (imageId: string) => setShowPoster(show.id, imageId),
|
|
onSuccess: () => {
|
|
toast.success(t('settings.saved'))
|
|
changed()
|
|
},
|
|
onError,
|
|
})
|
|
|
|
const effectiveProvider = provider || providers?.[0] || ''
|
|
// Метаданные ищем по оригинальному названию (иначе — по обычному).
|
|
const searchTerm = originalName.trim() || name.trim()
|
|
|
|
const search = useMutation({
|
|
mutationFn: () => searchMetadata(effectiveProvider, searchTerm),
|
|
onSuccess: (data) => {
|
|
setResults(data)
|
|
setSearched(true)
|
|
},
|
|
onError,
|
|
})
|
|
const apply = useMutation({
|
|
mutationFn: (externalId: string) => applyMetadata(show.id, effectiveProvider, externalId),
|
|
onSuccess: () => {
|
|
setResults([])
|
|
setSearched(false)
|
|
toast.success(t('admin.metadata.applied'))
|
|
changed()
|
|
},
|
|
onError,
|
|
})
|
|
// Одна кнопка «Сохранить»: пишем только изменённое. Оригинальное название идёт своим эндпоинтом
|
|
// (не трогает привязку к источнику), описание/год — ручной правкой метаданных.
|
|
const save = useMutation({
|
|
mutationFn: async () => {
|
|
const yearNum = year.trim() ? Number(year) : null
|
|
const infoChanged =
|
|
(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)
|
|
if (infoChanged)
|
|
await updateMetadata(show.id, { description: description.trim() || null, year: yearNum })
|
|
},
|
|
onSuccess: () => {
|
|
toast.success(t('settings.saved'))
|
|
changed()
|
|
},
|
|
onError,
|
|
})
|
|
const clear = useMutation({
|
|
mutationFn: () => clearMetadata(show.id),
|
|
onSuccess: () => {
|
|
setDescription('')
|
|
setYear('')
|
|
changed()
|
|
},
|
|
onError,
|
|
})
|
|
const refreshEpisodes = useMutation({
|
|
mutationFn: () => refreshEpisodesMetadata(show.id),
|
|
onSuccess: (count) => {
|
|
toast.success(t('admin.metadata.refreshedCount', { count }))
|
|
onChanged()
|
|
},
|
|
onError,
|
|
})
|
|
|
|
const linked =
|
|
!!show.metadataExternalId && !!show.metadataProvider && show.metadataProvider !== 'manual'
|
|
|
|
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="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 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
|
|
? t('admin.metadata.refreshing')
|
|
: t('admin.metadata.refreshEpisodes')}
|
|
</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>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|