Refactor ShowMetadataCard and API: remove obsolete poster upload functionality, streamline metadata saving process into a single mutation, and enhance UI for original name input. Update related state management and error handling for improved user experience.

This commit is contained in:
Leonid Pershin
2026-07-25 12:03:25 +03:00
parent 7efd616c29
commit afe6cc24aa
2 changed files with 22 additions and 87 deletions
@@ -1,5 +1,5 @@
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { useRef, useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client' import { HttpError } from '@/shared/api/client'
import type { MetadataCandidate, ShowDto } from '@/shared/api/types' import type { MetadataCandidate, ShowDto } from '@/shared/api/types'
@@ -20,12 +20,10 @@ import {
setShowOriginalName, setShowOriginalName,
setShowPoster, setShowPoster,
updateMetadata, updateMetadata,
uploadPoster,
} from './api' } from './api'
export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged: () => void }) { export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged: () => void }) {
const { t } = useTranslation() const { t } = useTranslation()
const posterInput = useRef<HTMLInputElement>(null)
const [galleryOpen, setGalleryOpen] = useState(false) const [galleryOpen, setGalleryOpen] = useState(false)
const [provider, setProvider] = useState('') const [provider, setProvider] = useState('')
const [originalName, setOriginalName] = useState(show.originalName ?? '') const [originalName, setOriginalName] = useState(show.originalName ?? '')
@@ -63,15 +61,6 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
}, },
onError, onError,
}) })
const saveOriginal = useMutation({
mutationFn: () => setShowOriginalName(show.id, originalName.trim() || null),
onSuccess: () => {
toast.success(t('settings.saved'))
setQuery(originalName.trim() || show.name)
onChanged()
},
onError,
})
const apply = useMutation({ const apply = useMutation({
mutationFn: (externalId: string) => applyMetadata(show.id, effectiveProvider, externalId), mutationFn: (externalId: string) => applyMetadata(show.id, effectiveProvider, externalId),
onSuccess: () => { onSuccess: () => {
@@ -82,14 +71,21 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
}, },
onError, onError,
}) })
const saveManual = useMutation({ // Одна кнопка «Сохранить»: пишем только изменённое. Оригинальное название идёт своим эндпоинтом
mutationFn: () => // (не трогает привязку к источнику), описание/год — ручной правкой метаданных.
updateMetadata(show.id, { const save = useMutation({
description: description.trim() || null, mutationFn: async () => {
year: year.trim() ? Number(year) : null, const yearNum = year.trim() ? Number(year) : null
}), const nameChanged = originalName.trim() !== (show.originalName ?? '')
const infoChanged =
(description.trim() || null) !== (show.description ?? null) || yearNum !== (show.year ?? null)
if (nameChanged) await setShowOriginalName(show.id, originalName.trim() || null)
if (infoChanged)
await updateMetadata(show.id, { description: description.trim() || null, year: yearNum })
},
onSuccess: () => { onSuccess: () => {
toast.success(t('settings.saved')) toast.success(t('settings.saved'))
setQuery(originalName.trim() || show.name)
changed() changed()
}, },
onError, onError,
@@ -103,11 +99,6 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
}, },
onError, onError,
}) })
const posterUpload = useMutation({
mutationFn: (file: File) => uploadPoster(show.id, file),
onSuccess: changed,
onError,
})
const refreshEpisodes = useMutation({ const refreshEpisodes = useMutation({
mutationFn: () => refreshEpisodesMetadata(show.id), mutationFn: () => refreshEpisodesMetadata(show.id),
onSuccess: (count) => { onSuccess: (count) => {
@@ -139,25 +130,6 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
<span className="text-xs text-muted-foreground">{t('admin.metadata.noPoster')}</span> <span className="text-xs text-muted-foreground">{t('admin.metadata.noPoster')}</span>
)} )}
</div> </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>
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}> <Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
{t('admin.metadata.pickFromGallery')} {t('admin.metadata.pickFromGallery')}
</Button> </Button>
@@ -173,23 +145,12 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
<div className="flex min-w-0 flex-1 flex-col gap-4"> <div className="flex min-w-0 flex-1 flex-col gap-4">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label>{t('admin.metadata.originalName')}</Label> <Label>{t('admin.metadata.originalName')}</Label>
<div className="flex items-end gap-2"> <Input
<Input value={originalName}
className="min-w-40 flex-1" maxLength={256}
value={originalName} placeholder={t('admin.metadata.originalNamePlaceholder')}
maxLength={256} onChange={(e) => setOriginalName(e.target.value)}
placeholder={t('admin.metadata.originalNamePlaceholder')} />
onChange={(e) => setOriginalName(e.target.value)}
/>
<Button
size="sm"
variant="outline"
disabled={saveOriginal.isPending || originalName === (show.originalName ?? '')}
onClick={() => saveOriginal.mutate()}
>
{t('common.save')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('admin.metadata.originalNameHint')}</p> <p className="text-xs text-muted-foreground">{t('admin.metadata.originalNameHint')}</p>
</div> </div>
@@ -284,7 +245,7 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button size="sm" disabled={saveManual.isPending} onClick={() => saveManual.mutate()}> <Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')} {t('common.save')}
</Button> </Button>
{linked && ( {linked && (
+1 -27
View File
@@ -1,4 +1,4 @@
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client' import { apiRequest } from '@/shared/api/client'
import type { import type {
CreatedIdResponse, CreatedIdResponse,
MetadataCandidate, MetadataCandidate,
@@ -84,29 +84,3 @@ export function setShowPoster(showId: string, imageId: string | null) {
export function refreshEpisodesMetadata(showId: string) { export function refreshEpisodesMetadata(showId: string) {
return apiRequest<number>(`/admin/metadata/shows/${showId}/refresh-episodes`, { method: 'POST' }) return apiRequest<number>(`/admin/metadata/shows/${showId}/refresh-episodes`, { method: 'POST' })
} }
/** Загрузка постера вручную (сырое тело, имя в query — как uploadMedia). */
export function uploadPoster(showId: string, file: File): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const q = new URLSearchParams({ fileName: file.name })
xhr.open('PUT', `/api/admin/metadata/shows/${showId}/poster?${q.toString()}`)
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve()
else {
let detail = `HTTP ${xhr.status}`
try {
const p = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
detail = p.detail ?? p.title ?? detail
} catch {
/* пусто */
}
reject(new HttpError({ detail }, xhr.status))
}
}
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
xhr.send(file)
})
}