Add upload to show functionality in MediaPanel: introduce file selection for uploading media files directly to a show, enhancing the upload store to support show associations. Update translations for new upload options and UI elements.

This commit is contained in:
Leonid Pershin
2026-07-25 08:16:05 +03:00
parent 4bebe64ff0
commit 2dd8bf9724
5 changed files with 311 additions and 6 deletions
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Upload } from 'lucide-react'
import { ListPlus, Upload } from 'lucide-react'
import { HttpError } from '@/shared/api/client'
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
import { Badge, type BadgeProps } from '@/shared/ui/badge'
@@ -9,6 +9,7 @@ import { Button } from '@/shared/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { deleteMedia, listMedia } from './api'
import { UploadToShowDialog } from './UploadToShowDialog'
import { useUploadStore } from './upload-store'
const PAGE_SIZE = 20
@@ -43,7 +44,9 @@ export function MediaPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const fileInput = useRef<HTMLInputElement>(null)
const fileInputShow = useRef<HTMLInputElement>(null)
const [filter, setFilter] = useState<MediaFilter>('active')
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
const enqueue = useUploadStore((s) => s.enqueue)
const { data, isLoading } = useQuery({
@@ -92,6 +95,22 @@ export function MediaPanel() {
e.target.value = ''
}}
/>
<input
ref={fileInputShow}
type="file"
accept="video/*,.mkv,.avi,.ts"
multiple
className="hidden"
onChange={(e) => {
const files = e.target.files
if (files && files.length > 0) setFilesForShow(Array.from(files))
e.target.value = ''
}}
/>
<Button size="sm" variant="outline" onClick={() => fileInputShow.current?.click()}>
<ListPlus className="h-4 w-4" />
{t('admin.media.uploadToShow')}
</Button>
<Button size="sm" onClick={() => fileInput.current?.click()}>
<Upload className="h-4 w-4" />
{t('admin.media.upload')}
@@ -99,6 +118,10 @@ export function MediaPanel() {
</div>
</div>
{filesForShow && (
<UploadToShowDialog files={filesForShow} onClose={() => setFilesForShow(null)} />
)}
<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">
@@ -0,0 +1,145 @@
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { listShows } from '@/features/admin/shows/api'
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
import { useUploadStore } from './upload-store'
export function UploadToShowDialog({ files, onClose }: { files: File[]; onClose: () => void }) {
const { t } = useTranslation()
const enqueue = useUploadStore((s) => s.enqueue)
const [showId, setShowId] = useState('')
const [seasonStr, setSeasonStr] = useState('')
const [regexStr, setRegexStr] = useState('')
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
const regexOk = isValidRegex(regexStr)
const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
// Предпросмотр: что распарсим для каждого файла при текущих настройках, в порядке добавления.
const previews = useMemo(() => {
const opts = {
seasonOverride: seasonOverride != null && Number.isFinite(seasonOverride) ? seasonOverride : null,
episodeRegex: regexOk ? regexStr : null,
}
return files
.map((file) => ({ file, name: file.name, parsed: parseEpisodeName(file.name, opts) }))
.sort(compareParsed)
}, [files, seasonOverride, regexStr, regexOk])
const recognized = previews.filter((p) => p.parsed.episode != null).length
const confirm = () => {
if (!showId) return
void enqueue(
previews.map((p) => p.file),
{ showId },
)
onClose()
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('admin.media.toShowTitle')}</DialogTitle>
<DialogDescription>
{t('admin.media.toShowSubtitle', { count: files.length })}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-3">
<div className="flex flex-col gap-1.5 sm:col-span-1">
<Label>{t('admin.media.toShowShow')}</Label>
<Select value={showId} onValueChange={setShowId}>
<SelectTrigger>
<SelectValue placeholder={t('admin.media.toShowPick')} />
</SelectTrigger>
<SelectContent>
{shows?.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.media.toShowSeason')}</Label>
<Input
type="number"
min={1}
placeholder={t('admin.media.toShowAuto')}
value={seasonStr}
onChange={(e) => setSeasonStr(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.media.toShowRegex')}</Label>
<Input
placeholder="^(\d+)"
value={regexStr}
onChange={(e) => setRegexStr(e.target.value)}
className={!regexOk ? 'border-red-500' : undefined}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
{t('admin.media.toShowHint')}
{!regexOk && (
<span className="ml-2 text-red-500">{t('admin.media.toShowRegexInvalid')}</span>
)}
</p>
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{t('admin.media.toShowPreview')}</span>
<span className="text-muted-foreground">
{t('admin.media.toShowRecognized', { recognized, total: files.length })}
</span>
</div>
<ul className="crt-panel max-h-64 divide-y divide-border overflow-y-auto rounded-md text-sm">
{previews.map((p) => {
const label = formatSeasonEpisode(p.parsed)
return (
<li key={p.name} className="flex items-center gap-3 px-3 py-1.5">
{label ? (
<Badge>{label}</Badge>
) : (
<Badge variant="muted">{t('admin.media.toShowUnknown')}</Badge>
)}
<span className="truncate" title={p.name}>
{p.name}
</span>
</li>
)
})}
</ul>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button size="sm" disabled={!showId} onClick={confirm}>
{t('admin.media.toShowConfirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,88 @@
export type ParseOptions = {
/** Ручной сезон — перебивает распознанный/дефолтный. */
seasonOverride?: number | null
/** Пользовательский regex для номера серии: 1 группа = серия, 2 группы = (сезон, серия). */
episodeRegex?: string | null
}
export type ParsedEpisode = { season: number | null; episode: number | null }
/**
* Пытается распознать сезон/серию из имени файла. Сначала встроенные шаблоны (SxxEyy, NxNN), затем —
* пользовательский regex (перебивает серию, а при двух группах и сезон), в конце — ручной сезон.
* Если серия распознана, а сезон нет — сезон считается первым.
*/
export function parseEpisodeName(name: string, opts?: ParseOptions): ParsedEpisode {
let season: number | null = null
let episode: number | null = null
const se = name.match(/[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})/)
if (se) {
season = Number(se[1])
episode = Number(se[2])
} else {
const nx = name.match(/(?:^|[^\d])(\d{1,2})x(\d{1,3})(?:[^\d]|$)/i)
if (nx) {
season = Number(nx[1])
episode = Number(nx[2])
}
}
const rawRegex = opts?.episodeRegex?.trim()
if (rawRegex) {
try {
const match = name.match(new RegExp(rawRegex, 'i'))
if (match) {
if (match.length >= 3 && match[1] != null && match[2] != null) {
season = Number(match[1])
episode = Number(match[2])
} else if (match[1] != null) {
episode = Number(match[1])
}
}
} catch {
// невалидный regex — просто игнорируем
}
}
if (opts?.seasonOverride != null) season = opts.seasonOverride
if (episode != null && season == null) season = 1
if (episode != null && !Number.isFinite(episode)) episode = null
if (season != null && !Number.isFinite(season)) season = null
return { season, episode }
}
const pad2 = (n: number) => String(n).padStart(2, '0')
/** «S14E17» либо null, если серия не распознана. */
export function formatSeasonEpisode(parsed: ParsedEpisode): string | null {
if (parsed.episode == null) return null
return `S${pad2(parsed.season ?? 1)}E${pad2(parsed.episode)}`
}
/** Проверяет корректность пользовательского regex (для подсветки ошибки в UI). */
export function isValidRegex(pattern: string): boolean {
if (!pattern.trim()) return true
try {
new RegExp(pattern)
return true
} catch {
return false
}
}
/** Сортировка по (сезон, серия); нераспознанные — в конец по имени. */
export function compareParsed(
a: { name: string; parsed: ParsedEpisode },
b: { name: string; parsed: ParsedEpisode },
): number {
const ae = a.parsed.episode
const be = b.parsed.episode
if (ae != null && be != null) {
return (a.parsed.season ?? 1) - (b.parsed.season ?? 1) || ae - be
}
if (ae != null) return -1
if (be != null) return 1
return a.name.localeCompare(b.name)
}
@@ -1,5 +1,7 @@
import { create } from 'zustand'
import { queryClient } from '@/shared/api/query-client'
import { addEpisode } from '@/features/admin/shows/api'
import { toast } from '@/shared/ui/toast-store'
import { listMedia, uploadMedia } from './api'
export type UploadItem = {
@@ -14,16 +16,19 @@ type UploadStore = {
active: boolean
minimized: boolean
skipped: number
enqueue: (files: File[]) => Promise<void>
enqueue: (files: File[], options?: EnqueueOptions) => Promise<void>
cancel: (id: string) => void
cancelAll: () => void
toggleMinimize: () => void
dismiss: () => void
}
/** Доп-опции загрузки: привязка загружаемых файлов к шоу (добавляются сериями после аплоада). */
export type EnqueueOptions = { showId?: string }
// Очередь и флаг живут вне React — загрузка продолжается при любой навигации.
let counter = 0
const queue: { id: string; file: File }[] = []
const queue: { id: string; file: File; showId?: string }[] = []
const controllers = new Map<string, AbortController>()
let running = false
@@ -43,9 +48,23 @@ async function pump() {
controllers.set(job.id, controller)
patch(job.id, { status: 'uploading', percent: 0 })
try {
await uploadMedia(job.file, (percent) => patch(job.id, { percent }), controller.signal)
const created = await uploadMedia(
job.file,
(percent) => patch(job.id, { percent }),
controller.signal,
)
patch(job.id, { status: 'done', percent: 100 })
void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
// Привязка к шоу: добавляем свежий ассет серией (порядок — как в очереди).
if (job.showId) {
try {
await addEpisode(job.showId, created.id)
void queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
} catch {
toast.error(`${job.file.name}: не удалось добавить в шоу`)
}
}
} catch (error) {
// Отмена (AbortError) — тихо: элемент уже убран из списка. Прочее — помечаем ошибкой.
if (!(error instanceof DOMException && error.name === 'AbortError'))
@@ -65,7 +84,7 @@ export const useUploadStore = create<UploadStore>((set) => ({
minimized: false,
skipped: 0,
enqueue: async (files) => {
enqueue: async (files, options) => {
// Пропускаем дубликаты: уже в библиотеке (не проваленные) и уже в текущей очереди.
let existing = new Set<string>()
try {
@@ -90,7 +109,7 @@ export const useUploadStore = create<UploadStore>((set) => ({
const newItems: UploadItem[] = toAdd.map((file) => {
const id = `u${++counter}`
queue.push({ id, file })
queue.push({ id, file, showId: options?.showId })
return { id, name: file.name, percent: 0, status: 'queued' }
})