Refactor media import components for improved layout and functionality
Updated the ManualInboxPanel, MediaImportDialog, MovieImportPanel, and UploadToShowPanel to enhance their layout and responsiveness. Adjusted flex properties to ensure consistent sizing and overflow handling across components. Simplified the MediaImportDialog by removing unnecessary file handling logic, and improved localization strings for clarity. These changes aim to enhance user experience and maintain a cohesive design across the media import interface.
This commit is contained in:
@@ -227,7 +227,7 @@ export function ManualInboxPanel({ onClose }: Readonly<{ onClose: () => void }>)
|
|||||||
<>
|
<>
|
||||||
<p className="text-sm text-muted-foreground">{t('admin.media.manualHint')}</p>
|
<p className="text-sm text-muted-foreground">{t('admin.media.manualHint')}</p>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||||
{/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */}
|
{/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.media.manualShow')}</Label>
|
<Label>{t('admin.media.manualShow')}</Label>
|
||||||
@@ -345,7 +345,7 @@ export function ManualInboxPanel({ onClose }: Readonly<{ onClose: () => void }>)
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="crt-panel max-h-80 overflow-y-auto rounded-md text-sm">
|
<div className="crt-panel min-h-0 flex-1 overflow-y-auto rounded-md text-sm">
|
||||||
{isLoading && <p className="px-3 py-2 text-muted-foreground">{t('common.loading')}</p>}
|
{isLoading && <p className="px-3 py-2 text-muted-foreground">{t('common.loading')}</p>}
|
||||||
{!isLoading && folders.length === 0 && (
|
{!isLoading && folders.length === 0 && (
|
||||||
<p className="px-3 py-2 text-muted-foreground">{t('admin.media.manualEmpty')}</p>
|
<p className="px-3 py-2 text-muted-foreground">{t('admin.media.manualEmpty')}</p>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { ManualInboxPanel } from './ManualInboxPanel'
|
|||||||
import { MovieImportPanel } from './MovieImportPanel'
|
import { MovieImportPanel } from './MovieImportPanel'
|
||||||
import { UploadToShowPanel } from './UploadToShowPanel'
|
import { UploadToShowPanel } from './UploadToShowPanel'
|
||||||
|
|
||||||
/** Способы пополнить библиотеку. «В шоу» появляется только когда файлы уже выбраны. */
|
/** Способы пополнить библиотеку: фильмы пачкой, серии из manual/, загрузка файлов в шоу. */
|
||||||
type Tab = 'movies' | 'manual' | 'toShow'
|
type Tab = 'movies' | 'manual' | 'toShow'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,22 +19,17 @@ type Tab = 'movies' | 'manual' | 'toShow'
|
|||||||
* выбранных файлов в шоу. Раньше это были три кнопки и три окна — а выбор между ними делается
|
* выбранных файлов в шоу. Раньше это были три кнопки и три окна — а выбор между ними делается
|
||||||
* один раз и по одному признаку: что за контент кладём.
|
* один раз и по одному признаку: что за контент кладём.
|
||||||
*/
|
*/
|
||||||
export function MediaImportDialog({
|
export function MediaImportDialog({ onClose }: Readonly<{ onClose: () => void }>) {
|
||||||
files,
|
|
||||||
onClose,
|
|
||||||
}: Readonly<{
|
|
||||||
/** Файлы, выбранные в проводнике до открытия окна: тогда сразу открывается вкладка «в шоу». */
|
|
||||||
files: File[] | null
|
|
||||||
onClose: () => void
|
|
||||||
}>) {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [tab, setTab] = useState<Tab>(files ? 'toShow' : 'movies')
|
const [tab, setTab] = useState<Tab>('movies')
|
||||||
|
|
||||||
const tabs: Tab[] = files ? ['toShow', 'movies', 'manual'] : ['movies', 'manual']
|
const tabs: Tab[] = ['movies', 'manual', 'toShow']
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
<DialogContent className="max-w-[min(96vw,80rem)]">
|
{/* Высота фиксирована: вкладки разного роста, и окно, прыгающее при переключении,
|
||||||
|
сбивает — то, на что метился курсор, уезжает из-под него. */}
|
||||||
|
<DialogContent className="flex h-[min(88vh,56rem)] w-full max-w-[min(96vw,80rem)] flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('admin.media.importTitle')}</DialogTitle>
|
<DialogTitle>{t('admin.media.importTitle')}</DialogTitle>
|
||||||
<DialogDescription>{t('admin.media.importHint')}</DialogDescription>
|
<DialogDescription>{t('admin.media.importHint')}</DialogDescription>
|
||||||
@@ -57,9 +52,11 @@ export function MediaImportDialog({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-3 pt-3">
|
||||||
{tab === 'movies' && <MovieImportPanel onClose={onClose} />}
|
{tab === 'movies' && <MovieImportPanel onClose={onClose} />}
|
||||||
{tab === 'manual' && <ManualInboxPanel onClose={onClose} />}
|
{tab === 'manual' && <ManualInboxPanel onClose={onClose} />}
|
||||||
{tab === 'toShow' && files && <UploadToShowPanel files={files} onClose={onClose} />}
|
{tab === 'toShow' && <UploadToShowPanel onClose={onClose} />}
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,12 +41,9 @@ export function MediaPanel() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const fileInput = useRef<HTMLInputElement>(null)
|
const fileInput = useRef<HTMLInputElement>(null)
|
||||||
const fileInputShow = useRef<HTMLInputElement>(null)
|
|
||||||
const [filter, setFilter] = useState<MediaFilter>('active')
|
const [filter, setFilter] = useState<MediaFilter>('active')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const { sort, toggle } = useTableSort('created', true)
|
const { sort, toggle } = useTableSort('created', true)
|
||||||
// Файлы, выбранные для загрузки в шоу: с ними окно импорта открывается сразу на нужной вкладке.
|
|
||||||
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
|
|
||||||
const [importOpen, setImportOpen] = useState(false)
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
const enqueue = useUploadStore((s) => s.enqueue)
|
const enqueue = useUploadStore((s) => s.enqueue)
|
||||||
|
|
||||||
@@ -170,21 +167,6 @@ export function MediaPanel() {
|
|||||||
e.target.value = ''
|
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))
|
|
||||||
setImportOpen(true)
|
|
||||||
}
|
|
||||||
e.target.value = ''
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
|
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
|
||||||
<FolderInput className="h-4 w-4" />
|
<FolderInput className="h-4 w-4" />
|
||||||
{t('admin.media.importButton')}
|
{t('admin.media.importButton')}
|
||||||
@@ -196,15 +178,7 @@ export function MediaPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{importOpen && (
|
{importOpen && <MediaImportDialog onClose={() => setImportOpen(false)} />}
|
||||||
<MediaImportDialog
|
|
||||||
files={filesForShow}
|
|
||||||
onClose={() => {
|
|
||||||
setImportOpen(false)
|
|
||||||
setFilesForShow(null)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="crt-panel overflow-x-auto rounded-md">
|
<div className="crt-panel overflow-x-auto rounded-md">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ export function MovieImportPanel({ onClose }: Readonly<{ onClose: () => void }>)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[60vh] overflow-auto rounded border border-border">
|
<div className="min-h-0 flex-1 overflow-auto rounded border border-border">
|
||||||
<table className="w-full table-fixed text-sm">
|
<table className="w-full table-fixed text-sm">
|
||||||
<thead className="sticky top-0 bg-background text-xs uppercase text-muted-foreground">
|
<thead className="sticky top-0 bg-background text-xs uppercase text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useMemo, useState } from 'react'
|
import { Upload } from 'lucide-react'
|
||||||
|
import { useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { qk } from '@/shared/api/query-keys'
|
import { qk } from '@/shared/api/query-keys'
|
||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
@@ -13,12 +14,16 @@ import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } fr
|
|||||||
import { matchShowByName } from './match-show'
|
import { matchShowByName } from './match-show'
|
||||||
import { useUploadStore } from './upload-store'
|
import { useUploadStore } from './upload-store'
|
||||||
|
|
||||||
export function UploadToShowPanel({
|
/**
|
||||||
files,
|
* Загрузка выбранных файлов в шоу: сезон и номера серий разбираются здесь же, а сама заливка идёт
|
||||||
onClose,
|
* очередью с прогрессом. Файлы выбираются в самой вкладке — окно открывается кнопкой «Импорт»,
|
||||||
}: Readonly<{ files: File[]; onClose: () => void }>) {
|
* и требовать выбрать файлы **до** него значило бы прятать способ за проводником.
|
||||||
|
*/
|
||||||
|
export function UploadToShowPanel({ onClose }: Readonly<{ onClose: () => void }>) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const enqueue = useUploadStore((s) => s.enqueue)
|
const enqueue = useUploadStore((s) => s.enqueue)
|
||||||
|
const [files, setFiles] = useState<File[]>([])
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null)
|
||||||
const [seasonStr, setSeasonStr] = useState('')
|
const [seasonStr, setSeasonStr] = useState('')
|
||||||
const [regexStr, setRegexStr] = useState('')
|
const [regexStr, setRegexStr] = useState('')
|
||||||
// Ручные правки привязки: имя файла → id шоу ('' — явно в библиотеку). Иначе берётся автоопределение.
|
// Ручные правки привязки: имя файла → id шоу ('' — явно в библиотеку). Иначе берётся автоопределение.
|
||||||
@@ -70,7 +75,31 @@ export function UploadToShowPanel({
|
|||||||
<>
|
<>
|
||||||
<p className="text-sm text-muted-foreground">{t('admin.media.autoDetectHint')}</p>
|
<p className="text-sm text-muted-foreground">{t('admin.media.autoDetectHint')}</p>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
ref={fileInput}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="video/*,.mkv,.avi,.ts"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
setFiles([...(e.target.files ?? [])])
|
||||||
|
setOverrides({})
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => fileInput.current?.click()}>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
{t('admin.media.toShowPick')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{files.length === 0
|
||||||
|
? t('admin.media.toShowEmpty')
|
||||||
|
: t('admin.media.toShowPicked', { count: files.length })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.media.toShowSeason')}</Label>
|
<Label>{t('admin.media.toShowSeason')}</Label>
|
||||||
@@ -117,7 +146,7 @@ export function UploadToShowPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md text-sm">
|
<ul className="crt-panel min-h-0 flex-1 divide-y divide-border overflow-y-auto rounded-md text-sm">
|
||||||
{previews.map((p) => {
|
{previews.map((p) => {
|
||||||
const label = formatSeasonEpisode(p.parsed)
|
const label = formatSeasonEpisode(p.parsed)
|
||||||
const current = assignment(p.name)
|
const current = assignment(p.name)
|
||||||
@@ -148,7 +177,7 @@ export function UploadToShowPanel({
|
|||||||
<Button variant="outline" size="sm" onClick={onClose}>
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={confirm}>
|
<Button size="sm" disabled={files.length === 0} onClick={confirm}>
|
||||||
{t('admin.media.toShowConfirm')}
|
{t('admin.media.toShowConfirm')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -318,6 +318,9 @@ export const en = {
|
|||||||
manualImported: 'Files imported: {{count}}',
|
manualImported: 'Files imported: {{count}}',
|
||||||
uploadToShow: 'Upload to show',
|
uploadToShow: 'Upload to show',
|
||||||
toShowTitle: 'Upload and add to show',
|
toShowTitle: 'Upload and add to show',
|
||||||
|
toShowPick: 'Pick files',
|
||||||
|
toShowEmpty: 'No files picked',
|
||||||
|
toShowPicked: 'Files picked: {{count}}',
|
||||||
autoDetectHint:
|
autoDetectHint:
|
||||||
'Each file is linked to the show whose original (or display) name appears in the release name, e.g. “The.Simpsons.S33E01…” → The Simpsons.',
|
'Each file is linked to the show whose original (or display) name appears in the release name, e.g. “The.Simpsons.S33E01…” → The Simpsons.',
|
||||||
toShowLibrary: 'To library',
|
toShowLibrary: 'To library',
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ export const ru = {
|
|||||||
importTitle: 'Импорт медиа',
|
importTitle: 'Импорт медиа',
|
||||||
importHint:
|
importHint:
|
||||||
'Три способа пополнить библиотеку: фильмы пачкой, серии из папки manual в шоу и загрузка выбранных файлов в шоу.',
|
'Три способа пополнить библиотеку: фильмы пачкой, серии из папки manual в шоу и загрузка выбранных файлов в шоу.',
|
||||||
importTabs: { movies: 'Фильмы', manual: 'Серии из manual', toShow: 'Выбранные файлы в шоу' },
|
importTabs: { movies: 'Фильмы', manual: 'Серии из manual', toShow: 'Файлы в шоу' },
|
||||||
moviesButton: 'Фильмы',
|
moviesButton: 'Фильмы',
|
||||||
manualTitle: 'Ручной разбор папки manual',
|
manualTitle: 'Ручной разбор папки manual',
|
||||||
manualHint:
|
manualHint:
|
||||||
@@ -315,6 +315,9 @@ export const ru = {
|
|||||||
manualImported: 'Импортировано файлов: {{count}}',
|
manualImported: 'Импортировано файлов: {{count}}',
|
||||||
uploadToShow: 'Загрузить в шоу',
|
uploadToShow: 'Загрузить в шоу',
|
||||||
toShowTitle: 'Загрузить и добавить в шоу',
|
toShowTitle: 'Загрузить и добавить в шоу',
|
||||||
|
toShowPick: 'Выбрать файлы',
|
||||||
|
toShowEmpty: 'Файлы не выбраны',
|
||||||
|
toShowPicked: 'Выбрано файлов: {{count}}',
|
||||||
autoDetectHint:
|
autoDetectHint:
|
||||||
'Каждый файл привяжется к шоу, чьё оригинальное (или отображаемое) название есть в имени релиза, напр. «The.Simpsons.S33E01…» → The Simpsons.',
|
'Каждый файл привяжется к шоу, чьё оригинальное (или отображаемое) название есть в имени релиза, напр. «The.Simpsons.S33E01…» → The Simpsons.',
|
||||||
toShowLibrary: 'В библиотеку',
|
toShowLibrary: 'В библиотеку',
|
||||||
|
|||||||
Reference in New Issue
Block a user