feat: redesign upload workbench layout
This commit is contained in:
parent
2d6ad73e1b
commit
5234f5b5db
@ -15,12 +15,30 @@ describe('App bilingual UI', () => {
|
||||
|
||||
expect(screen.getByLabelText('switch-ui-language-zh')).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByText('字幕语言')).toBeInTheDocument();
|
||||
expect(screen.getByText('上传视频')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('上传视频').length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('switch-ui-language-en'));
|
||||
|
||||
expect(screen.getByLabelText('switch-ui-language-en')).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByText('Subtitle Language')).toBeInTheDocument();
|
||||
expect(screen.getByText('Upload Video')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Upload Video').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows the upload workbench header and keeps the language switcher visible', () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByRole('banner')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('switch-ui-language-zh')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('switch-ui-language-en')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('switch-ui-language-en'));
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Upload & prepare' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Upload a source video, tune subtitle defaults, and choose dubbing settings before generation.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Video Translate')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
45
src/App.tsx
45
src/App.tsx
@ -38,13 +38,10 @@ function AppContent() {
|
||||
setCurrentView('editor');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 text-gray-800 font-sans">
|
||||
<div className="mx-auto max-w-6xl px-8 pt-4">
|
||||
<div className="flex justify-end">
|
||||
const languageSwitcher = (
|
||||
<div
|
||||
aria-label="app-language-switcher"
|
||||
className="inline-flex rounded-full border border-gray-200 bg-white p-1 shadow-sm"
|
||||
className="inline-flex rounded-full border border-slate-200/80 bg-white/80 p-1 shadow-sm shadow-slate-200/60 backdrop-blur"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@ -52,7 +49,7 @@ function AppContent() {
|
||||
aria-pressed={locale === 'zh'}
|
||||
onClick={() => setLocale('zh')}
|
||||
className={`px-3 py-1.5 text-sm rounded-full transition-colors ${
|
||||
locale === 'zh' ? 'bg-gray-900 text-white' : 'text-gray-600 hover:bg-gray-100'
|
||||
locale === 'zh' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{m.app.chinese}
|
||||
@ -63,14 +60,47 @@ function AppContent() {
|
||||
aria-pressed={locale === 'en'}
|
||||
onClick={() => setLocale('en')}
|
||||
className={`px-3 py-1.5 text-sm rounded-full transition-colors ${
|
||||
locale === 'en' ? 'bg-gray-900 text-white' : 'text-gray-600 hover:bg-gray-100'
|
||||
locale === 'en' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{m.app.english}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(14,165,233,0.10),_transparent_24%),radial-gradient(circle_at_top_right,_rgba(16,185,129,0.10),_transparent_22%),linear-gradient(180deg,_#f8fafc_0%,_#eef2ff_52%,_#f8fafc_100%)] text-slate-900 font-sans">
|
||||
<div className="mx-auto max-w-7xl px-4 py-5 sm:px-6 sm:py-8 lg:px-8">
|
||||
{currentView === 'upload' ? (
|
||||
<header
|
||||
role="banner"
|
||||
className="mb-8 flex flex-col gap-5 rounded-[28px] border border-white/70 bg-white/75 px-5 py-5 shadow-[0_18px_60px_-32px_rgba(15,23,42,0.35)] backdrop-blur sm:px-6 lg:flex-row lg:items-start lg:justify-between"
|
||||
>
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<span className="rounded-full border border-sky-200 bg-sky-50 px-3 py-1 font-medium text-sky-700">
|
||||
{m.upload.workbenchEyebrow}
|
||||
</span>
|
||||
<span className="text-slate-500">{m.app.productName}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-slate-950 sm:text-4xl">
|
||||
{m.upload.workbenchTitle}
|
||||
</h1>
|
||||
<p className="max-w-2xl text-sm leading-6 text-slate-600 sm:text-base">
|
||||
{m.upload.workbenchDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start lg:justify-end">{languageSwitcher}</div>
|
||||
</header>
|
||||
) : (
|
||||
<div className="mb-4 flex justify-end">{languageSwitcher}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-7xl px-4 pb-8 sm:px-6 lg:px-8">
|
||||
{currentView === 'upload' ? (
|
||||
<UploadScreen onUpload={handleVideoUpload} />
|
||||
) : (
|
||||
@ -84,6 +114,7 @@ function AppContent() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -56,6 +56,17 @@ describe('UploadScreen', () => {
|
||||
expect(preview).toHaveStyle({ fontSize: '32px', bottom: '18%' });
|
||||
});
|
||||
|
||||
it('renders upload, mode, subtitle defaults, and language cards inside the responsive workbench', () => {
|
||||
renderUploadScreen();
|
||||
|
||||
expect(screen.getByTestId('upload-workbench')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('upload-dropzone-card')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('upload-settings-column')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Mode & Workflow' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Subtitle Defaults' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Language & Dubbing' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a fixed English subtitle language and the supported TTS languages', () => {
|
||||
renderUploadScreen();
|
||||
|
||||
@ -125,4 +136,12 @@ describe('UploadScreen', () => {
|
||||
expect(screen.queryByRole('button', { name: /close trim/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /generate translated video/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('keeps the upload interaction wired through the redesigned dropzone card', () => {
|
||||
renderUploadScreen();
|
||||
|
||||
expect(screen.getByTestId('upload-dropzone-card')).toContainElement(
|
||||
screen.getByLabelText(/upload video file/i),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -33,6 +33,10 @@ export default function UploadScreen({
|
||||
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 activeModeDescription = mode === 'editing' ? m.upload.editingModeDesc : m.upload.simpleModeDesc;
|
||||
const settingsCardClass =
|
||||
'app-surface rounded-[28px] border border-white/70 px-5 py-5 shadow-[0_18px_60px_-34px_rgba(15,23,42,0.3)] sm:px-6';
|
||||
|
||||
const clearPendingUpload = () => {
|
||||
setTempFile(null);
|
||||
@ -55,91 +59,145 @@ export default function UploadScreen({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-8 flex gap-8 h-screen items-center">
|
||||
{/* Left: Upload Area */}
|
||||
<div className="flex-1 bg-white rounded-lg shadow-sm border border-gray-200 p-8 flex flex-col items-center justify-center min-h-[400px]">
|
||||
<div className="w-full h-full border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center bg-gray-50 relative">
|
||||
<div
|
||||
data-testid="upload-workbench"
|
||||
className="grid items-start gap-6 lg:gap-8 xl:grid-cols-[minmax(0,1.65fr)_minmax(320px,420px)]"
|
||||
>
|
||||
<section
|
||||
data-testid="upload-dropzone-card"
|
||||
className="app-surface min-w-0 rounded-[32px] border border-white/70 px-5 py-5 shadow-[0_24px_80px_-40px_rgba(15,23,42,0.35)] sm:px-6 sm:py-6 lg:px-8 lg:py-8"
|
||||
>
|
||||
<div className="mb-6 flex flex-col gap-4 sm:mb-8 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<h2 className="text-2xl font-semibold tracking-tight text-slate-950 sm:text-3xl">
|
||||
{m.upload.uploadPanelTitle}
|
||||
</h2>
|
||||
<p className="max-w-2xl text-sm leading-6 text-slate-600 sm:text-base">
|
||||
{m.upload.uploadPanelDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-sky-100 bg-sky-50/80 px-4 py-3 text-left shadow-sm shadow-sky-100/50">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-sky-600">
|
||||
{m.upload.modeWorkflowTitle}
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-semibold text-slate-900">{activeModeLabel}</p>
|
||||
<p className="mt-1 max-w-xs text-xs leading-5 text-slate-600">{activeModeDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden rounded-[28px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(248,250,252,0.95)_0%,rgba(226,232,240,0.88)_100%)] p-3 sm:p-4">
|
||||
<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-[360px] flex-col items-center justify-center rounded-[24px] border-2 border-dashed border-slate-300 bg-white/70 px-6 py-12 text-center sm:min-h-[420px] lg:min-h-[500px]">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
aria-label="Upload video file"
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
|
||||
accept="video/mp4,video/quicktime,video/webm"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Upload className="w-16 h-16 text-gray-400 mb-4" />
|
||||
<p className="text-gray-600 mb-6">{m.upload.clickToUpload}</p>
|
||||
<button className="bg-[#52c41a] hover:bg-[#46a616] text-white px-8 py-3 rounded-md font-medium flex items-center gap-2 transition-colors w-full max-w-md justify-center pointer-events-none">
|
||||
<Upload className="w-5 h-5" />
|
||||
|
||||
<div className="pointer-events-none relative z-0 flex max-w-xl flex-col items-center">
|
||||
<div className="mb-6 rounded-full bg-slate-900 p-5 text-white shadow-lg shadow-slate-300/70">
|
||||
<Upload className="h-10 w-10 sm:h-12 sm:w-12" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-slate-900 sm:text-2xl">{m.upload.uploadVideo}</h3>
|
||||
<p className="mt-3 max-w-md text-sm leading-6 text-slate-600 sm:text-base">
|
||||
{m.upload.clickToUpload}
|
||||
</p>
|
||||
<button className="mt-8 flex w-full max-w-sm items-center justify-center gap-2 rounded-2xl bg-[#16a34a] px-6 py-3.5 text-sm font-semibold text-white shadow-lg shadow-emerald-200 transition-colors pointer-events-none sm:text-base">
|
||||
<Upload className="h-5 w-5" />
|
||||
{m.upload.uploadVideo}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-4 w-full text-left">
|
||||
{m.upload.supportedFormats}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Settings */}
|
||||
<div className="w-[400px] flex flex-col gap-6">
|
||||
{/* Mode Selection */}
|
||||
<div className="flex gap-4">
|
||||
<div
|
||||
className={`flex-1 p-4 rounded-lg border-2 cursor-pointer transition-colors ${
|
||||
mode === 'editing' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-blue-200'
|
||||
<div className="mt-5 flex flex-col gap-3 text-sm text-slate-500 md:flex-row md:items-center md:justify-between">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<aside
|
||||
data-testid="upload-settings-column"
|
||||
className="min-w-0 space-y-5 xl:sticky xl:top-6"
|
||||
>
|
||||
<section className={settingsCardClass}>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-semibold text-slate-950">{m.upload.modeWorkflowTitle}</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-600">{m.upload.modeWorkflowDescription}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === 'editing'}
|
||||
className={`rounded-2xl border px-4 py-4 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-2 mb-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{mode === 'editing' ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-blue-500" />
|
||||
<CheckCircle2 className="h-5 w-5 text-sky-600" />
|
||||
) : (
|
||||
<Circle className="w-5 h-5 text-gray-300" />
|
||||
<Circle className="h-5 w-5 text-slate-300" />
|
||||
)}
|
||||
<span className="font-semibold text-gray-800">{m.upload.editingMode}</span>
|
||||
<span className="font-semibold text-slate-900">{m.upload.editingMode}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 ml-7">{m.upload.editingModeDesc}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 p-4 rounded-lg border-2 cursor-pointer transition-colors ${
|
||||
mode === 'simple' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-blue-200'
|
||||
<p className="mt-3 text-sm leading-6 text-slate-600">{m.upload.editingModeDesc}</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === 'simple'}
|
||||
className={`rounded-2xl border px-4 py-4 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-2 mb-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{mode === 'simple' ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-blue-500" />
|
||||
<CheckCircle2 className="h-5 w-5 text-sky-600" />
|
||||
) : (
|
||||
<Circle className="w-5 h-5 text-gray-300" />
|
||||
<Circle className="h-5 w-5 text-slate-300" />
|
||||
)}
|
||||
<span className="font-semibold text-gray-800">{m.upload.simpleMode}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 ml-7">{m.upload.simpleModeDesc}</p>
|
||||
<span className="font-semibold text-slate-900">{m.upload.simpleMode}</span>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-600">{m.upload.simpleModeDesc}</p>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<section className={settingsCardClass}>
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">{m.upload.subtitleDefaults}</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
{m.upload.subtitleDefaultsDesc}
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold text-slate-950">{m.upload.subtitleDefaults}</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-600">{m.upload.subtitleDefaultsDesc}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||
className="text-sm font-medium text-sky-700 transition-colors hover:text-sky-800"
|
||||
onClick={() => setSubtitleDefaults(DEFAULT_SUBTITLE_DEFAULTS)}
|
||||
>
|
||||
{m.upload.reset}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-slate-950 p-4 mb-4">
|
||||
<div className="relative h-48 rounded-lg overflow-hidden bg-[radial-gradient(circle_at_top,_rgba(148,163,184,0.35),_transparent_40%),linear-gradient(180deg,_#1e293b_0%,_#0f172a_100%)]">
|
||||
<div className="mb-5 rounded-[24px] border border-slate-200 bg-slate-950/95 p-3">
|
||||
<div className="relative h-40 overflow-hidden rounded-[18px] bg-[radial-gradient(circle_at_top,_rgba(148,163,184,0.35),_transparent_40%),linear-gradient(180deg,_#1e293b_0%,_#0f172a_100%)] sm:h-44">
|
||||
<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 -translate-x-1/2 text-white font-semibold text-center whitespace-pre-wrap max-w-[88%] px-3 py-1"
|
||||
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: `${subtitleDefaults.fontSize}px`,
|
||||
@ -153,11 +211,13 @@ export default function UploadScreen({
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label htmlFor="subtitle-position" className="text-sm font-medium text-gray-700">
|
||||
<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-gray-500">{format(m.upload.fromBottom, { value: subtitleDefaults.bottomOffsetPercent })}</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{format(m.upload.fromBottom, { value: subtitleDefaults.bottomOffsetPercent })}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="subtitle-position"
|
||||
@ -173,16 +233,18 @@ export default function UploadScreen({
|
||||
bottomOffsetPercent: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
className="w-full accent-blue-500"
|
||||
className="w-full accent-sky-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label htmlFor="subtitle-size" className="text-sm font-medium text-gray-700">
|
||||
<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-gray-500">{format(m.upload.pxValue, { value: subtitleDefaults.fontSize })}</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{format(m.upload.pxValue, { value: subtitleDefaults.fontSize })}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="subtitle-size"
|
||||
@ -198,48 +260,61 @@ export default function UploadScreen({
|
||||
fontSize: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
className="w-full accent-blue-500"
|
||||
className="w-full accent-sky-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`${settingsCardClass} flex flex-col`}>
|
||||
<div className="mb-5">
|
||||
<h2 className="text-lg font-semibold text-slate-950">{m.upload.languageDubbingTitle}</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-600">{m.upload.languageDubbingDescription}</p>
|
||||
</div>
|
||||
|
||||
{/* Language Selection */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 flex-1 flex flex-col">
|
||||
<div className="mb-5">
|
||||
<h3 className="font-semibold text-gray-800 mb-1">{m.upload.subtitleLanguage}</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">{m.upload.subtitleLanguageHint}</p>
|
||||
<div className="inline-flex items-center rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm font-medium text-gray-700">
|
||||
<div className="mb-5 rounded-2xl border border-slate-200 bg-white/80 p-4">
|
||||
<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>
|
||||
|
||||
<h3 className="font-semibold text-gray-800 mb-1">{m.upload.ttsLanguage}</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">{m.upload.ttsLanguageHint}</p>
|
||||
|
||||
{/* Alphabet Tabs */}
|
||||
<div className="flex gap-4 border-b border-gray-100 pb-2 mb-4 text-sm text-gray-500 overflow-x-auto">
|
||||
<button className="font-medium text-blue-600 border-b-2 border-blue-600 pb-2 -mb-[9px]">{m.upload.popular}</button>
|
||||
<button className="hover:text-gray-800">{m.upload.groupABC}</button>
|
||||
<button className="hover:text-gray-800">{m.upload.groupGHI}</button>
|
||||
<button className="hover:text-gray-800">{m.upload.groupDEF}</button>
|
||||
<div className="mb-4">
|
||||
<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>
|
||||
|
||||
{/* Language List */}
|
||||
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar">
|
||||
<div className="mb-4 flex gap-4 overflow-x-auto border-b border-slate-200 pb-2 text-sm text-slate-500">
|
||||
<button type="button" className="border-b-2 border-sky-600 pb-2 font-medium text-sky-600 -mb-[9px]">
|
||||
{m.upload.popular}
|
||||
</button>
|
||||
<button type="button" className="hover:text-slate-800">
|
||||
{m.upload.groupABC}
|
||||
</button>
|
||||
<button type="button" className="hover:text-slate-800">
|
||||
{m.upload.groupGHI}
|
||||
</button>
|
||||
<button type="button" className="hover:text-slate-800">
|
||||
{m.upload.groupDEF}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="custom-scrollbar max-h-[280px] flex-1 overflow-y-auto pr-2">
|
||||
{['C', 'E', 'F', 'G', 'I'].map((letter) => (
|
||||
<div key={letter} className="flex border-b border-gray-100 py-3 last:border-0">
|
||||
<div className="w-8 text-green-600 font-medium">{letter}</div>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 flex-1">
|
||||
<div key={letter} className="flex border-b border-slate-100 py-3 last:border-0">
|
||||
<div className="w-8 font-medium text-emerald-600">{letter}</div>
|
||||
<div className="flex flex-1 flex-wrap gap-x-3 gap-y-2 sm:gap-x-4">
|
||||
{LANGUAGES.filter((l) => l.group === letter).map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
className={`text-sm hover:text-blue-600 transition-colors ${
|
||||
type="button"
|
||||
className={`rounded-full px-2.5 py-1 text-sm transition-colors ${
|
||||
selectedTtsLanguage === lang.code
|
||||
? 'bg-green-600 text-white px-2 py-0.5 rounded'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: lang.code === 'zh' || lang.code === 'yue'
|
||||
? 'text-orange-500'
|
||||
: 'text-gray-700'
|
||||
? 'text-orange-500 hover:bg-orange-50'
|
||||
: 'text-slate-700 hover:bg-slate-100 hover:text-sky-700'
|
||||
}`}
|
||||
onClick={() => setSelectedTtsLanguage(lang.code)}
|
||||
>
|
||||
@ -252,10 +327,10 @@ export default function UploadScreen({
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`w-full py-3 rounded-md font-medium mt-4 transition-colors ${
|
||||
className={`mt-5 w-full rounded-2xl px-4 py-3.5 text-sm font-semibold transition-colors sm:text-base ${
|
||||
tempFile
|
||||
? 'bg-[#52c41a] hover:bg-[#46a616] text-white'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
? '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);
|
||||
@ -264,8 +339,8 @@ export default function UploadScreen({
|
||||
>
|
||||
{m.upload.generateTranslatedVideo}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
{showTrimModal && tempFile && (
|
||||
<TrimModal
|
||||
|
||||
22
src/i18n.tsx
22
src/i18n.tsx
@ -10,6 +10,15 @@ const messages = {
|
||||
productName: 'Translate 1.0',
|
||||
},
|
||||
upload: {
|
||||
workbenchEyebrow: 'Video Translate',
|
||||
workbenchTitle: '\u4e0a\u4f20\u5e76\u51c6\u5907',
|
||||
workbenchDescription: '\u4e0a\u4f20\u6e90\u89c6\u9891\uff0c\u8c03\u6574\u5b57\u5e55\u9ed8\u8ba4\u6837\u5f0f\uff0c\u5e76\u5728\u751f\u6210\u524d\u9009\u62e9\u914d\u97f3\u8bbe\u7f6e\u3002',
|
||||
uploadPanelTitle: '\u4e0a\u4f20\u6e90\u89c6\u9891',
|
||||
uploadPanelDescription: '\u62d6\u62fd\u6216\u70b9\u51fb\u9009\u62e9\u89c6\u9891\u6587\u4ef6\uff0c\u6211\u4eec\u4f1a\u5728\u4e0b\u4e00\u6b65\u8fdb\u5165\u88c1\u526a\u548c\u7ffb\u8bd1\u6d41\u7a0b\u3002',
|
||||
modeWorkflowTitle: '\u6a21\u5f0f\u4e0e\u6d41\u7a0b',
|
||||
modeWorkflowDescription: '\u9009\u62e9\u4f60\u5e0c\u671b\u7684\u5de5\u4f5c\u65b9\u5f0f\uff0c\u4fdd\u6301\u5f53\u524d\u7ffb\u8bd1\u6d41\u7a0b\u4e0d\u53d8\u3002',
|
||||
languageDubbingTitle: '\u8bed\u8a00\u4e0e\u914d\u97f3',
|
||||
languageDubbingDescription: '\u786e\u8ba4\u5b57\u5e55\u8bed\u8a00\u4e0e TTS \u76ee\u6807\u8bed\u8a00\uff0c\u518d\u8fdb\u5165\u751f\u6210\u6d41\u7a0b\u3002',
|
||||
uploadVideoFile: '\u4e0a\u4f20\u89c6\u9891\u6587\u4ef6',
|
||||
clickToUpload: '\u70b9\u51fb\u4e0a\u4f20\u6216\u5c06\u89c6\u9891\u62d6\u62fd\u5230\u6b64\u5904',
|
||||
uploadVideo: '\u4e0a\u4f20\u89c6\u9891',
|
||||
@ -134,6 +143,19 @@ const messages = {
|
||||
productName: 'Translate 1.0',
|
||||
},
|
||||
upload: {
|
||||
workbenchEyebrow: 'Video Translate',
|
||||
workbenchTitle: 'Upload & prepare',
|
||||
workbenchDescription:
|
||||
'Upload a source video, tune subtitle defaults, and choose dubbing settings before generation.',
|
||||
uploadPanelTitle: 'Upload source video',
|
||||
uploadPanelDescription:
|
||||
'Drop a video here or browse from your device. We will move into trim and translation setup next.',
|
||||
modeWorkflowTitle: 'Mode & Workflow',
|
||||
modeWorkflowDescription:
|
||||
'Choose the workflow that best fits your editing needs while keeping the translation flow intact.',
|
||||
languageDubbingTitle: 'Language & Dubbing',
|
||||
languageDubbingDescription:
|
||||
'Confirm the subtitle language and the dubbing target before starting generation.',
|
||||
uploadVideoFile: 'Upload video file',
|
||||
clickToUpload: 'Click to upload or drag files here',
|
||||
uploadVideo: 'Upload Video',
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
.app-surface {
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user