video_translate/src/components/UploadScreen.tsx
2026-03-21 14:52:27 +08:00

347 lines
15 KiB
TypeScript

import React, { useRef, useState } from 'react';
import { Upload, CheckCircle2, Circle } from 'lucide-react';
import TrimModal from './TrimModal';
import { DEFAULT_SUBTITLE_DEFAULTS, SubtitleDefaults } from '../types';
import { useI18n } from '../i18n';
const LANGUAGES = [
{ code: 'zh', apiName: 'Chinese', group: 'C' },
{ code: 'yue', apiName: 'Cantonese', group: 'C' },
{ code: 'en', apiName: 'English', group: 'E' },
{ code: 'id', apiName: 'Indonesian', group: 'I' },
{ code: 'de', apiName: 'German', group: 'G' },
{ code: 'fil', apiName: 'Filipino', group: 'F' },
{ code: 'fr', apiName: 'French', group: 'F' },
];
export default function UploadScreen({
onUpload,
}: {
onUpload: (
file: File,
targetLanguage: string,
ttsLanguage: string,
subtitleDefaults: SubtitleDefaults,
startTime?: number,
endTime?: number,
) => void
}) {
const { m, format } = useI18n();
const [mode, setMode] = useState<'editing' | 'simple'>('editing');
const [selectedTtsLanguage, setSelectedTtsLanguage] = useState('en');
const [showTrimModal, setShowTrimModal] = useState(false);
const [tempFile, setTempFile] = useState<File | null>(null);
const [subtitleDefaults, setSubtitleDefaults] = useState<SubtitleDefaults>(DEFAULT_SUBTITLE_DEFAULTS);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const activeModeLabel = mode === 'editing' ? m.upload.editingMode : m.upload.simpleMode;
const previewFontSize = Math.max(10, Math.round(subtitleDefaults.fontSize * 0.5));
const previewTextShadowSize = previewFontSize >= 18 ? 2 : 1;
const settingsCardClass =
'app-surface rounded-[24px] border border-white/70 px-4 py-4 shadow-[0_18px_60px_-34px_rgba(15,23,42,0.3)] sm:px-5';
const clearPendingUpload = () => {
setTempFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setTempFile(e.target.files[0]);
setShowTrimModal(true);
}
};
const handleTrimConfirm = (file: File, startTime: number, endTime: number) => {
setShowTrimModal(false);
const ttsLanguage = LANGUAGES.find((language) => language.code === selectedTtsLanguage)?.apiName || 'English';
onUpload(file, 'English', ttsLanguage, subtitleDefaults, startTime, endTime);
};
return (
<div
data-testid="upload-workbench"
className="grid items-start gap-4 lg:gap-5 xl:grid-cols-[minmax(0,1.55fr)_minmax(240px,0.72fr)_minmax(300px,0.95fr)]"
>
<section
data-testid="upload-dropzone-card"
className="app-surface min-w-0 rounded-[28px] border border-white/70 px-4 py-4 shadow-[0_24px_80px_-40px_rgba(15,23,42,0.35)] sm:px-5 sm:py-5 lg:px-6 lg:py-5"
>
<div className="mb-4 flex flex-col gap-3">
<div className="min-w-0 space-y-2">
<h2 className="text-xl font-semibold tracking-tight text-slate-950 sm:text-2xl">
{m.upload.uploadPanelTitle}
</h2>
<p className="max-w-2xl text-sm leading-5 text-slate-600">
{m.upload.uploadPanelDescription}
</p>
</div>
</div>
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_220px] xl:items-end">
<section
data-testid="upload-subtitle-defaults-panel"
className="rounded-[22px] border border-slate-200 bg-white/82 p-3 shadow-sm shadow-slate-200/70"
>
<div className="mb-3 flex items-start justify-between gap-4">
<div>
<h3 className="text-sm font-semibold text-slate-950">{m.upload.subtitleDefaults}</h3>
<p className="mt-1 text-xs leading-5 text-slate-600">{m.upload.subtitleDefaultsDesc}</p>
</div>
<button
type="button"
className="text-xs font-medium text-sky-700 transition-colors hover:text-sky-800"
onClick={() => setSubtitleDefaults(DEFAULT_SUBTITLE_DEFAULTS)}
>
{m.upload.reset}
</button>
</div>
<div className="mb-3 rounded-[18px] border border-slate-200 bg-slate-950/95 p-2.5">
<div className="relative h-24 overflow-hidden rounded-[14px] bg-[radial-gradient(circle_at_top,_rgba(148,163,184,0.35),_transparent_40%),linear-gradient(180deg,_#1e293b_0%,_#0f172a_100%)]">
<div className="absolute inset-0 bg-[linear-gradient(90deg,transparent_0%,rgba(255,255,255,0.04)_50%,transparent_100%)]" />
<p
data-testid="upload-subtitle-preview"
className="absolute left-1/2 max-w-[88%] -translate-x-1/2 whitespace-pre-wrap px-3 py-1 text-center font-semibold text-white"
style={{
bottom: `${subtitleDefaults.bottomOffsetPercent}%`,
fontSize: `${previewFontSize}px`,
textShadow: `${previewTextShadowSize}px 0 #000, -${previewTextShadowSize}px 0 #000, 0 ${previewTextShadowSize}px #000, 0 -${previewTextShadowSize}px #000`,
}}
>
{m.upload.subtitlePreview}
</p>
</div>
</div>
<div className="space-y-3">
<div>
<div className="mb-2 flex items-center justify-between">
<label htmlFor="subtitle-position" className="text-sm font-medium text-slate-700">
{m.upload.subtitleInitialPosition}
</label>
<span className="text-xs text-slate-500">
{format(m.upload.fromBottom, { value: subtitleDefaults.bottomOffsetPercent })}
</span>
</div>
<input
id="subtitle-position"
aria-label="Subtitle initial position"
type="range"
min="4"
max="30"
step="1"
value={subtitleDefaults.bottomOffsetPercent}
onChange={(e) =>
setSubtitleDefaults((previous) => ({
...previous,
bottomOffsetPercent: Number(e.target.value),
}))
}
className="w-full accent-sky-500"
/>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label htmlFor="subtitle-size" className="text-sm font-medium text-slate-700">
{m.upload.subtitleInitialSize}
</label>
<span className="text-xs text-slate-500">
{format(m.upload.pxValue, { value: subtitleDefaults.fontSize })}
</span>
</div>
<input
id="subtitle-size"
aria-label="Subtitle initial size"
type="range"
min="16"
max="40"
step="1"
value={subtitleDefaults.fontSize}
onChange={(e) =>
setSubtitleDefaults((previous) => ({
...previous,
fontSize: Number(e.target.value),
}))
}
className="w-full accent-sky-500"
/>
</div>
</div>
</section>
<div className="flex flex-col gap-2 text-sm text-slate-500 xl:items-end xl:text-right">
<p>{m.upload.supportedFormats}</p>
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white/80 px-3 py-1.5 font-medium text-slate-700">
{activeModeLabel}
</div>
</div>
</div>
<div
data-testid="upload-dropzone-surface"
className="relative mt-4 overflow-hidden rounded-[24px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(248,250,252,0.95)_0%,rgba(226,232,240,0.88)_100%)] p-2.5 sm:p-3"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.12),_transparent_38%)]" />
<div className="relative flex min-h-[230px] flex-col items-center justify-center rounded-[20px] border-2 border-dashed border-slate-300 bg-white/70 px-5 py-8 text-center sm:min-h-[250px] lg:min-h-[280px]">
<input
ref={fileInputRef}
type="file"
aria-label="Upload video file"
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
accept="video/mp4,video/quicktime,video/webm"
onChange={handleFileChange}
/>
<div className="pointer-events-none relative z-0 flex max-w-xl flex-col items-center">
<div className="mb-4 rounded-full bg-slate-900 p-4 text-white shadow-lg shadow-slate-300/70">
<Upload className="h-8 w-8 sm:h-10 sm:w-10" />
</div>
<h3 className="text-lg font-semibold text-slate-900 sm:text-xl">{m.upload.uploadVideo}</h3>
<p className="mt-2 max-w-md text-sm leading-5 text-slate-600">
{m.upload.clickToUpload}
</p>
<button className="mt-5 flex w-full max-w-xs items-center justify-center gap-2 rounded-2xl bg-[#16a34a] px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-emerald-200 transition-colors pointer-events-none">
<Upload className="h-5 w-5" />
{m.upload.uploadVideo}
</button>
</div>
</div>
</div>
</section>
<div
data-testid="upload-settings-column"
className="min-w-0 grid gap-4 xl:contents"
>
<section
data-testid="mode-card"
className={`${settingsCardClass} xl:col-start-2 xl:row-start-1`}
>
<div className="mb-3">
<h2 className="text-base font-semibold text-slate-950">{m.upload.modeWorkflowTitle}</h2>
<p className="mt-1 text-sm leading-5 text-slate-600">{m.upload.modeWorkflowDescription}</p>
</div>
<div className="grid gap-2">
<button
type="button"
aria-pressed={mode === 'editing'}
className={`rounded-2xl border px-3.5 py-3 text-left transition-all ${
mode === 'editing'
? 'border-sky-400 bg-sky-50 shadow-sm shadow-sky-100'
: 'border-slate-200 bg-white/80 hover:border-sky-200 hover:bg-slate-50'
}`}
onClick={() => setMode('editing')}
>
<div className="flex items-center gap-3">
{mode === 'editing' ? (
<CheckCircle2 className="h-5 w-5 text-sky-600" />
) : (
<Circle className="h-5 w-5 text-slate-300" />
)}
<span className="font-semibold text-slate-900">{m.upload.editingMode}</span>
</div>
<p className="mt-2 text-sm leading-5 text-slate-600">{m.upload.editingModeDesc}</p>
</button>
<button
type="button"
aria-pressed={mode === 'simple'}
className={`rounded-2xl border px-3.5 py-3 text-left transition-all ${
mode === 'simple'
? 'border-sky-400 bg-sky-50 shadow-sm shadow-sky-100'
: 'border-slate-200 bg-white/80 hover:border-sky-200 hover:bg-slate-50'
}`}
onClick={() => setMode('simple')}
>
<div className="flex items-center gap-3">
{mode === 'simple' ? (
<CheckCircle2 className="h-5 w-5 text-sky-600" />
) : (
<Circle className="h-5 w-5 text-slate-300" />
)}
<span className="font-semibold text-slate-900">{m.upload.simpleMode}</span>
</div>
<p className="mt-2 text-sm leading-5 text-slate-600">{m.upload.simpleModeDesc}</p>
</button>
</div>
</section>
<section
data-testid="language-card"
className={`${settingsCardClass} flex flex-col xl:col-start-3 xl:row-start-1`}
>
<div className="mb-3">
<h2 className="text-base font-semibold text-slate-950">{m.upload.languageDubbingTitle}</h2>
<p className="mt-1 text-sm leading-5 text-slate-600">{m.upload.languageDubbingDescription}</p>
</div>
<div className="mb-3 rounded-2xl border border-slate-200 bg-white/80 p-3">
<h3 className="font-semibold text-slate-900">{m.upload.subtitleLanguage}</h3>
<p className="mt-1 text-xs text-slate-500">{m.upload.subtitleLanguageHint}</p>
<div className="mt-3 inline-flex items-center rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm font-medium text-slate-700">
{m.upload.languages.en}
</div>
</div>
<div className="mb-3">
<h3 className="font-semibold text-slate-900">{m.upload.ttsLanguage}</h3>
<p className="mt-1 text-xs text-slate-500">{m.upload.ttsLanguageHint}</p>
</div>
<div
data-testid="tts-language-grid"
className="grid grid-cols-2 gap-2"
>
{LANGUAGES.map((lang) => (
<button
key={lang.code}
type="button"
className={`rounded-2xl border px-3 py-2 text-left text-sm font-medium transition-colors ${
selectedTtsLanguage === lang.code
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
: lang.code === 'zh' || lang.code === 'yue'
? 'border-orange-100 bg-orange-50 text-orange-600 hover:border-orange-200'
: 'border-slate-200 bg-white/80 text-slate-700 hover:border-sky-200 hover:text-sky-700'
}`}
onClick={() => setSelectedTtsLanguage(lang.code)}
>
{m.upload.languages[lang.code as keyof typeof m.upload.languages]}
</button>
))}
</div>
<button
className={`mt-4 w-full rounded-2xl px-4 py-3 text-sm font-semibold transition-colors ${
tempFile
? 'bg-[#16a34a] text-white shadow-lg shadow-emerald-200 hover:bg-[#15803d]'
: 'cursor-not-allowed bg-slate-200 text-slate-400'
}`}
onClick={() => {
if (tempFile) setShowTrimModal(true);
}}
disabled={!tempFile}
>
{m.upload.generateTranslatedVideo}
</button>
</section>
</div>
{showTrimModal && tempFile && (
<TrimModal
file={tempFile}
onClose={() => {
setShowTrimModal(false);
clearPendingUpload();
}}
onConfirm={handleTrimConfirm}
/>
)}
</div>
);
}