feat(dub): polish workspace controls

This commit is contained in:
debpalash
2026-08-11 04:29:19 +00:00
parent 04410a458d
commit e1964dfbb3
12 changed files with 820 additions and 202 deletions
+8
View File
@@ -8,6 +8,14 @@ the frozen-backend fallback mirror it for their toolchains.
## [Unreleased]
**Highlights**
- A faster, cleaner Dub workspace for multilingual production
### Changed
- The Dub workspace now has a compact production command bar, responsive flag-based language cards, media previews in Dub History, and a narrower Projects rail.
## [5.0.0] — 2026-08-11
**Highlights**
+13 -9
View File
@@ -1462,6 +1462,19 @@ function App() {
<div
className={`studio-with-history ${dubStep === 'idle' ? '' : 'studio-with-history--editing'}`}
>
{dubStep === 'idle' && (
<div className="studio-projects">
<WorkspaceProjects
projects={studioProjects}
activeProjectId={activeProjectId}
canSave={dubStep !== 'idle' || !!dubVideoFile}
saveProject={saveProject}
loadProject={loadProject}
deleteProject={deleteProject}
renameProject={renameProject}
/>
</div>
)}
<div className="studio-with-history__main">
<ErrorBoundary name="dub">
<Suspense fallback={<LazyFallback />}>
@@ -1529,15 +1542,6 @@ function App() {
editor (dubStep !== 'idle'). */}
{dubStep === 'idle' && (
<div className="studio-right">
<WorkspaceProjects
projects={studioProjects}
activeProjectId={activeProjectId}
canSave={dubStep !== 'idle' || !!dubVideoFile}
saveProject={saveProject}
loadProject={loadProject}
deleteProject={deleteProject}
renameProject={renameProject}
/>
<WorkspaceHistory
variant="dub"
dubHistory={dubHistory}
+200
View File
@@ -0,0 +1,200 @@
import React from 'react';
import { Globe } from 'lucide-react';
import AF from 'country-flag-icons/react/3x2/AF';
import AL from 'country-flag-icons/react/3x2/AL';
import AM from 'country-flag-icons/react/3x2/AM';
import AZ from 'country-flag-icons/react/3x2/AZ';
import BA from 'country-flag-icons/react/3x2/BA';
import BD from 'country-flag-icons/react/3x2/BD';
import BG from 'country-flag-icons/react/3x2/BG';
import BY from 'country-flag-icons/react/3x2/BY';
import CN from 'country-flag-icons/react/3x2/CN';
import CZ from 'country-flag-icons/react/3x2/CZ';
import DE from 'country-flag-icons/react/3x2/DE';
import DK from 'country-flag-icons/react/3x2/DK';
import EE from 'country-flag-icons/react/3x2/EE';
import ES from 'country-flag-icons/react/3x2/ES';
import ES_CT from 'country-flag-icons/react/3x2/ES-CT';
import ET from 'country-flag-icons/react/3x2/ET';
import FI from 'country-flag-icons/react/3x2/FI';
import FR from 'country-flag-icons/react/3x2/FR';
import GB from 'country-flag-icons/react/3x2/GB';
import GB_SCT from 'country-flag-icons/react/3x2/GB-SCT';
import GB_WLS from 'country-flag-icons/react/3x2/GB-WLS';
import GE from 'country-flag-icons/react/3x2/GE';
import GR from 'country-flag-icons/react/3x2/GR';
import HR from 'country-flag-icons/react/3x2/HR';
import HT from 'country-flag-icons/react/3x2/HT';
import HU from 'country-flag-icons/react/3x2/HU';
import ID from 'country-flag-icons/react/3x2/ID';
import IL from 'country-flag-icons/react/3x2/IL';
import IN from 'country-flag-icons/react/3x2/IN';
import IQ from 'country-flag-icons/react/3x2/IQ';
import IR from 'country-flag-icons/react/3x2/IR';
import IS from 'country-flag-icons/react/3x2/IS';
import IT from 'country-flag-icons/react/3x2/IT';
import JP from 'country-flag-icons/react/3x2/JP';
import KG from 'country-flag-icons/react/3x2/KG';
import KH from 'country-flag-icons/react/3x2/KH';
import KR from 'country-flag-icons/react/3x2/KR';
import KZ from 'country-flag-icons/react/3x2/KZ';
import LA from 'country-flag-icons/react/3x2/LA';
import LK from 'country-flag-icons/react/3x2/LK';
import LT from 'country-flag-icons/react/3x2/LT';
import LV from 'country-flag-icons/react/3x2/LV';
import MK from 'country-flag-icons/react/3x2/MK';
import MM from 'country-flag-icons/react/3x2/MM';
import MN from 'country-flag-icons/react/3x2/MN';
import MT from 'country-flag-icons/react/3x2/MT';
import MY from 'country-flag-icons/react/3x2/MY';
import NG from 'country-flag-icons/react/3x2/NG';
import NL from 'country-flag-icons/react/3x2/NL';
import NO from 'country-flag-icons/react/3x2/NO';
import NP from 'country-flag-icons/react/3x2/NP';
import NZ from 'country-flag-icons/react/3x2/NZ';
import PK from 'country-flag-icons/react/3x2/PK';
import PL from 'country-flag-icons/react/3x2/PL';
import PT from 'country-flag-icons/react/3x2/PT';
import RO from 'country-flag-icons/react/3x2/RO';
import RS from 'country-flag-icons/react/3x2/RS';
import RU from 'country-flag-icons/react/3x2/RU';
import SA from 'country-flag-icons/react/3x2/SA';
import SE from 'country-flag-icons/react/3x2/SE';
import SI from 'country-flag-icons/react/3x2/SI';
import SK from 'country-flag-icons/react/3x2/SK';
import SO from 'country-flag-icons/react/3x2/SO';
import TH from 'country-flag-icons/react/3x2/TH';
import TJ from 'country-flag-icons/react/3x2/TJ';
import TR from 'country-flag-icons/react/3x2/TR';
import TW from 'country-flag-icons/react/3x2/TW';
import TZ from 'country-flag-icons/react/3x2/TZ';
import UA from 'country-flag-icons/react/3x2/UA';
import US from 'country-flag-icons/react/3x2/US';
import UZ from 'country-flag-icons/react/3x2/UZ';
import VA from 'country-flag-icons/react/3x2/VA';
import VN from 'country-flag-icons/react/3x2/VN';
import WS from 'country-flag-icons/react/3x2/WS';
import ZA from 'country-flag-icons/react/3x2/ZA';
import ZW from 'country-flag-icons/react/3x2/ZW';
// Languages do not map one-to-one to countries. These are stable,
// representative flags for quick visual scanning; regional language variants
// keep their explicit flag (Simplified Chinese → CN, Traditional → TW).
// oxlint-disable-next-line react/only-export-components -- exported for the coverage regression test
export const LANGUAGE_FLAGS = {
af: ZA,
sq: AL,
am: ET,
ar: SA,
hy: AM,
az: AZ,
eu: ES,
be: BY,
bn: BD,
bs: BA,
bg: BG,
my: MM,
ca: ES_CT,
'cmn-Hans': CN,
'cmn-Hant': TW,
hr: HR,
cs: CZ,
da: DK,
nl: NL,
en: GB,
et: EE,
fi: FI,
fr: FR,
gl: ES,
ka: GE,
de: DE,
el: GR,
gu: IN,
ht: HT,
ha: NG,
haw: US,
he: IL,
hi: IN,
hu: HU,
is: IS,
id: ID,
it: IT,
ja: JP,
jw: ID,
kn: IN,
kk: KZ,
km: KH,
ko: KR,
ku: IQ,
ky: KG,
lo: LA,
la: VA,
lv: LV,
lt: LT,
mk: MK,
ms: MY,
ml: IN,
mt: MT,
mi: NZ,
mr: IN,
mn: MN,
ne: NP,
no: NO,
ps: AF,
fa: IR,
pl: PL,
pt: PT,
pa: IN,
ro: RO,
ru: RU,
sm: WS,
gd: GB_SCT,
sr: RS,
sn: ZW,
sd: PK,
si: LK,
sk: SK,
sl: SI,
so: SO,
es: ES,
su: ID,
sw: TZ,
sv: SE,
tg: TJ,
ta: IN,
te: IN,
th: TH,
tr: TR,
uk: UA,
ur: PK,
uz: UZ,
vi: VN,
cy: GB_WLS,
xh: ZA,
yi: IL,
yo: NG,
zu: ZA,
};
export default function LanguageFlag({ code, className = '' }) {
const Flag = LANGUAGE_FLAGS[code];
if (!Flag) {
return (
<Globe
size={16}
aria-hidden="true"
className={`shrink-0 text-[color:var(--chrome-fg-dim)] ${className}`}
data-language-flag={code}
data-testid={`language-flag-${code}`}
/>
);
}
return (
<Flag
aria-hidden="true"
className={`h-[12px] w-[18px] shrink-0 rounded-[2px] shadow-[0_0_0_1px_rgba(255,255,255,0.14)] ${className}`}
data-language-flag={code}
data-testid={`language-flag-${code}`}
/>
);
}
+67 -46
View File
@@ -1,9 +1,10 @@
import React, { useState, useMemo, useRef, useEffect, useLayoutEffect, useId } from 'react';
import { createPortal } from 'react-dom';
import { X, Search, Globe, Plus } from 'lucide-react';
import { X, Search, Plus } from 'lucide-react';
import { POPULAR_LANGS } from '../utils/constants';
import { LANG_CODES } from '../utils/languages';
import { useTranslation } from 'react-i18next';
import LanguageFlag from './LanguageFlag';
/**
* MultiLangPicker — chip-based multi-language selector for batch dubbing.
@@ -125,31 +126,42 @@ export default function MultiLangPicker({
return (
<div className="relative" ref={dropRef}>
<div className="flex flex-wrap gap-[4px] items-center min-h-[28px]">
{selected.map((s) => (
<span
key={s.code}
className="inline-flex items-center gap-[4px] px-[8px] py-[2px] bg-[var(--chrome-hover-bg)] border border-solid border-transparent rounded-full [font-family:var(--font-mono)] text-[0.68rem] font-medium text-[color:var(--chrome-fg)] uppercase"
<div className="flex items-start gap-[5px] min-h-[28px]">
{selected.length > 0 && (
<div
className="grid min-w-0 flex-1 grid-cols-[repeat(auto-fit,minmax(112px,1fr))] gap-[4px]"
data-testid="multi-lang-selected-grid"
>
<Globe size={9} aria-hidden="true" />
<span>{s.code}</span>
{!disabled && (
<button
type="button"
className="bg-transparent border-0 text-[color:var(--chrome-fg-muted)] cursor-pointer p-0 flex items-center rounded-full [transition:color_0.15s] hover:text-danger focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chrome-accent)]"
onClick={() => removeLang(s.code)}
aria-label={t('common.remove', { term: s.lang })}
{selected.map((s) => (
<span
key={s.code}
className="flex min-w-0 items-center gap-[5px] px-[6px] py-[3px] bg-[var(--chrome-hover-bg)] border border-solid border-transparent rounded-[var(--chrome-radius-pill)] [font-family:var(--font-sans)] text-[0.68rem] font-medium text-[color:var(--chrome-fg)]"
title={s.lang}
>
<X size={8} aria-hidden="true" />
</button>
)}
</span>
))}
<LanguageFlag code={s.code} />
<span className="min-w-0 flex-1 truncate">{s.lang}</span>
<span className="[font-family:var(--font-mono)] text-[0.58rem] uppercase text-[color:var(--chrome-fg-dim)]">
{s.code}
</span>
{!disabled && (
<button
type="button"
className="bg-transparent border-0 text-[color:var(--chrome-fg-muted)] cursor-pointer p-0 flex shrink-0 items-center rounded-full [transition:color_0.15s] hover:text-danger focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chrome-accent)]"
onClick={() => removeLang(s.code)}
aria-label={t('common.remove', { term: s.lang })}
>
<X size={8} aria-hidden="true" />
</button>
)}
</span>
))}
</div>
)}
{!disabled && (
<button
ref={triggerRef}
type="button"
className="flex items-center justify-center w-[24px] h-[24px] rounded-full border border-dashed border-transparent bg-transparent text-[color:var(--chrome-fg-muted)] cursor-pointer [transition:background-color_0.15s,color_0.15s,border-color_0.15s] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)] hover:border-solid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chrome-accent)]"
className="flex shrink-0 items-center justify-center w-[24px] h-[24px] mt-[2px] rounded-full border border-dashed border-transparent bg-transparent text-[color:var(--chrome-fg-muted)] cursor-pointer [transition:background-color_0.15s,color_0.15s,border-color_0.15s] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)] hover:border-solid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chrome-accent)]"
onClick={() => setDropOpen((open) => !open)}
title={t('dub.add_language')}
aria-label={t('dub.add_language')}
@@ -207,37 +219,46 @@ export default function MultiLangPicker({
<div className="[font-family:var(--font-mono)] text-[0.62rem] font-semibold uppercase [letter-spacing:0.04em] text-[color:var(--chrome-fg-dim)] pt-[6px] px-[10px] pb-[2px]">
{t('dub.popular')}
</div>
{popularFiltered.map((item) => (
<button
key={item.code}
type="button"
className="flex items-center gap-[8px] w-full px-[10px] py-[5px] bg-transparent border-0 text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.76rem] cursor-pointer text-left [transition:background_0.1s] hover:bg-[var(--chrome-hover-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--chrome-accent)]"
onClick={() => addLang(item.lang, item.code)}
>
<span className="[font-family:var(--font-mono)] text-[0.68rem] text-[color:var(--chrome-accent)] min-w-[28px] font-semibold">
{item.code}
</span>
<span className="min-w-0 truncate">{item.lang}</span>
</button>
))}
<div className="grid grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-[2px] px-[4px]">
{popularFiltered.map((item) => (
<button
key={item.code}
type="button"
className="flex min-w-0 items-center gap-[7px] rounded-[4px] px-[7px] py-[5px] bg-transparent border-0 text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.76rem] cursor-pointer text-left [transition:background_0.1s] hover:bg-[var(--chrome-hover-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--chrome-accent)]"
onClick={() => addLang(item.lang, item.code)}
>
<LanguageFlag code={item.code} />
<span className="[font-family:var(--font-mono)] text-[0.64rem] text-[color:var(--chrome-accent)] min-w-[24px] font-semibold uppercase">
{item.code}
</span>
<span className="min-w-0 truncate">{item.lang}</span>
</button>
))}
</div>
</>
)}
<div className="[font-family:var(--font-mono)] text-[0.62rem] font-semibold uppercase [letter-spacing:0.04em] text-[color:var(--chrome-fg-dim)] pt-[6px] px-[10px] pb-[2px]">
{t('dub.all_languages')}
</div>
{filteredLangs.slice(0, 50).map((lc) => (
<button
key={lc.code}
type="button"
className="flex items-center gap-[8px] w-full px-[10px] py-[5px] bg-transparent border-0 text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.76rem] cursor-pointer text-left [transition:background_0.1s] hover:bg-[var(--chrome-hover-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--chrome-accent)]"
onClick={() => addLang(lc.label, lc.code)}
>
<span className="[font-family:var(--font-mono)] text-[0.68rem] text-[color:var(--chrome-accent)] min-w-[28px] font-semibold">
{lc.code}
</span>
<span className="min-w-0 truncate">{lc.label}</span>
</button>
))}
<div
className="grid grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-[2px] px-[4px]"
data-testid="multi-lang-all-grid"
>
{filteredLangs.slice(0, 50).map((lc) => (
<button
key={lc.code}
type="button"
className="flex min-w-0 items-center gap-[7px] rounded-[4px] px-[7px] py-[5px] bg-transparent border-0 text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.76rem] cursor-pointer text-left [transition:background_0.1s] hover:bg-[var(--chrome-hover-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--chrome-accent)]"
onClick={() => addLang(lc.label, lc.code)}
>
<LanguageFlag code={lc.code} />
<span className="[font-family:var(--font-mono)] text-[0.64rem] text-[color:var(--chrome-accent)] min-w-[24px] font-semibold uppercase">
{lc.code}
</span>
<span className="min-w-0 truncate">{lc.label}</span>
</button>
))}
</div>
{filteredLangs.length > 50 && (
<div className="px-[10px] py-[8px] text-[0.7rem] text-[color:var(--chrome-fg-dim)] text-center">
{t('dub.more_to_narrow', { count: filteredLangs.length - 50 })}
@@ -1,7 +1,9 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, fireEvent, screen } from '@testing-library/react';
import { render, fireEvent, screen, within } from '@testing-library/react';
import '../i18n';
import MultiLangPicker from './MultiLangPicker';
import { LANGUAGE_FLAGS } from './LanguageFlag';
import { LANG_CODES } from '../utils/languages';
const rect = (overrides = {}) => ({
x: 40,
@@ -64,3 +66,34 @@ describe('MultiLangPicker viewport-safe menu', () => {
expect(trigger).toHaveFocus();
});
});
describe('MultiLangPicker responsive language cards', () => {
it('maps every supported language to a representative flag', () => {
expect(Object.keys(LANGUAGE_FLAGS).sort()).toEqual(LANG_CODES.map(({ code }) => code).sort());
});
it('shows selected languages with flags in a responsive grid', () => {
render(
<MultiLangPicker
selected={[
{ lang: 'Spanish', code: 'es' },
{ lang: 'Japanese', code: 'ja' },
]}
onChange={vi.fn()}
/>,
);
const grid = screen.getByTestId('multi-lang-selected-grid');
expect(grid).toHaveClass('grid');
expect(within(grid).getByTestId('language-flag-es')).toBeInTheDocument();
expect(within(grid).getByTestId('language-flag-ja')).toBeInTheDocument();
});
it('shows flags in the searchable result grid', () => {
render(<MultiLangPicker selected={[]} onChange={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Add language' }));
const results = screen.getByTestId('multi-lang-all-grid');
expect(results).toHaveClass('grid');
expect(within(results).getByTestId('language-flag-af')).toBeInTheDocument();
});
});
+91 -42
View File
@@ -18,6 +18,7 @@ import {
Fingerprint,
Wand2,
Film,
AudioWaveform,
Save,
Lock,
Download as DownloadIcon,
@@ -47,6 +48,45 @@ const displayTitle = (text) => {
return stripped || text || '';
};
const dubInputType = (item) => {
try {
const job = typeof item.job_data === 'string' ? JSON.parse(item.job_data) : item.job_data;
return job?.input_type === 'audio' ? 'audio' : 'video';
} catch {
return 'video';
}
};
function DubMediaPreview({ item, inputType }) {
const frameClass =
'relative flex h-[54px] w-[88px] flex-[0_0_88px] items-center justify-center overflow-hidden rounded-[var(--chrome-radius-pill)] bg-[var(--chrome-hover-bg)] text-[color:var(--chrome-fg-dim)]';
if (inputType === 'audio') {
return (
<div className={frameClass} aria-hidden="true">
<AudioWaveform size={28} strokeWidth={1.5} data-testid={`dub-audio-waveform-${item.id}`} />
</div>
);
}
return (
<div className={frameClass} aria-hidden="true">
<Film size={20} strokeWidth={1.5} />
<img
src={`${API}/dub/thumb/${encodeURIComponent(item.id)}`}
alt=""
loading="lazy"
decoding="async"
className="absolute inset-0 h-full w-full object-cover"
data-testid={`dub-thumbnail-${item.id}`}
onError={(event) => {
event.currentTarget.hidden = true;
}}
/>
</div>
);
}
/**
* Defer mounting <WaveformPlayer> (and its audio fetch + waveform decode)
* until the row scrolls into view — the server returns up to 50 history rows,
@@ -130,49 +170,58 @@ export default function WorkspaceHistory({
{t('history.empty_dub', { defaultValue: 'Your dubs will appear here.' })}
</div>
) : (
dubHistory.map((item) => (
<div
key={`dub-${item.id}`}
className="history-item history-item--dub"
onClick={() => restoreDubHistory(item)}
>
<div className="flex items-center justify-between gap-2 min-w-0">
<span className="history-kind history-kind--audio">
<Film size={9} /> {t('sidebar.dub_label')}
</span>
<span className="history-meta">
{item.segments_count} segs · {Math.round(item.duration || 0)}s
</span>
dubHistory.map((item) => {
const inputType = dubInputType(item);
const MediaIcon = inputType === 'audio' ? AudioWaveform : Film;
return (
<div
key={`dub-${item.id}`}
className="history-item history-item--dub"
onClick={() => restoreDubHistory(item)}
>
<div className="flex min-w-0 gap-[8px]">
<DubMediaPreview item={item} inputType={inputType} />
<div className="flex min-w-0 flex-1 flex-col gap-[2px]">
<div className="flex items-center justify-between gap-2 min-w-0">
<span className="history-kind history-kind--audio">
<MediaIcon size={9} /> {t('sidebar.dub_label')}
</span>
<span className="history-meta">
{item.segments_count} segs · {Math.round(item.duration || 0)}s
</span>
</div>
<div className="history-title">{item.filename}</div>
<div className="history-subtitle">
{[item.language, item.language_code]
.filter((v) => v && v !== 'und' && v !== 'Auto')
.join(' · ') || 'Auto'}
</div>
</div>
</div>
<div className="history-actions">
<button
className="history-action-btn accent"
onClick={(e) => {
e.stopPropagation();
restoreDubHistory(item);
}}
>
<FolderOpen size={10} /> {t('sidebar.open')}
</button>
<button
className="history-action-btn danger history-action-icon"
onClick={(e) => {
e.stopPropagation();
deleteHistory(item.id, 'dub');
}}
title="Delete"
>
<Trash2 size={10} />
</button>
</div>
</div>
<div className="history-title">{item.filename}</div>
<div className="history-subtitle">
{[item.language, item.language_code]
.filter((v) => v && v !== 'und' && v !== 'Auto')
.join(' · ') || 'Auto'}
</div>
<div className="history-actions">
<button
className="history-action-btn accent"
onClick={(e) => {
e.stopPropagation();
restoreDubHistory(item);
}}
>
<FolderOpen size={10} /> {t('sidebar.open')}
</button>
<button
className="history-action-btn danger history-action-icon"
onClick={(e) => {
e.stopPropagation();
deleteHistory(item.id, 'dub');
}}
title="Delete"
>
<Trash2 size={10} />
</button>
</div>
</div>
))
);
})
)}
</div>
</aside>
@@ -90,3 +90,72 @@ describe('WorkspaceHistory takes actions', () => {
expect(screen.queryByTestId('take-play-aa1')).toBeNull();
});
});
describe('WorkspaceHistory dub media previews', () => {
const renderDubRail = (dubHistory) =>
render(
<WorkspaceHistory
variant="dub"
dubHistory={dubHistory}
restoreDubHistory={noop}
deleteHistory={noop}
/>,
);
it('shows extracted thumbnails for YouTube and uploaded video dubs', () => {
renderDubRail([
{
id: 'youtube-job',
filename: 'YouTube clip',
segments_count: 3,
duration: 12,
job_data: JSON.stringify({ input_type: 'video' }),
},
{
id: 'uploaded-video',
filename: 'movie.mp4',
segments_count: 2,
duration: 8,
job_data: JSON.stringify({ video_path: '/dubs/uploaded-video/original.mp4' }),
},
]);
expect(screen.getByTestId('dub-thumbnail-youtube-job')).toHaveAttribute(
'src',
expect.stringContaining('/dub/thumb/youtube-job'),
);
expect(screen.getByTestId('dub-thumbnail-uploaded-video')).toHaveAttribute(
'src',
expect.stringContaining('/dub/thumb/uploaded-video'),
);
});
it('shows a waveform tile instead of a thumbnail for audio-only dubs', () => {
renderDubRail([
{
id: 'audio-job',
filename: 'interview.wav',
segments_count: 4,
duration: 30,
job_data: JSON.stringify({ input_type: 'audio' }),
},
]);
expect(screen.getByTestId('dub-audio-waveform-audio-job')).toBeInTheDocument();
expect(screen.queryByTestId('dub-thumbnail-audio-job')).toBeNull();
});
it('keeps malformed legacy job data renderable as a video row', () => {
renderDubRail([
{
id: 'legacy-job',
filename: 'legacy.mp4',
segments_count: 1,
duration: 5,
job_data: '{not-json',
},
]);
expect(screen.getByTestId('dub-thumbnail-legacy-job')).toBeInTheDocument();
});
});
+117 -102
View File
@@ -36,32 +36,61 @@ export default function DubHeader({
pipelineSteps,
onPipelineStep,
}) {
const generateLabel =
multiLangMode && multiLangs.length > 1
? t('dub.generate_dub_multi', {
count: multiLangs.length,
defaultValue: 'Generate {{count}} dubs',
})
: t('dub.generate_dub');
return (
<div className="flex flex-col gap-[2px] min-w-0 px-[10px] py-[4px] shrink-0 bg-[var(--color-bg-elev-1)] rounded-md mb-[2px]">
{/* Row 1: project title (left) + actions (right). Row 2: the pipeline
spine (Upload → … → Export) sits directly under the title with a
tight 2px gap — title-first, owner-requested order. */}
<div className="flex flex-wrap justify-between items-center gap-x-[var(--space-2)] gap-y-[4px] min-w-0">
<div className="label-row dub-head__title !gap-[6px]">
<FileText className="label-icon" size={11} />
<span className="font-medium text-[0.78rem] min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-fg normal-case">
<div
className="dub-command-bar"
data-testid="dub-command-bar"
role="toolbar"
aria-label={t('dub.video_dubbing_studio')}
>
<div className="dub-command-bar__identity">
<span className="dub-command-bar__file" aria-hidden="true">
<FileText size={13} />
</span>
<div className="min-w-0">
<div className="dub-command-bar__title" title={dubFilename}>
{dubFilename}
</span>
<span className="text-fg-muted font-normal whitespace-nowrap text-[0.68rem] normal-case shrink-0">
· {formatTime(dubDuration)} · {dubSegments.length} {t('dub.segs')}
</span>
{activeProjectName && activeProjectName !== dubFilename && (
<span className="text-[#b8bb26] ml-[var(--space-2)] whitespace-nowrap text-[0.68rem] normal-case overflow-hidden text-ellipsis min-w-0">
— {activeProjectName}
</div>
<div className="dub-command-bar__meta">
<span>{formatTime(dubDuration)}</span>
<span aria-hidden="true">·</span>
<span>
{dubSegments.length} {t('dub.segs')}
</span>
)}
{activeProjectName && activeProjectName !== dubFilename && (
<>
<span aria-hidden="true">·</span>
<span className="dub-command-bar__project" title={activeProjectName}>
{activeProjectName}
</span>
</>
)}
</div>
</div>
<div className="flex gap-[6px] items-center shrink-0">
{/* Icon-only secondary actions (tooltips carry the labels);
Generate Dub keeps its label as the primary verb. */}
</div>
<DubPipelineStepper
dubStep={dubStep}
inline
variant="command"
selectableSteps={pipelineSteps}
onStepSelect={onPipelineStep}
/>
<div className="dub-command-bar__actions">
<div className="dub-command-bar__utilities">
<Button
variant="subtle"
size="sm"
className="!size-[26px] !p-0"
onClick={saveProject}
title={t('dub.save')}
aria-label={t('dub.save')}
@@ -71,99 +100,85 @@ export default function DubHeader({
<Button
variant="danger"
size="sm"
className="!size-[26px] !p-0"
onClick={resetDub}
title={t('dub.reset')}
aria-label={t('dub.reset')}
>
<RotateCcw size={12} />
</Button>
{/* Primary actions live on the header bar (compact) — moved up from the footer. */}
<div className="flex gap-[6px] items-center pl-[var(--space-2)] ml-[2px]">
{dubStep === 'stopping' ? (
<FooterBtn
sm
tone="stopping"
disabled
icon={<Loader className="spinner" size={9} />}
label={t('dub.stopping')}
/>
) : dubStep === 'generating' ? (
<FooterBtn
sm
tone="danger"
onClick={handleDubStop}
icon={<Square size={9} />}
label={t('dub.stop_progress', {
current: dubProgress.current,
total: dubProgress.total,
})}
/>
) : (
<>
<FooterBtn
sm
tone={dubSegments.length && !isTranslating ? 'pink' : 'idle'}
onClick={onGenerateClick}
// The multi-language batch translates between generates while
// dubStep briefly sits back at 'editing' — keep the CTA inert
// during that phase so a re-click can't start a second batch.
disabled={!dubSegments.length || isTranslating}
icon={<Play size={11} />}
label={
multiLangMode && multiLangs.length > 1
? t('dub.generate_dub_multi', {
count: multiLangs.length,
defaultValue: 'Generate {{count}} dubs',
})
: t('dub.generate_dub')
}
/>
{dubStep === 'done' && incrementalPlan && incrementalPlan.stale?.length > 0 && (
<FooterBtn
sm
tone="pink"
onClick={() =>
handleDubGenerate({ regenOnly: incrementalPlan.stale, preview: true })
}
icon={<Play size={11} />}
label={t('dub.regen_changed', { count: incrementalPlan.stale.length })}
/>
)}
</>
)}
{dubStep === 'done' && (
<FooterBtn
sm
tone="idle"
disabled={qcRunning || !dubSegments.length}
onClick={handleDubQc}
icon={
qcRunning ? <Loader className="spinner" size={11} /> : <ShieldCheck size={11} />
}
title={t('dub.qc_btn', { defaultValue: 'Verify dub timing (second-pass check)' })}
aria-label={t('dub.qc_btn', {
defaultValue: 'Verify dub timing (second-pass check)',
})}
/>
)}
</div>
{dubStep === 'stopping' ? (
<FooterBtn
sm
tone="stopping"
disabled
className="dub-command-bar__primary"
icon={<Loader className="spinner" size={10} />}
label={t('dub.stopping')}
/>
) : dubStep === 'generating' ? (
<FooterBtn
sm
tone="danger"
className="dub-command-bar__primary"
onClick={handleDubStop}
icon={<Square size={9} />}
label={t('dub.stop_progress', {
current: dubProgress.current,
total: dubProgress.total,
})}
/>
) : (
<>
<FooterBtn
sm
tone={dubStep === 'done' ? 'green' : 'idle'}
disabled={dubStep !== 'done' && !dubSegments.length}
onClick={() => setExportOpen(true)}
icon={<Download size={12} />}
title={t('dub.export_btn')}
aria-label={t('dub.export_btn')}
tone={dubSegments.length && !isTranslating ? 'pink' : 'idle'}
className="dub-command-bar__primary"
onClick={onGenerateClick}
disabled={!dubSegments.length || isTranslating}
icon={<Play size={11} />}
label={generateLabel}
aria-label={generateLabel}
/>
</div>
</div>
{dubStep === 'done' && incrementalPlan && incrementalPlan.stale?.length > 0 && (
<FooterBtn
sm
tone="pink"
onClick={() =>
handleDubGenerate({ regenOnly: incrementalPlan.stale, preview: true })
}
icon={<Play size={11} />}
label={t('dub.regen_changed', { count: incrementalPlan.stale.length })}
/>
)}
</>
)}
{dubStep === 'done' && (
<FooterBtn
sm
tone="idle"
disabled={qcRunning || !dubSegments.length}
onClick={handleDubQc}
icon={qcRunning ? <Loader className="spinner" size={11} /> : <ShieldCheck size={11} />}
title={t('dub.qc_btn', { defaultValue: 'Verify dub timing (second-pass check)' })}
aria-label={t('dub.qc_btn', {
defaultValue: 'Verify dub timing (second-pass check)',
})}
/>
)}
<FooterBtn
sm
tone={dubStep === 'done' ? 'green' : 'idle'}
disabled={dubStep !== 'done' && !dubSegments.length}
onClick={() => setExportOpen(true)}
icon={<Download size={12} />}
title={t('dub.export_btn')}
aria-label={t('dub.export_btn')}
/>
</div>
<DubPipelineStepper
dubStep={dubStep}
inline
selectableSteps={pipelineSteps}
onStepSelect={onPipelineStep}
/>
</div>
);
}
@@ -0,0 +1,46 @@
import { render, screen, within } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import { describe, expect, it, vi } from 'vitest';
import i18n from '../../i18n';
import DubHeader from './DubHeader';
describe('DubHeader command bar', () => {
it('keeps project identity, compact pipeline, and batch action in one production bar', () => {
render(
<I18nextProvider i18n={i18n}>
<DubHeader
t={i18n.t.bind(i18n)}
dubFilename="trying the WORLD'S SMALLEST curling iron."
dubDuration={63.6}
dubSegments={Array.from({ length: 14 }, (_, id) => ({ id: String(id) }))}
activeProjectName=""
saveProject={vi.fn()}
resetDub={vi.fn()}
dubStep="editing"
handleDubStop={vi.fn()}
dubProgress={{ current: 0, total: 14 }}
onGenerateClick={vi.fn()}
isTranslating={false}
multiLangMode
multiLangs={Array.from({ length: 16 }, (_, id) => ({ code: `l${id}` }))}
incrementalPlan={null}
handleDubGenerate={vi.fn()}
qcRunning={false}
handleDubQc={vi.fn()}
setExportOpen={vi.fn()}
pipelineSteps={[]}
onPipelineStep={vi.fn()}
/>
</I18nextProvider>,
);
const bar = screen.getByTestId('dub-command-bar');
expect(within(bar).getByText("trying the WORLD'S SMALLEST curling iron.")).toBeInTheDocument();
expect(within(bar).getByText('1:03.6')).toBeInTheDocument();
expect(within(bar).getByText(/14 segs/i)).toBeInTheDocument();
expect(within(bar).getByRole('list', { name: 'Dubbing pipeline' })).toHaveClass(
'dub-stepper--command',
);
expect(within(bar).getByRole('button', { name: /Generate 16 dubs/i })).toBeInTheDocument();
});
});
@@ -32,7 +32,13 @@ const DUB_PHASE_BY_STEP = {
done: 5,
};
function DubPipelineStepper({ dubStep, inline = false, selectableSteps = [], onStepSelect }) {
function DubPipelineStepper({
dubStep,
inline = false,
variant,
selectableSteps = [],
onStepSelect,
}) {
const { t } = useTranslation();
const current = DUB_PHASE_BY_STEP[dubStep] ?? 0;
const busy =
@@ -43,7 +49,13 @@ function DubPipelineStepper({ dubStep, inline = false, selectableSteps = [], onS
dubStep === 'stopping';
return (
<div
className={inline ? 'dub-stepper dub-stepper--inline' : 'dub-stepper'}
className={[
'dub-stepper',
inline ? 'dub-stepper--inline' : '',
variant === 'command' ? 'dub-stepper--command' : '',
]
.filter(Boolean)
.join(' ')}
role="list"
aria-label={t('dub.pipeline', { defaultValue: 'Dubbing pipeline' })}
>
+154
View File
@@ -3827,6 +3827,86 @@ body:has(.capture-pill) {
flex: 1;
}
/* One compact hierarchy for project identity, pipeline position, and the
primary batch action. */
.dub-command-bar {
display: grid;
grid-template-columns: minmax(190px, 1fr) auto minmax(max-content, 1fr);
align-items: center;
gap: 10px;
min-width: 0;
min-height: 44px;
flex: 0 0 auto;
margin-bottom: 2px;
padding: 5px 7px;
border-radius: 8px;
background: color-mix(in srgb, var(--color-bg-elev-1) 94%, var(--chrome-bg));
box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--chrome-fg) 6%, transparent);
}
.dub-command-bar__identity {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
}
.dub-command-bar__file {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
flex: 0 0 auto;
border-radius: 7px;
color: var(--chrome-accent);
background: var(--chrome-accent-bg);
}
.dub-command-bar__title {
overflow: hidden;
color: var(--chrome-fg);
font-size: 0.73rem;
font-weight: 600;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.dub-command-bar__meta {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
margin-top: 2px;
color: var(--chrome-fg-dim);
font-family: var(--chrome-font-mono);
font-size: 0.58rem;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.dub-command-bar__project {
overflow: hidden;
color: var(--chrome-severity-ok);
text-overflow: ellipsis;
white-space: nowrap;
}
.dub-command-bar__actions,
.dub-command-bar__utilities {
display: flex;
align-items: center;
gap: 4px;
}
.dub-command-bar__actions { justify-content: flex-end; min-width: 0; }
.dub-command-bar__utilities {
margin-right: 2px;
padding-right: 6px;
border-right: 1px solid var(--chrome-border);
}
.dub-command-bar__primary {
flex: 0 0 auto;
min-height: 28px;
padding-inline: 11px !important;
font-size: 0.66rem !important;
font-weight: 600;
}
/* ── Column flex that fills height — overrides .studio-panel overflow ── */
.dub-panel-col {
margin-bottom: 0;
@@ -3940,6 +4020,61 @@ button.dub-stepper__action:focus-visible {
margin: 0 4px;
}
/* The active stage owns the emphasis; other stages remain quiet and dense. */
.dub-stepper--command {
gap: 1px;
padding: 3px;
border-radius: 7px;
background: color-mix(in srgb, var(--chrome-bg) 58%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--chrome-fg) 5%, transparent);
}
.dub-stepper--command .dub-stepper__step { gap: 0; font-size: 0.6rem; }
.dub-stepper--command .dub-stepper__step:not(:first-child)::before { display: none; }
.dub-stepper--command .dub-stepper__action {
gap: 4px;
min-height: 24px;
padding: 3px 6px;
border-radius: 5px;
}
.dub-stepper--command .dub-stepper__icon {
width: 14px;
height: 14px;
border: 0;
border-radius: 0;
}
.dub-stepper--command .dub-stepper__icon svg { width: 11px; height: 11px; }
.dub-stepper--command .dub-stepper__step.is-done .dub-stepper__icon {
color: var(--chrome-severity-ok);
background: transparent;
}
.dub-stepper--command .dub-stepper__step.is-active .dub-stepper__action {
color: var(--chrome-fg);
background: var(--chrome-accent-bg);
box-shadow: inset 0 0 0 1px var(--chrome-accent-border);
}
.dub-stepper--command .dub-stepper__step.is-active .dub-stepper__icon {
border: 0;
color: var(--chrome-accent);
box-shadow: none;
}
.shell-narrow .dub-command-bar,
.shell-mini .dub-command-bar {
grid-template-columns: minmax(0, 1fr) max-content;
gap: 5px 8px;
}
.shell-narrow .dub-stepper--command,
.shell-mini .dub-stepper--command {
grid-column: 1 / -1;
grid-row: 2;
justify-content: stretch;
}
.shell-narrow .dub-stepper--command .dub-stepper__step,
.shell-mini .dub-stepper--command .dub-stepper__step { flex: 1 1 auto; justify-content: center; }
.shell-mini .dub-stepper--command .dub-stepper__step:not(.is-active) .dub-stepper__label {
display: none;
}
/* ── Idle / drop-zone ─────────────────────────────────────────────────── */
.dub-idle-drop {
flex: 1;
@@ -4276,6 +4411,16 @@ button.dub-stepper__action:focus-visible {
background: var(--chrome-bg);
}
/* Dub projects stay compact while media history keeps the wider preview rail. */
.studio-projects {
flex: 0 0 240px;
display: flex;
flex-direction: column;
min-height: 0;
border-right: 1px solid var(--chrome-border);
background: var(--chrome-bg);
}
/* Panel chrome (.wh / .wh__*) moved to Tailwind utilities on WorkspaceHistory.jsx. */
/* On a narrow shell the right column drops below the definition column. Same
@@ -4289,6 +4434,15 @@ button.dub-stepper__action:focus-visible {
max-height: 60vh;
}
.shell-narrow .studio-projects,
.shell-mini .studio-projects {
flex: 0 0 auto;
order: 2;
border-right: none;
border-top: 1px solid var(--chrome-border);
max-height: 60vh;
}
/* ═══ from src/components/WorkspaceVoices.css ═══ */
/* WorkspaceVoices — right-side "Saved voices" panel. Row internals reuse the
shared .history-* classes (bundled app-wide); this file styles the panel
@@ -30,6 +30,7 @@ const raw = indexRaw.slice(startIdx, endIdx);
// Strip /* … */ comments so the guard checks real declarations, not the
// warning comment that quotes the forbidden `@media (max-width)` pattern.
const css = raw.replace(/\/\*[\s\S]*?\*\//g, '');
const app = readFileSync(resolve(process.cwd(), 'src/App.jsx'), 'utf8');
describe('workspace narrow-shell reflow (#476 CTA-clipping guard)', () => {
it('does NOT use a raw viewport @media (max-width) query', () => {
@@ -44,4 +45,10 @@ describe('workspace narrow-shell reflow (#476 CTA-clipping guard)', () => {
it('pins the action bar (Synthesize CTA) sticky so it stays on-screen', () => {
expect(css).toMatch(/\.studio-action-bar\s*\{[^}]*position:\s*sticky/s);
});
it('gives Dub Projects its own narrower rail than Dub History', () => {
expect(app).toMatch(/className="studio-projects">\s*<WorkspaceProjects/);
expect(css).toMatch(/\.studio-projects\s*\{[^}]*flex:\s*0 0 240px/s);
expect(css).toMatch(/\.studio-right\s*\{[^}]*flex:\s*0 0 340px/s);
});
});