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:
@@ -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' }
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user