feat(ui): chrome theme + retina typography + launchpad charisma + export drawer + setup wizard + projects + logs footer
## Typography system - Add Fontsource variable fonts: Inter, IBM Plex Mono, Source Serif 4 - Unify via --font-sans / --font-mono / --font-serif / --font-display tokens in ui/tokens.css - Enable OpenType features globally: cv11 (single-story a), ss01, ss03, zero, tabular-nums slashed-zero; font-optical-sizing: auto; font-synthesis: none - Alias legacy --chrome-font-mono to var(--font-mono); purge Google Fonts @import and hardcoded "ui-monospace, Menlo" refs across 43+ rules ## Chrome design tokens - Migrate every panel/button/input to --chrome-* tokens with color-mix(in srgb, … N%, transparent) tone tints - Flat-chromed .glass-panel, .studio-panel, .main-content, .launchpad, sidebar, segments, clone/design textarea, settings subsurfaces, export drawer, footer, modals - Kill radial gradients, paper-grain noise, 30s mesh animation - Rewrite Badge.css, Button.css, Tabs.css, Table.css to chrome tokens ## Launchpad charisma - Aurora backdrop: three drifting blurred blobs (pink 22s, green 28s, amber 32s) behind everything at z=0 - Hero halo + animated sweep line under H1 - Wave bars with per-bar --bar-delay / --bar-dur for breathing stagger - Action cards: single --card-hue drives bg/border/glow/spotlight - Cursor-tracked spotlight via onMouseMove → --mx/--my custom props → radial-gradient paint (no JS re-renders) - Eternal breath ring via .lp-glow-layer::after, staggered across three cards via :nth-child animation-delays - All animations gated by @media (prefers-reduced-motion: reduce) ## Export drawer (new) - ExportModal.jsx/css: 4-tab bottom drawer (Video / Audio / Subtitles / Package) rendered via createPortal - Presets (YouTube, Archive, Web, Podcast, Study), per-track checklist with All/None/Dubs-only, MP3 bitrate 128/192/256/320 - Non-blocking: pointer-events: none on outer, auto on sheet; ESC + click outside to close - DubTab: FooterBtn → React.forwardRef; export trigger opens drawer ## Dubbing ETA - Show elapsed time during generation; genElapsed state + fmtDur helper renders .dub-gen-overlay__stats block ## Setup wizard (new) - SetupWizard.jsx/css: first-run flow for engine probing, model downloads - Projects.jsx/css: dedicated projects browser page - LogsFooter.jsx/css: slide-up logs panel - api/setup.ts: client for setup router endpoints - App.jsx, NavRail, Header, store wired to new pages ## Version - frontend/package.json 0.2.0; refresh bun.lock Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a44167f22f
commit
e1966bdcd9
@@ -15,7 +15,12 @@
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/source-serif-4": "^5.2.9",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.0",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
@@ -96,6 +101,12 @@
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
|
||||
|
||||
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="],
|
||||
|
||||
"@fontsource-variable/source-serif-4": ["@fontsource-variable/source-serif-4@5.2.9", "", {}, "sha512-PPcxjLFk/fS0WHg79pDM2YNvz61kC+oYZ5cWZZyCS0DHpJncmuYOuiZAsvj4tDxlWPBEvxxcRLQQNmSaRbPkqw=="],
|
||||
|
||||
"@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.7", "", {}, "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w=="],
|
||||
|
||||
"@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="],
|
||||
|
||||
"@hapi/formula": ["@hapi/formula@3.0.2", "", {}, "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw=="],
|
||||
@@ -192,6 +203,10 @@
|
||||
|
||||
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.0", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw=="],
|
||||
|
||||
"@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="],
|
||||
|
||||
"@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="],
|
||||
|
||||
"@tauri-apps/plugin-window-state": ["@tauri-apps/plugin-window-state@2.4.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw=="],
|
||||
|
||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "omnivoice-studio",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -12,7 +12,12 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/source-serif-4": "^5.2.9",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.0",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
|
||||
+193
-8
@@ -15,7 +15,10 @@ const Settings = lazy(() => import('./pages/Settings'));
|
||||
const VoiceProfile = lazy(() => import('./pages/VoiceProfile'));
|
||||
const BatchQueue = lazy(() => import('./pages/BatchQueue'));
|
||||
const ToolsPage = lazy(() => import('./pages/ToolsPage'));
|
||||
const SetupWizard = lazy(() => import('./pages/SetupWizard'));
|
||||
const KeyboardCheatsheet = lazy(() => import('./components/KeyboardCheatsheet'));
|
||||
const LogsFooter = lazy(() => import('./components/LogsFooter'));
|
||||
const ProjectsPage = lazy(() => import('./pages/Projects'));
|
||||
import Header from './components/Header';
|
||||
import NavRail from './components/NavRail';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
@@ -188,7 +191,7 @@ function App() {
|
||||
const openVoiceProfile = useAppStore(s => s.openVoiceProfile);
|
||||
const closeVoiceProfile = useAppStore(s => s.closeVoiceProfile);
|
||||
const hideSidebar = mode === 'launchpad' || mode === 'settings' || mode === 'voice'
|
||||
|| mode === 'queue' || mode === 'tools';
|
||||
|| mode === 'queue' || mode === 'tools' || mode === 'projects';
|
||||
const availableSidebarTabs = mode === 'dub'
|
||||
? ['projects', 'history', 'downloads']
|
||||
: (mode === 'clone' || mode === 'design')
|
||||
@@ -332,6 +335,7 @@ function App() {
|
||||
const glossaryTerms = useAppStore(s => s.glossaryTerms);
|
||||
const setGlossaryTerms = useAppStore(s => s.setGlossaryTerms);
|
||||
const dualSubs = useAppStore(s => s.dualSubs);
|
||||
const burnSubs = useAppStore(s => s.burnSubs);
|
||||
const setDualSubs = useAppStore(s => s.setDualSubs);
|
||||
|
||||
const [translateProvider, setTranslateProvider] = useState('argos');
|
||||
@@ -538,10 +542,78 @@ function App() {
|
||||
// ── LOAD DATA FROM SERVER ──
|
||||
const [sysStats, setSysStats] = useState(null);
|
||||
|
||||
// First-run gate — `/setup/status` reports whether required HF models are
|
||||
// on disk. If not, we render <SetupWizard> in place of the main studio so
|
||||
// the user actually SEES the download instead of a silent 5 GB hang.
|
||||
//
|
||||
// Packaged .app note: the frozen backend sidecar takes several seconds to
|
||||
// import torch/torchaudio/whisper/etc. before it can serve /setup/status.
|
||||
// A single fetch on mount lands during that window, fails, and the wizard
|
||||
// would never render. So we retry with backoff until we get a response or
|
||||
// the user gives up. `setupChecked` gates main-UI render so we don't flash
|
||||
// the studio in front of a user who actually needs the wizard.
|
||||
const [setupNeeded, setSetupNeeded] = useState(false);
|
||||
const [setupChecked, setSetupChecked] = useState(false);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const { setupStatus } = await import('./api/setup');
|
||||
// ~30 attempts × ~1s ≈ 30s ceiling; enough for a cold sidecar on slow disks.
|
||||
for (let attempt = 0; attempt < 30 && !cancelled; attempt++) {
|
||||
try {
|
||||
const s = await setupStatus();
|
||||
if (cancelled) return;
|
||||
setSetupNeeded(!s.models_ready);
|
||||
setSetupChecked(true);
|
||||
return;
|
||||
} catch {
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
if (!cancelled) setSetupChecked(true);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// ── Tauri auto-updater ──
|
||||
// On boot, ask GitHub Releases if a newer build is available. If yes,
|
||||
// prompt the user, download the signed bundle, restart into the new
|
||||
// version. Only runs in packaged .app (not `tauri dev`) — the updater
|
||||
// endpoint 404s until the first signed release is published, and we
|
||||
// don't want that noise in the dev console.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!('__TAURI_INTERNALS__' in window)) return;
|
||||
if (import.meta.env.DEV) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [{ check }, { relaunch }, { ask }] = await Promise.all([
|
||||
import('@tauri-apps/plugin-updater'),
|
||||
import('@tauri-apps/plugin-process'),
|
||||
import('@tauri-apps/plugin-dialog'),
|
||||
]);
|
||||
const update = await check();
|
||||
if (cancelled || !update) return;
|
||||
const proceed = await ask(
|
||||
`A new version (${update.version}) of OmniVoice Studio is available.\n\nWhat's new:\n${update.body || '— see release notes'}\n\nDownload and install now?`,
|
||||
{ title: 'Update available', kind: 'info' },
|
||||
);
|
||||
if (!proceed) return;
|
||||
await update.downloadAndInstall();
|
||||
await relaunch();
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('Updater check failed (non-fatal):', e);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// ── DESKTOP NATIVE INTEGRATION ──
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
|
||||
// 1. Prevent default right-click to hide web nature
|
||||
const handleContextMenu = (e) => {
|
||||
// allow on inputs/textareas for copy/paste
|
||||
@@ -1265,7 +1337,7 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDubIngestUrl = async (url) => {
|
||||
const handleDubIngestUrl = async (url, opts = {}) => {
|
||||
const clean = (url || '').trim();
|
||||
if (!clean) return;
|
||||
setDubStep('uploading'); setDubError(''); setDubTracks([]); setDubPrepStage('download');
|
||||
@@ -1275,7 +1347,11 @@ function App() {
|
||||
dubClientJobIdRef.current = clientJobId;
|
||||
setDubJobId(clientJobId);
|
||||
try {
|
||||
const data = await dubIngestUrl(clean, clientJobId, { signal: ctrl.signal });
|
||||
const data = await dubIngestUrl(clean, clientJobId, {
|
||||
signal: ctrl.signal,
|
||||
fetchSubs: !!opts.fetchSubs,
|
||||
subLangs: opts.subLangs,
|
||||
});
|
||||
setDubJobId(data.job_id);
|
||||
setDubTaskId(data.task_id);
|
||||
await _waitForPrep(data.task_id, ctrl);
|
||||
@@ -1313,6 +1389,36 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// Retry transcribe on an existing job — the video + demucs + scene-cut
|
||||
// preprocessing is already on disk, so we skip straight back to the ASR
|
||||
// stream. Used by the "Retry" button on the transcribe-failed banner.
|
||||
const handleDubRetryTranscribe = async () => {
|
||||
if (!dubJobId) return;
|
||||
const ctrl = new AbortController();
|
||||
dubAbortCtrlRef.current = ctrl;
|
||||
setDubError('');
|
||||
setDubSegments([]);
|
||||
setDubStep('transcribing');
|
||||
setTranscribeStart(Date.now());
|
||||
try {
|
||||
await _waitForTranscribe(dubJobId, ctrl);
|
||||
setTranscribeStart(null);
|
||||
setDubStep('editing');
|
||||
} catch (err) {
|
||||
setTranscribeStart(null);
|
||||
if (err.name === 'AbortError') {
|
||||
toast('Retry cancelled');
|
||||
setDubStep('idle');
|
||||
} else {
|
||||
setDubError(err.message);
|
||||
setDubStep('idle');
|
||||
toast.error('Transcription failed: ' + err.message);
|
||||
}
|
||||
} finally {
|
||||
dubAbortCtrlRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Transcription elapsed timer
|
||||
useEffect(() => {
|
||||
if (!transcribeStart) { setTranscribeElapsed(0); return; }
|
||||
@@ -1533,12 +1639,11 @@ function App() {
|
||||
if (!destPath) return; // User cancelled
|
||||
|
||||
await exportAction({ source_filename: sourceIdentifier, destination_path: destPath, mode });
|
||||
const data = await res.json();
|
||||
toast.success(`Exported: ${fallbackName}`);
|
||||
loadExportHistory();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('Failed to bridge save dialog to rust/python backend.');
|
||||
toast.error(`Export failed: ${err?.message || err}`);
|
||||
}
|
||||
};
|
||||
const revealInFolder = async (filePath) => {
|
||||
@@ -1633,7 +1738,8 @@ function App() {
|
||||
if (exportTracks['original'] !== false) selected.push('original');
|
||||
dubTracks.forEach(t => { if (exportTracks[t] !== false) selected.push(t); });
|
||||
const tracksParam = selected.join(',');
|
||||
triggerDownload(`${API}/dub/download/${dubJobId}/dubbed_video.mp4?preserve_bg=${preserveBg}&default_track=${defaultTrack}&include_tracks=${encodeURIComponent(tracksParam)}`, 'dubbed_video.mp4');
|
||||
const burnParam = burnSubs ? `&burn_subs=1&dual=${dualSubs ? 1 : 0}` : '';
|
||||
triggerDownload(`${API}/dub/download/${dubJobId}/dubbed_video.mp4?preserve_bg=${preserveBg}&default_track=${defaultTrack}&include_tracks=${encodeURIComponent(tracksParam)}${burnParam}`, 'dubbed_video.mp4');
|
||||
};
|
||||
const handleDubAudioDownload = async () => {
|
||||
await finalizeTtsBeforeExport();
|
||||
@@ -1786,6 +1892,66 @@ function App() {
|
||||
};
|
||||
|
||||
|
||||
// First-run gate: if /setup/status says models aren't on disk yet, render
|
||||
// the wizard instead of the main studio. Dismisses itself once the user
|
||||
// completes the download (or clicks "Skip" if they want to limp along).
|
||||
// Also blocks render until we've heard back from the backend at least once
|
||||
// — the frozen sidecar's cold-start import is ~5-10 s and without this we
|
||||
// flash the empty studio before the wizard has a chance to mount.
|
||||
if (!setupChecked) {
|
||||
return (
|
||||
<div className="app-container sidebar-hidden" style={{ zoom: uiScale, display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', flexDirection: 'column', gap: 12, color: '#a89984', fontSize: 13 }}>
|
||||
<div style={{ fontSize: 18, color: '#ebdbb2' }}>OmniVoice Studio</div>
|
||||
<div>Starting backend…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (setupNeeded) {
|
||||
// Render outside the `app-container` grid so the wizard spans the full
|
||||
// viewport instead of getting squeezed into whatever grid cell the
|
||||
// studio layout reserves for the main content column.
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
/* Same pattern as .app-container: shrink by whatever the
|
||||
LogsFooter is currently occupying so it never covers the
|
||||
wizard footer buttons / content. */
|
||||
minHeight: 'calc(100vh - var(--logs-footer-height, 28px))',
|
||||
maxHeight: 'calc(100vh - var(--logs-footer-height, 28px))',
|
||||
width: '100%',
|
||||
overflow: 'auto',
|
||||
zoom: uiScale,
|
||||
background: 'var(--color-bg, #1d2021)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Invisible drag strip across the top 28 px of the wizard —
|
||||
matches the macOS traffic-light zone so the window can be
|
||||
dragged / double-click-zoomed from anywhere along the top. */}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
onDoubleClick={() => {
|
||||
if ('__TAURI_INTERNALS__' in window) {
|
||||
import('@tauri-apps/api/window').then(m =>
|
||||
m.getCurrentWindow().toggleMaximize()
|
||||
).catch(() => {});
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0,
|
||||
height: 28, zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
<SetupWizard onReady={() => setSetupNeeded(false)} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<LogsFooter />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
@@ -1814,7 +1980,6 @@ function App() {
|
||||
|
||||
<Header
|
||||
mode={mode} setMode={setMode}
|
||||
uiScale={uiScale} setUiScale={setUiScale}
|
||||
sysStats={sysStats} modelStatus={modelStatus}
|
||||
doubleClickMaximize={doubleClickMaximize}
|
||||
activeProjectName={activeProjectName}
|
||||
@@ -1863,6 +2028,20 @@ function App() {
|
||||
<ToolsPage onBack={() => setMode('launchpad')} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
) : mode === 'projects' ? (
|
||||
<ErrorBoundary name="projects">
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
<ProjectsPage
|
||||
studioProjects={studioProjects}
|
||||
profiles={profiles}
|
||||
history={history}
|
||||
exportHistory={exportHistory}
|
||||
onOpenDub={(id) => { loadProject(id); setMode('dub'); }}
|
||||
onOpenProfile={(id) => { openVoiceProfile(id); }}
|
||||
onRevealExport={(path) => { exportReveal({ path }).catch(() => {}); }}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
) : mode === 'launchpad' ? (
|
||||
<ErrorBoundary name="launchpad">
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
@@ -1896,6 +2075,7 @@ function App() {
|
||||
setDubLocalBlobUrl={setDubLocalBlobUrl}
|
||||
// Handlers — close over App.jsx scope so stay prop-threaded.
|
||||
handleDubAbort={handleDubAbort} handleDubUpload={handleDubUpload} handleDubIngestUrl={handleDubIngestUrl}
|
||||
handleDubRetryTranscribe={handleDubRetryTranscribe}
|
||||
handleDubStop={handleDubStop} handleDubGenerate={handleDubGenerate}
|
||||
handleDubDownload={handleDubDownload} handleDubAudioDownload={handleDubAudioDownload}
|
||||
handleAudioExport={handleAudioExport}
|
||||
@@ -2033,6 +2213,11 @@ function App() {
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* ═══ BOTTOM LOGS PANEL (VSCode-style) ═══ */}
|
||||
<Suspense fallback={null}>
|
||||
<LogsFooter />
|
||||
</Suspense>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export const API = (import.meta as any).env?.DEV ? 'http://localhost:8000' : '';
|
||||
// Backend always listens on localhost:8000 — both in dev (Vite @ 5173 talking
|
||||
// to a separate uvicorn) and in the built .app (Tauri webview @ tauri://localhost
|
||||
// talking to the bundled frozen backend sidecar). Relative fetches against
|
||||
// tauri://localhost don't reach the sidecar, so we hardcode the absolute host.
|
||||
export const API = 'http://localhost:8000';
|
||||
|
||||
export class ApiError extends Error {
|
||||
status?: number;
|
||||
|
||||
+20
-2
@@ -12,12 +12,30 @@ export async function dubUpload(
|
||||
return apiPost('/dub/upload', fd, { signal });
|
||||
}
|
||||
|
||||
export interface IngestUrlOptions {
|
||||
signal?: AbortSignal;
|
||||
/** Ask yt-dlp to also pull caption tracks (incl. YouTube auto-translations). */
|
||||
fetchSubs?: boolean;
|
||||
/** Limit caption fetch to specific lang codes; defaults to all available. */
|
||||
subLangs?: string[];
|
||||
}
|
||||
|
||||
export async function dubIngestUrl(
|
||||
url: string,
|
||||
jobId: string,
|
||||
{ signal }: { signal?: AbortSignal } = {},
|
||||
opts: IngestUrlOptions = {},
|
||||
): Promise<unknown> {
|
||||
return apiPost('/dub/ingest-url', { url, job_id: jobId }, { signal });
|
||||
const { signal, fetchSubs, subLangs } = opts;
|
||||
return apiPost(
|
||||
'/dub/ingest-url',
|
||||
{
|
||||
url,
|
||||
job_id: jobId,
|
||||
fetch_subs: fetchSubs || undefined,
|
||||
sub_langs: subLangs && subLangs.length ? subLangs : undefined,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
export function transcribeStreamUrl(jobId: string): string {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiJson, apiPost } from './client';
|
||||
import { apiJson, apiPost, apiDelete } from './client';
|
||||
import type {
|
||||
AllEnginesResponse,
|
||||
EngineFamily,
|
||||
@@ -6,6 +6,30 @@ import type {
|
||||
SelectEngineResponse,
|
||||
} from './types';
|
||||
|
||||
export interface TranslationEngine {
|
||||
id: string;
|
||||
display_name: string;
|
||||
pip_package: string | null;
|
||||
probe_module: string | null;
|
||||
category: 'offline' | 'online' | 'llm';
|
||||
needs_key: boolean;
|
||||
builtin?: boolean;
|
||||
notes?: string;
|
||||
installed: boolean;
|
||||
availability_reason: string;
|
||||
}
|
||||
export interface TranslationEnginesResponse {
|
||||
engines: TranslationEngine[];
|
||||
sandboxed: boolean;
|
||||
}
|
||||
export interface InstallEngineResponse {
|
||||
status: 'installed' | 'already_installed' | 'installed_but_probe_failed' | 'uninstalled' | 'no_op';
|
||||
engine: string;
|
||||
package?: string;
|
||||
log_tail?: string;
|
||||
restart_required?: boolean;
|
||||
}
|
||||
|
||||
export async function listEngines(): Promise<AllEnginesResponse> {
|
||||
return apiJson<AllEnginesResponse>('/engines');
|
||||
}
|
||||
@@ -24,6 +48,19 @@ export async function selectEngine(family: EngineFamily, backendId: string): Pro
|
||||
return apiPost<SelectEngineResponse>('/engines/select', { family, backend_id: backendId });
|
||||
}
|
||||
|
||||
export async function listTranslationEngines(): Promise<TranslationEnginesResponse> {
|
||||
return apiJson<TranslationEnginesResponse>('/engines/translation');
|
||||
}
|
||||
|
||||
export async function installTranslationEngine(id: string): Promise<InstallEngineResponse> {
|
||||
return apiPost<InstallEngineResponse>(`/engines/translation/${id}/install`, {});
|
||||
}
|
||||
|
||||
export async function uninstallTranslationEngine(id: string): Promise<InstallEngineResponse> {
|
||||
const res = await apiDelete(`/engines/translation/${id}`);
|
||||
return (await res.json()) as InstallEngineResponse;
|
||||
}
|
||||
|
||||
export interface JobsQuery {
|
||||
status?: string;
|
||||
projectId?: string;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { API, apiJson, apiPost, apiFetch } from './client';
|
||||
|
||||
export interface MissingModel {
|
||||
repo_id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SetupStatus {
|
||||
models_ready: boolean;
|
||||
missing: MissingModel[];
|
||||
hf_cache_dir: string;
|
||||
disk_free_gb: number;
|
||||
min_free_gb: number;
|
||||
enough_disk: boolean;
|
||||
}
|
||||
|
||||
export interface SetupProgressEvent {
|
||||
filename: string;
|
||||
downloaded: number;
|
||||
total: number;
|
||||
pct: number;
|
||||
phase: 'start' | 'progress' | 'done';
|
||||
}
|
||||
|
||||
export async function setupStatus(): Promise<SetupStatus> {
|
||||
return apiJson<SetupStatus>('/setup/status');
|
||||
}
|
||||
|
||||
export async function setupWarmup(): Promise<{ status: string }> {
|
||||
return apiPost<{ status: string }>('/setup/warmup');
|
||||
}
|
||||
|
||||
export function setupDownloadStreamUrl(): string {
|
||||
return `${API}/setup/download-stream`;
|
||||
}
|
||||
|
||||
// ── Model store ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface KnownModel {
|
||||
repo_id: string;
|
||||
label: string;
|
||||
role: 'TTS' | 'ASR' | 'Diarisation' | string;
|
||||
size_gb: number;
|
||||
required: boolean;
|
||||
note?: string;
|
||||
installed: boolean;
|
||||
size_on_disk_bytes: number;
|
||||
nb_files: number;
|
||||
}
|
||||
|
||||
export interface ModelList {
|
||||
models: KnownModel[];
|
||||
total_installed_bytes: number;
|
||||
hf_cache_dir: string;
|
||||
}
|
||||
|
||||
export async function listModels(): Promise<ModelList> {
|
||||
return apiJson<ModelList>('/models');
|
||||
}
|
||||
|
||||
export async function installModel(repo_id: string): Promise<{ status: string; repo_id: string }> {
|
||||
return apiPost('/models/install', { repo_id });
|
||||
}
|
||||
|
||||
// ── Device-aware model recommendation ─────────────────────────────────────
|
||||
|
||||
export interface RecommendedModel {
|
||||
repo_id: string;
|
||||
label: string;
|
||||
role: string;
|
||||
size_gb: number;
|
||||
required: boolean;
|
||||
note: string | null;
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
export interface Recommendations {
|
||||
device: {
|
||||
os: string;
|
||||
arch: string;
|
||||
is_mac_arm: boolean;
|
||||
is_mac_intel: boolean;
|
||||
is_linux: boolean;
|
||||
is_windows: boolean;
|
||||
has_cuda: boolean;
|
||||
label: string;
|
||||
};
|
||||
rationale: string;
|
||||
models: RecommendedModel[];
|
||||
download_gb_remaining: number;
|
||||
total_gb: number;
|
||||
all_installed: boolean;
|
||||
}
|
||||
|
||||
export async function getRecommendations(): Promise<Recommendations> {
|
||||
return apiJson<Recommendations>('/setup/recommendations');
|
||||
}
|
||||
|
||||
export async function deleteModel(repo_id: string): Promise<{ deleted: boolean; repo_id: string; freed_bytes: number }> {
|
||||
const r = await apiFetch(`/models/${encodeURIComponent(repo_id)}`, { method: 'DELETE' });
|
||||
return r.json();
|
||||
}
|
||||
|
||||
@@ -50,31 +50,48 @@ export default function CheckpointBanner({ stage, count, onContinue, onDismiss,
|
||||
|
||||
return (
|
||||
<div
|
||||
className="checkpoint-banner"
|
||||
style={{
|
||||
// Accent shows through as a left-edge bar instead of a gradient wash,
|
||||
// and the fill stays flat chrome so the banner rhymes with the rest
|
||||
// of the studio strips.
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 6,
|
||||
borderRadius: 6,
|
||||
background: `linear-gradient(90deg, ${cfg.accent}12 0%, rgba(255,255,255,0.01) 60%)`,
|
||||
border: `1px solid ${cfg.accent}33`,
|
||||
borderRadius: 'var(--chrome-radius-pill)',
|
||||
background: 'var(--chrome-bg)',
|
||||
border: '1px solid var(--chrome-border)',
|
||||
borderLeft: `2px solid ${cfg.accent}`,
|
||||
}}
|
||||
role="status"
|
||||
>
|
||||
<Icon size={16} color={cfg.accent} style={{ flexShrink: 0 }} />
|
||||
<Icon size={14} color={cfg.accent} style={{ flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<span style={{ fontSize: '0.72rem', fontWeight: 600, color: '#ebdbb2' }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--chrome-font-mono)',
|
||||
fontSize: 'var(--chrome-label-size)',
|
||||
letterSpacing: 'var(--chrome-label-track)',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: 600,
|
||||
color: 'var(--chrome-fg)',
|
||||
}}>
|
||||
{cfg.title}
|
||||
</span>
|
||||
{typeof count === 'number' && (
|
||||
<span style={{ fontSize: '0.6rem', color: '#a89984', fontVariantNumeric: 'tabular-nums' }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--chrome-font-mono)',
|
||||
fontSize: 'var(--chrome-label-size)',
|
||||
color: 'var(--chrome-fg-muted)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{count} segment{count === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontSize: '0.6rem', color: '#a89984', lineHeight: 1.35 }}>
|
||||
<span style={{ fontSize: '0.64rem', color: 'var(--chrome-fg-muted)', lineHeight: 1.35 }}>
|
||||
{cfg.hint}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,101 @@
|
||||
/* CompareModal — only layout + content-specific styles remain here.
|
||||
Base controls (Dialog, Panel, Button, Select, Textarea) come from ui/. */
|
||||
/* CompareModal — non-modal bottom drawer. Leaves the app interactive;
|
||||
no backdrop wash. ESC or click-outside dismisses. Mirrors
|
||||
ExportModal's drawer chrome so the two read as siblings. */
|
||||
|
||||
.compare-drawer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: var(--logs-footer-height, 28px);
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.compare-drawer__sheet {
|
||||
pointer-events: auto;
|
||||
width: min(880px, calc(100vw - 24px));
|
||||
max-height: min(80vh, 640px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--chrome-bg);
|
||||
border: 1px solid var(--chrome-border-strong);
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
box-shadow:
|
||||
0 -8px 24px rgba(0, 0, 0, 0.45),
|
||||
0 -1px 0 var(--chrome-border) inset;
|
||||
animation: compare-drawer-slide-up var(--dur-base, 180ms) var(--ease-out, cubic-bezier(.2,.8,.2,1));
|
||||
overflow: hidden;
|
||||
}
|
||||
@keyframes compare-drawer-slide-up {
|
||||
from { transform: translateY(100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.compare-drawer__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 10px var(--space-4);
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.02), transparent);
|
||||
position: relative;
|
||||
}
|
||||
.compare-drawer__handle {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 36px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--chrome-border-strong);
|
||||
}
|
||||
.compare-drawer__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-display, inherit);
|
||||
font-size: var(--text-md);
|
||||
font-weight: 600;
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
.compare-drawer__close {
|
||||
margin-left: auto;
|
||||
width: var(--chrome-icon-btn, 22px);
|
||||
height: var(--chrome-icon-btn, 22px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast), color var(--dur-fast), border-color var(--dur-fast);
|
||||
}
|
||||
.compare-drawer__close:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
border-color: var(--chrome-border-strong);
|
||||
}
|
||||
|
||||
.compare-drawer__body {
|
||||
padding: var(--space-4);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.compare-drawer__foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid var(--chrome-border);
|
||||
background: var(--chrome-bg);
|
||||
}
|
||||
|
||||
/* CompareModal — content-specific styles. Base controls (Panel, Button,
|
||||
Select, Textarea) come from ui/. */
|
||||
|
||||
.ui-compare__desc {
|
||||
margin: 0 0 var(--space-5);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Scale, Fingerprint, Play } from 'lucide-react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Scale, Fingerprint, Play, X } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { PRESETS } from '../utils/constants';
|
||||
import { generateSpeech } from '../api/generate';
|
||||
import { Dialog, Button, Panel, Field, Textarea, Select } from '../ui';
|
||||
import { Button, Panel, Field, Textarea, Select } from '../ui';
|
||||
import './CompareModal.css';
|
||||
|
||||
export default function CompareModal({
|
||||
@@ -19,6 +19,24 @@ export default function CompareModal({
|
||||
steps, cfg, speed, denoise, postprocess,
|
||||
fileToMediaUrl, loadHistory,
|
||||
}) {
|
||||
const drawerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e) => { if (e.key === 'Escape') onClose?.(); };
|
||||
const onPointer = (e) => {
|
||||
if (drawerRef.current && !drawerRef.current.contains(e.target)) onClose?.();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
// Defer click-outside so the opening click doesn't immediately close.
|
||||
const t = setTimeout(() => window.addEventListener('mousedown', onPointer), 0);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('mousedown', onPointer);
|
||||
clearTimeout(t);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
const runCompare = async () => {
|
||||
setIsComparing(true);
|
||||
setCompareResultA(null);
|
||||
@@ -73,14 +91,61 @@ export default function CompareModal({
|
||||
|
||||
const canCompare = !isComparing && compareVoiceA && compareVoiceB && compareText.trim();
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
title={<><Scale size={14} /> A/B Voice Comparison</>}
|
||||
footer={
|
||||
<>
|
||||
<div className="compare-drawer" role="dialog" aria-modal="false" aria-label="A/B Voice Comparison">
|
||||
<div className="compare-drawer__sheet" ref={drawerRef}>
|
||||
<header className="compare-drawer__head">
|
||||
<span className="compare-drawer__handle" aria-hidden="true" />
|
||||
<span className="compare-drawer__title">
|
||||
<Scale size={14} /> A/B Voice Comparison
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="compare-drawer__close"
|
||||
onClick={onClose}
|
||||
aria-label="Close comparison"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="compare-drawer__body">
|
||||
<p className="ui-compare__desc">
|
||||
Compare two voices side by side to make casting decisions. App stays interactive behind.
|
||||
</p>
|
||||
|
||||
<Field label="Test phrase">
|
||||
<Textarea
|
||||
value={compareText}
|
||||
onChange={e => setCompareText(e.target.value)}
|
||||
rows={2}
|
||||
style={{ resize: 'none' }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="ui-compare__grid">
|
||||
<CompareSide
|
||||
accent="var(--color-brand)"
|
||||
label="Voice A"
|
||||
profiles={profiles}
|
||||
value={compareVoiceA}
|
||||
onChange={setCompareVoiceA}
|
||||
audio={compareResultA}
|
||||
/>
|
||||
<CompareSide
|
||||
accent="var(--color-success)"
|
||||
label="Voice B"
|
||||
profiles={profiles}
|
||||
value={compareVoiceB}
|
||||
onChange={setCompareVoiceB}
|
||||
audio={compareResultB}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="compare-drawer__foot">
|
||||
<Button variant="ghost" onClick={onClose}>Close</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -91,41 +156,9 @@ export default function CompareModal({
|
||||
>
|
||||
{isComparing ? (compareProgress || 'Comparing…') : 'Compare'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="ui-compare__desc">
|
||||
Compare two voices side by side to make casting decisions.
|
||||
</p>
|
||||
|
||||
<Field label="Test phrase">
|
||||
<Textarea
|
||||
value={compareText}
|
||||
onChange={e => setCompareText(e.target.value)}
|
||||
rows={2}
|
||||
style={{ resize: 'none' }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="ui-compare__grid">
|
||||
<CompareSide
|
||||
accent="var(--color-brand)"
|
||||
label="Voice A"
|
||||
profiles={profiles}
|
||||
value={compareVoiceA}
|
||||
onChange={setCompareVoiceA}
|
||||
audio={compareResultA}
|
||||
/>
|
||||
<CompareSide
|
||||
accent="var(--color-success)"
|
||||
label="Voice B"
|
||||
profiles={profiles}
|
||||
value={compareVoiceB}
|
||||
onChange={setCompareVoiceB}
|
||||
audio={compareResultB}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,43 +26,43 @@ export default class ErrorBoundary extends React.Component {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 32, fontFamily: 'Nunito, Inter, sans-serif',
|
||||
padding: 32, fontFamily: 'var(--font-sans)',
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: 520, width: '100%',
|
||||
padding: 22, textAlign: 'center',
|
||||
background: 'linear-gradient(160deg, rgba(251,73,52,0.08), rgba(47,41,39,0.8))',
|
||||
border: '1px solid rgba(251,73,52,0.25)',
|
||||
borderRadius: '18px 22px 16px 24px / 20px 16px 22px 18px',
|
||||
boxShadow: '0 16px 40px -16px rgba(0,0,0,0.5)',
|
||||
background: 'var(--chrome-bg)',
|
||||
border: '1px solid color-mix(in srgb, var(--chrome-severity-err) 35%, transparent)',
|
||||
borderLeft: '2px solid var(--chrome-severity-err)',
|
||||
borderRadius: 'var(--chrome-radius-pill)',
|
||||
boxShadow: 'none',
|
||||
}}>
|
||||
<AlertCircle size={40} color="#fb4934" style={{ marginBottom: 10 }} />
|
||||
<AlertCircle size={32} color="var(--chrome-severity-err)" style={{ marginBottom: 10 }} />
|
||||
<h2 style={{
|
||||
fontFamily: 'Fraunces, Georgia, serif', fontSize: '1.35rem', fontWeight: 700,
|
||||
color: '#f5e6c5', margin: '0 0 6px',
|
||||
fontFamily: 'var(--font-serif)', fontStyle: 'italic', fontSize: '1.6rem', fontWeight: 400,
|
||||
color: 'var(--chrome-fg)', margin: '0 0 6px', letterSpacing: '-0.01em',
|
||||
}}>
|
||||
This tab hit a snag.
|
||||
</h2>
|
||||
<p style={{ color: '#a89984', fontSize: '0.82rem', margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||
<p style={{ color: 'var(--chrome-fg-muted)', fontSize: '0.82rem', margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||
Don't worry — the rest of the app still works. You can switch tabs, or try again below.
|
||||
</p>
|
||||
<pre style={{
|
||||
textAlign: 'left', fontSize: '0.7rem', color: '#fb4934',
|
||||
background: 'rgba(0,0,0,0.3)', padding: '8px 10px', borderRadius: 8,
|
||||
textAlign: 'left', fontSize: '0.72rem', color: 'var(--chrome-severity-err)',
|
||||
background: 'var(--chrome-hover-bg)', padding: '8px 10px', borderRadius: 'var(--chrome-radius-pill)',
|
||||
border: '1px solid var(--chrome-border)',
|
||||
maxHeight: 140, overflow: 'auto', margin: '0 0 14px',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}>{msg}</pre>
|
||||
<button
|
||||
onClick={this.reset}
|
||||
className="btn-primary"
|
||||
style={{
|
||||
padding: '8px 18px', fontSize: '0.78rem', fontWeight: 800,
|
||||
background: 'rgba(243,165,182,0.18)', color: '#fff9ef',
|
||||
border: '1px solid rgba(243,165,182,0.4)',
|
||||
padding: '6px 14px', fontSize: '0.78rem', fontWeight: 500,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={13} /> Try again
|
||||
<RefreshCw size={12} /> Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
/* ExportModal — non-blocking bottom drawer. Uses --chrome-* tokens so it
|
||||
reads as part of the studio chrome. Slides up from above the LogsFooter
|
||||
and leaves the dub editor interactive behind it (click-outside dismisses,
|
||||
no backdrop wash). */
|
||||
|
||||
.export-drawer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: var(--logs-footer-height, 28px);
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none; /* let clicks pass through to content behind */
|
||||
}
|
||||
.export-drawer__sheet {
|
||||
pointer-events: auto;
|
||||
width: min(880px, calc(100vw - 24px));
|
||||
max-height: min(70vh, 560px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--chrome-bg);
|
||||
border: 1px solid var(--chrome-border-strong);
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
box-shadow:
|
||||
0 -8px 24px rgba(0, 0, 0, 0.45),
|
||||
0 -1px 0 var(--chrome-border) inset;
|
||||
animation: export-drawer-slide-up var(--dur-base, 180ms) var(--ease-out, cubic-bezier(.2,.8,.2,1));
|
||||
overflow: hidden;
|
||||
}
|
||||
@keyframes export-drawer-slide-up {
|
||||
from { transform: translateY(100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.export-drawer__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 6px var(--space-4) 10px;
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.02), transparent);
|
||||
position: relative;
|
||||
}
|
||||
.export-drawer__handle {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 36px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--chrome-border-strong);
|
||||
}
|
||||
.export-drawer__close {
|
||||
margin-left: auto;
|
||||
width: var(--chrome-icon-btn, 22px);
|
||||
height: var(--chrome-icon-btn, 22px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast), color var(--dur-fast), border-color var(--dur-fast);
|
||||
}
|
||||
.export-drawer__close:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
border-color: var(--chrome-border-strong);
|
||||
}
|
||||
|
||||
.export-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.export-modal--drawer {
|
||||
padding: var(--space-3) var(--space-4) var(--space-4);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.export-modal__title-inner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.export-modal__filename {
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--chrome-fg-muted);
|
||||
margin-left: var(--space-2);
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.export-modal__kicker {
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
text-transform: uppercase;
|
||||
color: var(--chrome-fg-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ── Presets ───────────────────────────────────────────────── */
|
||||
.export-modal__presets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
}
|
||||
.export-modal__preset-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-sans);
|
||||
background: transparent;
|
||||
color: var(--chrome-fg-muted);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast), color var(--dur-fast), border-color var(--dur-fast);
|
||||
}
|
||||
.export-modal__preset-chip:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
border-color: var(--chrome-border-strong);
|
||||
}
|
||||
|
||||
/* ── Track row ─────────────────────────────────────────────── */
|
||||
.export-modal__tracks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.export-modal__section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.export-modal__track-quick {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--chrome-fg-dim);
|
||||
}
|
||||
.export-modal__track-quick button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--chrome-fg-muted);
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.export-modal__track-quick button:hover { color: var(--chrome-fg); }
|
||||
|
||||
.export-modal__track-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.export-modal__track {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--dur-fast), color var(--dur-fast), background var(--dur-fast);
|
||||
}
|
||||
.export-modal__track input { margin: 0; accent-color: var(--color-brand); }
|
||||
.export-modal__track.is-on {
|
||||
color: var(--chrome-fg);
|
||||
border-color: var(--chrome-border-strong);
|
||||
background: var(--chrome-hover-bg);
|
||||
}
|
||||
.export-modal__track.is-dub.is-on {
|
||||
border-color: var(--chrome-accent-border);
|
||||
background: var(--chrome-accent-bg);
|
||||
color: var(--chrome-accent);
|
||||
}
|
||||
.export-modal__track-label {
|
||||
font-family: var(--chrome-font-mono);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ── Tabs ──────────────────────────────────────────────────── */
|
||||
.export-modal__tabs {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
}
|
||||
.export-modal__tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--chrome-fg-muted);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
transition: color var(--dur-fast), border-color var(--dur-fast);
|
||||
}
|
||||
.export-modal__tab:hover { color: var(--chrome-fg); }
|
||||
.export-modal__tab.is-active {
|
||||
color: var(--chrome-accent);
|
||||
border-bottom-color: var(--chrome-accent);
|
||||
}
|
||||
|
||||
/* ── Body / fields ─────────────────────────────────────────── */
|
||||
.export-modal__body { min-height: 160px; }
|
||||
|
||||
.export-modal__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-4) var(--space-5);
|
||||
}
|
||||
.export-modal__field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.export-modal__field-head { display: flex; flex-direction: column; gap: 2px; }
|
||||
.export-modal__field-label {
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
text-transform: uppercase;
|
||||
color: var(--chrome-fg-muted);
|
||||
}
|
||||
.export-modal__field-hint {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--chrome-fg-dim);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.export-modal__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--chrome-fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.export-modal__toggle input { accent-color: var(--color-brand); }
|
||||
.export-modal__toggle--indent { margin-left: var(--space-4); color: var(--chrome-fg-muted); }
|
||||
|
||||
.export-modal__mt6 { margin-top: 6px; }
|
||||
|
||||
.export-modal__note {
|
||||
grid-column: 1 / -1;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--chrome-fg-dim);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--chrome-hover-bg);
|
||||
border-left: 2px solid var(--chrome-border-strong);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ── Package tab ───────────────────────────────────────────── */
|
||||
.export-modal__pkg-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.export-modal__pkg-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: var(--chrome-bg);
|
||||
}
|
||||
.export-modal__pkg-card.is-ghost { opacity: 0.75; }
|
||||
.export-modal__pkg-head {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--chrome-fg);
|
||||
font-weight: 500;
|
||||
}
|
||||
.export-modal__pkg-body {
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--chrome-fg-muted);
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Summary footer ────────────────────────────────────────── */
|
||||
.export-modal__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--chrome-border);
|
||||
}
|
||||
.export-modal__summary-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.export-modal__summary-name {
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--chrome-fg);
|
||||
background: var(--chrome-hover-bg);
|
||||
padding: 3px 8px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 340px;
|
||||
}
|
||||
.export-modal__summary-right {
|
||||
display: inline-flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import React, { useMemo, useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Film, Volume2, FileText, Package, Music, Layers, Download,
|
||||
Check, Globe, Zap, X,
|
||||
} from 'lucide-react';
|
||||
import { Button, Segmented, Badge } from '../ui';
|
||||
import './ExportModal.css';
|
||||
|
||||
/**
|
||||
* ExportModal — comprehensive export panel for the dubbing studio.
|
||||
*
|
||||
* Tabs: Video · Audio · Subtitles · Package. Each tab owns a small bundle of
|
||||
* format/track/quality controls. The shared track list at the top lets the
|
||||
* user pick which languages participate in whatever tab they land on — so
|
||||
* "export all dubs as SRT" and "mux these 3 tracks into the MP4" share one
|
||||
* source of truth instead of living as three separate dropdowns.
|
||||
*/
|
||||
const PRESETS = {
|
||||
youtube: { label: 'YouTube', tab: 'video', format: 'mp4', preserveBg: true, burnSubs: false, defaultTrack: 'dub' },
|
||||
archive: { label: 'Archive', tab: 'video', format: 'mp4', preserveBg: true, burnSubs: false, includeAll: true },
|
||||
web: { label: 'Web', tab: 'video', format: 'mp4', preserveBg: true, burnSubs: true, dualSubs: false },
|
||||
podcast: { label: 'Podcast', tab: 'audio', audioFormat: 'mp3', mp3Bitrate: '192', preserveBg: false },
|
||||
studyset: { label: 'Study set',tab: 'subs', subsFormat: 'srt', subsDual: true },
|
||||
};
|
||||
|
||||
export default function ExportModal({
|
||||
open, onClose,
|
||||
jobId, filename, dubTracks, dubLangCode,
|
||||
preserveBg, setPreserveBg,
|
||||
defaultTrack, setDefaultTrack,
|
||||
exportTracks, setExportTracks,
|
||||
dualSubs, setDualSubs,
|
||||
burnSubs, setBurnSubs,
|
||||
API,
|
||||
triggerDownload,
|
||||
handleDubDownload, handleDubAudioDownload, handleAudioExport,
|
||||
segmentCount = 0,
|
||||
}) {
|
||||
const [tab, setTab] = useState('video');
|
||||
|
||||
// ── Tab-local state (not persisted across sessions — each open is fresh).
|
||||
const [videoFormat, setVideoFormat] = useState('mp4'); // future: webm/mov
|
||||
const [audioFormat, setAudioFormat] = useState('wav'); // wav | mp3
|
||||
const [mp3Bitrate, setMp3Bitrate] = useState('192'); // 128/192/256/320
|
||||
const [audioBatch, setAudioBatch] = useState('each'); // each | primary — per-lang or single file
|
||||
const [audioPrimaryLang, setAudioPrimaryLang] = useState(dubLangCode || '');
|
||||
const [subsFormat, setSubsFormat] = useState('srt'); // srt | vtt | both
|
||||
const [subsDual, setSubsDual] = useState(!!dualSubs);
|
||||
const [subsBatch, setSubsBatch] = useState('target'); // target | all-dubs
|
||||
|
||||
// Reflect the parent's dual/burn once, then own them locally so the modal
|
||||
// can toy with them without committing on cancel.
|
||||
useEffect(() => { setSubsDual(!!dualSubs); }, [open, dualSubs]);
|
||||
|
||||
// ── Drawer dismiss — ESC closes; click-outside closes. The drawer is a
|
||||
// bottom sheet (non-blocking), so background interactions stay live.
|
||||
const drawerRef = useRef(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); onClose?.(); } };
|
||||
const onDown = (e) => {
|
||||
if (drawerRef.current && !drawerRef.current.contains(e.target)) onClose?.();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
window.addEventListener('mousedown', onDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('mousedown', onDown);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
const allTracks = useMemo(() => {
|
||||
const out = [{ code: 'original', label: 'Original', kind: 'original' }];
|
||||
(dubTracks || []).forEach(t => out.push({ code: t, label: t.toUpperCase(), kind: 'dub' }));
|
||||
return out;
|
||||
}, [dubTracks]);
|
||||
|
||||
const dubOnlyTracks = useMemo(() => allTracks.filter(t => t.kind === 'dub'), [allTracks]);
|
||||
const selectedTracks = allTracks.filter(t => exportTracks[t.code] !== false);
|
||||
const selectedDubs = selectedTracks.filter(t => t.kind === 'dub');
|
||||
|
||||
const toggleTrack = (code) => setExportTracks(prev => ({ ...prev, [code]: prev[code] === false ? true : false }));
|
||||
const setAllTracks = (on) => setExportTracks(Object.fromEntries(allTracks.map(t => [t.code, on])));
|
||||
const setDubsOnly = () => setExportTracks(Object.fromEntries(allTracks.map(t => [t.code, t.kind === 'dub'])));
|
||||
|
||||
// ── Presets — map label → state deltas and jump to the right tab.
|
||||
const applyPreset = (key) => {
|
||||
const p = PRESETS[key];
|
||||
if (!p) return;
|
||||
setTab(p.tab);
|
||||
if (p.preserveBg !== undefined) setPreserveBg(!!p.preserveBg);
|
||||
if (p.burnSubs !== undefined) setBurnSubs(!!p.burnSubs);
|
||||
if (p.dualSubs !== undefined) setSubsDual(!!p.dualSubs);
|
||||
if (p.audioFormat) setAudioFormat(p.audioFormat);
|
||||
if (p.mp3Bitrate) setMp3Bitrate(p.mp3Bitrate);
|
||||
if (p.subsFormat) setSubsFormat(p.subsFormat);
|
||||
if (p.subsDual !== undefined) setSubsDual(!!p.subsDual);
|
||||
if (p.includeAll) setAllTracks(true);
|
||||
if (p.defaultTrack === 'dub' && dubLangCode) setDefaultTrack(dubLangCode);
|
||||
};
|
||||
|
||||
// ── Filename preview — purely cosmetic, mirrors how the server names files.
|
||||
const baseName = useMemo(() => {
|
||||
const raw = (filename || 'output').replace(/\.[^.]+$/, '');
|
||||
return raw.replace(/[^A-Za-z0-9 _-]/g, '').trim() || 'output';
|
||||
}, [filename]);
|
||||
|
||||
const filenamePreview = (() => {
|
||||
if (tab === 'video') return `dubbed_${baseName}_…mp4`;
|
||||
if (tab === 'audio') {
|
||||
const ext = audioFormat;
|
||||
if (audioBatch === 'each') return `dubbed_<lang>_${baseName}_…${ext} (${selectedDubs.length} files)`;
|
||||
return `dubbed_${audioPrimaryLang || dubLangCode}_${baseName}_…${ext}`;
|
||||
}
|
||||
if (tab === 'subs') {
|
||||
const langs = subsBatch === 'all-dubs' ? selectedDubs.length || 1 : 1;
|
||||
const exts = subsFormat === 'both' ? 'srt+vtt' : subsFormat;
|
||||
return `subtitles${subsDual ? '_dual' : ''}.${exts} (${langs} file${langs === 1 ? '' : 's'})`;
|
||||
}
|
||||
return 'archive.zip';
|
||||
})();
|
||||
|
||||
// ── Validity: what's runnable right now?
|
||||
const canVideo = selectedTracks.length > 0 && (dubTracks || []).length > 0;
|
||||
const canAudio = audioBatch === 'each'
|
||||
? selectedDubs.length > 0
|
||||
: !!audioPrimaryLang && (dubTracks || []).includes(audioPrimaryLang);
|
||||
const canSubs = segmentCount > 0 && (subsBatch !== 'all-dubs' || selectedDubs.length > 0);
|
||||
|
||||
// ── Runners — fire backend calls based on tab. Each returns quickly;
|
||||
// toasts inside triggerDownload keep the user informed.
|
||||
const runVideo = () => {
|
||||
handleDubDownload?.();
|
||||
onClose?.();
|
||||
};
|
||||
const runAudio = () => {
|
||||
const langs = audioBatch === 'each'
|
||||
? selectedDubs.map(t => t.code)
|
||||
: [audioPrimaryLang || dubLangCode];
|
||||
langs.forEach(lang => {
|
||||
if (!lang) return;
|
||||
const q = `preserve_bg=${preserveBg ? 1 : 0}&lang=${encodeURIComponent(lang)}`;
|
||||
if (audioFormat === 'wav') {
|
||||
const url = `${API}/dub/download-audio/${jobId}/dubbed_${lang}.wav?${q}`;
|
||||
handleAudioExport?.(url, `dubbed_${lang}.wav`);
|
||||
} else {
|
||||
const url = `${API}/dub/download-mp3/${jobId}/dubbed_${lang}.mp3?${q}&bitrate=${mp3Bitrate}k`;
|
||||
handleAudioExport?.(url, `dubbed_${lang}.mp3`);
|
||||
}
|
||||
});
|
||||
onClose?.();
|
||||
};
|
||||
const runSubs = () => {
|
||||
const targets = subsBatch === 'all-dubs' ? selectedDubs.map(t => t.code) : [dubLangCode];
|
||||
const formats = subsFormat === 'both' ? ['srt', 'vtt'] : [subsFormat];
|
||||
targets.forEach(lang => {
|
||||
formats.forEach(ext => {
|
||||
const name = `subtitles${subsDual ? '_dual' : ''}_${lang}.${ext}`;
|
||||
const url = `${API}/dub/${ext}/${jobId}/${name}?dual=${subsDual ? 1 : 0}`;
|
||||
triggerDownload?.(url, name);
|
||||
});
|
||||
});
|
||||
onClose?.();
|
||||
};
|
||||
const runStems = () => {
|
||||
handleAudioExport?.(`${API}/dub/export-stems/${jobId}`, 'stems.zip');
|
||||
onClose?.();
|
||||
};
|
||||
const runClips = () => {
|
||||
handleAudioExport?.(`${API}/dub/export-segments/${jobId}`, 'segments.zip');
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const runMap = {
|
||||
video: { fn: runVideo, can: canVideo, label: 'Export MP4' },
|
||||
audio: { fn: runAudio, can: canAudio, label: audioBatch === 'each' ? `Export ${selectedDubs.length} audio file${selectedDubs.length === 1 ? '' : 's'}` : 'Export audio' },
|
||||
subs: { fn: runSubs, can: canSubs, label: 'Export subtitles' },
|
||||
pkg: { fn: null, can: false, label: 'Export' },
|
||||
};
|
||||
const active = runMap[tab];
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="export-drawer" role="dialog" aria-modal="false" aria-label="Export options">
|
||||
<div className="export-drawer__sheet" ref={drawerRef}>
|
||||
<header className="export-drawer__head">
|
||||
<span className="export-drawer__handle" aria-hidden="true" />
|
||||
<span className="export-modal__title-inner">
|
||||
<Download size={13} /> Export
|
||||
{filename && <span className="export-modal__filename">· {filename}</span>}
|
||||
</span>
|
||||
<button type="button" className="export-drawer__close" onClick={onClose} aria-label="Close export drawer">
|
||||
<X size={13} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="export-modal export-modal--drawer">
|
||||
{/* Preset chips */}
|
||||
<div className="export-modal__presets">
|
||||
<span className="export-modal__kicker">PRESETS</span>
|
||||
{Object.entries(PRESETS).map(([k, v]) => (
|
||||
<button key={k} type="button" className="export-modal__preset-chip" onClick={() => applyPreset(k)} title={`Jump to ${v.tab} tab with ${v.label} defaults`}>
|
||||
<Zap size={9} /> {v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Track checklist — shared across tabs */}
|
||||
<div className="export-modal__tracks">
|
||||
<div className="export-modal__section-head">
|
||||
<span className="export-modal__kicker"><Globe size={9} /> TRACKS</span>
|
||||
<div className="export-modal__track-quick">
|
||||
<button type="button" onClick={() => setAllTracks(true)}>All</button>
|
||||
<span>·</span>
|
||||
<button type="button" onClick={() => setAllTracks(false)}>None</button>
|
||||
<span>·</span>
|
||||
<button type="button" onClick={setDubsOnly}>Dubs only</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="export-modal__track-row">
|
||||
{allTracks.map(t => {
|
||||
const on = exportTracks[t.code] !== false;
|
||||
return (
|
||||
<label key={t.code} className={`export-modal__track ${on ? 'is-on' : ''} ${t.kind === 'original' ? 'is-original' : 'is-dub'}`}>
|
||||
<input type="checkbox" checked={on} onChange={() => toggleTrack(t.code)} />
|
||||
<span className="export-modal__track-label">{t.label}</span>
|
||||
{t.kind === 'dub' && t.code === dubLangCode && <Badge tone="brand" size="xs">primary</Badge>}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="export-modal__tabs">
|
||||
<button type="button" className={`export-modal__tab ${tab === 'video' ? 'is-active' : ''}`} onClick={() => setTab('video')}>
|
||||
<Film size={10} /> Video
|
||||
</button>
|
||||
<button type="button" className={`export-modal__tab ${tab === 'audio' ? 'is-active' : ''}`} onClick={() => setTab('audio')}>
|
||||
<Volume2 size={10} /> Audio
|
||||
</button>
|
||||
<button type="button" className={`export-modal__tab ${tab === 'subs' ? 'is-active' : ''}`} onClick={() => setTab('subs')}>
|
||||
<FileText size={10} /> Subtitles
|
||||
</button>
|
||||
<button type="button" className={`export-modal__tab ${tab === 'pkg' ? 'is-active' : ''}`} onClick={() => setTab('pkg')}>
|
||||
<Package size={10} /> Package
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab body */}
|
||||
<div className="export-modal__body">
|
||||
{tab === 'video' && (
|
||||
<div className="export-modal__grid">
|
||||
<Field label="Container">
|
||||
<Segmented size="sm" value={videoFormat} onChange={setVideoFormat} items={[
|
||||
{ value: 'mp4', label: 'MP4 (H.264)' },
|
||||
]} />
|
||||
</Field>
|
||||
<Field label="Default audio track" hint="Which audio stream plays by default when the viewer opens the file">
|
||||
<select className="input-base input-base--xs" value={defaultTrack} onChange={e => setDefaultTrack(e.target.value)}>
|
||||
{exportTracks['original'] !== false && <option value="original">Original</option>}
|
||||
{(dubTracks || []).filter(t => exportTracks[t] !== false).map(t => (
|
||||
<option key={t} value={t}>{t.toUpperCase()} (Dub)</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Background audio">
|
||||
<label className="export-modal__toggle">
|
||||
<input type="checkbox" checked={preserveBg} onChange={e => setPreserveBg(e.target.checked)} />
|
||||
Mix music/FX under every dubbed track
|
||||
</label>
|
||||
</Field>
|
||||
<Field label="Subtitles in video">
|
||||
<label className="export-modal__toggle">
|
||||
<input type="checkbox" checked={burnSubs} onChange={e => setBurnSubs(e.target.checked)} />
|
||||
Burn subtitles into picture (hardsub)
|
||||
</label>
|
||||
{burnSubs && (
|
||||
<label className="export-modal__toggle export-modal__toggle--indent">
|
||||
<input type="checkbox" checked={!!dualSubs} onChange={e => setDualSubs(e.target.checked)} />
|
||||
Dual (translated on top of italicised original)
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'audio' && (
|
||||
<div className="export-modal__grid">
|
||||
<Field label="Format">
|
||||
<Segmented size="sm" value={audioFormat} onChange={setAudioFormat} items={[
|
||||
{ value: 'wav', label: 'WAV (lossless)' },
|
||||
{ value: 'mp3', label: 'MP3 (compressed)' },
|
||||
]} />
|
||||
</Field>
|
||||
{audioFormat === 'mp3' && (
|
||||
<Field label="Bitrate">
|
||||
<Segmented size="sm" value={mp3Bitrate} onChange={setMp3Bitrate} items={[
|
||||
{ value: '128', label: '128k' },
|
||||
{ value: '192', label: '192k' },
|
||||
{ value: '256', label: '256k' },
|
||||
{ value: '320', label: '320k' },
|
||||
]} />
|
||||
</Field>
|
||||
)}
|
||||
<Field label="What to export">
|
||||
<Segmented size="sm" value={audioBatch} onChange={setAudioBatch} items={[
|
||||
{ value: 'each', label: 'Every selected dub (separate files)' },
|
||||
{ value: 'primary', label: 'Single language' },
|
||||
]} />
|
||||
{audioBatch === 'primary' && (
|
||||
<select className="input-base input-base--xs export-modal__mt6"
|
||||
value={audioPrimaryLang} onChange={e => setAudioPrimaryLang(e.target.value)}>
|
||||
{(dubTracks || []).map(t => <option key={t} value={t}>{t.toUpperCase()}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Background audio">
|
||||
<label className="export-modal__toggle">
|
||||
<input type="checkbox" checked={preserveBg} onChange={e => setPreserveBg(e.target.checked)} />
|
||||
Mix music/FX under the dubbed voice
|
||||
</label>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'subs' && (
|
||||
<div className="export-modal__grid">
|
||||
<Field label="Format">
|
||||
<Segmented size="sm" value={subsFormat} onChange={setSubsFormat} items={[
|
||||
{ value: 'srt', label: 'SRT' },
|
||||
{ value: 'vtt', label: 'VTT' },
|
||||
{ value: 'both', label: 'Both' },
|
||||
]} />
|
||||
</Field>
|
||||
<Field label="Layout">
|
||||
<Segmented size="sm" value={subsDual ? 'dual' : 'single'} onChange={v => setSubsDual(v === 'dual')} items={[
|
||||
{ value: 'single', label: 'Single line' },
|
||||
{ value: 'dual', label: 'Dual (translated + original)' },
|
||||
]} />
|
||||
</Field>
|
||||
<Field label="Languages">
|
||||
<Segmented size="sm" value={subsBatch} onChange={setSubsBatch} items={[
|
||||
{ value: 'target', label: `Current target (${dubLangCode || '—'})` },
|
||||
{ value: 'all-dubs', label: `All selected dubs (${selectedDubs.length})` },
|
||||
]} />
|
||||
</Field>
|
||||
<div className="export-modal__note">
|
||||
Subtitles are generated from segment text as-is — edit the segment table before exporting if you spotted typos.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'pkg' && (
|
||||
<div className="export-modal__pkg-grid">
|
||||
<PkgCard
|
||||
icon={<Package size={14} />} title="Per-segment clips (.zip)"
|
||||
body="Every generated segment as a numbered WAV inside a zip — good for review, voice-over post, or dataset building."
|
||||
onClick={runClips} cta="Export clips zip"
|
||||
/>
|
||||
<PkgCard
|
||||
icon={<Layers size={14} />} title="Stems (.zip)"
|
||||
body="Isolated vocal track + background (music/FX) as separate WAVs. Useful for downstream audio editing."
|
||||
onClick={runStems} cta="Export stems zip"
|
||||
/>
|
||||
<PkgCard
|
||||
icon={<Music size={14} />} title="Audio tracks (individual files)"
|
||||
body={`Jump to the Audio tab to export per-language dubs in WAV or MP3 (${(dubTracks || []).length} dub${(dubTracks || []).length === 1 ? '' : 's'} available).`}
|
||||
onClick={() => setTab('audio')} cta="Open audio tab"
|
||||
ghost
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary footer */}
|
||||
<div className="export-modal__summary">
|
||||
<div className="export-modal__summary-left">
|
||||
<span className="export-modal__kicker">OUTPUT</span>
|
||||
<code className="export-modal__summary-name" title={filenamePreview}>{filenamePreview}</code>
|
||||
</div>
|
||||
<div className="export-modal__summary-right">
|
||||
{tab !== 'pkg' && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary" size="sm"
|
||||
onClick={active.fn} disabled={!active.can}
|
||||
leading={<Download size={11} />}
|
||||
title={active.can ? '' : 'Nothing selected or track unavailable'}
|
||||
>
|
||||
{active.label}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{tab === 'pkg' && (
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>Close</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }) {
|
||||
return (
|
||||
<div className="export-modal__field">
|
||||
<div className="export-modal__field-head">
|
||||
<span className="export-modal__field-label">{label}</span>
|
||||
{hint && <span className="export-modal__field-hint">{hint}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PkgCard({ icon, title, body, onClick, cta, ghost = false }) {
|
||||
return (
|
||||
<div className={`export-modal__pkg-card ${ghost ? 'is-ghost' : ''}`}>
|
||||
<div className="export-modal__pkg-head">{icon}<span>{title}</span></div>
|
||||
<p className="export-modal__pkg-body">{body}</p>
|
||||
<Button variant={ghost ? 'subtle' : 'primary'} size="sm" onClick={onClick} leading={ghost ? null : <Check size={10} />}>
|
||||
{cta}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Globe, Fingerprint, Wand2, Film, RefreshCw, Settings2, ChevronRight, Zap } from 'lucide-react';
|
||||
import { Button, Badge, Segmented } from '../ui';
|
||||
import { Globe, Fingerprint, Wand2, Film, FolderOpen, RefreshCw, Settings2, ChevronRight, Zap } from 'lucide-react';
|
||||
import { Button, Badge } from '../ui';
|
||||
|
||||
const VIEW_META = {
|
||||
launchpad: { label: 'Launchpad', Icon: Globe, accent: '#f3a5b6', kicker: 'Studio' },
|
||||
clone: { label: 'Voice Clone', Icon: Fingerprint, accent: '#d3869b', kicker: 'Studio' },
|
||||
design: { label: 'Voice Design', Icon: Wand2, accent: '#8ec07c', kicker: 'Studio' },
|
||||
dub: { label: 'Dubbing', Icon: Film, accent: '#fe8019', kicker: 'Studio' },
|
||||
projects: { label: 'Projects', Icon: FolderOpen, accent: '#83a598', kicker: 'Library' },
|
||||
settings: { label: 'Settings', Icon: Settings2, accent: '#fabd2f', kicker: 'Preferences' },
|
||||
};
|
||||
|
||||
@@ -32,7 +33,7 @@ function WaveBars({ color = '#f3a5b6', active }) {
|
||||
}
|
||||
|
||||
export default function Header({
|
||||
mode, setMode, uiScale, setUiScale, sysStats, modelStatus, doubleClickMaximize,
|
||||
mode, setMode, sysStats, modelStatus, doubleClickMaximize,
|
||||
activeProjectName, onFlushMemory,
|
||||
}) {
|
||||
const [flushing, setFlushing] = useState(false);
|
||||
@@ -93,20 +94,10 @@ export default function Header({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right: wave + UI scale + sys stats */}
|
||||
{/* Right: wave + sys stats. UI scale (S/M/L) lives in the bottom
|
||||
LogsFooter bar so all app-wide chrome sits together. */}
|
||||
<div className="hq-col-right">
|
||||
<WaveBars color={view.accent} active={modelStatus === 'ready' || modelStatus === 'loading'} />
|
||||
<Segmented
|
||||
className="hq-scale"
|
||||
size="xs"
|
||||
value={uiScale}
|
||||
onChange={setUiScale}
|
||||
items={[
|
||||
{ value: 1, label: 'S', title: 'Small UI scale' },
|
||||
{ value: 1.3, label: 'M', title: 'Medium UI scale' },
|
||||
{ value: 1.5, label: 'L', title: 'Large UI scale' },
|
||||
]}
|
||||
/>
|
||||
{sysStats && (
|
||||
<div className="hq-stats">
|
||||
<span><b className="hq-stats__key">RAM</b> {sysStats.ram.toFixed(1)}/{sysStats.total_ram.toFixed(0)}G</span>
|
||||
|
||||
@@ -49,13 +49,13 @@ function Kbd({ children }) {
|
||||
display: 'inline-flex', alignItems: 'center', gap: 2,
|
||||
padding: '2px 8px',
|
||||
minWidth: 28, height: 22,
|
||||
background: 'rgba(0,0,0,0.35)',
|
||||
border: '1px solid rgba(243,165,182,0.25)',
|
||||
borderRadius: 6,
|
||||
color: '#f5e6c5',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
fontSize: '0.68rem', fontWeight: 700,
|
||||
boxShadow: '0 2px 0 rgba(0,0,0,0.25)',
|
||||
background: 'var(--chrome-hover-bg)',
|
||||
border: '1px solid var(--chrome-border-strong)',
|
||||
borderRadius: 'var(--chrome-radius-pill)',
|
||||
color: 'var(--chrome-fg)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem', fontWeight: 500,
|
||||
boxShadow: 'none',
|
||||
}}>
|
||||
{children}
|
||||
</span>
|
||||
@@ -69,9 +69,10 @@ export default function KeyboardCheatsheet({ open, onClose }) {
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'radial-gradient(circle at 30% 20%, rgba(243,165,182,0.08) 0%, rgba(0,0,0,0.82) 60%)',
|
||||
backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)',
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
backdropFilter: 'none', WebkitBackdropFilter: 'none',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
fontFamily: 'var(--font-sans)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -79,24 +80,25 @@ export default function KeyboardCheatsheet({ open, onClose }) {
|
||||
style={{
|
||||
width: 'min(720px, 90vw)', maxHeight: '82vh', overflow: 'auto',
|
||||
padding: 22,
|
||||
background: 'linear-gradient(160deg, rgba(47,41,39,0.98), rgba(38,33,31,0.98))',
|
||||
border: '1px solid rgba(243,165,182,0.2)',
|
||||
borderRadius: '18px 22px 16px 24px / 20px 16px 22px 18px',
|
||||
boxShadow: '0 22px 50px -18px rgba(0,0,0,0.6), 0 0 0 1px rgba(243,165,182,0.08)',
|
||||
background: 'var(--chrome-bg)',
|
||||
border: '1px solid var(--chrome-border-strong)',
|
||||
borderRadius: 'var(--chrome-radius-pill)',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Command size={18} color="#f3a5b6" />
|
||||
<Command size={16} color="var(--chrome-accent)" />
|
||||
<h2 style={{
|
||||
margin: 0, fontFamily: 'Fraunces, Georgia, serif',
|
||||
fontWeight: 700, fontSize: '1.3rem', color: '#f5e6c5',
|
||||
margin: 0, fontFamily: 'var(--font-serif)', fontStyle: 'italic',
|
||||
fontWeight: 400, fontSize: '1.5rem', color: 'var(--chrome-fg)',
|
||||
letterSpacing: '-0.01em',
|
||||
}}>
|
||||
Keyboard shortcuts
|
||||
</h2>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#a89984', cursor: 'pointer' }}>
|
||||
<X size={18} />
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--chrome-fg-muted)', cursor: 'pointer' }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -104,22 +106,23 @@ export default function KeyboardCheatsheet({ open, onClose }) {
|
||||
{SECTIONS.map((sec) => (
|
||||
<div key={sec.title}>
|
||||
<div style={{
|
||||
fontFamily: 'Nunito, sans-serif', fontWeight: 800, fontSize: '0.62rem',
|
||||
textTransform: 'uppercase', letterSpacing: '0.14em',
|
||||
color: '#d3869b', marginBottom: 10, paddingBottom: 6,
|
||||
borderBottom: '1px dashed rgba(243,165,182,0.15)',
|
||||
fontFamily: 'var(--font-mono)', fontWeight: 600,
|
||||
fontSize: 'var(--chrome-label-size)',
|
||||
textTransform: 'uppercase', letterSpacing: 'var(--chrome-label-track)',
|
||||
color: 'var(--chrome-fg-muted)', marginBottom: 10, paddingBottom: 6,
|
||||
borderBottom: '1px solid var(--chrome-border)',
|
||||
}}>{sec.title}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{sec.items.map(([keys, desc]) => (
|
||||
<div key={keys} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, fontFamily: 'Nunito, sans-serif' }}>
|
||||
<span style={{ color: '#a89984', fontSize: '0.78rem' }}>{desc}</span>
|
||||
<div key={keys} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, fontFamily: 'var(--font-sans)' }}>
|
||||
<span style={{ color: 'var(--chrome-fg-muted)', fontSize: '0.8rem' }}>{desc}</span>
|
||||
<span style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
|
||||
{keys.split(' / ').map((group, i, arr) => (
|
||||
<React.Fragment key={group}>
|
||||
<span style={{ display: 'flex', gap: 2 }}>
|
||||
{group.split('+').map((k) => <Kbd key={k}>{k}</Kbd>)}
|
||||
</span>
|
||||
{i < arr.length - 1 && <span style={{ color: '#665c54', alignSelf: 'center', fontSize: '0.7rem' }}>or</span>}
|
||||
{i < arr.length - 1 && <span style={{ color: 'var(--chrome-fg-dim)', alignSelf: 'center', fontSize: '0.7rem' }}>or</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
@@ -130,7 +133,7 @@ export default function KeyboardCheatsheet({ open, onClose }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 18, textAlign: 'center', color: '#6b6657', fontFamily: 'Nunito, sans-serif', fontSize: '0.7rem' }}>
|
||||
<div style={{ marginTop: 18, textAlign: 'center', color: 'var(--chrome-fg-dim)', fontFamily: 'var(--font-sans)', fontSize: '0.72rem' }}>
|
||||
Press <Kbd>?</Kbd> any time to open this.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/* VSCode-style bottom log panel. Fixed to the bottom of the viewport so
|
||||
the main app can freely scroll underneath; we reserve bottom padding
|
||||
via `body { padding-bottom: 28px }` in the global styles (added via
|
||||
the component's mount — see App.jsx). */
|
||||
|
||||
.logs-footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Chrome tokens defined at :root in index.css. The Header topbar
|
||||
uses the same set, so changes here flow to both surfaces. */
|
||||
background: var(--chrome-bg);
|
||||
border-top: 1px solid var(--chrome-border);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
color: var(--chrome-fg);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.logs-footer--collapsed {
|
||||
height: var(--chrome-bar-h);
|
||||
}
|
||||
|
||||
.logs-footer--open {
|
||||
/* height is set inline via React state */
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
/* Drag handle — 4 px strip on the top edge. Cursor + subtle hover
|
||||
hint so it feels grabbable. */
|
||||
.logs-footer__resize {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 5px;
|
||||
cursor: ns-resize;
|
||||
z-index: 1;
|
||||
}
|
||||
.logs-footer__resize:hover {
|
||||
background: rgba(243, 165, 182, 0.35);
|
||||
}
|
||||
|
||||
/* Top bar — always visible */
|
||||
.logs-footer__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logs-footer__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.logs-footer__title {
|
||||
color: #a89984;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-right: 4px;
|
||||
}
|
||||
/* Thin vertical divider between the UI-scale toggle (leftmost) and the
|
||||
Logs title + source pills. Keeps the two distinct control groups from
|
||||
visually running into each other. */
|
||||
.logs-footer__divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
margin: 0 4px;
|
||||
}
|
||||
.logs-footer__scale {
|
||||
margin-right: 2px;
|
||||
}
|
||||
.logs-footer__toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #a89984;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.logs-footer__toggle:hover {
|
||||
color: #ebdbb2;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Source pills with badges */
|
||||
.logs-footer__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
height: 20px;
|
||||
border-radius: 3px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: #a89984;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.logs-footer__pill:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #ebdbb2;
|
||||
}
|
||||
.logs-footer__pill--active {
|
||||
background: rgba(243, 165, 182, 0.12);
|
||||
border-color: rgba(243, 165, 182, 0.35);
|
||||
color: #f3a5b6;
|
||||
}
|
||||
.logs-footer__pill--error {
|
||||
color: #fb4934;
|
||||
}
|
||||
.logs-footer__pill--warn {
|
||||
color: #fabd2f;
|
||||
}
|
||||
.logs-footer__pill-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
.logs-footer__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
padding: 0 5px;
|
||||
height: 14px;
|
||||
border-radius: 7px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--chrome-fg);
|
||||
font-family: var(--chrome-font-mono);
|
||||
}
|
||||
.logs-footer__badge--error {
|
||||
background: rgba(251, 73, 52, 0.2);
|
||||
color: #fb4934;
|
||||
}
|
||||
.logs-footer__badge--warn {
|
||||
background: rgba(250, 189, 47, 0.2);
|
||||
color: #fabd2f;
|
||||
}
|
||||
|
||||
/* Action buttons on the right */
|
||||
.logs-footer__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.logs-footer__icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #a89984;
|
||||
cursor: pointer;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.logs-footer__icon-btn:hover {
|
||||
color: #ebdbb2;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.logs-footer__icon-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.logs-footer__icon-btn--report:hover {
|
||||
color: #fabd2f;
|
||||
}
|
||||
.logs-footer__icon-btn .spinner {
|
||||
animation: logs-spin 1s linear infinite;
|
||||
}
|
||||
@keyframes logs-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Body (scrollable log area) */
|
||||
.logs-footer__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
padding: 4px 8px;
|
||||
user-select: text;
|
||||
}
|
||||
.logs-footer__body::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
.logs-footer__body::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); border-radius: 4px; }
|
||||
.logs-footer__body::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.15); }
|
||||
.logs-footer__empty {
|
||||
padding: 12px;
|
||||
color: var(--chrome-fg-dim);
|
||||
font-style: normal;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logs-footer__line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.logs-footer__line--error {
|
||||
background: rgba(251, 73, 52, 0.06);
|
||||
}
|
||||
.logs-footer__line--warn {
|
||||
background: rgba(250, 189, 47, 0.04);
|
||||
}
|
||||
.logs-footer__line-icon {
|
||||
padding-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logs-footer__line-text {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: #ebdbb2;
|
||||
flex: 1;
|
||||
}
|
||||
.logs-footer__line--error .logs-footer__line-text { color: #fb4934; }
|
||||
.logs-footer__line--warn .logs-footer__line-text { color: #fabd2f; }
|
||||
@@ -0,0 +1,357 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ChevronUp, ChevronDown, RefreshCw, Trash2, Copy, Bug, X,
|
||||
AlertTriangle, AlertCircle, Info, FileText,
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
systemLogs, systemLogsTauri, clearSystemLogs, clearTauriLogs,
|
||||
} from '../api/system';
|
||||
import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer';
|
||||
import { Segmented } from '../ui';
|
||||
import { useAppStore } from '../store';
|
||||
import './LogsFooter.css';
|
||||
|
||||
/**
|
||||
* VSCode-style bottom panel for logs. Always-visible 28 px collapsed bar
|
||||
* shows error/warning counts per source (Backend, Frontend, Tauri); click
|
||||
* any pill or the chevron to expand into a resizable panel. State — which
|
||||
* tab is active, is it collapsed, panel height — persists in localStorage
|
||||
* so the panel remembers where the user left it across launches.
|
||||
*/
|
||||
|
||||
const SOURCES = [
|
||||
{ id: 'backend', label: 'Backend', icon: FileText },
|
||||
{ id: 'frontend', label: 'Frontend', icon: FileText },
|
||||
{ id: 'tauri', label: 'Tauri', icon: FileText },
|
||||
];
|
||||
|
||||
const LS_HEIGHT = 'omnivoice.logs.height';
|
||||
const LS_ACTIVE = 'omnivoice.logs.active';
|
||||
|
||||
const MIN_H = 180;
|
||||
const MAX_H = 720;
|
||||
|
||||
// Severity heuristics. We scan each line for these keywords so the UI
|
||||
// can show badge counts per source without waiting for a structured
|
||||
// logger. Matches word-boundary to avoid false positives on identifiers
|
||||
// like `warning_count` or `error_handler`.
|
||||
const RE_ERROR = /\b(error|fatal|exception|traceback)\b/i;
|
||||
const RE_WARN = /\b(warn(ing)?|deprecated)\b/i;
|
||||
|
||||
function classifyLine(raw) {
|
||||
const text = typeof raw === 'string' ? raw : (raw?.msg ?? String(raw));
|
||||
if (RE_ERROR.test(text)) return 'error';
|
||||
if (RE_WARN.test(text)) return 'warn';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function countLevels(lines) {
|
||||
let error = 0, warn = 0;
|
||||
for (const l of lines) {
|
||||
const sev = classifyLine(l);
|
||||
if (sev === 'error') error++;
|
||||
else if (sev === 'warn') warn++;
|
||||
}
|
||||
return { error, warn, total: lines.length };
|
||||
}
|
||||
|
||||
function formatFrontendLine(entry) {
|
||||
const ts = new Date(entry.t).toISOString().slice(11, 23);
|
||||
return `[${ts}] ${entry.level.toUpperCase()} ${entry.msg}`;
|
||||
}
|
||||
|
||||
function SeverityIcon({ level, size = 11 }) {
|
||||
if (level === 'error') return <AlertCircle size={size} color="#fb4934" />;
|
||||
if (level === 'warn') return <AlertTriangle size={size} color="#fabd2f" />;
|
||||
return <Info size={size} color="#7c6f64" />;
|
||||
}
|
||||
|
||||
function UiScaleToggle() {
|
||||
// Sources the zoom factor directly from the store so no prop-drilling
|
||||
// is required. Same three values the Header previously exposed; moved
|
||||
// here so app-wide chrome lives in one place.
|
||||
const uiScale = useAppStore(s => s.uiScale);
|
||||
const setUiScale = useAppStore(s => s.setUiScale);
|
||||
return (
|
||||
<Segmented
|
||||
className="logs-footer__scale"
|
||||
size="xs"
|
||||
value={uiScale}
|
||||
onChange={setUiScale}
|
||||
items={[
|
||||
{ value: 1, label: 'S', title: 'Small UI scale' },
|
||||
{ value: 1.3, label: 'M', title: 'Medium UI scale' },
|
||||
{ value: 1.5, label: 'L', title: 'Large UI scale' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SourcePill({ source, counts, active, onClick }) {
|
||||
const hasErrors = counts.error > 0;
|
||||
const hasWarns = counts.warn > 0;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
'logs-footer__pill',
|
||||
active ? 'logs-footer__pill--active' : '',
|
||||
hasErrors ? 'logs-footer__pill--error' : hasWarns ? 'logs-footer__pill--warn' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="logs-footer__pill-label">{source.label}</span>
|
||||
{hasErrors && (
|
||||
<span className="logs-footer__badge logs-footer__badge--error">{counts.error}</span>
|
||||
)}
|
||||
{!hasErrors && hasWarns && (
|
||||
<span className="logs-footer__badge logs-footer__badge--warn">{counts.warn}</span>
|
||||
)}
|
||||
{!hasErrors && !hasWarns && counts.total > 0 && (
|
||||
<span className="logs-footer__badge">{counts.total}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LogsFooter() {
|
||||
// Always start collapsed on every launch — per-session toggling works
|
||||
// but nothing persists. Kill the legacy key on the way out so users
|
||||
// who had it stored as "open" before aren't stuck on the next load.
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem('omnivoice.logs.collapsed');
|
||||
}
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [height, setHeight] = useState(() => {
|
||||
const v = Number(localStorage.getItem(LS_HEIGHT));
|
||||
return Number.isFinite(v) && v >= MIN_H && v <= MAX_H ? v : 300;
|
||||
});
|
||||
const [active, setActive] = useState(() => {
|
||||
const v = localStorage.getItem(LS_ACTIVE);
|
||||
return SOURCES.some(s => s.id === v) ? v : 'backend';
|
||||
});
|
||||
|
||||
// Raw log state per source. Backend / Tauri come from HTTP; frontend
|
||||
// comes from the in-process ring buffer in consoleBuffer.js.
|
||||
const [lines, setLines] = useState({ backend: [], frontend: [], tauri: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
useEffect(() => localStorage.setItem(LS_HEIGHT, String(height)), [height]);
|
||||
useEffect(() => localStorage.setItem(LS_ACTIVE, active), [active]);
|
||||
|
||||
// Expose the current footer height as a CSS variable on :root so the
|
||||
// studio's .app-container grid + the setup-wizard wrapper both shrink
|
||||
// by exactly the right amount. Keeps sidebar + main content out from
|
||||
// under the expanded panel without any JS-driven layout math.
|
||||
useEffect(() => {
|
||||
const h = collapsed ? 28 : height;
|
||||
document.documentElement.style.setProperty('--logs-footer-height', `${h}px`);
|
||||
return () => {
|
||||
document.documentElement.style.setProperty('--logs-footer-height', '28px');
|
||||
};
|
||||
}, [collapsed, height]);
|
||||
|
||||
const fetchBackend = useCallback(async () => {
|
||||
try {
|
||||
const r = await systemLogs(300);
|
||||
setLines(prev => ({ ...prev, backend: r.lines || [] }));
|
||||
} catch { /* backend may be warming up — don't spam toasts */ }
|
||||
}, []);
|
||||
|
||||
const fetchTauri = useCallback(async () => {
|
||||
try {
|
||||
const r = await systemLogsTauri(300);
|
||||
setLines(prev => ({ ...prev, tauri: r.lines || [] }));
|
||||
} catch { /* tauri log may not exist in dev */ }
|
||||
}, []);
|
||||
|
||||
const pullFrontend = useCallback(() => {
|
||||
const raw = getFrontendLogs();
|
||||
setLines(prev => ({
|
||||
...prev,
|
||||
frontend: raw.map(formatFrontendLine),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([fetchBackend(), fetchTauri()]);
|
||||
pullFrontend();
|
||||
setLoading(false);
|
||||
}, [fetchBackend, fetchTauri, pullFrontend]);
|
||||
|
||||
// Poll on a slow interval (badges update without user action), faster
|
||||
// when the panel is open + focused on a source.
|
||||
useEffect(() => {
|
||||
refreshAll();
|
||||
const slow = setInterval(refreshAll, collapsed ? 8000 : 3000);
|
||||
return () => clearInterval(slow);
|
||||
}, [refreshAll, collapsed]);
|
||||
|
||||
// Auto-scroll to bottom when new lines arrive and panel is open.
|
||||
useEffect(() => {
|
||||
if (collapsed) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 40;
|
||||
if (atBottom) el.scrollTop = el.scrollHeight;
|
||||
}, [lines, active, collapsed]);
|
||||
|
||||
const counts = useMemo(() => ({
|
||||
backend: countLevels(lines.backend),
|
||||
frontend: countLevels(lines.frontend),
|
||||
tauri: countLevels(lines.tauri),
|
||||
}), [lines]);
|
||||
|
||||
const openTo = (id) => { setActive(id); setCollapsed(false); };
|
||||
|
||||
// ── Resize handle (drag the top edge) ───────────────────────────────
|
||||
const dragRef = useRef(null);
|
||||
const onDragStart = (e) => {
|
||||
e.preventDefault();
|
||||
const startY = e.clientY;
|
||||
const startH = height;
|
||||
const move = (ev) => {
|
||||
const dy = startY - ev.clientY;
|
||||
const next = Math.min(MAX_H, Math.max(MIN_H, startH + dy));
|
||||
setHeight(next);
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener('mousemove', move);
|
||||
window.removeEventListener('mouseup', up);
|
||||
};
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('mouseup', up);
|
||||
};
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────────────
|
||||
const onClear = async () => {
|
||||
try {
|
||||
if (active === 'backend') await clearSystemLogs();
|
||||
else if (active === 'tauri') await clearTauriLogs();
|
||||
else if (active === 'frontend') clearFrontendLogs();
|
||||
setLines(prev => ({ ...prev, [active]: [] }));
|
||||
toast.success(`${active} log cleared`);
|
||||
} catch (e) {
|
||||
toast.error(`Clear failed: ${e?.message || e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onCopy = async () => {
|
||||
try {
|
||||
const raw = (lines[active] || []).join('\n');
|
||||
await navigator.clipboard.writeText(raw);
|
||||
toast.success(`Copied ${active} log`);
|
||||
} catch (e) {
|
||||
toast.error(`Copy failed: ${e?.message || e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onReportIssue = async () => {
|
||||
// Collate a short diagnostic dump — last 80 lines per source + counts
|
||||
// + user agent — onto the clipboard so the user can paste into a
|
||||
// GitHub issue without hand-collecting files.
|
||||
const header = [
|
||||
`OmniVoice Studio — diagnostic report`,
|
||||
`When: ${new Date().toISOString()}`,
|
||||
`UA: ${navigator.userAgent}`,
|
||||
`Counts: backend err=${counts.backend.error}/warn=${counts.backend.warn}, ` +
|
||||
`frontend err=${counts.frontend.error}/warn=${counts.frontend.warn}, ` +
|
||||
`tauri err=${counts.tauri.error}/warn=${counts.tauri.warn}`,
|
||||
'',
|
||||
].join('\n');
|
||||
const body = SOURCES.map(s => {
|
||||
const l = lines[s.id] || [];
|
||||
return `── ${s.label} (last ${Math.min(l.length, 80)} of ${l.length}) ──────────────\n` +
|
||||
l.slice(-80).join('\n');
|
||||
}).join('\n\n');
|
||||
try {
|
||||
await navigator.clipboard.writeText(header + body);
|
||||
toast.success('Diagnostic report copied — paste it into a GitHub issue.');
|
||||
} catch (e) {
|
||||
toast.error(`Report failed: ${e?.message || e}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────
|
||||
const current = lines[active] || [];
|
||||
|
||||
return (
|
||||
<div className={['logs-footer', collapsed ? 'logs-footer--collapsed' : 'logs-footer--open'].join(' ')}
|
||||
style={collapsed ? undefined : { height }}>
|
||||
{!collapsed && (
|
||||
<div
|
||||
ref={dragRef}
|
||||
className="logs-footer__resize"
|
||||
onMouseDown={onDragStart}
|
||||
title="Drag to resize"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="logs-footer__bar">
|
||||
<div className="logs-footer__left">
|
||||
<UiScaleToggle />
|
||||
<span className="logs-footer__divider" />
|
||||
<button
|
||||
type="button"
|
||||
className="logs-footer__toggle"
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
title={collapsed ? 'Expand logs' : 'Collapse logs'}
|
||||
>
|
||||
{collapsed ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
<span className="logs-footer__title">Logs</span>
|
||||
{SOURCES.map(s => (
|
||||
<SourcePill
|
||||
key={s.id}
|
||||
source={s}
|
||||
counts={counts[s.id]}
|
||||
active={!collapsed && active === s.id}
|
||||
onClick={() => (collapsed ? openTo(s.id) : setActive(s.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="logs-footer__actions">
|
||||
<button className="logs-footer__icon-btn" onClick={refreshAll} disabled={loading} title="Refresh">
|
||||
<RefreshCw size={12} className={loading ? 'spinner' : ''} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn" onClick={onCopy} title="Copy visible log">
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn" onClick={onClear} title="Clear">
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn logs-footer__icon-btn--report" onClick={onReportIssue} title="Report issue (copy diagnostic)">
|
||||
<Bug size={12} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn" onClick={() => setCollapsed(true)} title="Close">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div ref={scrollRef} className="logs-footer__body">
|
||||
{current.length === 0 && (
|
||||
<div className="logs-footer__empty">
|
||||
{active === 'frontend' ? 'No frontend console output yet.' : 'No lines.'}
|
||||
</div>
|
||||
)}
|
||||
{current.map((line, i) => {
|
||||
const level = classifyLine(line);
|
||||
return (
|
||||
<div key={i} className={`logs-footer__line logs-footer__line--${level}`}>
|
||||
<span className="logs-footer__line-icon"><SeverityIcon level={level} /></span>
|
||||
<pre className="logs-footer__line-text">{typeof line === 'string' ? line : JSON.stringify(line)}</pre>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Globe, Fingerprint, Wand2, Film, Settings2, ArrowLeftRight,
|
||||
Globe, Fingerprint, Wand2, Film, FolderOpen, Settings2, ArrowLeftRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
const ITEMS = [
|
||||
@@ -8,6 +8,7 @@ const ITEMS = [
|
||||
{ id: 'clone', label: 'Clone', Icon: Fingerprint, accent: '#d3869b' },
|
||||
{ id: 'design', label: 'Design', Icon: Wand2, accent: '#8ec07c' },
|
||||
{ id: 'dub', label: 'Dub', Icon: Film, accent: '#fe8019' },
|
||||
{ id: 'projects', label: 'Projects', Icon: FolderOpen, accent: '#83a598' },
|
||||
];
|
||||
const FOOTER_ITEMS = [
|
||||
{ id: 'settings', label: 'Settings', Icon: Settings2, accent: '#fabd2f' },
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
/* Match chrome — same bg + hairline as top/bottom bars so the sidebar
|
||||
header reads as part of the frame, not a separate panel. */
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
background: var(--chrome-bg);
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -15,23 +17,27 @@
|
||||
|
||||
.sidebar__tab {
|
||||
flex: 1;
|
||||
height: 26px;
|
||||
height: var(--chrome-pill-h);
|
||||
max-width: 60px;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: var(--color-fg-muted);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--chrome-fg-muted);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
transition: border-color var(--dur-base), background var(--dur-base), color var(--dur-base);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
--sidebar-tab-accent: var(--color-brand);
|
||||
}
|
||||
.sidebar__tab:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
.sidebar.is-collapsed .sidebar__tab { max-width: 100%; }
|
||||
.sidebar__tab.is-active {
|
||||
border-color: color-mix(in srgb, var(--sidebar-tab-accent) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--sidebar-tab-accent) 15%, transparent);
|
||||
background: color-mix(in srgb, var(--sidebar-tab-accent) 12%, transparent);
|
||||
color: var(--sidebar-tab-accent);
|
||||
}
|
||||
.sidebar__tab:focus-visible {
|
||||
@@ -57,7 +63,9 @@
|
||||
width: 100%;
|
||||
font-size: var(--text-sm);
|
||||
padding: 4px 26px 4px 22px;
|
||||
border-radius: var(--radius-pill);
|
||||
/* Chrome-radius instead of 999 px pill so the input rhymes with the
|
||||
footer / header pills. Colors come from the base Input primitive. */
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
}
|
||||
.sidebar__search-clear {
|
||||
position: absolute;
|
||||
@@ -71,8 +79,21 @@
|
||||
}
|
||||
|
||||
/* ── Save-project button tint ───────────────────────────────── */
|
||||
.sidebar__save-btn { margin-bottom: var(--space-4); color: var(--color-info); border-color: rgba(131,165,152,0.30); background: rgba(131,165,152,0.15); }
|
||||
.sidebar__save-btn.is-active-project { color: #b8bb26; border-color: rgba(184,187,38,0.35); background: rgba(184,187,38,0.15); }
|
||||
.sidebar__save-btn {
|
||||
margin-bottom: var(--space-4);
|
||||
color: var(--chrome-fg);
|
||||
border: 1px solid var(--chrome-border-strong);
|
||||
background: transparent;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
font-family: var(--font-sans);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.sidebar__save-btn:hover { background: var(--chrome-hover-bg); }
|
||||
.sidebar__save-btn.is-active-project {
|
||||
color: #b8bb26;
|
||||
border-color: color-mix(in srgb, #b8bb26 45%, transparent);
|
||||
background: color-mix(in srgb, #b8bb26 10%, transparent);
|
||||
}
|
||||
.sidebar__save-btn--full { justify-content: center; }
|
||||
|
||||
/* ── Clear-history row ──────────────────────────────────────── */
|
||||
@@ -81,51 +102,64 @@
|
||||
|
||||
/* ── Empty-state placeholders ───────────────────────────────── */
|
||||
.sidebar__empty {
|
||||
color: var(--color-fg-muted);
|
||||
color: var(--chrome-fg-muted);
|
||||
text-align: center;
|
||||
padding: 24px 12px;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.sidebar__empty-icon { opacity: 0.3; margin-bottom: var(--space-4); }
|
||||
.sidebar__empty-title { font-size: var(--text-md); margin: 0 0 var(--space-2); }
|
||||
.sidebar__empty-sub { font-size: var(--text-xs); margin: 0; opacity: 0.6; }
|
||||
.sidebar__empty-icon { opacity: 0.3; margin-bottom: var(--space-4); color: var(--chrome-fg-dim); }
|
||||
.sidebar__empty-title { font-size: 0.82rem; margin: 0 0 var(--space-2); color: var(--chrome-fg); font-weight: 500; letter-spacing: 0.02em; }
|
||||
.sidebar__empty-sub { font-size: 0.7rem; margin: 0; color: var(--chrome-fg-dim); line-height: 1.5; }
|
||||
|
||||
/* Section label — matches the status-bar's "LOGS" uppercase treatment.
|
||||
Labels are displays (not buttons) but this one IS clickable (collapses
|
||||
the section), so the chevron on the right signals interactivity while
|
||||
the text keeps the chrome label typography. */
|
||||
.sidebar__section-title {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-4);
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
text-transform: uppercase;
|
||||
color: var(--chrome-fg-muted);
|
||||
margin-bottom: var(--space-3);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 2px 0;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.sidebar__section-title:hover { color: var(--chrome-fg); }
|
||||
|
||||
/* ── Collapsed-mode hand-drawn icon tiles ───────────────────── */
|
||||
/* ── Collapsed-mode icon tiles — chrome square buttons ────────── */
|
||||
.sidebar__icon-tile {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 12px 16px 10px 18px / 14px 10px 16px 12px;
|
||||
/* Squircle corners dropped in favour of chrome-radius to match the
|
||||
NavRail and footer icon buttons. Same transparent-until-hover
|
||||
treatment since these are interactive. */
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
color: var(--color-fg-muted);
|
||||
transition:
|
||||
transform var(--dur-base) var(--ease-spring),
|
||||
background var(--dur-fast) var(--ease-out),
|
||||
border-color var(--dur-fast) var(--ease-out);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--chrome-fg-muted);
|
||||
transition: background var(--dur-fast) var(--ease-out),
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.sidebar__icon-tile:hover {
|
||||
transform: rotate(0deg) scale(1.08) !important;
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
.sidebar__icon-tile.is-active {
|
||||
background: linear-gradient(140deg, rgba(243, 165, 182, 0.25), rgba(250, 189, 47, 0.15));
|
||||
border-color: rgba(243, 165, 182, 0.4);
|
||||
color: #fff9ef;
|
||||
background: var(--chrome-accent-bg);
|
||||
border-color: var(--chrome-accent-border);
|
||||
color: var(--chrome-accent);
|
||||
}
|
||||
.sidebar__icon-tile__lock {
|
||||
position: absolute;
|
||||
@@ -177,9 +211,9 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
.history-subtitle--seed {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.58rem;
|
||||
color: #6b6657;
|
||||
color: var(--chrome-fg-dim);
|
||||
}
|
||||
|
||||
/* Inline mini-player in generate history */
|
||||
|
||||
+835
-512
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
// Fonts load before tokens so --font-* can resolve immediately (no FOUT).
|
||||
// Inter ships as a single variable file; Source Serif 4 too. Plex Mono has
|
||||
// no variable build so we pull the three weights we use (400/500/600).
|
||||
import '@fontsource-variable/inter'
|
||||
import '@fontsource/ibm-plex-mono/400.css'
|
||||
import '@fontsource/ibm-plex-mono/500.css'
|
||||
import '@fontsource/ibm-plex-mono/600.css'
|
||||
import '@fontsource-variable/source-serif-4'
|
||||
import './ui' // design-system tokens load first so index.css can override if needed
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
.clone-slider-kicker {
|
||||
margin-left: 6px;
|
||||
font-size: 0.58rem;
|
||||
color: #7c6f64;
|
||||
color: var(--chrome-fg-dim);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
+295
-76
@@ -26,23 +26,25 @@
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
padding: 4px 6px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 4px var(--space-3);
|
||||
background: var(--chrome-bg);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
margin-bottom: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dub-preview-toggle__kicker {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-fg-subtle);
|
||||
font-weight: var(--weight-bold);
|
||||
margin-right: var(--space-2);
|
||||
letter-spacing: 0.05em;
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
color: var(--chrome-fg-muted);
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
text-transform: uppercase;
|
||||
margin-right: var(--space-2);
|
||||
}
|
||||
.dub-preview-toggle__hint {
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--color-fg-subtle);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--chrome-label-size);
|
||||
color: var(--chrome-fg-dim);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -157,31 +159,56 @@
|
||||
margin-bottom: var(--space-2);
|
||||
padding: 0 var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-fg-muted);
|
||||
color: var(--chrome-fg-muted);
|
||||
font-family: var(--font-sans);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.dub-outputs-row label { display: flex; align-items: center; gap: var(--space-2); cursor: pointer; }
|
||||
.dub-outputs-row label:hover { color: var(--chrome-fg); }
|
||||
.dub-outputs-row input[type="checkbox"] { accent-color: var(--chrome-accent); }
|
||||
|
||||
/* ── Export track checkbox row ───────────────────────────────── */
|
||||
.dub-tracks-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
padding: 4px 6px;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
padding: 4px var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-fg-muted);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--chrome-fg-muted);
|
||||
font-family: var(--font-sans);
|
||||
background: var(--chrome-bg);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
border: 1px solid var(--chrome-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.dub-tracks-row__title { font-weight: var(--weight-bold); color: var(--color-fg); }
|
||||
.dub-tracks-row label { display: flex; align-items: center; gap: var(--space-2); cursor: pointer; }
|
||||
.dub-tracks-row label.is-on.is-success { color: var(--color-success); }
|
||||
.dub-tracks-row label.is-off { color: var(--color-fg-subtle); }
|
||||
.dub-tracks-row label.is-on { color: var(--color-fg); }
|
||||
.dub-tracks-row label span.code { text-transform: uppercase; }
|
||||
.dub-tracks-row__title {
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
text-transform: uppercase;
|
||||
color: var(--chrome-fg-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.dub-tracks-row label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--dur-fast), background var(--dur-fast), color var(--dur-fast);
|
||||
}
|
||||
.dub-tracks-row label input { accent-color: var(--chrome-accent); }
|
||||
.dub-tracks-row label.is-on.is-success {
|
||||
color: var(--chrome-severity-ok);
|
||||
border-color: color-mix(in srgb, var(--chrome-severity-ok) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--chrome-severity-ok) 10%, transparent);
|
||||
}
|
||||
.dub-tracks-row label.is-off { color: var(--chrome-fg-dim); }
|
||||
.dub-tracks-row label.is-on { color: var(--chrome-fg); border-color: var(--chrome-border-strong); background: var(--chrome-hover-bg); }
|
||||
.dub-tracks-row label span.code { text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
|
||||
/* ── Utility: column flex that fills remaining height ────────── */
|
||||
.dub-col { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
@@ -289,13 +316,66 @@
|
||||
|
||||
.dub-ghost-footer { padding: 4px 8px; flex-shrink: 0; }
|
||||
|
||||
/* Settings bar (right-pane) */
|
||||
/* Settings bar (right-pane) — expanded translation settings.
|
||||
Two-row layout: fields on top (CSS grid so nothing truncates),
|
||||
primary/secondary actions flush-right underneath. Wraps into a
|
||||
container card so it reads as a distinct cluster from the bulk-select
|
||||
and segment table that follow it. */
|
||||
.dub-settings-bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
padding: 7px 9px;
|
||||
background: var(--chrome-bg);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
}
|
||||
.dub-settings-bar__fields {
|
||||
display: grid;
|
||||
/* collapse-btn | Language | ISO | Engine | Quality | Style(flex) */
|
||||
grid-template-columns: auto minmax(120px, 1.1fr) minmax(95px, 0.9fr) minmax(140px, 1.2fr) minmax(155px, 1.1fr) minmax(120px, 1.2fr);
|
||||
gap: 3px 8px;
|
||||
align-items: end;
|
||||
}
|
||||
.dub-settings-bar__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.dub-settings-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0; /* lets <select> shrink inside the grid cell instead of overflowing */
|
||||
}
|
||||
.dub-settings-field .label-row {
|
||||
font-size: 0.62rem;
|
||||
color: var(--color-fg-muted);
|
||||
margin: 0;
|
||||
}
|
||||
.dub-settings-field .input-base,
|
||||
.dub-settings-field .dub-cast__select,
|
||||
.dub-settings-field .dub-engine-select,
|
||||
.dub-settings-field .input-base--xs {
|
||||
width: 100%;
|
||||
font-size: 0.68rem;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.dub-settings-field__hint {
|
||||
font-size: 0.55rem;
|
||||
color: var(--color-fg-subtle);
|
||||
font-style: italic;
|
||||
margin-left: 3px;
|
||||
}
|
||||
/* Quality Segmented is a button group, not an input — let it size itself. */
|
||||
.dub-settings-field--quality > *:last-child { width: 100%; }
|
||||
@media (max-width: 960px) {
|
||||
.dub-settings-bar__fields {
|
||||
grid-template-columns: auto 1fr 1fr;
|
||||
}
|
||||
.dub-settings-field--style { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
/* Collapsed-summary strip that replaces the full grid once language/engine/
|
||||
@@ -304,12 +384,14 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 4px 6px;
|
||||
padding: 4px var(--space-3);
|
||||
margin-bottom: 4px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--chrome-bg);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.68rem;
|
||||
color: var(--chrome-fg-muted);
|
||||
}
|
||||
.dub-settings-summary__trigger {
|
||||
display: inline-flex;
|
||||
@@ -325,9 +407,9 @@
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.dub-settings-summary__trigger strong { color: var(--color-fg); font-weight: 600; }
|
||||
.dub-settings-summary__trigger strong { color: var(--chrome-fg); font-weight: 600; }
|
||||
.dub-settings-summary__style {
|
||||
color: var(--color-fg-subtle);
|
||||
color: var(--chrome-fg-dim);
|
||||
font-style: italic;
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
@@ -335,20 +417,71 @@
|
||||
flex: 0 0 auto;
|
||||
padding: 0 6px;
|
||||
}
|
||||
.dub-engine-select { font-size: 0.65rem; padding: 5px 8px; }
|
||||
.dub-quality-field { flex: none; min-width: 140px; }
|
||||
.dub-engine-select { font-size: 0.68rem; padding: 4px 6px; min-width: 140px; }
|
||||
|
||||
/* Small opt-in below the URL ingest row: toggle YouTube caption pulling. */
|
||||
.dub-ingest-sub-opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
padding: 4px 6px;
|
||||
font-size: 0.62rem;
|
||||
color: var(--color-fg-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.dub-ingest-sub-opt:hover { color: var(--color-fg); background: rgba(255, 255, 255, 0.05); }
|
||||
.dub-ingest-sub-opt input[type="checkbox"] { margin: 0; accent-color: #d3869b; }
|
||||
|
||||
/* Inline "install engine" chip next to the Engine label. Tight + brand-tinted
|
||||
so it reads as an affordance, not a badge. Disappears after a successful
|
||||
install (refreshEngines re-renders). */
|
||||
.dub-engine-install-chip {
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
font-size: 0.55rem;
|
||||
line-height: 1.4;
|
||||
background: rgba(211, 134, 155, 0.14);
|
||||
border: 1px solid rgba(211, 134, 155, 0.35);
|
||||
color: #d3869b;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.dub-engine-install-chip:hover { background: rgba(211, 134, 155, 0.22); }
|
||||
.dub-engine-install-chip:disabled,
|
||||
.dub-engine-install-chip--disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Transcript collapsible + content */
|
||||
.dub-transcript-toggle-wrap { margin-bottom: 4px; }
|
||||
.dub-transcript-toggle__inner { margin-top: 0; padding: 2px 6px; font-size: 0.65rem; }
|
||||
.dub-transcript-toggle__inner {
|
||||
margin-top: 0;
|
||||
padding: 3px var(--space-3);
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
color: var(--chrome-fg-muted);
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
text-transform: uppercase;
|
||||
background: var(--chrome-bg);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
}
|
||||
.dub-transcript-toggle__inner:hover { color: var(--chrome-fg); }
|
||||
.dub-transcript-body {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
background: var(--chrome-bg);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
padding: 6px;
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 0 0 var(--chrome-radius-pill) var(--chrome-radius-pill);
|
||||
padding: var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--chrome-fg-muted);
|
||||
line-height: 1.5;
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
@@ -359,29 +492,48 @@
|
||||
.dub-glossary-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
padding: 3px var(--space-3);
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-fg-muted);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.08);
|
||||
border-radius: 999px;
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
text-transform: uppercase;
|
||||
color: var(--chrome-fg-muted);
|
||||
background: transparent;
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.dub-glossary-chip:hover {
|
||||
background: rgba(184, 187, 38, 0.06);
|
||||
border-color: rgba(184, 187, 38, 0.24);
|
||||
color: var(--color-fg);
|
||||
background: var(--chrome-hover-bg);
|
||||
border-color: var(--chrome-border-strong);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
/* Footer layout */
|
||||
.dub-footer-panel { padding: 4px 8px; flex-shrink: 0; }
|
||||
/* Footer layout — flatten the warm studio-panel backdrop so the chrome
|
||||
action strip below has somewhere neutral to sit. Keep the content
|
||||
padding tight. */
|
||||
.dub-footer-panel {
|
||||
padding: 6px var(--space-4);
|
||||
flex-shrink: 0;
|
||||
background: var(--chrome-bg) !important;
|
||||
border: 1px solid var(--chrome-border) !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
.dub-footer-panel::before { display: none !important; }
|
||||
.dub-footer-btns { display: flex; gap: 4px; }
|
||||
.dub-footer-btns--spaced { margin-top: 4px; }
|
||||
|
||||
/* Output options row value column */
|
||||
.dub-outputs-title-strong { font-weight: 600; color: var(--color-fg); }
|
||||
.dub-outputs-title-strong {
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
text-transform: uppercase;
|
||||
color: var(--chrome-fg-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Bulk-select row controls */
|
||||
.dub-bulk-select { font-size: 0.62rem; padding: 2px 4px; }
|
||||
@@ -427,41 +579,108 @@
|
||||
.dub-gen-overlay__title.is-stopping { color: var(--color-fg-muted); }
|
||||
.dub-gen-overlay__bar { width: 80%; max-width: 240px; }
|
||||
.dub-gen-overlay__text { font-size: 0.65rem; color: var(--color-fg-muted); }
|
||||
.dub-gen-overlay__stats { display: flex; gap: var(--space-5); font-size: 0.68rem; color: var(--color-fg-muted); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ── Cast / speaker-voices strip below waveform ──────────────── */
|
||||
.dub-cast { margin-top: 4px; padding: 3px 6px; background: rgba(255, 255, 255, 0.02); border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.04); }
|
||||
.dub-cast__row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.dub-cast__kicker { font-size: 0.62rem; color: var(--color-fg-muted); font-weight: 600; }
|
||||
.dub-cast__pair { display: flex; align-items: center; gap: 3px; }
|
||||
.dub-cast__label { font-size: 0.62rem; color: var(--color-fg); }
|
||||
.dub-cast {
|
||||
margin-top: 4px;
|
||||
padding: 4px var(--space-3);
|
||||
background: var(--chrome-bg);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
border: 1px solid var(--chrome-border);
|
||||
}
|
||||
.dub-cast__row { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
|
||||
.dub-cast__kicker {
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
color: var(--chrome-fg-muted);
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dub-cast__pair { display: flex; align-items: center; gap: 4px; }
|
||||
.dub-cast__label { font-family: var(--chrome-font-mono); font-size: 0.65rem; color: var(--chrome-fg); }
|
||||
.dub-cast__select { width: 100px; padding: 1px 4px; font-size: 0.62rem; }
|
||||
|
||||
/* Muted placeholder cast strip shown in the idle skeleton */
|
||||
.dub-cast--muted { background: rgba(255, 255, 255, 0.015); border: 1px solid rgba(255, 255, 255, 0.03); }
|
||||
.dub-cast--muted { background: transparent; border-color: var(--chrome-border); }
|
||||
.dub-cast--muted .dub-cast__label,
|
||||
.dub-cast--muted .dub-cast__kicker { color: #504945; }
|
||||
.dub-cast--muted__chip { font-size: 0.62rem; color: #504945; padding: 1px 4px; background: rgba(255, 255, 255, 0.02); border-radius: 2px; }
|
||||
.dub-cast--muted .dub-cast__kicker { color: var(--chrome-fg-dim); }
|
||||
.dub-cast--muted__chip {
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: 0.62rem;
|
||||
color: var(--chrome-fg-dim);
|
||||
padding: 1px 6px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
}
|
||||
|
||||
/* ── Output options row (footer) ─────────────────────────────── */
|
||||
.dub-outputs-title { font-weight: 600; color: var(--color-fg); }
|
||||
.dub-outputs-default { font-size: 0.6rem; padding: 2px 4px; width: 120px; }
|
||||
|
||||
/* ── Footer download buttons ─────────────────────────────────── */
|
||||
/* .btn-primary provides the base layout; tones are applied here. */
|
||||
/* Flat chrome surfaces — color = accent (text+border), not a gradient fill.
|
||||
Matches the status-bar chrome so the footer row reads as one strip instead
|
||||
of a jelly of pastel pills. Tones tint via color-mix so a single formula
|
||||
handles danger/ok/brand/etc consistently. */
|
||||
.dub-footer-btn {
|
||||
margin-top: 0 !important;
|
||||
flex: 1;
|
||||
padding: 4px 8px !important;
|
||||
font-size: var(--text-base) !important;
|
||||
padding: 6px 10px !important;
|
||||
font-family: var(--font-sans) !important;
|
||||
font-size: var(--text-sm) !important;
|
||||
letter-spacing: 0.02em !important;
|
||||
text-transform: none !important;
|
||||
background: transparent !important;
|
||||
border: 1px solid var(--chrome-border-strong) !important;
|
||||
color: var(--chrome-fg) !important;
|
||||
border-radius: var(--chrome-radius-pill) !important;
|
||||
box-shadow: none !important;
|
||||
transition: background var(--dur-fast), border-color var(--dur-fast), color var(--dur-fast) !important;
|
||||
}
|
||||
.dub-footer-btn--sm { padding: 4px 7px !important; font-size: var(--text-xs) !important; }
|
||||
.dub-footer-btn:hover:not(:disabled) { background: var(--chrome-hover-bg) !important; }
|
||||
.dub-footer-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.dub-footer-btn--sm { padding: 4px 8px !important; font-size: var(--text-xs) !important; }
|
||||
|
||||
.dub-footer-btn--idle { opacity: 0.4; }
|
||||
.dub-footer-btn--stopping { background: linear-gradient(135deg, #504945, #3c3836) !important; opacity: 0.8; }
|
||||
.dub-footer-btn--danger { background: linear-gradient(135deg, #fb4934, #cc241d) !important; }
|
||||
.dub-footer-btn--green { background: linear-gradient(135deg, #8ec07c, #689d6a) !important; }
|
||||
.dub-footer-btn--blue { background: linear-gradient(135deg, #83a598, #458588) !important; }
|
||||
.dub-footer-btn--pink { background: linear-gradient(135deg, #d3869b, #b16286) !important; }
|
||||
.dub-footer-btn--lime { background: linear-gradient(135deg, #b8bb26, #98971a) !important; }
|
||||
.dub-footer-btn--amber { background: linear-gradient(135deg, #fabd2f, #d79921) !important; }
|
||||
.dub-footer-btn--orange { background: linear-gradient(135deg, #fe8019, #d65d0e) !important; }
|
||||
.dub-footer-btn--idle { color: var(--chrome-fg-muted) !important; border-color: var(--chrome-border) !important; }
|
||||
.dub-footer-btn--stopping { color: var(--chrome-fg-muted) !important; border-color: var(--chrome-border) !important; }
|
||||
.dub-footer-btn--danger {
|
||||
color: var(--chrome-severity-err) !important;
|
||||
border-color: color-mix(in srgb, var(--chrome-severity-err) 45%, transparent) !important;
|
||||
background: color-mix(in srgb, var(--chrome-severity-err) 10%, transparent) !important;
|
||||
}
|
||||
.dub-footer-btn--danger:hover:not(:disabled) { background: color-mix(in srgb, var(--chrome-severity-err) 18%, transparent) !important; }
|
||||
.dub-footer-btn--green {
|
||||
color: var(--chrome-severity-ok) !important;
|
||||
border-color: color-mix(in srgb, var(--chrome-severity-ok) 45%, transparent) !important;
|
||||
background: color-mix(in srgb, var(--chrome-severity-ok) 10%, transparent) !important;
|
||||
}
|
||||
.dub-footer-btn--green:hover:not(:disabled) { background: color-mix(in srgb, var(--chrome-severity-ok) 18%, transparent) !important; }
|
||||
.dub-footer-btn--pink {
|
||||
color: var(--chrome-accent) !important;
|
||||
border-color: var(--chrome-accent-border) !important;
|
||||
background: var(--chrome-accent-bg) !important;
|
||||
}
|
||||
.dub-footer-btn--pink:hover:not(:disabled) { background: color-mix(in srgb, var(--chrome-accent) 20%, transparent) !important; }
|
||||
.dub-footer-btn--blue {
|
||||
color: #83a598 !important;
|
||||
border-color: color-mix(in srgb, #83a598 45%, transparent) !important;
|
||||
background: color-mix(in srgb, #83a598 10%, transparent) !important;
|
||||
}
|
||||
.dub-footer-btn--lime {
|
||||
color: #b8bb26 !important;
|
||||
border-color: color-mix(in srgb, #b8bb26 45%, transparent) !important;
|
||||
background: color-mix(in srgb, #b8bb26 10%, transparent) !important;
|
||||
}
|
||||
.dub-footer-btn--amber {
|
||||
color: var(--chrome-severity-warn) !important;
|
||||
border-color: color-mix(in srgb, var(--chrome-severity-warn) 45%, transparent) !important;
|
||||
background: color-mix(in srgb, var(--chrome-severity-warn) 10%, transparent) !important;
|
||||
}
|
||||
.dub-footer-btn--orange {
|
||||
color: #fe8019 !important;
|
||||
border-color: color-mix(in srgb, #fe8019 45%, transparent) !important;
|
||||
background: color-mix(in srgb, #fe8019 10%, transparent) !important;
|
||||
}
|
||||
|
||||
+280
-122
@@ -1,7 +1,7 @@
|
||||
import React, { Suspense, lazy, useState } from 'react';
|
||||
import React, { Suspense, lazy, useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
PanelLeftOpen, PanelLeftClose, Film, Save, UploadCloud, Sparkles, Loader, Square,
|
||||
FileText, Play, DownloadIcon, Volume2, Music, Package, Layers, Link2,
|
||||
FileText, Play, DownloadIcon, Volume2, Link2,
|
||||
Languages, ChevronDown, ChevronUp, Wand2, Trash2, Check, Globe, UserSquare2, User, AlertCircle,
|
||||
} from 'lucide-react';
|
||||
// lucide-react exports DownloadIcon as "Download"; alias here to match App.jsx naming.
|
||||
@@ -15,8 +15,11 @@ import { POPULAR_LANGS, POPULAR_ISO, PRESETS } from '../utils/constants';
|
||||
import { LANG_CODES } from '../utils/languages';
|
||||
import { formatTime } from '../utils/format';
|
||||
import { API } from '../api/client';
|
||||
import { Button, Segmented, Badge, Progress, Menu } from '../ui';
|
||||
import { listTranslationEngines, installTranslationEngine } from '../api/engines';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Button, Segmented, Badge, Progress } from '../ui';
|
||||
import GlossaryPanel from '../components/GlossaryPanel';
|
||||
import ExportModal from '../components/ExportModal';
|
||||
import './DubTab.css';
|
||||
|
||||
const DubSegmentTable = lazy(() => import('../components/DubSegmentTable'));
|
||||
@@ -37,7 +40,7 @@ export default function DubTab(props) {
|
||||
segmentPreviewLoading,
|
||||
selectedSegIds,
|
||||
setDubVideoFile, setDubLocalBlobUrl,
|
||||
handleDubAbort, handleDubUpload, handleDubIngestUrl, handleDubStop, handleDubGenerate,
|
||||
handleDubAbort, handleDubUpload, handleDubIngestUrl, handleDubRetryTranscribe, handleDubStop, handleDubGenerate,
|
||||
handleDubDownload, handleDubAudioDownload, handleAudioExport,
|
||||
speakerClones = {},
|
||||
handleSegmentPreview, onDirectSegment, handleTranslateAll, handleCleanupSegments,
|
||||
@@ -82,10 +85,70 @@ export default function DubTab(props) {
|
||||
const setTranslateQuality = useAppStore(s => s.setTranslateQuality);
|
||||
const dualSubs = useAppStore(s => s.dualSubs);
|
||||
const setDualSubs = useAppStore(s => s.setDualSubs);
|
||||
const burnSubs = useAppStore(s => s.burnSubs);
|
||||
const setBurnSubs = useAppStore(s => s.setBurnSubs);
|
||||
|
||||
const showIdleSkeleton = !(dubJobId && (dubStep === 'editing' || dubStep === 'generating' || dubStep === 'done'));
|
||||
const [ingestUrl, setIngestUrl] = useState('');
|
||||
const [previewMode, setPreviewMode] = useState('original'); // 'original' | 'dubbed'
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
|
||||
// Live ETA while generating — elapsed ticks each second; remaining is
|
||||
// extrapolated from the current/total rate so it's only meaningful once
|
||||
// at least one segment has rendered and ~2s of clock has passed.
|
||||
const [genElapsed, setGenElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
if (dubStep !== 'generating') { setGenElapsed(0); return; }
|
||||
const start = Date.now();
|
||||
setGenElapsed(0);
|
||||
const id = setInterval(() => setGenElapsed(Math.floor((Date.now() - start) / 1000)), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [dubStep]);
|
||||
const genRemaining = (() => {
|
||||
if (dubStep !== 'generating') return null;
|
||||
if (!dubProgress.total || !dubProgress.current || genElapsed < 2) return null;
|
||||
const perSeg = genElapsed / dubProgress.current;
|
||||
return Math.max(0, Math.round(perSeg * (dubProgress.total - dubProgress.current)));
|
||||
})();
|
||||
|
||||
// Translation-engine availability → drives the Engine dropdown's disabled
|
||||
// state and the inline Install chip. Lazy-fetched once; refreshed after
|
||||
// any install/uninstall so the chip disappears on success.
|
||||
const [engines, setEngines] = useState([]);
|
||||
const [enginesSandboxed, setEnginesSandboxed] = useState(false);
|
||||
const [engineInstalling, setEngineInstalling] = useState(null); // engine id being installed
|
||||
const refreshEngines = useCallback(async () => {
|
||||
try {
|
||||
const res = await listTranslationEngines();
|
||||
setEngines(res.engines || []);
|
||||
setEnginesSandboxed(!!res.sandboxed);
|
||||
} catch {
|
||||
setEngines([]);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => { refreshEngines(); }, [refreshEngines]);
|
||||
const activeEngineEntry = engines.find(e => e.id === translateProvider);
|
||||
const activeEngineUnavailable = activeEngineEntry && !activeEngineEntry.installed;
|
||||
const handleInstallEngine = async (engineId) => {
|
||||
if (!engineId || enginesSandboxed) return;
|
||||
setEngineInstalling(engineId);
|
||||
const progressToast = toast.loading(`Installing ${engineId}…`);
|
||||
try {
|
||||
const res = await installTranslationEngine(engineId);
|
||||
await refreshEngines();
|
||||
if (res.restart_required) {
|
||||
toast(`${engineId} installed. Restart the backend to load it.`, { icon: '🔄', id: progressToast, duration: 7000 });
|
||||
} else if (res.status === 'already_installed') {
|
||||
toast(`${engineId} was already installed`, { icon: 'ℹ️', id: progressToast });
|
||||
} else {
|
||||
toast.success(`${engineId} installed`, { id: progressToast });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(`Install failed: ${String(err.message || err).slice(0, 200)}`, { id: progressToast, duration: 8000 });
|
||||
} finally {
|
||||
setEngineInstalling(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Collapse secondary settings (Language/ISO/Style/Engine/Quality) into an
|
||||
// accordion. Once the user has translated, the row's job is done; show a
|
||||
@@ -119,9 +182,21 @@ export default function DubTab(props) {
|
||||
return next;
|
||||
});
|
||||
};
|
||||
// Persist the "pull YouTube captions" intent across ingests — it's opt-in
|
||||
// per-URL but almost always on once the user discovers it. Stored on the
|
||||
// component instead of the global store to avoid polluting cross-project
|
||||
// prefs with what's really a per-ingest choice.
|
||||
const [fetchYtSubs, setFetchYtSubs] = useState(false);
|
||||
const onIngestUrl = () => {
|
||||
if (!ingestUrl.trim() || !handleDubIngestUrl) return;
|
||||
handleDubIngestUrl(ingestUrl.trim());
|
||||
handleDubIngestUrl(ingestUrl.trim(), {
|
||||
fetchSubs: fetchYtSubs,
|
||||
// Default to "all" available tracks — YouTube's auto-translator makes
|
||||
// every major language available on demand, so letting yt-dlp grab
|
||||
// them all up-front means switching target language later doesn't
|
||||
// need another round trip.
|
||||
subLangs: fetchYtSubs ? undefined : undefined,
|
||||
});
|
||||
setIngestUrl('');
|
||||
};
|
||||
const hasDubbedTrack = dubStep === 'done' && dubLangCode && dubLangCode !== 'und' && (dubTracks?.length > 0 || !!dubTracks);
|
||||
@@ -159,6 +234,28 @@ export default function DubTab(props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transcription failure banner — shown in the idle state when a
|
||||
job exists but transcription produced zero segments (or threw).
|
||||
Surfaces the backend error detail and offers one-click retry,
|
||||
which re-runs the ASR stream on the same job without re-uploading. */}
|
||||
{dubError && dubJobId && dubStep === 'idle' && (
|
||||
<div className="dub-footer-banner">
|
||||
<Badge tone="danger">
|
||||
<AlertCircle size={11} /> {dubError}
|
||||
</Badge>
|
||||
{handleDubRetryTranscribe && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleDubRetryTranscribe}
|
||||
leading={<Sparkles size={10} />}
|
||||
>
|
||||
Retry transcription
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SPLIT LAYOUT skeleton */}
|
||||
<div className={`dub-split-grid ${dubVideoFile ? 'dub-split-2' : 'dub-split-1'}`}>
|
||||
{/* LEFT */}
|
||||
@@ -242,6 +339,19 @@ export default function DubTab(props) {
|
||||
Ingest
|
||||
</button>
|
||||
</div>
|
||||
<label
|
||||
className="dub-ingest-sub-opt"
|
||||
title="When the URL is a caption-bearing host (YouTube, Vimeo, TED…), also pull the original captions and any YouTube auto-translations. Seeds the editor without running Whisper; skip Translate All for languages YouTube already covers."
|
||||
onClick={e => { e.stopPropagation(); }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fetchYtSubs}
|
||||
onChange={e => setFetchYtSubs(e.target.checked)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
<span>Pull YouTube captions + auto-translations</span>
|
||||
</label>
|
||||
</label>
|
||||
)}
|
||||
|
||||
@@ -401,6 +511,10 @@ export default function DubTab(props) {
|
||||
</div>
|
||||
{dubStep === 'generating' && (
|
||||
<>
|
||||
<div className="dub-gen-overlay__stats">
|
||||
<span>⏱ {fmtDur(genElapsed)} elapsed</span>
|
||||
{genRemaining !== null && <span>~{fmtDur(genRemaining)} remaining</span>}
|
||||
</div>
|
||||
<div className="dub-gen-overlay__bar">
|
||||
<Progress
|
||||
value={dubProgress.total ? (dubProgress.current / dubProgress.total) * 100 : 0}
|
||||
@@ -496,98 +610,127 @@ export default function DubTab(props) {
|
||||
)}
|
||||
{settingsOpen && (
|
||||
<div className="dub-settings-bar">
|
||||
<button
|
||||
type="button"
|
||||
className="dub-settings-summary__trigger dub-settings-close"
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
title="Collapse translation settings"
|
||||
>
|
||||
<ChevronUp size={10} />
|
||||
</button>
|
||||
<div className="dub-skel-field">
|
||||
<div className="label-row"><Globe className="label-icon" size={9} /> Language</div>
|
||||
<select
|
||||
className="input-base dub-cast__select"
|
||||
value={dubLang}
|
||||
onChange={(e) => {
|
||||
const lang = e.target.value;
|
||||
setDubLang(lang);
|
||||
const match = LANG_CODES.find(lc => lc.label.toLowerCase() === lang.toLowerCase());
|
||||
if (match) setDubLangCode(match.code);
|
||||
}}
|
||||
<div className="dub-settings-bar__fields">
|
||||
<button
|
||||
type="button"
|
||||
className="dub-settings-summary__trigger dub-settings-close"
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
title="Collapse translation settings"
|
||||
>
|
||||
<optgroup label="Popular">
|
||||
{POPULAR_LANGS.map(l => <option key={`p-${l}`} value={l}>{l}</option>)}
|
||||
</optgroup>
|
||||
<optgroup label="All languages">
|
||||
{ALL_LANGUAGES
|
||||
.filter(l => !POPULAR_LANGS.includes(l))
|
||||
.map(l => <option key={l} value={l}>{l}</option>)}
|
||||
</optgroup>
|
||||
</select>
|
||||
<ChevronUp size={10} />
|
||||
</button>
|
||||
<div className="dub-settings-field">
|
||||
<div className="label-row"><Globe className="label-icon" size={9} /> Language</div>
|
||||
<select
|
||||
className="input-base dub-cast__select"
|
||||
value={dubLang}
|
||||
onChange={(e) => {
|
||||
const lang = e.target.value;
|
||||
setDubLang(lang);
|
||||
const match = LANG_CODES.find(lc => lc.label.toLowerCase() === lang.toLowerCase());
|
||||
if (match) setDubLangCode(match.code);
|
||||
}}
|
||||
>
|
||||
<optgroup label="Popular">
|
||||
{POPULAR_LANGS.map(l => <option key={`p-${l}`} value={l}>{l}</option>)}
|
||||
</optgroup>
|
||||
<optgroup label="All languages">
|
||||
{ALL_LANGUAGES
|
||||
.filter(l => !POPULAR_LANGS.includes(l))
|
||||
.map(l => <option key={l} value={l}>{l}</option>)}
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div className="dub-settings-field dub-settings-field--iso">
|
||||
<div className="label-row">ISO</div>
|
||||
<select
|
||||
className="input-base dub-cast__select"
|
||||
value={dubLangCode}
|
||||
onChange={(e) => setDubLangCode(e.target.value)}
|
||||
>
|
||||
{LANG_CODES.map(lc => (
|
||||
<option key={lc.code} value={lc.code}>{lc.code} — {lc.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="dub-settings-field">
|
||||
<div className="label-row">
|
||||
Engine
|
||||
{activeEngineUnavailable && !enginesSandboxed && (
|
||||
<button
|
||||
type="button"
|
||||
className="dub-engine-install-chip"
|
||||
onClick={() => handleInstallEngine(translateProvider)}
|
||||
disabled={engineInstalling === translateProvider}
|
||||
title={activeEngineEntry?.notes || 'Install this engine'}
|
||||
>
|
||||
{engineInstalling === translateProvider ? '…installing' : `+ install ${activeEngineEntry?.pip_package || ''}`}
|
||||
</button>
|
||||
)}
|
||||
{activeEngineUnavailable && enginesSandboxed && (
|
||||
<span className="dub-engine-install-chip dub-engine-install-chip--disabled" title="Installs are disabled in packaged builds">
|
||||
needs dev install
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<select className="input-base dub-engine-select" value={translateProvider} onChange={e => setTranslateProvider(e.target.value)}>
|
||||
{(engines.length ? engines : [
|
||||
{ id: 'argos', display_name: 'Argos (Fast Local)', installed: true },
|
||||
{ id: 'nllb', display_name: 'NLLB (Heavy Local)', installed: true },
|
||||
{ id: 'google', display_name: 'Google (Online)', installed: true },
|
||||
{ id: 'openai', display_name: 'OpenAI (LLM)', installed: true },
|
||||
]).map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.installed ? p.display_name : `${p.display_name} — needs install`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="dub-settings-field dub-settings-field--quality">
|
||||
<div className="label-row" title="Cinematic = 3-step LLM refinement (translate → reflect → adapt). Needs an LLM configured.">Quality</div>
|
||||
<Segmented
|
||||
size="sm"
|
||||
value={translateQuality}
|
||||
onChange={setTranslateQuality}
|
||||
items={[
|
||||
{ value: 'fast', label: 'Fast' },
|
||||
{ value: 'cinematic', label: 'Cinematic' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="dub-settings-field dub-settings-field--style">
|
||||
<div className="label-row"><UserSquare2 className="label-icon" size={9} /> Style <span className="dub-settings-field__hint">optional</span></div>
|
||||
<input className="input-base input-base--xs" placeholder="e.g. female" value={dubInstruct} onChange={e => setDubInstruct(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="dub-skel-field--sm">
|
||||
<div className="label-row">ISO Code</div>
|
||||
<select
|
||||
className="input-base dub-cast__select"
|
||||
value={dubLangCode}
|
||||
onChange={(e) => setDubLangCode(e.target.value)}
|
||||
<div className="dub-settings-bar__actions">
|
||||
<Button
|
||||
variant="subtle" size="sm"
|
||||
onClick={() => editSegments(dubSegments.map(s => ({ ...s, text: s.text_original || s.text, translate_error: undefined })))}
|
||||
disabled={!dubSegments.some(s => s.text_original && s.text_original !== s.text)}
|
||||
title="Restore all segments to the original transcribed text"
|
||||
>
|
||||
{LANG_CODES.map(lc => (
|
||||
<option key={lc.code} value={lc.code}>{lc.code} — {lc.label}</option>
|
||||
))}
|
||||
</select>
|
||||
↺ Restore
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle" size="sm"
|
||||
onClick={handleCleanupSegments}
|
||||
disabled={!dubSegments.length || !dubJobId}
|
||||
title="Merge tiny fragments and adjacent short segments"
|
||||
leading={<Wand2 size={10} />}
|
||||
>
|
||||
Clean Up
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary" size="sm"
|
||||
onClick={handleTranslateAll}
|
||||
disabled={isTranslating || !dubSegments.length}
|
||||
loading={isTranslating}
|
||||
leading={!isTranslating && <Languages size={10} />}
|
||||
>
|
||||
{isTranslating ? 'Translating…' : 'Translate All'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="dub-skel-field">
|
||||
<div className="label-row"><UserSquare2 className="label-icon" size={9} /> Style</div>
|
||||
<input className="input-base input-base--xs" placeholder="e.g. female" value={dubInstruct} onChange={e => setDubInstruct(e.target.value)} />
|
||||
</div>
|
||||
<div className="dub-skel-field">
|
||||
<div className="label-row">Engine</div>
|
||||
<select className="input-base dub-engine-select" value={translateProvider} onChange={e => setTranslateProvider(e.target.value)}>
|
||||
{[{ id: 'argos', name: 'Argos (Fast Local)' }, { id: 'nllb', name: 'NLLB (Heavy Local)' }, { id: 'google', name: 'Google (Online)' }, { id: 'openai', name: 'OpenAI (LLM)' }].map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="dub-quality-field">
|
||||
<div className="label-row" title="Cinematic = 3-step LLM refinement (translate → reflect → adapt). Needs an LLM configured.">Quality</div>
|
||||
<Segmented
|
||||
size="sm"
|
||||
value={translateQuality}
|
||||
onChange={setTranslateQuality}
|
||||
items={[
|
||||
{ value: 'fast', label: 'Fast' },
|
||||
{ value: 'cinematic', label: 'Cinematic' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="subtle" size="sm"
|
||||
onClick={handleTranslateAll}
|
||||
disabled={isTranslating || !dubSegments.length}
|
||||
loading={isTranslating}
|
||||
leading={!isTranslating && <Languages size={10} />}
|
||||
>
|
||||
{isTranslating ? 'Translating…' : 'Translate All'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle" size="sm"
|
||||
onClick={() => editSegments(dubSegments.map(s => ({ ...s, text: s.text_original || s.text, translate_error: undefined })))}
|
||||
disabled={!dubSegments.some(s => s.text_original && s.text_original !== s.text)}
|
||||
title="Restore all segments to the original transcribed text"
|
||||
>
|
||||
↺ Restore
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle" size="sm"
|
||||
onClick={handleCleanupSegments}
|
||||
disabled={!dubSegments.length || !dubJobId}
|
||||
title="Merge tiny fragments and adjacent short segments"
|
||||
leading={<Wand2 size={10} />}
|
||||
>
|
||||
Clean Up
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -729,6 +872,9 @@ export default function DubTab(props) {
|
||||
<label title="Export subtitles with translated text on top and original italicised underneath.">
|
||||
<input type="checkbox" checked={!!dualSubs} onChange={e => setDualSubs(e.target.checked)} /> Dual subtitles
|
||||
</label>
|
||||
<label title="Render subtitles directly into the MP4 video stream (hardsubs). Uses the dual-subtitle format when Dual subtitles is on.">
|
||||
<input type="checkbox" checked={!!burnSubs} onChange={e => setBurnSubs(e.target.checked)} /> Burn subtitles
|
||||
</label>
|
||||
<label>
|
||||
Default Track:
|
||||
<select className="input-base dub-outputs-default" value={defaultTrack} onChange={e => setDefaultTrack(e.target.value)}>
|
||||
@@ -775,41 +921,48 @@ export default function DubTab(props) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Menu
|
||||
placement="top-end"
|
||||
<FooterBtn
|
||||
tone={dubStep === 'done' ? 'green' : 'idle'}
|
||||
disabled={dubStep !== 'done' && !dubSegments.length}
|
||||
items={[
|
||||
{ id: 'mp4', label: 'MP4 (video + dubbed audio)', icon: Download, disabled: dubStep !== 'done', onSelect: handleDubDownload },
|
||||
{ id: 'wav', label: 'WAV (audio only)', icon: Volume2, disabled: dubStep !== 'done', onSelect: handleDubAudioDownload },
|
||||
'separator',
|
||||
{ id: 'srt', label: dualSubs ? 'SRT ✦ (dual subtitles)' : 'SRT (subtitles)', icon: FileText, disabled: !dubSegments.length,
|
||||
onSelect: () => triggerDownload(`${API}/dub/srt/${dubJobId}/subtitles.srt?dual=${dualSubs ? 1 : 0}`, dualSubs ? 'subtitles_dual.srt' : 'subtitles.srt') },
|
||||
{ id: 'vtt', label: dualSubs ? 'VTT ✦ (dual subtitles)' : 'VTT (subtitles)', icon: FileText, disabled: !dubSegments.length,
|
||||
onSelect: () => triggerDownload(`${API}/dub/vtt/${dubJobId}/subtitles.vtt?dual=${dualSubs ? 1 : 0}`, dualSubs ? 'subtitles_dual.vtt' : 'subtitles.vtt') },
|
||||
'separator',
|
||||
{ id: 'mp3', label: 'MP3 (compressed audio)', icon: Music, disabled: dubStep !== 'done',
|
||||
onSelect: () => handleAudioExport(`${API}/dub/download-mp3/${dubJobId}/audio.mp3?preserve_bg=${preserveBg}`, 'dubbed_audio.mp3') },
|
||||
{ id: 'clips', label: 'Clips zip (per-segment WAVs)', icon: Package, disabled: dubStep !== 'done',
|
||||
onSelect: () => handleAudioExport(`${API}/dub/export-segments/${dubJobId}`, 'segments.zip') },
|
||||
{ id: 'stems', label: 'Stems zip (vocals + BG)', icon: Layers, disabled: dubStep !== 'done',
|
||||
onSelect: () => handleAudioExport(`${API}/dub/export-stems/${dubJobId}`, 'stems.zip') },
|
||||
]}
|
||||
>
|
||||
<FooterBtn
|
||||
tone={dubStep === 'done' ? 'green' : 'idle'}
|
||||
disabled={dubStep !== 'done' && !dubSegments.length}
|
||||
icon={<Download size={11} />}
|
||||
label="Export ▾"
|
||||
/>
|
||||
</Menu>
|
||||
onClick={() => setExportOpen(true)}
|
||||
icon={<Download size={11} />}
|
||||
label="Export…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ExportModal
|
||||
open={exportOpen}
|
||||
onClose={() => setExportOpen(false)}
|
||||
jobId={dubJobId}
|
||||
filename={dubFilename}
|
||||
dubTracks={dubTracks}
|
||||
dubLangCode={dubLangCode}
|
||||
preserveBg={preserveBg} setPreserveBg={setPreserveBg}
|
||||
defaultTrack={defaultTrack} setDefaultTrack={setDefaultTrack}
|
||||
exportTracks={exportTracks} setExportTracks={setExportTracks}
|
||||
dualSubs={dualSubs} setDualSubs={setDualSubs}
|
||||
burnSubs={burnSubs} setBurnSubs={setBurnSubs}
|
||||
API={API}
|
||||
triggerDownload={triggerDownload}
|
||||
handleDubDownload={handleDubDownload}
|
||||
handleDubAudioDownload={handleDubAudioDownload}
|
||||
handleAudioExport={handleAudioExport}
|
||||
segmentCount={dubSegments.length}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtDur(s) {
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return sec ? `${m}m ${sec}s` : `${m}m`;
|
||||
}
|
||||
|
||||
const PREP_STAGE_LABEL = {
|
||||
download: 'Downloading video…',
|
||||
extract: 'Extracting audio…',
|
||||
@@ -889,8 +1042,13 @@ function TranscribeOverlay({ elapsed, duration, onAbort }) {
|
||||
/**
|
||||
* FooterBtn — the gradient-per-tone download button family in the action footer.
|
||||
* Uses the legacy .btn-primary as the shape/hover base, just picks a tone class.
|
||||
* forwardRef so <Menu> can wire its triggerRef to the underlying button —
|
||||
* without this the Export menu can't compute coords and never opens.
|
||||
*/
|
||||
function FooterBtn({ tone = 'idle', sm = false, disabled, onClick, icon, label }) {
|
||||
const FooterBtn = React.forwardRef(function FooterBtn(
|
||||
{ tone = 'idle', sm = false, disabled, onClick, icon, label, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const cls = [
|
||||
'btn-primary',
|
||||
'dub-footer-btn',
|
||||
@@ -898,8 +1056,8 @@ function FooterBtn({ tone = 'idle', sm = false, disabled, onClick, icon, label }
|
||||
`dub-footer-btn--${tone}`,
|
||||
].filter(Boolean).join(' ');
|
||||
return (
|
||||
<button className={cls} disabled={disabled} onClick={onClick}>
|
||||
<button ref={ref} className={cls} disabled={disabled} onClick={onClick} {...rest}>
|
||||
{icon} {label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -21,17 +21,43 @@ function DubThumb({ jobId, fallback }) {
|
||||
);
|
||||
}
|
||||
|
||||
const Squiggle = () => (
|
||||
<svg className="lp-underline" viewBox="0 0 240 8" preserveAspectRatio="none" aria-hidden="true">
|
||||
<path
|
||||
d="M2 5 Q 20 1 40 4 T 80 4 T 120 3 T 160 5 T 200 3 T 238 4"
|
||||
stroke="#f3a5b6"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
// Squiggle was replaced by the .lp-hero__sweep span — a pure-CSS animated
|
||||
// accent line under the H1. Less static, no SVG dependency.
|
||||
|
||||
/**
|
||||
* ActionCard — the three big Launchpad tiles. Reads its accent from a
|
||||
* single `--card-hue` var so the CSS derives background / border / glow /
|
||||
* spotlight from one hex color. Cursor-tracking spotlight: pointer events
|
||||
* set --mx/--my so `.lp-glow-layer` can paint a radial gradient at the
|
||||
* cursor position. Eternal breath ring lives on `.lp-glow-layer::after`
|
||||
* and pulses forever whether the card is hovered or not.
|
||||
*/
|
||||
function ActionCard({ hue, Icon, title, accent, count, onClick, children }) {
|
||||
const handleMouseMove = (e) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
e.currentTarget.style.setProperty('--mx', `${e.clientX - r.left}px`);
|
||||
e.currentTarget.style.setProperty('--my', `${e.clientY - r.top}px`);
|
||||
};
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="lp-action-card lp-animate lp-glow-card"
|
||||
onClick={onClick}
|
||||
onMouseMove={handleMouseMove}
|
||||
style={{ '--card-hue': hue }}
|
||||
>
|
||||
<span className="lp-glow-layer" aria-hidden="true" />
|
||||
{count > 0 && <span className="card-count">{count}</span>}
|
||||
<div className="card-icon">
|
||||
<Icon size={18} color={hue} />
|
||||
</div>
|
||||
<h3>
|
||||
{title} <span className="lp-action-card__emoji" aria-hidden="true">{accent}</span>
|
||||
</h3>
|
||||
<p className="card-desc">{children}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Launchpad({
|
||||
profiles, studioProjects, dubHistory,
|
||||
@@ -42,52 +68,54 @@ export default function Launchpad({
|
||||
|
||||
return (
|
||||
<div className="launchpad">
|
||||
{/* Ambient backdrop — chrome-accent aurora that drifts forever. Lives
|
||||
behind everything at z=0, contributes the "eternal glow" the user
|
||||
asked for without painting any one surface. */}
|
||||
<div className="lp-aurora" aria-hidden="true">
|
||||
<span className="lp-aurora__blob lp-aurora__blob--pink" />
|
||||
<span className="lp-aurora__blob lp-aurora__blob--green" />
|
||||
<span className="lp-aurora__blob lp-aurora__blob--amber" />
|
||||
</div>
|
||||
|
||||
{/* Hero */}
|
||||
<div className="lp-hero">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 24, flexWrap: 'wrap' }}>
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2px', height: '24px' }}>
|
||||
{[14, 20, 10, 24, 16, 22, 12, 18].map((h, i) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2px', height: '22px' }}>
|
||||
{[10, 14, 8, 16, 12, 14, 9, 12].map((h, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="lp-wave-bar"
|
||||
style={{
|
||||
height: h,
|
||||
background: 'linear-gradient(to top, #f3a5b6, #fabd2f)',
|
||||
animationDelay: `${i * 0.15}s`,
|
||||
opacity: 0.7 + (i % 3) * 0.1,
|
||||
borderRadius: 4,
|
||||
// Per-bar animation offsets + distinct durations give
|
||||
// a breathing, never-identical pulse instead of the
|
||||
// rigid uniform bounce the old version had.
|
||||
'--bar-h': `${h}px`,
|
||||
'--bar-delay': `${i * 0.17}s`,
|
||||
'--bar-dur': `${1.8 + (i % 3) * 0.4}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span style={{ fontFamily: 'Nunito, sans-serif', fontSize: '0.7rem', fontWeight: 800, letterSpacing: '0.14em', textTransform: 'uppercase', color: '#7c6f64' }}>
|
||||
hello there 👋
|
||||
</span>
|
||||
<span className="lp-kicker">hello there</span>
|
||||
</div>
|
||||
<h1>
|
||||
<h1 className="lp-hero__title">
|
||||
<span className="lp-hero__halo" aria-hidden="true" />
|
||||
Make voices that <em>sound like you</em>.
|
||||
<Squiggle />
|
||||
<span className="lp-hero__sweep" aria-hidden="true" />
|
||||
</h1>
|
||||
<p>
|
||||
Clone a voice, design a new one, or dub a video into any of <span className="lp-pill">646 languages</span>.
|
||||
Built for creators who care how it sounds. 🎧
|
||||
Built for creators who care how it sounds.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => setIsCompareModalOpen(true)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '10px 18px', fontSize: '0.78rem', width: 'auto', marginTop: 8,
|
||||
borderRadius: '999px',
|
||||
fontFamily: 'Nunito, sans-serif', fontWeight: 800,
|
||||
flexShrink: 0, transform: 'rotate(1deg)',
|
||||
}}
|
||||
className="lp-ab-compare"
|
||||
title="Try two voices side by side"
|
||||
>
|
||||
<Scale size={14} /> A/B Compare
|
||||
<Scale size={12} /> A/B Compare
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -95,44 +123,15 @@ export default function Launchpad({
|
||||
|
||||
{/* Action Cards */}
|
||||
<div className="lp-actions">
|
||||
<div
|
||||
className="lp-action-card lp-animate"
|
||||
onClick={() => setMode('clone')}
|
||||
style={{ '--card-accent': 'rgba(211,134,155,0.1)', '--card-border': 'rgba(211,134,155,0.25)' }}
|
||||
>
|
||||
{cloneProfiles.length > 0 && <span className="card-count" style={{ background: 'rgba(211,134,155,0.12)', color: '#d3869b' }}>{cloneProfiles.length}</span>}
|
||||
<div className="card-icon" style={{ background: 'rgba(211,134,155,0.1)' }}>
|
||||
<Fingerprint size={18} color="#d3869b" />
|
||||
</div>
|
||||
<h3>Voice Clone ✨</h3>
|
||||
<p className="card-desc">Drop in a short clip — we'll mirror it. One sample is usually enough.</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="lp-action-card lp-animate"
|
||||
onClick={() => setMode('design')}
|
||||
style={{ '--card-accent': 'rgba(142,192,124,0.1)', '--card-border': 'rgba(142,192,124,0.25)' }}
|
||||
>
|
||||
{designProfiles.length > 0 && <span className="card-count" style={{ background: 'rgba(142,192,124,0.12)', color: '#8ec07c' }}>{designProfiles.length}</span>}
|
||||
<div className="card-icon" style={{ background: 'rgba(142,192,124,0.1)' }}>
|
||||
<Wand2 size={18} color="#8ec07c" />
|
||||
</div>
|
||||
<h3>Voice Design 🧪</h3>
|
||||
<p className="card-desc">Build a new voice from a sentence. Gender, age, accent, mood — your call.</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="lp-action-card lp-animate"
|
||||
onClick={() => setMode('dub')}
|
||||
style={{ '--card-accent': 'rgba(254,128,25,0.1)', '--card-border': 'rgba(254,128,25,0.25)' }}
|
||||
>
|
||||
{studioProjects.length > 0 && <span className="card-count" style={{ background: 'rgba(254,128,25,0.12)', color: '#fe8019' }}>{studioProjects.length}</span>}
|
||||
<div className="card-icon" style={{ background: 'rgba(254,128,25,0.1)' }}>
|
||||
<Film size={18} color="#fe8019" />
|
||||
</div>
|
||||
<h3>Video Dubbing 🎬</h3>
|
||||
<p className="card-desc">Transcribe, translate, re-voice. Keep each speaker, line up the timing, ship it.</p>
|
||||
</div>
|
||||
<ActionCard hue="#d3869b" Icon={Fingerprint} title="Voice Clone" accent="✨" count={cloneProfiles.length} onClick={() => setMode('clone')}>
|
||||
Drop in a short clip — we'll mirror it. One sample is usually enough.
|
||||
</ActionCard>
|
||||
<ActionCard hue="#8ec07c" Icon={Wand2} title="Voice Design" accent="🧪" count={designProfiles.length} onClick={() => setMode('design')}>
|
||||
Build a new voice from a sentence. Gender, age, accent, mood — your call.
|
||||
</ActionCard>
|
||||
<ActionCard hue="#fe8019" Icon={Film} title="Video Dubbing" accent="🎬" count={studioProjects.length} onClick={() => setMode('dub')}>
|
||||
Transcribe, translate, re-voice. Keep each speaker, line up the timing, ship it.
|
||||
</ActionCard>
|
||||
</div>
|
||||
|
||||
{/* Recent Projects */}
|
||||
@@ -172,7 +171,16 @@ export default function Launchpad({
|
||||
<div className="proj-name">{p.name}</div>
|
||||
<div className="proj-meta" style={{ fontStyle: 'italic' }}>{p.instruct}</div>
|
||||
</div>
|
||||
{p.is_locked && <span style={{ fontSize: '0.5rem', padding: '1px 6px', borderRadius: 4, background: 'rgba(184,187,38,0.12)', color: '#b8bb26', fontWeight: 600 }}>LOCKED</span>}
|
||||
{p.is_locked && <span style={{
|
||||
fontFamily: 'var(--chrome-font-mono)',
|
||||
fontSize: 'var(--chrome-label-size)',
|
||||
letterSpacing: 'var(--chrome-label-track)',
|
||||
padding: '1px 7px',
|
||||
borderRadius: 'var(--chrome-radius-pill)',
|
||||
background: 'color-mix(in srgb, #b8bb26 10%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, #b8bb26 40%, transparent)',
|
||||
color: '#b8bb26', fontWeight: 600,
|
||||
}}>LOCKED</span>}
|
||||
<button className="proj-action" onClick={() => { setMode('design'); handleSelectProfile(p); }}>Open</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -222,8 +230,13 @@ export default function Launchpad({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ fontFamily: 'Fraunces, Georgia, serif', fontSize: '1rem', color: '#a89984', margin: 0, fontStyle: 'italic' }}>
|
||||
Nothing here yet. Pick a card above — we'll wait. ☕
|
||||
<p style={{
|
||||
fontFamily: 'var(--chrome-font-mono)',
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--chrome-fg-muted)',
|
||||
margin: 0,
|
||||
}}>
|
||||
Nothing here yet — pick a card above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/* Projects page — browse all project types in one place. Uses the same
|
||||
chrome tokens as the top/bottom bars so the page frame reads as a
|
||||
continuation of the studio shell. */
|
||||
|
||||
.projects {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Header strip — title + search + view toggle ────────────────── */
|
||||
.projects__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 18px;
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
background: var(--chrome-bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.projects__title {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--chrome-fg);
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.projects__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.projects__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: var(--chrome-pill-h);
|
||||
padding: 0 10px;
|
||||
background: var(--chrome-hover-bg);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
color: var(--chrome-fg-muted);
|
||||
flex: 1;
|
||||
max-width: 420px;
|
||||
}
|
||||
.projects__search input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--chrome-fg);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.projects__search input::placeholder { color: var(--chrome-fg-dim); }
|
||||
|
||||
.projects__view-toggle {
|
||||
display: inline-flex;
|
||||
gap: 1px;
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
overflow: hidden;
|
||||
background: var(--chrome-hover-bg);
|
||||
}
|
||||
.projects__view-toggle button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
width: 26px;
|
||||
height: var(--chrome-pill-h);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.projects__view-toggle button:hover { color: var(--chrome-fg); }
|
||||
.projects__view-toggle button.is-active {
|
||||
background: var(--chrome-accent-bg);
|
||||
color: var(--chrome-accent);
|
||||
}
|
||||
|
||||
/* ── Body: filter rail + content pane ──────────────────────────── */
|
||||
.projects__body {
|
||||
display: grid;
|
||||
grid-template-columns: 200px minmax(0, 1fr);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.projects__rail {
|
||||
border-right: 1px solid var(--chrome-border);
|
||||
background: var(--chrome-bg);
|
||||
padding: 10px 8px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.projects__rail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--chrome-fg-muted);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
text-align: left;
|
||||
}
|
||||
.projects__rail-item span:first-of-type { flex: 1; }
|
||||
.projects__rail-item:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
.projects__rail-item.is-active {
|
||||
background: var(--chrome-accent-bg);
|
||||
border-color: var(--chrome-accent-border);
|
||||
color: var(--chrome-accent);
|
||||
}
|
||||
.projects__rail-count {
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--chrome-fg-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.projects__rail-item.is-active .projects__rail-count { color: var(--chrome-accent); }
|
||||
|
||||
/* ── Content: card grid / list view ────────────────────────────── */
|
||||
.projects__content {
|
||||
padding: 14px 18px;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.projects__content--grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
.projects__content--list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.projects__empty {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 48px 16px;
|
||||
color: var(--chrome-fg-dim);
|
||||
text-align: center;
|
||||
}
|
||||
.projects__empty p { max-width: 380px; margin: 0; font-size: 0.82rem; line-height: 1.5; }
|
||||
|
||||
/* ── Cards ─────────────────────────────────────────────────────── */
|
||||
.projects__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
background: var(--chrome-bg);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-left: 3px solid var(--card-accent, var(--chrome-accent));
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
color: var(--chrome-fg);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.12s, background 0.12s, transform 0.12s;
|
||||
}
|
||||
.projects__card:hover {
|
||||
border-color: var(--chrome-border-strong);
|
||||
background: color-mix(in srgb, var(--card-accent, var(--chrome-accent)) 5%, var(--chrome-bg));
|
||||
}
|
||||
.projects__card:active { transform: translateY(1px); }
|
||||
|
||||
.projects__card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.projects__card-kind {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
color: var(--card-accent, var(--chrome-accent));
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.projects__card-trailing {
|
||||
color: var(--chrome-fg-dim);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
.projects__card-time {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-family: var(--chrome-font-mono);
|
||||
}
|
||||
.projects__card-title {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--chrome-fg);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.projects__card-sub {
|
||||
font-size: 0.74rem;
|
||||
color: var(--chrome-fg-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* List-view variant — same card rendered flatter */
|
||||
.projects__content--list .projects__card {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
gap: 14px;
|
||||
}
|
||||
.projects__content--list .projects__card-head { flex-shrink: 0; width: 110px; }
|
||||
.projects__content--list .projects__card-title { flex: 1; }
|
||||
.projects__content--list .projects__card-sub { flex-shrink: 0; width: 120px; }
|
||||
@@ -0,0 +1,241 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Search, FolderOpen, Film, Fingerprint, Wand2, Music, Download,
|
||||
LayoutGrid, List as ListIcon, Clock,
|
||||
} from 'lucide-react';
|
||||
import './Projects.css';
|
||||
|
||||
/**
|
||||
* Projects — browse everything (studio dubs, voice profiles, generation
|
||||
* history, exports) in one place.
|
||||
*
|
||||
* Shape:
|
||||
* ┌─────────────────────────────────────────────┐
|
||||
* │ header strip (search + view toggle) │
|
||||
* ├─ filter rail ─┬── content grid/list ────────┤
|
||||
* │ All │ │
|
||||
* │ Dubs (3) │ [card] [card] [card] │
|
||||
* │ Profiles (12) │ [card] [card] ... │
|
||||
* │ History (48) │ │
|
||||
* │ Exports (7) │ │
|
||||
* └───────────────┴──────────────────────────────┘
|
||||
*
|
||||
* Props reuse what App.jsx already loads — no new fetchers are added so
|
||||
* this page stays in sync with the Sidebar and Launchpad automatically.
|
||||
*/
|
||||
|
||||
const FILTERS = [
|
||||
{ id: 'all', label: 'All', Icon: FolderOpen },
|
||||
{ id: 'dubs', label: 'Dub Projects', Icon: Film },
|
||||
{ id: 'profiles', label: 'Voice Profiles', Icon: Fingerprint },
|
||||
{ id: 'history', label: 'History', Icon: Music },
|
||||
{ id: 'exports', label: 'Exports', Icon: Download },
|
||||
];
|
||||
|
||||
function fmtTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = typeof ts === 'number' ? ts : Date.parse(ts);
|
||||
if (!Number.isFinite(d)) return '';
|
||||
const diff = Date.now() - d;
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||||
return `${Math.floor(s / 86400)}d ago`;
|
||||
}
|
||||
|
||||
function fmtDuration(sec) {
|
||||
if (!sec) return '';
|
||||
const n = Number(sec);
|
||||
if (!Number.isFinite(n)) return '';
|
||||
if (n < 60) return `${Math.floor(n)}s`;
|
||||
const m = Math.floor(n / 60);
|
||||
const s = Math.floor(n % 60);
|
||||
return `${m}m ${s}s`;
|
||||
}
|
||||
|
||||
function Card({ kind, accent, title, subtitle, trailing, onClick, IconC }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="projects__card"
|
||||
onClick={onClick}
|
||||
style={{ '--card-accent': accent }}
|
||||
>
|
||||
<div className="projects__card-head">
|
||||
<span className="projects__card-kind">
|
||||
{IconC && <IconC size={11} />}
|
||||
{kind}
|
||||
</span>
|
||||
<span className="projects__card-trailing">{trailing}</span>
|
||||
</div>
|
||||
<div className="projects__card-title" title={title}>{title}</div>
|
||||
{subtitle && <div className="projects__card-sub" title={subtitle}>{subtitle}</div>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Projects({
|
||||
studioProjects = [],
|
||||
profiles = [],
|
||||
history = [],
|
||||
exportHistory = [],
|
||||
onOpenDub, // (projectId) => void — loads project + switches to dub mode
|
||||
onOpenProfile, // (voiceId) => void
|
||||
onRevealExport, // (path) => void
|
||||
}) {
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [query, setQuery] = useState('');
|
||||
const [view, setView] = useState('grid'); // grid | list
|
||||
|
||||
// Normalise every source into a common shape so the filter + search +
|
||||
// sort pipeline is identical regardless of origin.
|
||||
const items = useMemo(() => {
|
||||
const list = [];
|
||||
for (const p of studioProjects) {
|
||||
list.push({
|
||||
type: 'dubs',
|
||||
id: p.id,
|
||||
title: p.name || p.video_path?.split('/').pop() || p.id,
|
||||
subtitle: fmtDuration(p.duration),
|
||||
ts: (p.updated_at || p.created_at || 0) * 1000,
|
||||
accent: '#fe8019',
|
||||
Icon: Film,
|
||||
onClick: () => onOpenDub?.(p.id),
|
||||
});
|
||||
}
|
||||
for (const pr of profiles) {
|
||||
const kind = pr.kind || 'clone';
|
||||
list.push({
|
||||
type: 'profiles',
|
||||
id: pr.id,
|
||||
title: pr.name || pr.id,
|
||||
subtitle: kind === 'design' ? 'Designed voice' : 'Cloned voice',
|
||||
ts: (pr.updated_at || pr.created_at || 0) * 1000,
|
||||
accent: kind === 'design' ? '#8ec07c' : '#d3869b',
|
||||
Icon: kind === 'design' ? Wand2 : Fingerprint,
|
||||
onClick: () => onOpenProfile?.(pr.id),
|
||||
});
|
||||
}
|
||||
for (const h of history) {
|
||||
list.push({
|
||||
type: 'history',
|
||||
id: h.filename || h.id || String(Math.random()),
|
||||
title: (h.text || h.prompt || h.filename || 'Generated audio').slice(0, 80),
|
||||
subtitle: h.language || h.voice || '',
|
||||
ts: h.timestamp || h.created_at || 0,
|
||||
accent: '#f3a5b6',
|
||||
Icon: Music,
|
||||
onClick: undefined,
|
||||
});
|
||||
}
|
||||
for (const e of exportHistory) {
|
||||
list.push({
|
||||
type: 'exports',
|
||||
id: e.path || e.id,
|
||||
title: e.path?.split('/').pop() || e.filename || 'Export',
|
||||
subtitle: e.mode || '',
|
||||
ts: (e.created_at || 0) * 1000,
|
||||
accent: '#fabd2f',
|
||||
Icon: Download,
|
||||
onClick: () => e.path && onRevealExport?.(e.path),
|
||||
});
|
||||
}
|
||||
list.sort((a, b) => (b.ts || 0) - (a.ts || 0));
|
||||
return list;
|
||||
}, [studioProjects, profiles, history, exportHistory, onOpenDub, onOpenProfile, onRevealExport]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c = { all: items.length };
|
||||
for (const it of items) c[it.type] = (c[it.type] || 0) + 1;
|
||||
return c;
|
||||
}, [items]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return items.filter(it => {
|
||||
if (filter !== 'all' && it.type !== filter) return false;
|
||||
if (!q) return true;
|
||||
return (it.title + ' ' + (it.subtitle || '')).toLowerCase().includes(q);
|
||||
});
|
||||
}, [items, filter, query]);
|
||||
|
||||
return (
|
||||
<div className="projects">
|
||||
<div className="projects__header">
|
||||
<h1 className="projects__title">Projects</h1>
|
||||
<div className="projects__toolbar">
|
||||
<div className="projects__search">
|
||||
<Search size={12} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search projects, profiles, history, exports…"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="projects__view-toggle">
|
||||
<button
|
||||
className={view === 'grid' ? 'is-active' : ''}
|
||||
onClick={() => setView('grid')}
|
||||
title="Card grid"
|
||||
type="button"
|
||||
>
|
||||
<LayoutGrid size={12} />
|
||||
</button>
|
||||
<button
|
||||
className={view === 'list' ? 'is-active' : ''}
|
||||
onClick={() => setView('list')}
|
||||
title="List"
|
||||
type="button"
|
||||
>
|
||||
<ListIcon size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="projects__body">
|
||||
<aside className="projects__rail">
|
||||
{FILTERS.map(f => {
|
||||
const FI = f.Icon;
|
||||
const n = counts[f.id] ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
className={`projects__rail-item ${filter === f.id ? 'is-active' : ''}`}
|
||||
onClick={() => setFilter(f.id)}
|
||||
>
|
||||
<FI size={12} />
|
||||
<span>{f.label}</span>
|
||||
<span className="projects__rail-count">{n}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</aside>
|
||||
|
||||
<section className={`projects__content projects__content--${view}`}>
|
||||
{visible.length === 0 && (
|
||||
<div className="projects__empty">
|
||||
<FolderOpen size={28} />
|
||||
<p>{query ? `No matches for “${query}”` : 'Nothing here yet. Start a dub, design a voice, or generate audio to see it appear.'}</p>
|
||||
</div>
|
||||
)}
|
||||
{visible.map(it => (
|
||||
<Card
|
||||
key={`${it.type}:${it.id}`}
|
||||
kind={it.type.toUpperCase()}
|
||||
accent={it.accent}
|
||||
title={it.title}
|
||||
subtitle={it.subtitle}
|
||||
trailing={<span className="projects__card-time"><Clock size={10} />{fmtTime(it.ts)}</span>}
|
||||
onClick={it.onClick}
|
||||
IconC={it.Icon}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+156
-20
@@ -1,9 +1,24 @@
|
||||
/* Settings page — page-specific styles layered over the design system. */
|
||||
/* Settings page — page-specific styles layered over the design system.
|
||||
Chrome tokens only: every surface here should rhyme with the status bar. */
|
||||
|
||||
.settings-row__mono { font-family: var(--font-mono); }
|
||||
.settings-muted { color: var(--color-fg-subtle); font-size: var(--text-md); }
|
||||
.settings-prose { margin: 0 0 var(--space-5); color: var(--color-fg-muted); line-height: 1.6; font-size: var(--text-lg); }
|
||||
.settings-prose strong { color: #f5e6c5; }
|
||||
/* Tighter page chrome — reclaim whitespace around title and section padding. */
|
||||
.settings-page { padding: 18px 28px 32px; }
|
||||
.settings-page h1 { font-size: 1.15rem; margin: 0 0 2px; }
|
||||
.settings-page .settings-subtitle{ font-size: 0.72rem; margin-bottom: 14px; }
|
||||
.settings-tabs-ui { margin-bottom: var(--space-3); }
|
||||
|
||||
.settings-section--compact { padding: 10px 12px; margin-bottom: 10px; }
|
||||
|
||||
.settings-row__mono { font-family: var(--chrome-font-mono); color: var(--chrome-fg-muted); }
|
||||
.settings-muted { color: var(--chrome-fg-dim); font-size: var(--text-md); font-family: var(--font-sans); }
|
||||
.settings-prose {
|
||||
margin: 0 0 var(--space-5);
|
||||
color: var(--chrome-fg-muted);
|
||||
line-height: 1.6;
|
||||
font-size: 0.82rem;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.settings-prose strong { color: var(--chrome-fg); font-weight: 600; }
|
||||
|
||||
.settings-section__head-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); }
|
||||
.settings-section__head-left { display: flex; align-items: center; gap: var(--space-4); }
|
||||
@@ -14,14 +29,14 @@
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-fg-subtle);
|
||||
color: var(--chrome-fg-dim);
|
||||
margin: var(--space-4) 0;
|
||||
font-family: var(--font-mono);
|
||||
font-family: var(--chrome-font-mono);
|
||||
}
|
||||
|
||||
.settings-log__empty {
|
||||
color: var(--color-fg-subtle);
|
||||
font-style: italic;
|
||||
color: var(--chrome-fg-dim);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.settings-link-row {
|
||||
@@ -38,8 +53,14 @@
|
||||
|
||||
/* ── Engines tab ─────────────────────────────────────────────── */
|
||||
.engines-family { margin-bottom: var(--space-5); }
|
||||
.engines-family__active { margin-left: var(--space-4); font-weight: var(--weight-regular); color: var(--color-fg-subtle); font-size: var(--text-xs); }
|
||||
.engines-family__active code { color: var(--color-fg); }
|
||||
.engines-family__active {
|
||||
margin-left: var(--space-4);
|
||||
font-weight: 400;
|
||||
color: var(--chrome-fg-dim);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--chrome-font-mono);
|
||||
}
|
||||
.engines-family__active code { color: var(--chrome-fg); }
|
||||
|
||||
.engines-list {
|
||||
list-style: none;
|
||||
@@ -54,14 +75,129 @@
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-left: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-md);
|
||||
background: transparent;
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-left: 2px solid var(--chrome-border-strong);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.78rem;
|
||||
color: var(--chrome-fg-muted);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.engines-list li.is-ok { border-left-color: var(--color-success); }
|
||||
.engines-list li.is-off { border-left-color: var(--color-warn); opacity: 0.8; }
|
||||
.engines-list__name { flex: 1; min-width: 240px; color: var(--color-fg); }
|
||||
.engines-list__name code { color: var(--color-fg); font-family: var(--font-mono); font-size: var(--text-sm); }
|
||||
.engines-list__reason { flex-basis: 100%; font-size: var(--text-xs); color: var(--color-fg-subtle); font-style: italic; padding-left: var(--space-4); }
|
||||
.engines-list li.is-ok {
|
||||
border-left-color: var(--chrome-severity-ok);
|
||||
background: color-mix(in srgb, var(--chrome-severity-ok) 5%, transparent);
|
||||
}
|
||||
.engines-list li.is-off {
|
||||
border-left-color: var(--chrome-severity-warn);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.engines-list__name { flex: 1; min-width: 240px; color: var(--chrome-fg); }
|
||||
.engines-list__name code { color: var(--chrome-fg); font-family: var(--chrome-font-mono); font-size: var(--text-sm); }
|
||||
.engines-list__reason { flex-basis: 100%; font-size: var(--text-xs); color: var(--chrome-fg-dim); font-family: var(--font-sans); padding-left: var(--space-4); }
|
||||
|
||||
/* ── Models tab ──────────────────────────────────────────────── */
|
||||
.reco-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 6px var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
border: 1px solid color-mix(in srgb, #8ec07c 30%, transparent);
|
||||
border-left: 2px solid #8ec07c;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: color-mix(in srgb, #8ec07c 5%, transparent);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--chrome-fg-muted);
|
||||
}
|
||||
.reco-banner__text { flex: 1; }
|
||||
.reco-banner__text strong { color: var(--chrome-fg); font-weight: 600; }
|
||||
.reco-banner__size { color: var(--chrome-fg-dim); font-size: var(--text-xs); }
|
||||
|
||||
.models-row__tag {
|
||||
margin-left: var(--space-2);
|
||||
padding: 1px 6px;
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
text-transform: uppercase;
|
||||
color: #d3869b;
|
||||
border: 1px solid color-mix(in srgb, #d3869b 40%, transparent);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: color-mix(in srgb, #d3869b 8%, transparent);
|
||||
}
|
||||
|
||||
/* ── Models tab: toolbar + role tabs + table ─────────────────── */
|
||||
.models-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: 4px 2px 10px;
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--chrome-fg-muted);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.models-toolbar__stats {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.models-toolbar__stats strong { color: var(--chrome-fg); font-weight: 600; }
|
||||
.models-toolbar__sep { color: var(--chrome-fg-dim); }
|
||||
.models-toolbar__cache code { color: var(--chrome-fg); font-family: var(--chrome-font-mono); font-size: var(--text-xs); }
|
||||
|
||||
.models-roletabs { margin: 8px 0 10px; }
|
||||
|
||||
.reco-banner--action {
|
||||
align-items: center;
|
||||
padding: 8px var(--space-4);
|
||||
border-left-color: #f3a5b6;
|
||||
border-color: color-mix(in srgb, #f3a5b6 30%, transparent);
|
||||
background: color-mix(in srgb, #f3a5b6 5%, transparent);
|
||||
}
|
||||
.reco-banner__body { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.reco-banner__title { color: var(--chrome-fg); font-weight: 600; font-size: var(--text-sm); }
|
||||
.reco-banner__rationale { color: var(--chrome-fg-muted); font-size: var(--text-xs); line-height: 1.4; }
|
||||
|
||||
/* Table — dense rows, fixed-width action column. */
|
||||
.models-table { border-radius: 8px; }
|
||||
.models-table .ui-table-header { padding: 4px var(--space-3); }
|
||||
.models-table__body { display: flex; flex-direction: column; max-height: 540px; overflow-y: auto; }
|
||||
|
||||
.models-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 8px var(--space-3);
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--chrome-fg);
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.models-row:last-child { border-bottom: none; }
|
||||
.models-row.is-ok { border-left-color: var(--chrome-severity-ok); }
|
||||
.models-row.is-off { border-left-color: var(--chrome-border-strong); opacity: 0.92; }
|
||||
.models-row:hover { background: var(--chrome-hover-bg); }
|
||||
|
||||
.models-row__cell { min-width: 0; }
|
||||
.models-row__name { display: flex; flex-direction: column; gap: 2px; }
|
||||
.models-row__title { display: inline-flex; align-items: center; gap: var(--space-2); color: var(--chrome-fg); font-weight: 500; }
|
||||
.models-row__repo { color: var(--chrome-fg-muted); font-size: var(--text-xs); font-family: var(--chrome-font-mono); }
|
||||
.models-row__repo code{ color: var(--chrome-fg-muted); }
|
||||
.models-row__note { color: var(--chrome-fg-dim); }
|
||||
.models-row__progress { color: var(--chrome-accent); font-size: var(--text-xs); font-family: var(--chrome-font-mono); margin-top: 2px; }
|
||||
.models-row__size { font-family: var(--chrome-font-mono); font-size: var(--text-xs); color: var(--chrome-fg-muted); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.models-row__actions {
|
||||
justify-content: flex-end;
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
opacity: 0.55;
|
||||
transition: opacity var(--dur-fast, 120ms) ease;
|
||||
}
|
||||
.models-row:hover .models-row__actions { opacity: 1; }
|
||||
|
||||
+342
-94
@@ -1,13 +1,14 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Cpu, FileText, Info, ShieldCheck, RefreshCw, Trash2, ExternalLink,
|
||||
CheckCircle, AlertCircle, Plug, Mic, MessageSquare,
|
||||
CheckCircle, AlertCircle, Plug, Mic, MessageSquare, Download,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { systemInfo, systemLogs, systemLogsTauri, clearSystemLogs, clearTauriLogs, modelStatus as fetchModelStatus } from '../api/system';
|
||||
import { listEngines, selectEngine } from '../api/engines';
|
||||
import { listModels, installModel, deleteModel, setupDownloadStreamUrl, getRecommendations } from '../api/setup';
|
||||
import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer';
|
||||
import { Tabs, Segmented, Button, Badge, Panel } from '../ui';
|
||||
import { Tabs, Segmented, Button, Badge, Panel, Table } from '../ui';
|
||||
import { useAppStore } from '../store';
|
||||
import './Settings.css';
|
||||
|
||||
@@ -42,10 +43,281 @@ function Row({ label, value, mono }) {
|
||||
);
|
||||
}
|
||||
|
||||
function EnginesTab() {
|
||||
function fmtBytes(n) {
|
||||
if (!n || n <= 0) return '—';
|
||||
if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(2)} GB`;
|
||||
if (n >= 1024 ** 2) return `${(n / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${Math.round(n / 1024)} KB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model store — list every known HF model, show install state, let the
|
||||
* user install / reinstall / delete individual models. Per-model download
|
||||
* progress is pulled from the shared /setup/download-stream SSE.
|
||||
*/
|
||||
export function ModelStoreTab({ info, modelBadge }) {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(new Set()); // repo_ids currently working
|
||||
const [progress, setProgress] = useState({}); // filename → event
|
||||
const [reco, setReco] = useState(null); // /setup/recommendations payload
|
||||
const [installingReco, setInstallingReco] = useState(false);
|
||||
const [activeRole, setActiveRole] = useState(null);
|
||||
const esRef = React.useRef(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setData(await listModels()); }
|
||||
catch (e) { toast.error(`Failed to list models: ${e.message}`); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const reloadReco = useCallback(async () => {
|
||||
try { setReco(await getRecommendations()); }
|
||||
catch (e) { /* non-fatal — Recommendation card just hides */ void e; }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { reload(); reloadReco(); }, [reload, reloadReco]);
|
||||
|
||||
// Open the progress stream once when the tab mounts; close on unmount.
|
||||
useEffect(() => {
|
||||
const es = new EventSource(setupDownloadStreamUrl());
|
||||
esRef.current = es;
|
||||
es.onmessage = (evt) => {
|
||||
try {
|
||||
const ev = JSON.parse(evt.data);
|
||||
if (ev?.filename) setProgress(prev => ({ ...prev, [ev.filename]: ev }));
|
||||
} catch { /* keepalive / ignore */ }
|
||||
};
|
||||
return () => es.close();
|
||||
}, []);
|
||||
|
||||
// When progress events stop firing, auto-refresh the list so "installed"
|
||||
// flips without a manual click.
|
||||
useEffect(() => {
|
||||
const filesInFlight = Object.values(progress).filter(e => e.phase !== 'done');
|
||||
if (filesInFlight.length === 0 && Object.keys(progress).length > 0) {
|
||||
const t = setTimeout(reload, 1500);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [progress, reload]);
|
||||
|
||||
const withBusy = async (repoId, fn, successMsg) => {
|
||||
setBusy(prev => new Set(prev).add(repoId));
|
||||
try {
|
||||
await fn();
|
||||
if (successMsg) toast.success(successMsg);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast.error(e.message || String(e));
|
||||
} finally {
|
||||
setBusy(prev => { const s = new Set(prev); s.delete(repoId); return s; });
|
||||
}
|
||||
};
|
||||
|
||||
const onInstall = (repoId) => withBusy(repoId, () => installModel(repoId), 'Install started — progress in the row');
|
||||
const onDelete = (repoId) => {
|
||||
if (!confirm(`Delete ${repoId}? You can reinstall it later.`)) return;
|
||||
return withBusy(repoId, () => deleteModel(repoId), `Deleted ${repoId}`);
|
||||
};
|
||||
const onReinstall = async (repoId) => {
|
||||
if (!confirm(`Reinstall ${repoId}? This will delete the current copy and download again.`)) return;
|
||||
await withBusy(repoId, async () => {
|
||||
await deleteModel(repoId);
|
||||
await installModel(repoId);
|
||||
}, 'Reinstalling');
|
||||
};
|
||||
|
||||
const onInstallRecommended = async () => {
|
||||
if (!reco) return;
|
||||
const missing = reco.models.filter(m => !m.installed);
|
||||
if (missing.length === 0) {
|
||||
toast.success('Recommended models are already installed.');
|
||||
return;
|
||||
}
|
||||
setInstallingReco(true);
|
||||
try {
|
||||
// Parallel install — backend /models/install spawns each download on
|
||||
// its own asyncio task so ordering doesn't matter.
|
||||
await Promise.all(missing.map(m => installModel(m.repo_id)));
|
||||
toast.success(`Started downloading ${missing.length} model${missing.length > 1 ? 's' : ''}`);
|
||||
await Promise.all([reload(), reloadReco()]);
|
||||
} catch (e) {
|
||||
toast.error(`Install failed: ${e.message || e}`);
|
||||
} finally {
|
||||
setInstallingReco(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<h2><Cpu size={16} color="#f3a5b6" /> Models</h2>
|
||||
<div className="settings-muted">Loading…</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const groups = (data.models || []).reduce((acc, m) => {
|
||||
const k = (m.role || 'other').toLowerCase();
|
||||
(acc[k] = acc[k] || []).push(m);
|
||||
return acc;
|
||||
}, {});
|
||||
const ROLE_ORDER = ['tts', 'asr', 'diarisation', 'diarization', 'llm'];
|
||||
const ROLE_LABEL = { tts: 'TTS', asr: 'ASR', diarisation: 'Diarisation', diarization: 'Diarisation', llm: 'LLM', other: 'Other' };
|
||||
const roles = Object.keys(groups).sort((a, b) => {
|
||||
const ai = ROLE_ORDER.indexOf(a), bi = ROLE_ORDER.indexOf(b);
|
||||
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
|
||||
});
|
||||
const currentRole = activeRole && groups[activeRole] ? activeRole : roles[0];
|
||||
const rows = currentRole ? groups[currentRole] : [];
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'name', label: 'Model', flex: 3 },
|
||||
{ key: 'size', label: 'Size', width: 80, align: 'right' },
|
||||
{ key: 'status', label: 'Status', width: 110, align: 'center' },
|
||||
{ key: 'actions', label: '', width: 108, align: 'right' },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="settings-section settings-section--compact">
|
||||
<div className="models-toolbar">
|
||||
<div className="models-toolbar__stats">
|
||||
<span><strong>{fmtBytes(data.total_installed_bytes)}</strong> on disk</span>
|
||||
<span className="models-toolbar__sep">·</span>
|
||||
<span className="models-toolbar__cache">cache: <code>{data.hf_cache_dir}</code></span>
|
||||
{info && <span className="models-toolbar__sep">·</span>}
|
||||
{info && <span>model: {modelBadge}</span>}
|
||||
</div>
|
||||
<Button variant="subtle" size="sm" onClick={reload} leading={<RefreshCw size={11} />}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{reco && reco.all_installed && (
|
||||
<div className="reco-banner">
|
||||
<CheckCircle size={12} color="#8ec07c" />
|
||||
<span className="reco-banner__text">
|
||||
Recommended bundle installed for <strong>{reco.device.label}</strong>
|
||||
</span>
|
||||
<span className="reco-banner__size">{reco.total_gb} GB</span>
|
||||
</div>
|
||||
)}
|
||||
{reco && !reco.all_installed && (
|
||||
<div className="reco-banner reco-banner--action">
|
||||
<div className="reco-banner__body">
|
||||
<div className="reco-banner__title">Recommended for {reco.device.label}</div>
|
||||
<div className="reco-banner__rationale">{reco.rationale}</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onInstallRecommended}
|
||||
disabled={installingReco}
|
||||
leading={installingReco ? <RefreshCw size={12} className="spinner" /> : null}
|
||||
>
|
||||
{installingReco ? 'Starting…' : `Install ~${reco.download_gb_remaining} GB`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{roles.length > 1 && (
|
||||
<Segmented
|
||||
size="sm"
|
||||
value={currentRole}
|
||||
onChange={setActiveRole}
|
||||
className="models-roletabs"
|
||||
items={roles.map(r => {
|
||||
const installed = groups[r].filter(m => m.installed).length;
|
||||
return {
|
||||
value: r,
|
||||
label: `${ROLE_LABEL[r] || r.toUpperCase()} ${installed}/${groups[r].length}`,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Table className="models-table">
|
||||
<Table.Header columns={COLUMNS} />
|
||||
<div className="models-table__body">
|
||||
{rows.map((m) => {
|
||||
const rowBusy = busy.has(m.repo_id);
|
||||
const files = Object.values(progress).filter(p => p.filename && p.filename.includes(m.repo_id.split('/').pop()));
|
||||
const activeFile = files.find(f => f.phase !== 'done');
|
||||
return (
|
||||
<div key={m.repo_id} className={`models-row ${m.installed ? 'is-ok' : 'is-off'}`}>
|
||||
<div className="models-row__cell models-row__name" style={{ flex: 3 }}>
|
||||
<span className="models-row__title">
|
||||
{m.label}
|
||||
{m.required && <span className="models-row__tag">required</span>}
|
||||
</span>
|
||||
<span className="models-row__repo">
|
||||
<code>{m.repo_id}</code>
|
||||
{m.note && <span className="models-row__note"> · {m.note}</span>}
|
||||
</span>
|
||||
{activeFile && (
|
||||
<span className="models-row__progress">
|
||||
⬇ {activeFile.filename.split('/').pop()} · {Math.round(activeFile.pct * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="models-row__cell models-row__size" style={{ width: 80, textAlign: 'right' }}>
|
||||
{m.installed ? fmtBytes(m.size_on_disk_bytes) : `${m.size_gb} GB`}
|
||||
</div>
|
||||
<div className="models-row__cell" style={{ width: 110, display: 'flex', justifyContent: 'center' }}>
|
||||
{rowBusy
|
||||
? <Badge tone="warn" size="xs"><RefreshCw size={10} className="spinner" /> working</Badge>
|
||||
: m.installed
|
||||
? <Badge tone="success" size="xs">installed</Badge>
|
||||
: <Badge tone="neutral" size="xs">not installed</Badge>}
|
||||
</div>
|
||||
<div className="models-row__cell models-row__actions" style={{ width: 108 }}>
|
||||
{!m.installed && !rowBusy && (
|
||||
<Button
|
||||
variant="subtle" size="sm"
|
||||
onClick={() => onInstall(m.repo_id)}
|
||||
leading={<Download size={11} />}
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
)}
|
||||
{m.installed && !rowBusy && (
|
||||
<>
|
||||
<Button
|
||||
variant="icon" iconSize="sm"
|
||||
onClick={() => onReinstall(m.repo_id)}
|
||||
title="Reinstall"
|
||||
aria-label="Reinstall"
|
||||
>
|
||||
<RefreshCw size={11} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="icon" iconSize="sm"
|
||||
onClick={() => onDelete(m.repo_id)}
|
||||
title="Delete"
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Table>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function EnginesTab() {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [switching, setSwitching] = useState(null);
|
||||
const [activeFam, setActiveFam] = useState('tts');
|
||||
const reviewMode = useAppStore(s => s.reviewMode);
|
||||
const setReviewMode = useAppStore(s => s.setReviewMode);
|
||||
|
||||
@@ -76,96 +348,94 @@ function EnginesTab() {
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const fams = ['tts', 'asr', 'llm'].filter(f => data[f]);
|
||||
const currentFam = fams.includes(activeFam) ? activeFam : fams[0];
|
||||
const family = currentFam ? data[currentFam] : null;
|
||||
const famTint = currentFam ? FAMILY_META[currentFam].tint : 'neutral';
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'name', label: 'Backend', flex: 3 },
|
||||
{ key: 'status', label: 'Status', width: 120, align: 'center' },
|
||||
{ key: 'action', label: '', width: 90, align: 'right' },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<h2 className="settings-section__head-row">
|
||||
<span className="settings-section__head-left">
|
||||
<Plug size={16} color="#d3869b" /> Engines
|
||||
</span>
|
||||
<span className="settings-section__head-actions">
|
||||
<Button variant="subtle" size="sm" onClick={reload} leading={<RefreshCw size={11} />}>
|
||||
Refresh
|
||||
</Button>
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<p className="settings-prose" style={{ marginTop: 0 }}>
|
||||
Click <strong>Use</strong> to switch which backend handles each stage. Your pick persists across restarts.
|
||||
Env vars (<code>OMNIVOICE_TTS_BACKEND</code>, …) still override the UI so power-users can pin a backend.
|
||||
Unavailable engines show why — usually a missing optional dep or a hardware mismatch.
|
||||
</p>
|
||||
|
||||
<Panel variant="flat" padding="md" className="engines-family" title="Pipeline review mode">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<section className="settings-section settings-section--compact">
|
||||
<div className="models-toolbar">
|
||||
<div className="models-toolbar__stats">
|
||||
<Segmented
|
||||
size="sm"
|
||||
size="xs"
|
||||
value={reviewMode}
|
||||
onChange={setReviewMode}
|
||||
items={[
|
||||
{ value: 'on', label: 'Review between stages' },
|
||||
{ value: 'on', label: 'Review' },
|
||||
{ value: 'off', label: 'Rapid-fire' },
|
||||
]}
|
||||
/>
|
||||
<span style={{ fontSize: '0.62rem', color: '#a89984', flex: 1 }}>
|
||||
{reviewMode === 'on'
|
||||
? 'Banners nudge you to check transcripts and translations before advancing.'
|
||||
: 'Banners hidden — go straight from Prepare to Translate to Generate.'}
|
||||
<span className="models-toolbar__sep">·</span>
|
||||
<span>
|
||||
{reviewMode === 'on' ? 'Stage banners on' : 'Stage banners off'}
|
||||
</span>
|
||||
</div>
|
||||
</Panel>
|
||||
<Button variant="subtle" size="sm" onClick={reload} leading={<RefreshCw size={11} />}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{['tts', 'asr', 'llm'].map(fam => {
|
||||
const { label, icon: FamIcon, tint } = FAMILY_META[fam];
|
||||
const family = data[fam];
|
||||
if (!family) return null;
|
||||
return (
|
||||
<Panel
|
||||
key={fam}
|
||||
variant="flat"
|
||||
padding="md"
|
||||
className="engines-family"
|
||||
title={
|
||||
<>
|
||||
<FamIcon size={13} /> {label}
|
||||
<span className="engines-family__active">
|
||||
active: <code>{family.active}</code>
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ul className="engines-list">
|
||||
{family.backends.map(b => {
|
||||
const isActive = family.active === b.id;
|
||||
const isSwitching = switching === `${fam}:${b.id}`;
|
||||
return (
|
||||
<li key={b.id} className={b.available ? 'is-ok' : 'is-off'}>
|
||||
<span className="engines-list__name">
|
||||
<code>{b.id}</code> — {b.display_name}
|
||||
{fams.length > 1 && (
|
||||
<Segmented
|
||||
size="sm"
|
||||
value={currentFam}
|
||||
onChange={setActiveFam}
|
||||
className="models-roletabs"
|
||||
items={fams.map(f => ({
|
||||
value: f,
|
||||
label: `${FAMILY_META[f].label} · ${data[f].active}`,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{family && (
|
||||
<Table className="models-table">
|
||||
<Table.Header columns={COLUMNS} />
|
||||
<div className="models-table__body">
|
||||
{family.backends.map(b => {
|
||||
const isActive = family.active === b.id;
|
||||
const isSwitching = switching === `${currentFam}:${b.id}`;
|
||||
return (
|
||||
<div key={b.id} className={`models-row ${b.available ? 'is-ok' : 'is-off'}`}>
|
||||
<div className="models-row__cell models-row__name" style={{ flex: 3 }}>
|
||||
<span className="models-row__title">
|
||||
{b.display_name}
|
||||
{isActive && <Badge tone={famTint} size="xs">active</Badge>}
|
||||
</span>
|
||||
{isActive && <Badge tone={tint}>active</Badge>}
|
||||
<span className="models-row__repo">
|
||||
<code>{b.id}</code>
|
||||
{!b.available && b.reason && <span className="models-row__note"> · {b.reason}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="models-row__cell" style={{ width: 120, display: 'flex', justifyContent: 'center' }}>
|
||||
{b.available
|
||||
? <Badge tone="success" size="xs">ready</Badge>
|
||||
: <Badge tone="warn" size="xs">unavailable</Badge>}
|
||||
</div>
|
||||
<div className="models-row__cell models-row__actions" style={{ width: 90 }}>
|
||||
{!isActive && b.available && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => onSelect(fam, b.id)}
|
||||
variant="subtle" size="sm"
|
||||
onClick={() => onSelect(currentFam, b.id)}
|
||||
loading={isSwitching}
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
)}
|
||||
{!b.available && b.reason && (
|
||||
<span className="engines-list__reason">{b.reason}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Panel>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Table>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -276,29 +546,7 @@ export default function Settings() {
|
||||
className="settings-tabs-ui"
|
||||
/>
|
||||
|
||||
{activeTab === 'models' && (
|
||||
<section className="settings-section">
|
||||
<h2><Cpu size={16} color="#f3a5b6" /> Models</h2>
|
||||
{info ? (
|
||||
<>
|
||||
<Row label="TTS checkpoint" value={info.model_checkpoint} mono />
|
||||
<Row label="ASR model (Whisper)" value={info.asr_model} mono />
|
||||
<Row label="Translate provider" value={info.translate_provider} />
|
||||
<Row label="Device" value={info.device?.toUpperCase()} />
|
||||
<Row label="Status" value={modelBadge} />
|
||||
<Row label="Idle timeout" value={`${info.idle_timeout_seconds}s`} />
|
||||
<Row
|
||||
label="Hugging Face token"
|
||||
value={info.has_hf_token
|
||||
? <Badge tone="success"><CheckCircle size={11} /> Set</Badge>
|
||||
: <Badge tone="warn"><AlertCircle size={11} /> Missing — diarization disabled</Badge>}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="settings-muted">Loading…</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{activeTab === 'models' && <ModelStoreTab info={info} modelBadge={modelBadge} />}
|
||||
|
||||
{activeTab === 'engines' && <EnginesTab />}
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
.setup-wizard {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 32px 40px 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Hero stays constrained + centered so the headline doesn't stretch
|
||||
uncomfortably wide on ultrawide displays; the embedded panels below
|
||||
get the full width. */
|
||||
.setup-wizard__hero,
|
||||
.setup-wizard__steps {
|
||||
max-width: 720px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* Guided-flow step header ─────────────────────────────────────────────── */
|
||||
.setup-wizard__steps {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.setup-wizard__step {
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-fg-muted);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.setup-wizard__step--active {
|
||||
background: rgba(243, 165, 182, 0.12);
|
||||
border-color: rgba(243, 165, 182, 0.35);
|
||||
color: #f3a5b6;
|
||||
}
|
||||
.setup-wizard__step--done {
|
||||
color: #8ec07c;
|
||||
border-color: rgba(142, 192, 124, 0.35);
|
||||
}
|
||||
.setup-wizard__embed {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.setup-wizard__nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.setup-wizard--centered {
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.setup-wizard__hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 24px 12px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.setup-wizard__hero h1 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.setup-wizard__sub {
|
||||
margin: 0;
|
||||
color: var(--color-fg-muted);
|
||||
font-size: 0.85rem;
|
||||
max-width: 440px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.setup-wizard__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.setup-wizard__card--error {
|
||||
background: rgba(251, 73, 52, 0.06);
|
||||
border-color: rgba(251, 73, 52, 0.3);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.setup-wizard__card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-fg-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.setup-wizard__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.setup-wizard__row-body { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.setup-wizard__row-title { font-weight: 600; font-size: 0.85rem; }
|
||||
|
||||
.setup-wizard__muted {
|
||||
color: var(--color-fg-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.setup-wizard__muted code { color: var(--color-fg-subtle); }
|
||||
|
||||
.setup-wizard__warn { color: var(--color-warn, #fabd2f); font-weight: 600; }
|
||||
|
||||
.setup-wizard__models {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.setup-wizard__models li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.setup-wizard__models code { font-size: 0.68rem; }
|
||||
|
||||
.setup-wizard__aggregate {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.setup-wizard__files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 2px 2px;
|
||||
}
|
||||
.setup-wizard__file {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 120px 44px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.setup-wizard__file-name {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--chrome-fg-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.setup-wizard__file-pct {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-fg-muted);
|
||||
}
|
||||
|
||||
.setup-wizard__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.setup-wizard__footnote {
|
||||
color: var(--color-fg-subtle);
|
||||
font-size: 0.65rem;
|
||||
margin: 4px 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
.setup-wizard__footnote code { font-size: 0.62rem; }
|
||||
@@ -0,0 +1,185 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { CheckCircle, Loader, Sparkles, ArrowRight } from 'lucide-react';
|
||||
import { Button } from '../ui';
|
||||
import { setupStatus } from '../api/setup';
|
||||
import { ModelStoreTab, EnginesTab } from './Settings';
|
||||
import './SetupWizard.css';
|
||||
|
||||
// macOS convention: double-click the title-bar drag region to toggle zoom.
|
||||
// Same pattern as components/Header.jsx — we don't import `@tauri-apps/api`
|
||||
// eagerly so the browser preview doesn't need it.
|
||||
const doubleClickMaximize = async () => {
|
||||
try {
|
||||
if (!('__TAURI_INTERNALS__' in window)) return;
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
getCurrentWindow().toggleMaximize();
|
||||
} catch { /* non-tauri preview — ignore */ }
|
||||
};
|
||||
|
||||
/**
|
||||
* First-run / "no models installed" gate.
|
||||
*
|
||||
* Re-uses the real Settings → Models and Settings → Engines panels inside a
|
||||
* 3-step guided flow so the wizard stays a single source of truth. If either
|
||||
* tab gets a feature later (new engine, new model column), onboarding
|
||||
* inherits it for free — no parallel UI to maintain.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Welcome — hero + explainer + "continue"
|
||||
* 2. Models — embed ModelStoreTab. User installs the required weights
|
||||
* (OmniVoice + Systran/faster-whisper-large-v3). Polls
|
||||
* /setup/status in the background — the "Finish" button
|
||||
* unlocks as soon as `models_ready` flips true.
|
||||
* 3. Engines — embed EnginesTab. User picks TTS/ASR/LLM backends
|
||||
* (or just clicks through on sensible defaults).
|
||||
* Finish — onReady() dismisses the wizard, main studio renders.
|
||||
*/
|
||||
export default function SetupWizard({ onReady }) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [status, setStatus] = useState(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try { setStatus(await setupStatus()); }
|
||||
catch { /* backend warming up — retry on interval */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
// Poll while on the Models step so the Finish button unlocks as soon as
|
||||
// downloads complete, without the user having to click "Recheck".
|
||||
useEffect(() => {
|
||||
if (step !== 1) return;
|
||||
const iv = setInterval(reload, 4000);
|
||||
return () => clearInterval(iv);
|
||||
}, [step, reload]);
|
||||
|
||||
const modelsReady = !!status?.models_ready;
|
||||
|
||||
return (
|
||||
<div className="setup-wizard">
|
||||
{/* Title-bar drag region. Covers the whole hero + step pill area so
|
||||
the user can drag the window by the top of the wizard and
|
||||
double-click to toggle zoom — matches macOS native behaviour. */}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
onDoubleClick={doubleClickMaximize}
|
||||
className="setup-wizard__hero"
|
||||
>
|
||||
<Sparkles size={36} color="#d3869b" />
|
||||
<h1 data-tauri-drag-region>Welcome to OmniVoice Studio</h1>
|
||||
<p className="setup-wizard__sub" data-tauri-drag-region>
|
||||
Dubbing, voice cloning, and voice design — all running locally on
|
||||
your machine. Three quick steps and you're in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="setup-wizard__steps">
|
||||
{['Welcome', 'Install models', 'Pick engines'].map((label, i) => (
|
||||
<button
|
||||
key={label}
|
||||
className={[
|
||||
'setup-wizard__step',
|
||||
step === i ? 'setup-wizard__step--active' : '',
|
||||
step > i ? 'setup-wizard__step--done' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => setStep(i)}
|
||||
type="button"
|
||||
>
|
||||
{step > i ? '✓ ' : `${i + 1}. `}{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
{step === 0 && (
|
||||
<div className="setup-wizard__embed" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<strong>What happens next</strong>
|
||||
<ol style={{ margin: 0, paddingLeft: 20, lineHeight: 1.7, color: 'var(--color-fg-muted)', fontSize: '0.9rem' }}>
|
||||
<li>
|
||||
<strong>Install models</strong> — we'll download ~5 GB of weights
|
||||
(OmniVoice TTS + Whisper). You'll see the full catalogue here —
|
||||
required ones first, plus optional engines you can enable now or
|
||||
later.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Pick engines</strong> — choose which TTS / ASR / LLM
|
||||
backends to use. Defaults work; power users can pin specific
|
||||
engines per family.
|
||||
</li>
|
||||
<li>
|
||||
<strong>You're in.</strong> First launch takes ~5-10 minutes to
|
||||
download. After that, every launch is instant and fully offline.
|
||||
</li>
|
||||
</ol>
|
||||
<div>
|
||||
<Button
|
||||
variant="primary" size="lg"
|
||||
onClick={() => setStep(1)}
|
||||
trailing={<ArrowRight size={14} />}
|
||||
>
|
||||
Get started
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<div className="setup-wizard__embed">
|
||||
<ModelStoreTab info={null} modelBadge={null} />
|
||||
</div>
|
||||
<div className="setup-wizard__nav">
|
||||
<Button variant="ghost" onClick={() => setStep(0)}>Back</Button>
|
||||
<Button
|
||||
variant={modelsReady ? 'primary' : 'ghost'}
|
||||
onClick={() => setStep(2)}
|
||||
trailing={<ArrowRight size={14} />}
|
||||
disabled={!modelsReady}
|
||||
title={modelsReady ? '' : 'Install the required models above to continue.'}
|
||||
>
|
||||
{modelsReady
|
||||
? 'Required models ready — continue'
|
||||
: 'Waiting for required models…'}
|
||||
</Button>
|
||||
</div>
|
||||
{!modelsReady && status?.missing?.length > 0 && (
|
||||
<p className="setup-wizard__muted" style={{ textAlign: 'center', fontSize: '0.78rem', margin: 0 }}>
|
||||
Still needed:{' '}
|
||||
{status.missing.map(m => m.label).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<>
|
||||
<div className="setup-wizard__embed">
|
||||
<EnginesTab />
|
||||
</div>
|
||||
<div className="setup-wizard__nav">
|
||||
<Button variant="ghost" onClick={() => setStep(1)}>Back</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onReady}
|
||||
leading={<CheckCircle size={14} />}
|
||||
>
|
||||
Enter studio
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!status && step > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'center', color: 'var(--color-fg-muted)' }}>
|
||||
<Loader className="spinner" size={14} /> Checking setup…
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="setup-wizard__footnote">
|
||||
Downloads come from <code>huggingface.co</code>. Cache: {' '}
|
||||
<code>{status?.hf_cache_dir || '~/.cache/huggingface'}</code>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export const useAppStore = create<AppStore>()(
|
||||
partialize: (s) => ({
|
||||
translateQuality: s.translateQuality,
|
||||
dualSubs: s.dualSubs,
|
||||
burnSubs: s.burnSubs,
|
||||
glossaryVisible: s.glossaryVisible,
|
||||
reviewMode: s.reviewMode,
|
||||
mode: s.mode,
|
||||
|
||||
@@ -13,6 +13,7 @@ export type TranslateQuality = 'fast' | 'cinematic';
|
||||
export interface PrefsSlice {
|
||||
translateQuality: TranslateQuality;
|
||||
dualSubs: boolean;
|
||||
burnSubs: boolean;
|
||||
glossaryVisible: boolean;
|
||||
/**
|
||||
* Phase 4.3 — staged checkpoints. When 'on', between-stage banners nudge
|
||||
@@ -23,6 +24,7 @@ export interface PrefsSlice {
|
||||
|
||||
setTranslateQuality: (q: TranslateQuality) => void;
|
||||
setDualSubs: (on: boolean) => void;
|
||||
setBurnSubs: (on: boolean) => void;
|
||||
setGlossaryVisible: (on: boolean) => void;
|
||||
setReviewMode: (mode: 'on' | 'off') => void;
|
||||
}
|
||||
@@ -30,11 +32,13 @@ export interface PrefsSlice {
|
||||
export const createPrefsSlice: StateCreator<PrefsSlice, [], [], PrefsSlice> = (set) => ({
|
||||
translateQuality: 'fast',
|
||||
dualSubs: false,
|
||||
burnSubs: false,
|
||||
glossaryVisible: true,
|
||||
reviewMode: 'on',
|
||||
|
||||
setTranslateQuality: (q) => set({ translateQuality: q }),
|
||||
setDualSubs: (on) => set({ dualSubs: on }),
|
||||
setBurnSubs: (on) => set({ burnSubs: on }),
|
||||
setGlossaryVisible: (on) => set({ glossaryVisible: on }),
|
||||
setReviewMode: (mode) => set({ reviewMode: mode }),
|
||||
});
|
||||
|
||||
+18
-17
@@ -1,16 +1,16 @@
|
||||
/* ── Badge primitive ────────────────────────────────────────────── */
|
||||
/* ── Badge primitive — chrome tokens ─────────────────────────────── */
|
||||
|
||||
.ui-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-ui);
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: 0.03em;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
text-transform: uppercase;
|
||||
border: 1px solid currentColor;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
background: transparent;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
line-height: 1.2;
|
||||
@@ -21,21 +21,22 @@
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 6px currentColor;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* sizes */
|
||||
.ui-badge--size-xs { padding: 0px 5px; font-size: var(--text-2xs); }
|
||||
.ui-badge--size-sm { padding: 1px 6px; font-size: var(--text-xs); }
|
||||
.ui-badge--size-xs { padding: 0px 6px; font-size: var(--chrome-label-size); }
|
||||
.ui-badge--size-sm { padding: 1px 7px; font-size: var(--chrome-label-size); }
|
||||
|
||||
/* tones — colour applied via `color` so dot + border follow */
|
||||
.ui-badge--neutral { color: var(--color-fg-subtle); border-color: var(--color-border); background: rgba(255,255,255,0.04); }
|
||||
.ui-badge--brand { color: var(--color-brand); border-color: rgba(211,134,155,0.4); background: rgba(211,134,155,0.12); }
|
||||
.ui-badge--success { color: var(--color-success); border-color: rgba(142,192,124,0.4); background: rgba(142,192,124,0.10); }
|
||||
.ui-badge--warn { color: var(--color-accent); border-color: rgba(250,189,47,0.4); background: rgba(250,189,47,0.10); }
|
||||
.ui-badge--danger { color: var(--color-danger); border-color: rgba(251,73,52,0.4); background: rgba(251,73,52,0.10); }
|
||||
.ui-badge--info { color: var(--color-info); border-color: rgba(131,165,152,0.4); background: rgba(131,165,152,0.10); }
|
||||
.ui-badge--violet { color: #a78bfa; border-color: rgba(167,139,250,0.3); background: rgba(94,106,210,0.12); }
|
||||
/* tones — color applied via `color` so dot + border follow. Fill stays near-
|
||||
transparent so the badge reads as a chrome chip, not a filled pill. */
|
||||
.ui-badge--neutral { color: var(--chrome-fg-muted); border-color: var(--chrome-border-strong); background: transparent; }
|
||||
.ui-badge--brand { color: var(--chrome-accent); border-color: var(--chrome-accent-border); background: var(--chrome-accent-bg); }
|
||||
.ui-badge--success { color: var(--chrome-severity-ok); border-color: color-mix(in srgb, var(--chrome-severity-ok) 45%, transparent); background: color-mix(in srgb, var(--chrome-severity-ok) 10%, transparent); }
|
||||
.ui-badge--warn { color: var(--chrome-severity-warn); border-color: color-mix(in srgb, var(--chrome-severity-warn) 45%, transparent); background: color-mix(in srgb, var(--chrome-severity-warn) 10%, transparent); }
|
||||
.ui-badge--danger { color: var(--chrome-severity-err); border-color: color-mix(in srgb, var(--chrome-severity-err) 45%, transparent); background: color-mix(in srgb, var(--chrome-severity-err) 10%, transparent); }
|
||||
.ui-badge--info { color: #83a598; border-color: color-mix(in srgb, #83a598 45%, transparent); background: color-mix(in srgb, #83a598 10%, transparent); }
|
||||
.ui-badge--violet { color: var(--chrome-fg-muted); border-color: var(--chrome-border-strong); background: transparent; }
|
||||
|
||||
/* pulse modifier — for "loading" / "thinking" states */
|
||||
.ui-badge--pulse .ui-badge__dot { animation: pulse 1.5s ease-in-out infinite; }
|
||||
|
||||
+78
-87
@@ -1,4 +1,4 @@
|
||||
/* ── Button primitive ────────────────────────────────────────────── */
|
||||
/* ── Button primitive — chrome tokens only ────────────────────────── */
|
||||
|
||||
.ui-btn {
|
||||
display: inline-flex;
|
||||
@@ -6,29 +6,26 @@
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: transparent;
|
||||
color: var(--color-fg);
|
||||
color: var(--chrome-fg);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: var(--weight-semibold);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background var(--dur-base) var(--ease-out),
|
||||
border-color var(--dur-base) var(--ease-out),
|
||||
color var(--dur-base) var(--ease-out),
|
||||
transform var(--dur-base) var(--ease-spring),
|
||||
box-shadow var(--dur-base) var(--ease-out),
|
||||
filter var(--dur-base) var(--ease-out);
|
||||
background var(--dur-fast) var(--ease-out),
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.ui-btn:disabled {
|
||||
opacity: 0.35;
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(0.5);
|
||||
filter: none;
|
||||
}
|
||||
.ui-btn:focus-visible {
|
||||
outline: none;
|
||||
@@ -40,137 +37,131 @@
|
||||
.ui-btn.is-loading { cursor: wait; }
|
||||
|
||||
/* ── Size scale ──────────────────────────────────────────────────── */
|
||||
.ui-btn--size-sm { padding: 3px 8px; font-size: var(--text-xs); border-radius: var(--radius-sm); }
|
||||
.ui-btn--size-md { padding: 6px 10px; font-size: var(--text-md); }
|
||||
.ui-btn--size-sm { padding: 3px 10px; font-size: var(--text-xs); border-radius: var(--chrome-radius-pill); }
|
||||
.ui-btn--size-md { padding: 6px 12px; font-size: var(--text-md); border-radius: var(--chrome-radius-pill); }
|
||||
|
||||
/* ── Variants ────────────────────────────────────────────────────── */
|
||||
|
||||
/* primary — gradient CTA, supersedes legacy .btn-primary */
|
||||
/* primary — flat chrome accent, no gradient / shimmer */
|
||||
.ui-btn--primary {
|
||||
background: linear-gradient(135deg, var(--color-brand) 0%, var(--color-brand-hover) 100%);
|
||||
color: var(--color-fg-inverse);
|
||||
border-color: var(--color-border-strong);
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
background: var(--chrome-accent-bg);
|
||||
color: var(--chrome-accent);
|
||||
border-color: var(--chrome-accent-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
.ui-btn--primary::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 auto 0 -100%;
|
||||
width: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left var(--dur-xslow) var(--ease-out);
|
||||
}
|
||||
.ui-btn--primary:hover:not(:disabled)::before { left: 100%; }
|
||||
.ui-btn--primary::before { display: none; }
|
||||
.ui-btn--primary:hover:not(:disabled) {
|
||||
filter: brightness(1.15);
|
||||
box-shadow: 0 4px 12px var(--color-brand-glow), inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-1px);
|
||||
filter: none;
|
||||
background: color-mix(in srgb, var(--chrome-accent) 22%, transparent);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
.ui-btn--primary:active:not(:disabled) {
|
||||
transform: scale(0.97) translateY(1px);
|
||||
filter: brightness(0.9);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transform: none;
|
||||
filter: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* subtle — default neutral surface action */
|
||||
.ui-btn--subtle {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-fg-muted);
|
||||
background: transparent;
|
||||
border-color: var(--chrome-border);
|
||||
color: var(--chrome-fg-muted);
|
||||
}
|
||||
.ui-btn--subtle:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
color: var(--color-fg);
|
||||
border-color: var(--color-border-strong);
|
||||
transform: translateY(-1px);
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
border-color: var(--chrome-border-strong);
|
||||
transform: none;
|
||||
}
|
||||
.ui-btn--subtle:active:not(:disabled) { transform: translateY(0); }
|
||||
.ui-btn--subtle:active:not(:disabled) { transform: none; }
|
||||
|
||||
/* ghost — transparent, hover surfaces */
|
||||
.ui-btn--ghost {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: var(--color-fg-muted);
|
||||
color: var(--chrome-fg-muted);
|
||||
}
|
||||
.ui-btn--ghost:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--color-fg);
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
/* danger — destructive */
|
||||
.ui-btn--danger {
|
||||
background: rgba(251, 73, 52, 0.12);
|
||||
border-color: rgba(251, 73, 52, 0.30);
|
||||
color: var(--color-danger);
|
||||
background: color-mix(in srgb, var(--chrome-severity-err) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--chrome-severity-err) 45%, transparent);
|
||||
color: var(--chrome-severity-err);
|
||||
}
|
||||
.ui-btn--danger:hover:not(:disabled) {
|
||||
background: rgba(251, 73, 52, 0.22);
|
||||
border-color: var(--color-danger);
|
||||
background: color-mix(in srgb, var(--chrome-severity-err) 18%, transparent);
|
||||
border-color: var(--chrome-severity-err);
|
||||
}
|
||||
|
||||
/* chip — selectable tag */
|
||||
.ui-btn--chip {
|
||||
padding: 2px 6px;
|
||||
padding: 2px 8px;
|
||||
font-size: var(--text-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-fg-muted);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: transparent;
|
||||
border-color: var(--chrome-border);
|
||||
color: var(--chrome-fg-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: var(--weight-medium);
|
||||
font-weight: 500;
|
||||
}
|
||||
.ui-btn--chip:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
color: var(--color-fg);
|
||||
border-color: rgba(255, 255, 255, 0.20);
|
||||
transform: translateY(-1px);
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
border-color: var(--chrome-border-strong);
|
||||
transform: none;
|
||||
}
|
||||
.ui-btn--chip.is-active {
|
||||
background: rgba(142, 192, 124, 0.12);
|
||||
color: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
background: color-mix(in srgb, var(--chrome-severity-ok) 12%, transparent);
|
||||
color: var(--chrome-severity-ok);
|
||||
border-color: color-mix(in srgb, var(--chrome-severity-ok) 45%, transparent);
|
||||
}
|
||||
|
||||
/* preset — violet-tinted picker tile */
|
||||
/* preset — neutral chrome tile, no violet tint */
|
||||
.ui-btn--preset {
|
||||
padding: 3px 6px;
|
||||
padding: 3px 8px;
|
||||
font-size: var(--text-xs);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(94, 106, 210, 0.06);
|
||||
border-color: rgba(94, 106, 210, 0.15);
|
||||
color: #c7d2fe;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: transparent;
|
||||
border-color: var(--chrome-border);
|
||||
color: var(--chrome-fg-muted);
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.ui-btn--preset:hover:not(:disabled) {
|
||||
background: rgba(94, 106, 210, 0.15);
|
||||
border-color: #a78bfa;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 6px rgba(94, 106, 210, 0.15);
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
border-color: var(--chrome-border-strong);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.ui-btn--preset.is-active {
|
||||
background: rgba(94, 106, 210, 0.20);
|
||||
border-color: #a78bfa;
|
||||
color: #e0e7ff;
|
||||
background: var(--chrome-accent-bg);
|
||||
border-color: var(--chrome-accent-border);
|
||||
color: var(--chrome-accent);
|
||||
}
|
||||
|
||||
/* icon — square, centered, for toolbar/inline actions */
|
||||
.ui-btn--icon {
|
||||
padding: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-fg-muted);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: transparent;
|
||||
border-color: var(--chrome-border);
|
||||
color: var(--chrome-fg-muted);
|
||||
}
|
||||
.ui-btn--icon-sm { width: 20px; height: 20px; }
|
||||
.ui-btn--icon-md { width: 22px; height: 22px; }
|
||||
.ui-btn--icon:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
color: var(--color-fg);
|
||||
border-color: var(--color-border-strong);
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
border-color: var(--chrome-border-strong);
|
||||
}
|
||||
.ui-btn--icon.is-active {
|
||||
background: rgba(211, 134, 155, 0.15);
|
||||
color: var(--color-brand);
|
||||
border-color: rgba(211, 134, 155, 0.30);
|
||||
background: var(--chrome-accent-bg);
|
||||
color: var(--chrome-accent);
|
||||
border-color: var(--chrome-accent-border);
|
||||
}
|
||||
|
||||
+16
-12
@@ -5,8 +5,8 @@
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--chrome-bg);
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -31,14 +31,16 @@
|
||||
.ui-table-toolbar__search-icon {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
color: var(--color-fg-subtle);
|
||||
color: var(--chrome-fg-dim);
|
||||
pointer-events: none;
|
||||
}
|
||||
.ui-table-toolbar__search-input {
|
||||
width: 100%;
|
||||
padding-left: 22px;
|
||||
padding-right: 22px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-xs);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
}
|
||||
.ui-table-toolbar__search-clear {
|
||||
position: absolute !important;
|
||||
@@ -50,8 +52,9 @@
|
||||
}
|
||||
|
||||
.ui-table-toolbar__meta {
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-fg-subtle);
|
||||
color: var(--chrome-fg-dim);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -62,15 +65,16 @@
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 3px var(--space-3);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--color-fg-muted);
|
||||
background: var(--chrome-bg);
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
font-weight: 600;
|
||||
color: var(--chrome-fg-muted);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
letter-spacing: 0.03em;
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
+32
-40
@@ -1,90 +1,82 @@
|
||||
/* ── Tabs primitive ─────────────────────────────────────────────── */
|
||||
/* ── Tabs primitive — chrome tokens ────────────────────────────── */
|
||||
|
||||
.ui-tabs {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
--ui-tab-accent: var(--color-brand);
|
||||
--ui-tab-accent: var(--chrome-accent);
|
||||
}
|
||||
|
||||
/* pill variant — floating segmented bar */
|
||||
/* pill variant — flat chrome segmented bar */
|
||||
.ui-tabs--pill {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
background: var(--chrome-bg);
|
||||
padding: 3px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.45);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
border: 1px solid var(--chrome-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
.ui-tabs--pill .ui-tabs__tab {
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 14px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-pill);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: transparent;
|
||||
color: var(--color-fg-muted);
|
||||
font-family: var(--font-ui);
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--chrome-fg-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
font-size: var(--text-base);
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
letter-spacing: 0.005em;
|
||||
transition:
|
||||
transform 0.22s var(--ease-spring),
|
||||
background var(--dur-base) var(--ease-out),
|
||||
color var(--dur-base) var(--ease-out),
|
||||
box-shadow var(--dur-base) var(--ease-out);
|
||||
background var(--dur-fast) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out),
|
||||
border-color var(--dur-fast) var(--ease-out);
|
||||
position: relative;
|
||||
}
|
||||
.ui-tabs--pill .ui-tabs__tab:hover:not(.is-active) {
|
||||
color: #f5e6c5;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
transform: translateY(-1px);
|
||||
color: var(--chrome-fg);
|
||||
background: var(--chrome-hover-bg);
|
||||
transform: none;
|
||||
}
|
||||
.ui-tabs--pill .ui-tabs__tab.is-active {
|
||||
background: linear-gradient(
|
||||
140deg,
|
||||
color-mix(in srgb, var(--ui-tab-accent) 22%, transparent) 0%,
|
||||
rgba(250, 189, 47, 0.18) 100%
|
||||
);
|
||||
color: #fff9ef;
|
||||
border-color: color-mix(in srgb, var(--ui-tab-accent) 40%, transparent);
|
||||
box-shadow:
|
||||
0 2px 0 rgba(0, 0, 0, 0.2),
|
||||
0 6px 16px -8px color-mix(in srgb, var(--ui-tab-accent) 50%, transparent),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
font-weight: var(--weight-black);
|
||||
transform: translateY(-1px);
|
||||
background: var(--chrome-accent-bg);
|
||||
color: var(--chrome-accent);
|
||||
border-color: var(--chrome-accent-border);
|
||||
box-shadow: none;
|
||||
font-weight: 600;
|
||||
transform: none;
|
||||
}
|
||||
.ui-tabs--pill .ui-tabs__tab.is-active .ui-tabs__icon { color: var(--ui-tab-accent); }
|
||||
.ui-tabs--pill .ui-tabs__tab.is-active .ui-tabs__icon { color: var(--chrome-accent); }
|
||||
|
||||
/* underline variant — compact secondary nav */
|
||||
.ui-tabs--underline {
|
||||
gap: var(--space-5);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
}
|
||||
.ui-tabs--underline .ui-tabs__tab {
|
||||
padding: 6px 2px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--color-fg-muted);
|
||||
color: var(--chrome-fg-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: var(--weight-semibold);
|
||||
font-weight: 500;
|
||||
font-size: var(--text-base);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
transition: color var(--dur-base), border-color var(--dur-base);
|
||||
transition: color var(--dur-fast), border-color var(--dur-fast);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.ui-tabs--underline .ui-tabs__tab:hover:not(.is-active) { color: var(--color-fg); }
|
||||
.ui-tabs--underline .ui-tabs__tab:hover:not(.is-active) { color: var(--chrome-fg); }
|
||||
.ui-tabs--underline .ui-tabs__tab.is-active {
|
||||
color: var(--color-fg);
|
||||
border-color: var(--ui-tab-accent);
|
||||
color: var(--chrome-accent);
|
||||
border-color: var(--chrome-accent);
|
||||
}
|
||||
|
||||
/* size */
|
||||
|
||||
@@ -53,11 +53,16 @@
|
||||
--radius-xl: 10px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
/* ── Typography ─────────────────────────────────────────────────── */
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-display:'Fraunces', Georgia, serif;
|
||||
--font-ui: 'Nunito', 'Inter', sans-serif;
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
/* ── Typography (Option A — Codex-inspired stack) ──────────────────
|
||||
Inter for UI/body (closest OSS Söhne analog), IBM Plex Mono for chrome
|
||||
labels + data + logs (warmer than Geist Mono, pairs with OpenAI's feel),
|
||||
Source Serif 4 for hero moments only. Variable faces where possible so
|
||||
weight 400–700 comes from a single file download. */
|
||||
--font-sans: 'Inter Variable', 'Inter', -apple-system, BlinkMacSystemFont, 'Söhne', 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace;
|
||||
--font-serif: 'Source Serif 4 Variable', 'Source Serif 4', 'Söhne Breit', Georgia, serif;
|
||||
--font-display: var(--font-serif);
|
||||
--font-ui: var(--font-sans);
|
||||
|
||||
--text-2xs: 0.58rem;
|
||||
--text-xs: 0.62rem;
|
||||
|
||||
Reference in New Issue
Block a user