347 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|