refactor(pages): modularize Clone/Gallery/Profile pages (all files <500) (#760)

Phase 3 — same standard as #758/#759, applied to the last three over-cap pages.
Pure-mechanical, no behavior change.

- VoiceGallery.jsx 768 → 205: relocate the already-separate zone components
  (ArchetypesZone, ArchetypeCard, CommunityZone, ImportsZone) + shared helpers
  into components/gallery/.
- CloneDesignTab.jsx 837 → 395: split the ~540-line JSX return into section
  components (ScriptPanel, AudioMethodPanel, DesignMethodPanel, ActionBar) +
  MicButton, under components/clone/. State stays in the page.
- VoiceProfile.jsx 515 → 287: split the main return into ProfileHeader /
  ProfileDetails / ProfileActivity under components/profile/.

Safety contract for the JSX splits (no render tests): explicit NAMED props on
every section so eslint no-undef verifies completeness on both ends; JSX moved
verbatim. Verified: 0 no-undef across all changed files; every original
className preserved (diffed main vs new set); every file <500 lines.

Verified: vite build passes; FULL frontend suite 638/638 pass.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-30 03:36:31 +05:30
committed by GitHub
co-authored by mergetest Claude Opus 4.8
parent a7f813b7a9
commit b9707e0d8f
16 changed files with 1666 additions and 1397 deletions
+160
View File
@@ -0,0 +1,160 @@
import { Globe, SlidersHorizontal, Settings2, ChevronUp, ChevronDown, Play, Square } from 'lucide-react';
import { Button, Progress } from '../../ui';
import SearchableSelect from '../SearchableSelect';
import ALL_LANGUAGES from '../../languages.json';
import { POPULAR_LANGS } from '../../utils/constants';
import { stopActivePlayback } from '../../utils/playback';
export default function ActionBar({
t, showOverrides, setShowOverrides,
cfg, setCfg, speed, setSpeed, tShift, setTShift, posTemp, setPosTemp,
classTemp, setClassTemp, layerPenalty, setLayerPenalty, duration, setDuration,
denoise, setDenoise, postprocess, setPostprocess,
language, setLanguage, steps, setSteps,
showHearDemo, playDemoOutput, demoAudioPlaying, demoAudioRef, demoReleaseRef, setDemoAudioPlaying,
outputPlaying, isGenerating, handleGenerate, generationTime, wasGeneratingRef,
}) {
return (
<div className="studio-action-bar clone-panel--overflow-visible">
{showOverrides && (
<div className="override-content">
<div className="grid-4">
<div>
<div className="label-row label-row--spread"><span>CFG</span><span className="val-bubble">{cfg}</span></div>
<input type="range" min="1.0" max="4.0" step="0.1" value={cfg} onChange={e => setCfg(Number(e.target.value))} />
</div>
<div>
<div className="label-row label-row--spread"><span>{t('clone.speed')}</span><span className="val-bubble">{speed}x</span></div>
<input type="range" min="0.5" max="2.0" step="0.1" value={speed} onChange={e => setSpeed(Number(e.target.value))} />
</div>
<div>
<div className="label-row label-row--spread"><span>{t('clone.tshift')}</span><span className="val-bubble">{tShift}</span></div>
<input type="range" min="0" max="1.0" step="0.05" value={tShift} onChange={e => setTShift(Number(e.target.value))} />
</div>
<div>
<div className="label-row label-row--spread"><span>{t('clone.pos_temp')}</span><span className="val-bubble">{posTemp}</span></div>
<input type="range" min="0" max="10" step="0.5" value={posTemp} onChange={e => setPosTemp(Number(e.target.value))} />
</div>
<div>
<div className="label-row label-row--spread"><span>{t('clone.class_temp')}</span><span className="val-bubble">{classTemp}</span></div>
<input type="range" min="0" max="2" step="0.1" value={classTemp} onChange={e => setClassTemp(Number(e.target.value))} />
</div>
<div>
<div className="label-row label-row--spread"><span>{t('clone.layer_pen')}</span><span className="val-bubble">{layerPenalty}</span></div>
<input type="range" min="0" max="10" step="0.5" value={layerPenalty} onChange={e => setLayerPenalty(Number(e.target.value))} />
</div>
<div>
<div className="label-row"><span>{t('clone.duration')}</span></div>
<input type="text" className="input-base clone-duration-input" value={duration} onChange={e => setDuration(e.target.value)} placeholder={t('clone.auto')} />
</div>
<div className="clone-prod-col">
<label className="clone-prod-check">
<input type="checkbox" checked={denoise} onChange={e => setDenoise(e.target.checked)} /> {t('clone.denoise')}
</label>
<label className="clone-prod-check">
<input type="checkbox" checked={postprocess} onChange={e => setPostprocess(e.target.checked)} /> {t('clone.postprocess')}
</label>
</div>
</div>
</div>
)}
{/* Controls row: language · steps · overrides disclosure */}
<div className="studio-action-bar__row">
<div className="studio-action-bar__lang">
<Globe size={12} className="label-icon" />
<SearchableSelect
value={language}
options={ALL_LANGUAGES}
popular={POPULAR_LANGS}
recentsKey="omnivoice.recents.genLang"
onChange={setLanguage}
/>
</div>
<label className="studio-action-bar__steps" title={t('clone.steps')}>
<SlidersHorizontal size={12} className="label-icon" />
<input type="range" min="8" max="64" value={steps} onChange={e => setSteps(Number(e.target.value))} />
<span className="val-bubble">{steps}</span>
</label>
<button
type="button"
className="studio-action-bar__overrides"
onClick={() => setShowOverrides(!showOverrides)}
aria-expanded={showOverrides}
>
<Settings2 size={13} /> {t('clone.production_overrides')}
{showOverrides ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
</button>
</div>
{showHearDemo ? (
<>
<Button
variant="primary"
block
onClick={playDemoOutput}
leading={<Play size={14} />}
className="clone-footer-cta"
>
{demoAudioPlaying ? t('demo.stop_demo') : t('demo.hear_demo')}
</Button>
<div className="clone-hear-demo-chip">
{t('demo.prerendered_chip')}
</div>
<audio
ref={demoAudioRef}
onEnded={() => {
setDemoAudioPlaying(false);
demoReleaseRef.current?.();
demoReleaseRef.current = null;
}}
preload="none"
/>
</>
) : outputPlaying && !isGenerating ? (
/* Synthesized output is playing — the CTA becomes a Stop button
(#316) so playback can be halted immediately. */
<Button
variant="primary"
block
onClick={stopActivePlayback}
leading={<Square size={14} />}
className="clone-footer-cta"
>
{t('clone.stop_playback')}
</Button>
) : (
<Button
variant="primary"
block
loading={isGenerating}
onClick={handleGenerate}
leading={!isGenerating && <Play size={14} />}
className="clone-footer-cta"
>
{isGenerating ? t('clone.synthesizing', { seconds: generationTime }) : t('clone.synthesize')}
</Button>
)}
{isGenerating && (
<Progress
value={Math.min((generationTime / 8) * 100, 95)}
tone="brand"
size="sm"
className="clone-footer-cta"
/>
)}
{/* 10x P4 a11y (spec §3): persistent polite live region — screen
readers hear generation start AND finish in-workspace, without
relying on the FloatingPill. sr-only keeps it out of the
action-bar flex flow; static text avoids per-second re-announces
from the ticking "Synthesizing… (Ns)" button label. */}
<div className="sr-only" role="status" aria-live="polite">
{isGenerating
? t('clone.generating_status', { defaultValue: 'Generating audio…' })
: wasGeneratingRef.current
? t('clone.generating_done_status', { defaultValue: 'Generation finished' })
: null}
</div>
</div>
);
}
@@ -0,0 +1,142 @@
import { UploadCloud, X, Save, Dice5 } from 'lucide-react';
import { Button, Input } from '../../ui';
import MicButton from './MicButton';
export default function AudioMethodPanel({
t, selectedProfile, setSelectedProfile, profiles, ingestRefAudio, refAudio,
isCleaning, isRecording, recordingTime, startRecording, stopRecording,
refText, setRefText, instruct, setInstruct,
defineMethod, designSeed, setDesignSeed, keepSeed, setKeepSeed,
showSaveProfile, setShowSaveProfile, profileName, setProfileName, handleSaveProfile,
}) {
return (
<div>
{/* Saved voices now live in the right-side WorkspaceVoices panel. */}
{!selectedProfile && (
<div className="clone-drop-row">
<input
type="file"
accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg"
onChange={e => { const f = e.target.files[0]; ingestRefAudio(f); e.target.value = ''; }}
className="dub-hidden-file"
id="audio-upload"
/>
<label
htmlFor="audio-upload"
className="file-drag clone-drop-zone"
onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add('is-dragging'); }}
onDragLeave={e => { e.currentTarget.classList.remove('is-dragging'); }}
onDrop={e => {
e.preventDefault();
e.currentTarget.classList.remove('is-dragging');
const file = e.dataTransfer.files[0];
const okType = file && (file.type.startsWith('audio/') || /\.(mp3|wav|m4a|flac|ogg|aac|webm)$/i.test(file.name));
if (okType) ingestRefAudio(file);
}}
>
<UploadCloud color="#a89984" size={18} />
<p>{refAudio ? <span className="clone-drop-filename">{refAudio.name}</span> : t('clone.drop_audio')}</p>
</label>
<MicButton
isCleaning={isCleaning}
isRecording={isRecording}
recordingTime={recordingTime}
onStart={startRecording}
onStop={stopRecording}
/>
</div>
)}
{selectedProfile && (
<div className="clone-profile-banner">
<span className="clone-profile-banner__label">
{t('clone.using_profile', { name: profiles.find(p => p.id === selectedProfile)?.name })}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedProfile(null)}
leading={<X size={11} />}
>
{t('clone.clear')}
</Button>
</div>
)}
<div className="grid-2 grid-2--indent">
<div>
<div className="label-row">{t('clone.transcript')}</div>
<input type="text" className="input-base" value={refText} onChange={e => setRefText(e.target.value)} placeholder={t('clone.optional')} />
</div>
<div>
<div className="label-row">{t('clone.style')}</div>
<input type="text" className="input-base" value={instruct} onChange={e => setInstruct(e.target.value)} placeholder={t('clone.style_placeholder')} />
</div>
</div>
{/* #526: voice-design seed — show + pin + re-roll so tweaks can
stay on the same base timbre. Design mode only. */}
{defineMethod === 'design' && (
<div className="design-seed">
<div className="label-row">{t('clone.seed_label')}</div>
<div className="design-seed__row">
<input
type="number"
className="input-base design-seed__input"
value={designSeed ?? ''}
placeholder={t('clone.seed_placeholder')}
onChange={e => {
const v = e.target.value.trim();
if (v === '') { setDesignSeed(null); return; }
const n = parseInt(v, 10);
if (Number.isInteger(n)) { setDesignSeed(n); setKeepSeed(true); }
}}
/>
<Button
variant="subtle"
size="sm"
onClick={() => { setDesignSeed(Math.floor(Math.random() * 2147483647)); setKeepSeed(true); }}
leading={<Dice5 size={12} />}
title={t('clone.seed_reroll_hint')}
>
{t('clone.seed_reroll')}
</Button>
<label className="design-seed__keep">
<input type="checkbox" checked={keepSeed} onChange={e => setKeepSeed(e.target.checked)} />
<span>{t('clone.seed_keep')}</span>
</label>
</div>
</div>
)}
{/* Save as profile */}
{refAudio && !selectedProfile && (
<div className="clone-save-profile">
{!showSaveProfile ? (
<Button
variant="subtle"
size="sm"
onClick={() => setShowSaveProfile(true)}
leading={<Save size={12} />}
>
{t('clone.save_as_profile')}
</Button>
) : (
<div className="clone-save-profile__row">
<Input
size="sm"
placeholder={t('clone.profile_name')}
value={profileName}
onChange={e => setProfileName(e.target.value)}
/>
<Button variant="subtle" size="sm" onClick={handleSaveProfile}>{t('clone.save')}</Button>
<Button variant="ghost" size="sm" onClick={() => setShowSaveProfile(false)}>{t('clone.cancel')}</Button>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,179 @@
import { ChevronUp, ChevronDown, Save } from 'lucide-react';
import { Button, Input } from '../../ui';
import { PRESETS, CATEGORIES } from '../../utils/constants';
import {
PRESET_ICONS, PERSONALITY_ICONS, FALLBACK_VOICE_ICON, FALLBACK_PERSONALITY_ICON, stripVoiceEmoji,
} from '../../utils/voiceIcons';
import { buildDesignInstruct } from '../../utils/voiceInstruct';
export default function DesignMethodPanel({
t, describeText, onDescribeChange, describeMatchedAny, describeUnmatched,
chipPersonalities, activePersonality, applyPersonality, applyPreset,
identityOpen, setIdentityOpen, identityRecipe,
vdStates, setVdStates, onChipKeyDown,
showSaveProfile, setShowSaveProfile, profileName, setProfileName,
handleSaveDesignProfile, instruct, language,
}) {
return (
<div>
{/* ── Describe your voice (#317) — free text drives the controls.
The placeholder explains itself; no extra header (10x §1.2). ── */}
<div className="describe-voice-block">
<textarea
className="input-base describe-voice-area"
rows={2}
placeholder={t('clone.describe_placeholder')}
value={describeText}
onChange={onDescribeChange}
/>
{describeText.trim() && !describeMatchedAny && (
<div className="describe-voice-feedback" role="status">
{t('clone.describe_no_match')}
</div>
)}
{describeMatchedAny && describeUnmatched.length > 0 && (
<div className="describe-voice-feedback" role="status">
{t('clone.describe_unmatched', { items: describeUnmatched.join(', ') })}
</div>
)}
<div className="describe-voice-hint">{t('clone.describe_hint')}</div>
</div>
{/* ONE preset system (10x §1.3): personalities + the old PROMPT
presets share a single scrollable "Starting points" lane —
both set vdStates + instruct; two widgets for one slot was
the confusion. */}
<div className="starting-points">
<div className="starting-points__label">{t('clone.starting_points', { defaultValue: 'Starting points' })}</div>
<div className="personality-strip starting-points__strip">
{chipPersonalities.map(p => {
const Icon = PERSONALITY_ICONS[p.id] || FALLBACK_PERSONALITY_ICON;
return (
<button
key={p.id}
type="button"
className={`personality-chip ${activePersonality === p.id ? 'active' : ''}`}
onClick={() => applyPersonality(p)}
>
<span className="personality-chip__icon"><Icon size={13} /></span>
{stripVoiceEmoji(t(`clone.personality_${p.id}`, { defaultValue: p.name }))}
</button>
);
})}
{PRESETS.map(p => {
const Icon = PRESET_ICONS[p.id] || FALLBACK_VOICE_ICON;
return (
<button key={p.id} type="button" className="personality-chip" onClick={() => applyPreset(p)}>
<span className="personality-chip__icon"><Icon size={13} /></span>
{stripVoiceEmoji(t(`clone.preset_${p.id}`, { defaultValue: p.name }))}
</button>
);
})}
</div>
</div>
{/* Identity recipe (10x §1.5): once any category is set, the
chip groups collapse to one quiet line — the current voice
recipe — and the describe box rewrites it live. All-Auto
(first run) starts expanded. */}
<button
type="button"
className="identity-line"
onClick={() => setIdentityOpen(o => !o)}
aria-expanded={identityOpen}
>
<span className="identity-line__kicker">{t('clone.identity', { defaultValue: 'Identity' })}</span>
<span className="identity-line__recipe">{identityRecipe}</span>
{identityOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{identityOpen && (
<div className="clone-sliders-col">
{Object.entries(CATEGORIES).map(([key, options]) => {
const many = options.length > 6;
const optLabel = (val) => {
const tKey = `clone.opt_${val.replace(/[ -]/g, '_')}`;
const tl = t(tKey);
return tl !== tKey ? tl : val;
};
return (
<div key={key} className={`clone-cat ${many ? 'clone-cat--select' : 'clone-cat--chips'}`}>
<div className="label-row label-row--sm">
{t(`clone.cat_${key}`)}
<span className="clone-slider-kicker">
{vdStates[key] === 'Auto' ? t('clone.auto_kicker') : `· ${optLabel(vdStates[key])}`}
</span>
</div>
{many ? (
<select
className="input-base"
value={vdStates[key]}
onChange={e => setVdStates({ ...vdStates, [key]: e.target.value })}
>
{options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
) : (
<div className="chip-group" role="radiogroup" aria-label={t(`clone.cat_${key}`)}>
{options.map((opt, i) => {
const optTKey = `clone.opt_${opt.replace(/[ -]/g, '_')}`;
const optTl = t(optTKey);
const optLabel = optTl !== optTKey ? optTl : opt;
const checked = vdStates[key] === opt;
// Roving tabindex: the checked chip is the group's
// single tab stop (first chip if nothing matches).
const roving = checked || (!options.includes(vdStates[key]) && i === 0);
return (
<button
key={opt}
type="button"
role="radio"
aria-checked={checked}
tabIndex={roving ? 0 : -1}
className={`chip ${checked ? 'active' : ''}`}
onClick={() => setVdStates({ ...vdStates, [key]: opt })}
onKeyDown={e => onChipKeyDown(e, key, options)}
>
{opt === 'Auto'
? <span className="chip-auto"><FALLBACK_VOICE_ICON size={11} /> {stripVoiceEmoji(t('clone.opt_Auto'))}</span>
: optLabel}
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
{/* Save the current design as a reusable profile (0005): the
backend renders a deterministic identity sample (seed 42)
and stores the slider picks for later re-editing. */}
<div className="clone-save-profile">
{!showSaveProfile ? (
<Button
variant="subtle"
size="sm"
onClick={() => setShowSaveProfile(true)}
leading={<Save size={12} />}
>
{t('clone.save_design_as_profile', { defaultValue: 'Save design as profile' })}
</Button>
) : (
<div className="clone-save-profile__row">
<Input
size="sm"
placeholder={t('clone.profile_name')}
value={profileName}
onChange={e => setProfileName(e.target.value)}
/>
<Button variant="subtle" size="sm"
onClick={() => handleSaveDesignProfile(vdStates, buildDesignInstruct(vdStates, instruct).instruct, language)}>
{t('clone.save')}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowSaveProfile(false)}>{t('clone.cancel')}</Button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next';
import { Sparkles, Square, Mic } from 'lucide-react';
export default function MicButton({ isCleaning, isRecording, recordingTime, onStart, onStop }) {
const { t } = useTranslation();
if (isCleaning) {
return (
<div className="mic-btn mic-btn--cleaning">
<Sparkles size={18} className="spinner" />
<span>{t('clone.cleaning')}</span>
</div>
);
}
if (isRecording) {
return (
<button type="button" onClick={onStop} className="mic-btn mic-btn--recording">
<Square size={18} fill="currentColor" />
<span>{recordingTime}s</span>
</button>
);
}
return (
<button type="button" onClick={onStart} className="mic-btn mic-btn--idle" title={t('clone.record')}>
<Mic size={18} />
<span>{t('clone.record')}</span>
</button>
);
}
@@ -0,0 +1,88 @@
import { Command, Plus, ChevronDown } from 'lucide-react';
import DemoPresetGrid from '../DemoPresetGrid';
import { TAGS } from '../../utils/constants';
export default function ScriptPanel({
t, defineMethod, text, setText, activePersonality,
demoPresets, applyDemoPreset,
showDemoCoachmark, setShowDemoCoachmark, selectedProfile, DEMO_PROFILE_ID,
textAreaRef, insertOpen, setInsertOpen, insertTag,
}) {
return (
<div className="studio-column">
{/* overflow-visible: the ⊕ Insert popover opens above the textarea and
must escape the panel's `overflow:auto` box instead of being clipped
into its scroll region (#481). */}
<div className="studio-panel clone-panel--overflow-visible">
<div className="label-row label-row--center">
<Command className="label-icon" size={14} /> {t('clone.script', { defaultValue: 'Script' })}
</div>
{/* Design-tab empty state: 7-card demo grid until the user
interacts; then it steps aside for the standard form. */}
{defineMethod === 'design' && !text && !activePersonality && demoPresets.length > 0 && (
<DemoPresetGrid presets={demoPresets} onUse={applyDemoPreset} />
)}
{showDemoCoachmark && defineMethod === 'audio' && selectedProfile === DEMO_PROFILE_ID && (
<div className="clone-coachmark" role="note">
<span className="clone-coachmark__icon">💡</span>
<span className="clone-coachmark__msg">
{t('demo.clone_coachmark')}
</span>
<button
type="button"
className="clone-coachmark__close"
onClick={() => setShowDemoCoachmark(false)}
aria-label="Dismiss coach mark"
>
×
</button>
</div>
)}
<div className="clone-script-wrap">
<textarea
ref={textAreaRef}
className="input-base clone-text-area"
placeholder={defineMethod === 'audio' ? t('clone.prompt_placeholder') : t('clone.design_placeholder')}
value={text}
onChange={e => {
setText(e.target.value);
if (showDemoCoachmark) setShowDemoCoachmark(false);
}}
/>
{/* Expression tokens live behind a popover — fourteen permanent
chips were renting the page's best pixels for an occasional
power feature (10x spec §1.4). */}
<button
type="button"
className={`clone-insert-btn ${insertOpen ? 'is-open' : ''}`}
onClick={() => setInsertOpen(o => !o)}
aria-expanded={insertOpen}
aria-label={t('clone.insert_token', { defaultValue: 'Insert expression token' })}
>
<Plus size={11} /> {t('clone.insert', { defaultValue: 'Insert' })} <ChevronDown size={10} />
</button>
{insertOpen && (
<div className="clone-insert-backdrop" onClick={() => setInsertOpen(false)} />
)}
{insertOpen && (
<div className="clone-insert-pop" role="menu">
{TAGS.map(tag => (
<button key={tag} className="tag-btn" role="menuitem"
onClick={() => { insertTag(tag); setInsertOpen(false); }}>
{tag}
</button>
))}
<button
className="tag-btn clone-auto-extract-btn" role="menuitem"
onClick={() => { insertTag('[B EY1 S]'); setInsertOpen(false); }}
>
[CMU]
</button>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,64 @@
import React from 'react';
import { Play, Loader, Star, Wand2, UserPlus } from 'lucide-react';
import { ArchetypeAvatar, AccentFlag, NowPlaying, USE_CASE_COLOR } from '../../utils/archetypeIcons';
import { facetLabel } from './constants';
// ── Archetype card ───────────────────────────────────────────────────────────
export default function ArchetypeCard({
a, t, viewMode, isFavorite, isPlaying, isLoadingPreview,
onPreview, onUse, onDesign, onToggleFavorite,
}) {
const color = USE_CASE_COLOR[a.use_case] || '#83a598';
const sub = [a.facets.gender, a.facets.age, a.facets.pitch]
.filter(Boolean).map(facetLabel).join(' · ');
const dialect = a.attrs?.ChineseDialect && a.attrs.ChineseDialect !== 'Auto' ? a.attrs.ChineseDialect : null;
const accentLabel = a.facets.accent
? facetLabel(a.facets.accent)
: (dialect || (a.language === 'Chinese' ? 'Chinese' : null));
return (
<div className={`archetype-card ${viewMode} ${isPlaying ? 'playing' : ''}`} style={{ '--card-accent': color }}>
<div className="arch-head">
<ArchetypeAvatar item={a} />
<div className="arch-title">
<div className="archetype-name">{a.name}</div>
{sub && <div className="archetype-sub">{sub}</div>}
</div>
<button
className={`fav-btn ${isFavorite ? 'on' : ''}`}
onClick={() => onToggleFavorite(a.id)}
title={t('gallery.favorite', { defaultValue: 'Favorite' })}
>
<Star size={15} fill={isFavorite ? 'currentColor' : 'none'} />
</button>
</div>
{/* Always render the chip row (even when empty) so every card shares the
same height and the action rows align across the grid. */}
<div className="archetype-chips">
{accentLabel && (
<span className="facet-chip with-flag">
<AccentFlag accent={a.facets.accent} lang={a.language} size={14} />
{accentLabel}
</span>
)}
{a.facets.whisper && (
<span className="facet-chip">{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}</span>
)}
</div>
<div className="arch-foot">
<button className="preview-btn" onClick={() => onPreview(a)} title={t('gallery.preview', { defaultValue: 'Preview' })}>
{isLoadingPreview ? <Loader className="spin" size={15} /> : isPlaying ? <NowPlaying color={color} /> : <Play size={15} />}
<span>{t('gallery.preview', { defaultValue: 'Preview' })}</span>
</button>
<button className="use-btn" onClick={() => onUse(a)}>
<UserPlus size={14} /> {t('gallery.use_voice', { defaultValue: 'Use voice' })}
</button>
<button className="designer-btn" onClick={() => onDesign(a)} title={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}>
<Wand2 size={14} />
</button>
</div>
</div>
);
}
@@ -0,0 +1,180 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Loader, Star, RotateCcw, Grid, List } from 'lucide-react';
import { Button } from '../../ui';
import { useArchetypeCategories, useArchetypes } from '../../api/hooks';
import { ArchetypeIcon } from '../../utils/archetypeIcons';
import { titleCase, facetLabel } from './constants';
import ArchetypeCard from './ArchetypeCard';
const BROWSE_PAGE = 60;
// Facet vocabularies — values must match the backend taxonomy tokens exactly.
const FACETS = {
gender: ['male', 'female'],
age: ['child', 'teenager', 'young adult', 'middle-aged', 'elderly'],
pitch: ['very low pitch', 'low pitch', 'moderate pitch', 'high pitch', 'very high pitch'],
accent: [
'american accent', 'british accent', 'australian accent', 'canadian accent',
'indian accent', 'chinese accent', 'japanese accent', 'korean accent',
'portuguese accent', 'russian accent',
],
// English + Chinese come from the generated catalog; the rest are curated
// multilingual designed voices. Values must match the archetype `language`
// field (a languages.json entry) exactly — that drives the backend filter.
lang: [
'English', 'Chinese', 'Spanish', 'French', 'German', 'Italian',
'Portuguese', 'Russian', 'Hindi', 'Japanese', 'Korean',
],
};
const hasActiveFilters = (f) => Object.values(f).some((v) => v !== null && v !== '');
// ── Archetypes zone ─────────────────────────────────────────────────────────
export default function ArchetypesZone({
t, filters, setFilter, resetFilters, favorites, toggleFavorite,
viewMode, setViewMode, playingId, loadingPreviewId, onPreview, onUse, onDesign,
}) {
const [favOnly, setFavOnly] = useState(false);
const [offset, setOffset] = useState(0);
useEffect(() => { setOffset(0); }, [filters]);
const cleanFilters = useMemo(() => {
const out = {};
Object.entries(filters).forEach(([k, v]) => { if (v !== null && v !== '') out[k] = v; });
return out;
}, [filters]);
// The Featured strip shows only when nothing is filtered; in that case Browse
// excludes featured to avoid duplicating it. Once any filter is active the
// Featured strip is hidden (see below), so Browse must include featured too —
// otherwise the curated multilingual languages (Spanish/French/…), which have
// *only* featured archetypes, would filter down to an empty list.
const showFeatured = !hasActiveFilters(filters) && !favOnly;
const categoriesQ = useArchetypeCategories();
const featuredQ = useArchetypes({ featured: true, limit: 100 });
const browseQ = useArchetypes({
...cleanFilters,
...(showFeatured ? { featured: false } : {}),
limit: BROWSE_PAGE,
offset,
});
const categories = categoriesQ.data || [];
const featured = featuredQ.data?.items || [];
const browse = browseQ.data?.items || [];
const total = browseQ.data?.total ?? 0;
const favSet = useMemo(() => new Set(favorites), [favorites]);
const applyFav = (list) => (favOnly ? list.filter((a) => favSet.has(a.id)) : list);
// NOTE: no `key` here — React keys must be passed directly on the element,
// not spread in (spreading a `key` prop triggers a dev warning + is ignored).
const cardProps = (a) => ({
a, t, viewMode,
isFavorite: favSet.has(a.id),
isPlaying: playingId === a.id,
isLoadingPreview: loadingPreviewId === a.id,
onPreview, onUse, onDesign, onToggleFavorite: toggleFavorite,
});
return (
<div className="gallery-content gallery-scroll">
<div className="facet-bar">
{/* Three filter lanes (categories · facets · toggles), each its own
horizontally-scrollable portion; the view toggle is pinned right. */}
<div className="facet-group facet-group--cats use-case-chips">
<button className={`category-chip ${!filters.use_case ? 'selected' : ''}`} onClick={() => setFilter('use_case', null)}>
{t('gallery.all', { defaultValue: 'All' })}
</button>
{categories.map((c) => (
<button
key={c.id}
className={`category-chip ${filters.use_case === c.id ? 'selected' : ''}`}
onClick={() => setFilter('use_case', filters.use_case === c.id ? null : c.id)}
title={c.name}
>
<ArchetypeIcon name={c.icon} size={13} />
{t(`archetypes.use_${c.id}`, { defaultValue: c.name })}
</button>
))}
</div>
<div className="facet-group facet-group--facets facet-selects">
{['gender', 'age', 'pitch', 'accent', 'lang'].map((dim) => (
<select
key={dim}
className="facet-select"
value={filters[dim] ?? ''}
onChange={(e) => setFilter(dim, e.target.value || null)}
>
<option value="">{t(`archetypes.facet_${dim}`, { defaultValue: titleCase(dim) })}</option>
{FACETS[dim].map((opt) => <option key={opt} value={opt}>{facetLabel(opt)}</option>)}
</select>
))}
</div>
<div className="facet-group facet-group--toggles">
<label className="facet-toggle">
<input
type="checkbox"
checked={filters.whisper === true}
onChange={(e) => setFilter('whisper', e.target.checked ? true : null)}
/>
{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}
</label>
<label className="facet-toggle">
<input type="checkbox" checked={favOnly} onChange={(e) => setFavOnly(e.target.checked)} />
<Star size={12} /> {t('gallery.favorites', { defaultValue: 'Favorites' })}
</label>
<button className="facet-reset" onClick={() => { resetFilters(); setFavOnly(false); }}>
<RotateCcw size={12} /> {t('gallery.reset', { defaultValue: 'Reset' })}
</button>
</div>
<div className="view-toggle">
<button className={viewMode === 'grid' ? 'active' : ''} onClick={() => setViewMode('grid')} title="Grid"><Grid size={14} /></button>
<button className={viewMode === 'list' ? 'active' : ''} onClick={() => setViewMode('list')} title="List"><List size={14} /></button>
</div>
</div>
{showFeatured && (
<section className="archetype-section">
<div className="content-header"><div className="content-title">{t('archetypes.featured', { defaultValue: 'Featured' })}</div></div>
<div className={`archetype-grid ${viewMode}`}>
{applyFav(featured).map((a) => <ArchetypeCard key={a.id} {...cardProps(a)} />)}
</div>
</section>
)}
<section className="archetype-section">
<div className="content-header">
<div className="content-title">
{t('archetypes.browse_all', { defaultValue: 'Browse all' })}
<span className="count-badge">{total}</span>
</div>
</div>
{browseQ.isLoading ? (
<div className="loading"><Loader className="spin" size={18} /></div>
) : (
<>
<div className={`archetype-grid ${viewMode}`}>
{applyFav(browse).map((a) => <ArchetypeCard key={a.id} {...cardProps(a)} />)}
</div>
{applyFav(browse).length === 0 && (
<div className="empty">{t('gallery.no_matches', { defaultValue: 'No voices match these filters.' })}</div>
)}
{offset + BROWSE_PAGE < total && !favOnly && (
<div className="load-more">
<Button variant="ghost" onClick={() => setOffset(offset + BROWSE_PAGE)} disabled={browseQ.isFetching}>
{browseQ.isFetching ? <Loader className="spin" size={14} /> : null}
{t('gallery.load_more', { defaultValue: 'Load more' })}
</Button>
</div>
)}
</>
)}
</section>
</div>
);
}
@@ -0,0 +1,75 @@
import React, { useMemo } from 'react';
import { Loader, Send } from 'lucide-react';
import { useCommunityItems } from '../../api/hooks';
import { addCommunityItem, communitySubmitUrl } from '../../api/community';
import { openExternal } from '../../api/external';
import ArchetypeCard from './ArchetypeCard';
// ── Community zone (marketplace) ─────────────────────────────────────────────
export default function CommunityZone({ t, playingId, loadingPreviewId, favorites, toggleFavorite, onPlayAudio, flash, onDesign }) {
const itemsQ = useCommunityItems({ limit: 100 });
const items = itemsQ.data?.items || [];
const favSet = useMemo(() => new Set(favorites), [favorites]);
const submit = async (type) => {
try {
const { url } = await communitySubmitUrl(type);
await openExternal(url);
} catch {
flash(t('gallery.submit_failed', { defaultValue: 'Could not open the submission form.' }));
}
};
return (
<div className="gallery-content gallery-scroll">
<div className="import-explainer community-explainer">
<span>{t('gallery.community_explainer', { defaultValue: 'Designed presets and recorded voices shared by the community, loaded from the omnivoice-gallery.' })}</span>
<div className="submit-actions">
<button className="submit-btn" onClick={() => submit('preset')}>
<Send size={13} /> {t('gallery.submit_preset', { defaultValue: 'Submit a preset' })}
</button>
<button className="submit-btn" onClick={() => submit('voice')}>
<Send size={13} /> {t('gallery.submit_voice', { defaultValue: 'Submit a voice' })}
</button>
</div>
</div>
{itemsQ.isLoading ? (
<div className="loading"><Loader className="spin" size={18} /></div>
) : items.length === 0 ? (
<div className="empty">
{t('gallery.community_empty', { defaultValue: 'No community voices loaded yet — connect to the internet and reopen, or be the first to submit one.' })}
</div>
) : (
<div className="archetype-grid grid">
{items.map((it) => (
<ArchetypeCard
key={it.id}
a={it}
t={t}
viewMode="grid"
isFavorite={favSet.has(it.id)}
isPlaying={playingId === it.id}
isLoadingPreview={loadingPreviewId === it.id}
onToggleFavorite={toggleFavorite}
onPreview={(item) => (item.audio?.url
? onPlayAudio(item.audio.url, item.id)
: flash(t('gallery.no_preview', { defaultValue: 'No preview — add it with "Use voice" to hear it.' })))}
onUse={async (item) => {
try {
const r = await addCommunityItem(item.id, item.name);
flash(t('gallery.saved_as_profile', { defaultValue: 'Added "{{name}}" to your voices.', name: r.name }));
} catch {
flash(t('gallery.use_failed', { defaultValue: 'Could not add that voice.' }));
}
}}
onDesign={(item) => (item.instruct
? onDesign(item.instruct)
: flash(t('gallery.no_designer', { defaultValue: 'Recorded voice — use "Use voice" instead.' })))}
/>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,262 @@
import React, { useState, useRef } from 'react';
import {
Search, Download, Play, Pause, Trash2, X, Loader, UserPlus, Upload, Scissors, Package,
} from 'lucide-react';
import { Button, Input } from '../../ui';
import { useGalleryVoices } from '../../api/hooks';
import { importPersona } from '../../api/profiles';
import {
searchYoutube, downloadYoutubeClip, deleteGalleryVoice,
saveVoiceAsProfile, uploadVoiceClip, previewVoiceUrl,
} from '../../api/gallery';
import AudioTrimmer from '../AudioTrimmer';
import { apiUrl } from '../../api/client';
import { askConfirm } from '../../utils/dialog';
// ── My Imports zone (neutral importer) ───────────────────────────────────────
export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGallery, flash }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [trimming, setTrimming] = useState(null); // { voice, file }
const fileRef = useRef(null);
const personaRef = useRef(null);
const [importingPersona, setImportingPersona] = useState(false);
const voicesQ = useGalleryVoices();
const voices = voicesQ.data || [];
const reload = () => voicesQ.refetch();
// Import a portable .ovsvoice (or legacy .omnivoice) persona bundle (#29).
const handlePersonaImport = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setImportingPersona(true);
try {
const fd = new FormData();
fd.append('file', file);
const res = await importPersona(fd);
reload();
flash(t('gallery.persona_imported', {
defaultValue: 'Imported "{{name}}"{{unverified}}.', name: res.name,
unverified: res.verified_own_voice ? '' : t('gallery.persona_unverified_suffix', { defaultValue: ' (unverified)' }),
}));
} catch (err) {
const code = String(err?.message || err);
const msg = code.includes('413')
? t('gallery.persona_too_large', { defaultValue: 'That bundle is too large (max 100 MB).' })
: t('gallery.persona_import_failed', { defaultValue: 'Could not import that persona bundle.' });
flash(msg);
} finally {
setImportingPersona(false);
if (personaRef.current) personaRef.current.value = '';
}
};
const isUrl = /^https?:\/\//i.test(query.trim());
const handleSearch = async () => {
const q = query.trim();
if (!q) return;
if (isUrl) {
setIsDownloading(true);
try {
await downloadYoutubeClip({
video_url: q, start_time: 0, duration: 15,
character_name: t('gallery.imported_clip', { defaultValue: 'Imported clip' }),
category: 'import', description: q,
});
reload();
setQuery('');
} catch (e) {
flash(t('gallery.download_failed', { defaultValue: 'Download failed: {{msg}}', msg: e.message }));
} finally {
setIsDownloading(false);
}
return;
}
setIsSearching(true);
try {
const r = await searchYoutube(q, 'import', 10);
setResults(r.results || []);
} catch (e) {
flash(t('gallery.search_failed', { defaultValue: 'Search failed.' }));
} finally {
setIsSearching(false);
}
};
const handleDownload = async (info) => {
setIsDownloading(true);
try {
await downloadYoutubeClip({
video_url: `https://youtube.com/watch?v=${info.video_id}`,
start_time: 0,
duration: Math.min(parseFloat(info.duration) || 15, 30),
character_name: (info.title || '').substring(0, 40),
category: 'import',
description: info.title,
});
reload();
setResults([]);
} catch (e) {
flash(t('gallery.download_failed', { defaultValue: 'Download failed: {{msg}}', msg: e.message }));
} finally {
setIsDownloading(false);
}
};
const handleUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const fd = new FormData();
fd.append('name', file.name.replace(/\.[^.]+$/, ''));
fd.append('category', 'import');
fd.append('audio', file);
try {
await uploadVoiceClip(fd);
reload();
} catch (err) {
flash(t('gallery.upload_failed', { defaultValue: 'Upload failed.' }));
} finally {
if (fileRef.current) fileRef.current.value = '';
}
};
const handleSaveProfile = async (v) => {
try {
await saveVoiceAsProfile(v.id, v.name);
flash(t('gallery.saved_as_profile', { defaultValue: 'Added "{{name}}" to your voices.', name: v.name }));
} catch (e) {
flash(t('gallery.save_failed', { defaultValue: 'Could not save profile.' }));
}
};
const handleDelete = async (v) => {
if (!(await askConfirm(t('gallery.confirm_delete', { defaultValue: 'Delete "{{name}}"?', name: v.name })))) return;
try { await deleteGalleryVoice(v.id); reload(); } catch { /* noop */ }
};
const handleTrimClick = async (v) => {
try {
const resp = await fetch(apiUrl(previewVoiceUrl(v.id)));
if (!resp.ok) throw new Error('fetch failed');
const blob = await resp.blob();
const file = new File([blob], `${v.name}.wav`, { type: 'audio/wav' });
setTrimming({ voice: v, file });
} catch (e) {
flash(t('gallery.trim_load_failed', { defaultValue: 'Could not load audio for trimming.' }));
}
};
const handleConfirmTrim = async (trimmedFile) => {
if (!trimming) return;
const { voice } = trimming;
const fd = new FormData();
fd.append('name', `${voice.name} (Cropped)`);
fd.append('character', voice.character || '');
fd.append('category', 'import');
fd.append('description', voice.description || '');
fd.append('audio', trimmedFile);
try { await uploadVoiceClip(fd); reload(); setTrimming(null); } catch (e) {
flash(t('gallery.upload_failed', { defaultValue: 'Upload failed.' }));
}
};
return (
<div className="gallery-content">
<div className="import-explainer">
{t('gallery.import_explainer', {
defaultValue: 'Paste a URL you have the rights to (or upload a file), trim the part you need, and save it as a voice. You are responsible for the licensing of anything you import.',
})}
</div>
<div className="gallery-search">
<div className="search-row">
<Input
placeholder={t('gallery.import_placeholder', { defaultValue: 'Paste a video/audio URL, or type to search…' })}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
/>
<Button onClick={handleSearch} disabled={isSearching || isDownloading} size="sm">
{isSearching || isDownloading ? <Loader size={14} className="spin" /> : isUrl ? <Download size={14} /> : <Search size={14} />}
</Button>
<input ref={fileRef} type="file" accept="audio/*,video/*" hidden onChange={handleUpload} />
<Button variant="ghost" size="sm" onClick={() => fileRef.current?.click()} title={t('gallery.upload', { defaultValue: 'Upload file' })}>
<Upload size={14} />
</Button>
<input ref={personaRef} type="file" accept=".ovsvoice,.omnivoice" hidden onChange={handlePersonaImport} />
<Button variant="ghost" size="sm" disabled={importingPersona}
onClick={() => personaRef.current?.click()}
title={t('gallery.import_persona', { defaultValue: 'Import a .ovsvoice persona bundle' })}>
{importingPersona ? <Loader size={14} className="spin" /> : <Package size={14} />}
</Button>
</div>
</div>
{results.length > 0 && (
<div className="search-results-panel">
<div className="panel-header">
<span>{t('gallery.search_results', { defaultValue: '{{count}} results', count: results.length })}</span>
<button className="close-btn" onClick={() => setResults([])}><X size={14} /></button>
</div>
<div className="results-list">
{results.map((r, i) => (
<div key={i} className="result-row">
<div className="result-info">
<span className="result-title">{r.title}</span>
<span className="result-meta">{r.duration || '?'}s</span>
</div>
<Button size="sm" onClick={() => handleDownload(r)} disabled={isDownloading}>
<Download size={12} /> {t('gallery.import', { defaultValue: 'Import' })}
</Button>
</div>
))}
</div>
</div>
)}
<div className="content-header">
<div className="content-title">
{t('gallery.my_imports', { defaultValue: 'My Imports' })}<span className="count-badge">{voices.length}</span>
</div>
</div>
{voicesQ.isLoading ? (
<div className="loading"><Loader className="spin" size={18} /></div>
) : voices.length === 0 ? (
<div className="empty">{t('gallery.no_imports', { defaultValue: 'Nothing imported yet. Paste a URL above to get started.' })}</div>
) : (
<div className="voice-list">
{voices.map((v) => (
<div key={v.id} className="voice-card">
<button className="voice-play" onClick={() => onPlayGallery(v)}>
{loadingPreviewId === v.id ? <Loader className="spin" size={16} /> : playingId === v.id ? <Pause size={16} /> : <Play size={16} />}
</button>
<div className="voice-info">
<span className="voice-name">{v.name}</span>
<span className="voice-meta">{Math.round(v.duration || 0)}s</span>
</div>
<div className="voice-actions">
<button className="action-btn" onClick={() => handleTrimClick(v)} title={t('gallery.trim', { defaultValue: 'Trim' })}><Scissors size={14} /></button>
<button className="action-btn" onClick={() => handleSaveProfile(v)} title={t('gallery.use_voice', { defaultValue: 'Use voice' })}><UserPlus size={14} /></button>
<button className="action-btn danger" onClick={() => handleDelete(v)} title={t('gallery.delete', { defaultValue: 'Delete' })}><Trash2 size={14} /></button>
</div>
</div>
))}
</div>
)}
{trimming && (
<AudioTrimmer
file={trimming.file}
maxSeconds={60}
onConfirm={handleConfirmTrim}
onCancel={() => setTrimming(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,3 @@
// Shared archetype facet helpers — used by ArchetypesZone + ArchetypeCard.
export const titleCase = (s) => (s ? String(s).replace(/\b\w/g, (c) => c.toUpperCase()) : s);
export const facetLabel = (v) => titleCase(String(v).replace(' pitch', '').replace(' accent', ''));
@@ -0,0 +1,95 @@
import { Play, Sparkles, FolderOpen } from 'lucide-react';
import { Panel, Button, Textarea, Field, Badge } from '../../ui';
import WaveformPlayer from '../WaveformPlayer';
/**
* ProfileActivity — "Try it" preview panel + usage panel for the VoiceProfile
* page. Pure presentation; state/handlers live in the parent VoiceProfile.
*/
export default function ProfileActivity({
t, testText, setTestText, testGenerating, runTest, testAudioUrl,
autoPlayPreview, usage, onOpenProject,
}) {
return (
<>
{/* Try-it */}
<Panel
variant="flat"
padding="md"
title={<><Play size={13} /> {t('voice_profile.try_voice')}</>}
>
<Field
label={t('voice_profile.test_phrase')}
hint={t('voice_profile.test_help')}
>
<Textarea
rows={2}
value={testText}
onChange={e => setTestText(e.target.value)}
placeholder={t('voice_profile.test_placeholder')}
/>
</Field>
<div className="voice-profile__tryit-actions">
<Button
variant="primary"
size="sm"
loading={testGenerating}
onClick={runTest}
disabled={!testText.trim()}
leading={!testGenerating && <Sparkles size={12} />}
>
{testGenerating ? t('voice_profile.generating') : t('voice_profile.gen_preview')}
</Button>
{testAudioUrl && (
<WaveformPlayer
src={testAudioUrl}
source="profile-test"
autoPlay={autoPlayPreview}
className="voice-profile__tryit-audio"
/>
)}
</div>
</Panel>
{/* Usage */}
<Panel variant="flat" padding="md" title={<>{t('voice_profile.used_title')}</>}>
{!usage || (!usage.synth_total && !usage.projects?.length) ? (
<div className="voice-profile__usage-empty">
{t('voice_profile.used_empty')}
</div>
) : (
<>
<div className="voice-profile__usage-counts">
<Badge tone="brand">
{t('voice_profile.synth_clips', { count: usage.synth_total })}
</Badge>
<Badge tone="info">
{t('voice_profile.projects_count', { count: usage.projects.length })}
</Badge>
<Badge tone="success">
{t('voice_profile.dubbed_segments', { count: usage.project_total_segments })}
</Badge>
</div>
{usage.projects.length > 0 && (
<ul className="voice-profile__usage-list">
{usage.projects.slice(0, 10).map(p => (
<li key={p.project_id}>
<button
type="button"
onClick={() => onOpenProject?.(p.project_id)}
className="voice-profile__usage-link"
>
<FolderOpen size={11} />
<span className="voice-profile__usage-name">{p.project_name}</span>
<span className="voice-profile__usage-count">{p.segment_count} segs</span>
</button>
</li>
))}
</ul>
)}
</>
)}
</Panel>
</>
);
}
@@ -0,0 +1,124 @@
import { X, Check, Lock, Unlock, ShieldCheck, Square, Mic } from 'lucide-react';
import { Panel, Button, Input, Textarea, Field, Badge } from '../../ui';
/**
* ProfileDetails — editable details panel + consent-lock panel for the
* VoiceProfile page. Pure presentation; state/handlers live in the parent.
*/
export default function ProfileDetails({
profile, editing, draft, setDraft, saving, cancelEdits, saveEdits,
onUnlock, onRevokeConsent, consentStatement, consentRec, consentSubmitting, t,
}) {
return (
<>
{/* Editable details */}
<Panel
variant="flat"
padding="md"
title={<>{t('voice_profile.details')}</>}
actions={editing ? (
<>
<Button variant="ghost" size="sm" onClick={cancelEdits} leading={<X size={12} />}>{t('common.cancel')}</Button>
<Button variant="primary" size="sm" onClick={saveEdits} loading={saving} leading={!saving && <Check size={12} />}>{t('common.save')}</Button>
</>
) : null}
>
<div className="voice-profile__grid-2">
<Field label={t('voice_profile.style_instruct')}>
{editing ? (
<Textarea
rows={2}
value={draft.instruct}
onChange={e => setDraft({ ...draft, instruct: e.target.value })}
placeholder={t('voice_profile.style_placeholder')}
/>
) : (
<div className="voice-profile__readonly">
{profile.instruct || <em> none </em>}
</div>
)}
</Field>
<Field label={t('voice_profile.language')}>
{editing ? (
<Input
value={draft.language}
onChange={e => setDraft({ ...draft, language: e.target.value })}
placeholder={t('clone.auto')}
/>
) : (
<div className="voice-profile__readonly">{profile.language || 'Auto'}</div>
)}
</Field>
</div>
<Field label={t('voice_profile.ref_transcript')} hint={t('voice_profile.ref_help')}>
{editing ? (
<Textarea
rows={2}
value={draft.ref_text}
onChange={e => setDraft({ ...draft, ref_text: e.target.value })}
placeholder={t('clone.optional')}
/>
) : (
<div className="voice-profile__readonly voice-profile__readonly--transcript">
{profile.ref_text || <em> none </em>}
</div>
)}
</Field>
{profile.is_locked && !editing && (
<div className="voice-profile__lock-row">
<Badge tone="warn" dot><Lock size={10} /> {t('voice_profile.locked')}</Badge>
<span className="voice-profile__lock-hint">
{t('voice_profile.locked_explain')}
</span>
<Button variant="subtle" size="sm" onClick={onUnlock} leading={<Unlock size={12} />}>{t('voice_profile.unlock')}</Button>
</div>
)}
</Panel>
{/* Consent lock (Wave 0.2) — verify this is your own voice */}
<Panel
variant="flat"
padding="md"
title={<><ShieldCheck size={12} /> {t('voice_profile.consent_title')}</>}
>
{profile.verified_own_voice ? (
<div className="voice-profile__lock-row">
<Badge tone="success" dot><ShieldCheck size={10} /> {t('voice_profile.verified')}</Badge>
<span className="voice-profile__lock-hint">
{t('voice_profile.consent_verified_explain', {
date: profile.consent_recorded_at
? new Date(profile.consent_recorded_at * 1000).toLocaleDateString()
: '',
})}
</span>
<Button variant="subtle" size="sm" onClick={onRevokeConsent} leading={<X size={12} />}>
{t('voice_profile.consent_revoke')}
</Button>
</div>
) : (
<>
<p className="voice-profile__readonly">{t('voice_profile.consent_explain')}</p>
<blockquote className="voice-profile__readonly voice-profile__readonly--transcript">
{consentStatement}
</blockquote>
{consentRec.isRecording ? (
<Button variant="danger" size="sm" onClick={consentRec.stopRecording} leading={<Square size={12} />}>
{t('voice_profile.consent_stop')} ({consentRec.recordingTime}s)
</Button>
) : (
<Button
variant="primary"
size="sm"
onClick={consentRec.startRecording}
loading={consentSubmitting || consentRec.isCleaning}
leading={!(consentSubmitting || consentRec.isCleaning) && <Mic size={12} />}
>
{t('voice_profile.consent_record')}
</Button>
)}
</>
)}
</Panel>
</>
);
}
@@ -0,0 +1,102 @@
import {
ArrowLeft, Pencil, Download, Trash2, ShieldCheck, Lock, Clock, Volume2,
} from 'lucide-react';
import { Panel, Button, Input, Badge } from '../../ui';
import WaveformPlayer from '../WaveformPlayer';
/**
* ProfileHeader — toolbar + hero (identity) for the VoiceProfile page.
* Pure presentation; all state/handlers live in the parent VoiceProfile.
*/
export default function ProfileHeader({
profile, isDesign, TypeIcon, onBack, editing, setEditing,
includeReference, setIncludeReference, onExportPersona, exporting,
onDelete, draft, setDraft, createdDate, audioUrl, t,
}) {
return (
<>
{/* Toolbar */}
<div className="voice-profile__bar">
<Button variant="ghost" size="sm" onClick={onBack} leading={<ArrowLeft size={12} />}>
{t('common.back')}
</Button>
<span className="voice-profile__crumb">
<TypeIcon size={12} /> {isDesign ? t('voice_profile.designed') : t('voice_profile.cloned')} voice
</span>
<div className="voice-profile__bar-spacer" />
{!editing && (
<Button variant="subtle" size="sm" onClick={() => setEditing(true)} leading={<Pencil size={12} />}>
{t('voice_profile.edit')}
</Button>
)}
{!editing && (
<label
className="voice-profile__persona-privacy"
title={t('voice_profile.persona_include_ref_hint', { defaultValue: 'Include the raw reference clip. Off = share only a watermarked preview (recommended).' })}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11 }}
>
<input type="checkbox" checked={includeReference} onChange={(e) => setIncludeReference(e.target.checked)} />
{t('voice_profile.persona_include_ref', { defaultValue: 'Include voice clip' })}
</label>
)}
{!editing && (
<Button variant="subtle" size="sm" onClick={onExportPersona} loading={exporting}
leading={!exporting && <Download size={12} />}>
{t('voice_profile.persona_export', { defaultValue: 'Export persona' })}
</Button>
)}
<Button variant="danger" size="sm" onClick={onDelete} leading={<Trash2 size={12} />}>
{t('common.delete')}
</Button>
</div>
{/* Hero */}
<Panel variant="glass" padding="md" className="voice-profile__hero">
<div className="voice-profile__hero-left">
<div className="voice-profile__icon-badge" data-kind={isDesign ? 'design' : 'clone'}>
<TypeIcon size={22} />
</div>
<div className="voice-profile__hero-title">
{editing ? (
<Input
size="lg"
value={draft.name}
onChange={e => setDraft({ ...draft, name: e.target.value })}
placeholder={t('voice_profile.name_placeholder')}
autoFocus
/>
) : (
<h1>{profile.name}</h1>
)}
<div className="voice-profile__badges">
{!!profile.verified_own_voice && (
<Badge tone="success" dot><ShieldCheck size={10} /> {t('voice_profile.verified')}</Badge>
)}
{profile.is_locked
? <Badge tone="warn" dot><Lock size={10} /> {t('voice_profile.locked')}</Badge>
: <Badge tone="neutral">{t('voice_profile.free')}</Badge>}
{profile.language && profile.language !== 'Auto' && (
<Badge tone="info">{profile.language}</Badge>
)}
<Badge tone="neutral" size="xs">
<Clock size={9} /> {createdDate}
</Badge>
{profile.seed != null && (
<Badge tone="violet" size="xs">seed {profile.seed}</Badge>
)}
</div>
</div>
</div>
{(profile.ref_audio_path || profile.locked_audio_path) && (
<div className="voice-profile__audio">
<div className="voice-profile__audio-label">
<Volume2 size={11} /> {profile.is_locked ? t('voice_profile.locked_ref') : t('voice_profile.ref_audio')}
</div>
<WaveformPlayer src={audioUrl} source="profile-ref" className="voice-profile__audio-el" />
</div>
)}
</Panel>
</>
);
}
+109 -551
View File
@@ -1,24 +1,18 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Command, Globe, SlidersHorizontal, Volume2, Plus,
UploadCloud, Square, Mic, Save, UserSquare2, Settings2, ChevronUp, ChevronDown,
Sparkles, Play, X, Wand2, Dice5,
} from 'lucide-react';
import { useState, useEffect, useRef } from 'react';
import { Volume2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import SearchableSelect from '../components/SearchableSelect';
import DemoPresetGrid from '../components/DemoPresetGrid';
import ALL_LANGUAGES from '../languages.json';
import { POPULAR_LANGS, PRESETS, TAGS, CATEGORIES } from '../utils/constants';
import {
PRESET_ICONS, PERSONALITY_ICONS, FALLBACK_VOICE_ICON, FALLBACK_PERSONALITY_ICON, stripVoiceEmoji,
} from '../utils/voiceIcons';
import { Button, Input, Slider, Progress, Segmented } from '../ui';
import { CATEGORIES } from '../utils/constants';
import { Segmented } from '../ui';
import { useAppStore } from '../store';
import { API, apiPost } from '../api/client';
import { mergeDescribedAttrs, buildDesignInstruct } from '../utils/voiceInstruct';
import { mergeDescribedAttrs } from '../utils/voiceInstruct';
import { listEngines } from '../api/engines';
import { claimPlayback, stopActivePlayback, usePlaybackSource } from '../utils/playback';
import ScriptPanel from '../components/clone/ScriptPanel';
import AudioMethodPanel from '../components/clone/AudioMethodPanel';
import DesignMethodPanel from '../components/clone/DesignMethodPanel';
import ActionBar from '../components/clone/ActionBar';
import './CloneDesignTab.css';
export default function CloneDesignTab(props) {
@@ -267,86 +261,29 @@ export default function CloneDesignTab(props) {
setActivePersonality(p.id);
};
return (
<div className="studio-def-col">
<div className="clone-split-grid">
{/* ═══ SCRIPT — what should it say ═══ */}
<div className="studio-column">
{/* overflow-visible: the ⊕ Insert popover opens above the textarea and
must escape the panel's `overflow:auto` box instead of being clipped
into its scroll region (#481). */}
<div className="studio-panel clone-panel--overflow-visible">
<div className="label-row label-row--center">
<Command className="label-icon" size={14} /> {t('clone.script', { defaultValue: 'Script' })}
</div>
{/* Design-tab empty state: 7-card demo grid until the user
interacts; then it steps aside for the standard form. */}
{defineMethod === 'design' && !text && !activePersonality && demoPresets.length > 0 && (
<DemoPresetGrid presets={demoPresets} onUse={applyDemoPreset} />
)}
{showDemoCoachmark && defineMethod === 'audio' && selectedProfile === DEMO_PROFILE_ID && (
<div className="clone-coachmark" role="note">
<span className="clone-coachmark__icon">💡</span>
<span className="clone-coachmark__msg">
{t('demo.clone_coachmark')}
</span>
<button
type="button"
className="clone-coachmark__close"
onClick={() => setShowDemoCoachmark(false)}
aria-label="Dismiss coach mark"
>
×
</button>
</div>
)}
<div className="clone-script-wrap">
<textarea
ref={textAreaRef}
className="input-base clone-text-area"
placeholder={defineMethod === 'audio' ? t('clone.prompt_placeholder') : t('clone.design_placeholder')}
value={text}
onChange={e => {
setText(e.target.value);
if (showDemoCoachmark) setShowDemoCoachmark(false);
}}
/>
{/* Expression tokens live behind a popover — fourteen permanent
chips were renting the page's best pixels for an occasional
power feature (10x spec §1.4). */}
<button
type="button"
className={`clone-insert-btn ${insertOpen ? 'is-open' : ''}`}
onClick={() => setInsertOpen(o => !o)}
aria-expanded={insertOpen}
aria-label={t('clone.insert_token', { defaultValue: 'Insert expression token' })}
>
<Plus size={11} /> {t('clone.insert', { defaultValue: 'Insert' })} <ChevronDown size={10} />
</button>
{insertOpen && (
<div className="clone-insert-backdrop" onClick={() => setInsertOpen(false)} />
)}
{insertOpen && (
<div className="clone-insert-pop" role="menu">
{TAGS.map(tag => (
<button key={tag} className="tag-btn" role="menuitem"
onClick={() => { insertTag(tag); setInsertOpen(false); }}>
{tag}
</button>
))}
<button
className="tag-btn clone-auto-extract-btn" role="menuitem"
onClick={() => { insertTag('[B EY1 S]'); setInsertOpen(false); }}
>
[CMU]
</button>
</div>
)}
</div>
</div>
</div>
<ScriptPanel
t={t}
defineMethod={defineMethod}
text={text}
setText={setText}
activePersonality={activePersonality}
demoPresets={demoPresets}
applyDemoPreset={applyDemoPreset}
showDemoCoachmark={showDemoCoachmark}
setShowDemoCoachmark={setShowDemoCoachmark}
selectedProfile={selectedProfile}
DEMO_PROFILE_ID={DEMO_PROFILE_ID}
textAreaRef={textAreaRef}
insertOpen={insertOpen}
setInsertOpen={setInsertOpen}
insertTag={insertTag}
/>
{/* ═══ VOICE — who says it ═══ */}
<div className="studio-column">
@@ -367,471 +304,92 @@ export default function CloneDesignTab(props) {
</div>
{defineMethod === 'audio' ? (
<div>
{/* Saved voices now live in the right-side WorkspaceVoices panel. */}
{!selectedProfile && (
<div className="clone-drop-row">
<input
type="file"
accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg"
onChange={e => { const f = e.target.files[0]; ingestRefAudio(f); e.target.value = ''; }}
className="dub-hidden-file"
id="audio-upload"
/>
<label
htmlFor="audio-upload"
className="file-drag clone-drop-zone"
onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add('is-dragging'); }}
onDragLeave={e => { e.currentTarget.classList.remove('is-dragging'); }}
onDrop={e => {
e.preventDefault();
e.currentTarget.classList.remove('is-dragging');
const file = e.dataTransfer.files[0];
const okType = file && (file.type.startsWith('audio/') || /\.(mp3|wav|m4a|flac|ogg|aac|webm)$/i.test(file.name));
if (okType) ingestRefAudio(file);
}}
>
<UploadCloud color="#a89984" size={18} />
<p>{refAudio ? <span className="clone-drop-filename">{refAudio.name}</span> : t('clone.drop_audio')}</p>
</label>
<MicButton
isCleaning={isCleaning}
isRecording={isRecording}
recordingTime={recordingTime}
onStart={startRecording}
onStop={stopRecording}
/>
</div>
)}
{selectedProfile && (
<div className="clone-profile-banner">
<span className="clone-profile-banner__label">
{t('clone.using_profile', { name: profiles.find(p => p.id === selectedProfile)?.name })}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedProfile(null)}
leading={<X size={11} />}
>
{t('clone.clear')}
</Button>
</div>
)}
<div className="grid-2 grid-2--indent">
<div>
<div className="label-row">{t('clone.transcript')}</div>
<input type="text" className="input-base" value={refText} onChange={e => setRefText(e.target.value)} placeholder={t('clone.optional')} />
</div>
<div>
<div className="label-row">{t('clone.style')}</div>
<input type="text" className="input-base" value={instruct} onChange={e => setInstruct(e.target.value)} placeholder={t('clone.style_placeholder')} />
</div>
</div>
{/* #526: voice-design seed — show + pin + re-roll so tweaks can
stay on the same base timbre. Design mode only. */}
{defineMethod === 'design' && (
<div className="design-seed">
<div className="label-row">{t('clone.seed_label')}</div>
<div className="design-seed__row">
<input
type="number"
className="input-base design-seed__input"
value={designSeed ?? ''}
placeholder={t('clone.seed_placeholder')}
onChange={e => {
const v = e.target.value.trim();
if (v === '') { setDesignSeed(null); return; }
const n = parseInt(v, 10);
if (Number.isInteger(n)) { setDesignSeed(n); setKeepSeed(true); }
}}
/>
<Button
variant="subtle"
size="sm"
onClick={() => { setDesignSeed(Math.floor(Math.random() * 2147483647)); setKeepSeed(true); }}
leading={<Dice5 size={12} />}
title={t('clone.seed_reroll_hint')}
>
{t('clone.seed_reroll')}
</Button>
<label className="design-seed__keep">
<input type="checkbox" checked={keepSeed} onChange={e => setKeepSeed(e.target.checked)} />
<span>{t('clone.seed_keep')}</span>
</label>
</div>
</div>
)}
{/* Save as profile */}
{refAudio && !selectedProfile && (
<div className="clone-save-profile">
{!showSaveProfile ? (
<Button
variant="subtle"
size="sm"
onClick={() => setShowSaveProfile(true)}
leading={<Save size={12} />}
>
{t('clone.save_as_profile')}
</Button>
) : (
<div className="clone-save-profile__row">
<Input
size="sm"
placeholder={t('clone.profile_name')}
value={profileName}
onChange={e => setProfileName(e.target.value)}
/>
<Button variant="subtle" size="sm" onClick={handleSaveProfile}>{t('clone.save')}</Button>
<Button variant="ghost" size="sm" onClick={() => setShowSaveProfile(false)}>{t('clone.cancel')}</Button>
</div>
)}
</div>
)}
</div>
<AudioMethodPanel
t={t}
selectedProfile={selectedProfile}
setSelectedProfile={setSelectedProfile}
profiles={profiles}
ingestRefAudio={ingestRefAudio}
refAudio={refAudio}
isCleaning={isCleaning}
isRecording={isRecording}
recordingTime={recordingTime}
startRecording={startRecording}
stopRecording={stopRecording}
refText={refText}
setRefText={setRefText}
instruct={instruct}
setInstruct={setInstruct}
defineMethod={defineMethod}
designSeed={designSeed}
setDesignSeed={setDesignSeed}
keepSeed={keepSeed}
setKeepSeed={setKeepSeed}
showSaveProfile={showSaveProfile}
setShowSaveProfile={setShowSaveProfile}
profileName={profileName}
setProfileName={setProfileName}
handleSaveProfile={handleSaveProfile}
/>
) : (
<div>
{/* ── Describe your voice (#317) — free text drives the controls.
The placeholder explains itself; no extra header (10x §1.2). ── */}
<div className="describe-voice-block">
<textarea
className="input-base describe-voice-area"
rows={2}
placeholder={t('clone.describe_placeholder')}
value={describeText}
onChange={onDescribeChange}
/>
{describeText.trim() && !describeMatchedAny && (
<div className="describe-voice-feedback" role="status">
{t('clone.describe_no_match')}
</div>
)}
{describeMatchedAny && describeUnmatched.length > 0 && (
<div className="describe-voice-feedback" role="status">
{t('clone.describe_unmatched', { items: describeUnmatched.join(', ') })}
</div>
)}
<div className="describe-voice-hint">{t('clone.describe_hint')}</div>
</div>
{/* ONE preset system (10x §1.3): personalities + the old PROMPT
presets share a single scrollable "Starting points" lane —
both set vdStates + instruct; two widgets for one slot was
the confusion. */}
<div className="starting-points">
<div className="starting-points__label">{t('clone.starting_points', { defaultValue: 'Starting points' })}</div>
<div className="personality-strip starting-points__strip">
{chipPersonalities.map(p => {
const Icon = PERSONALITY_ICONS[p.id] || FALLBACK_PERSONALITY_ICON;
return (
<button
key={p.id}
type="button"
className={`personality-chip ${activePersonality === p.id ? 'active' : ''}`}
onClick={() => applyPersonality(p)}
>
<span className="personality-chip__icon"><Icon size={13} /></span>
{stripVoiceEmoji(t(`clone.personality_${p.id}`, { defaultValue: p.name }))}
</button>
);
})}
{PRESETS.map(p => {
const Icon = PRESET_ICONS[p.id] || FALLBACK_VOICE_ICON;
return (
<button key={p.id} type="button" className="personality-chip" onClick={() => applyPreset(p)}>
<span className="personality-chip__icon"><Icon size={13} /></span>
{stripVoiceEmoji(t(`clone.preset_${p.id}`, { defaultValue: p.name }))}
</button>
);
})}
</div>
</div>
{/* Identity recipe (10x §1.5): once any category is set, the
chip groups collapse to one quiet line — the current voice
recipe — and the describe box rewrites it live. All-Auto
(first run) starts expanded. */}
<button
type="button"
className="identity-line"
onClick={() => setIdentityOpen(o => !o)}
aria-expanded={identityOpen}
>
<span className="identity-line__kicker">{t('clone.identity', { defaultValue: 'Identity' })}</span>
<span className="identity-line__recipe">{identityRecipe}</span>
{identityOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{identityOpen && (
<div className="clone-sliders-col">
{Object.entries(CATEGORIES).map(([key, options]) => {
const many = options.length > 6;
const optLabel = (val) => {
const tKey = `clone.opt_${val.replace(/[ -]/g, '_')}`;
const tl = t(tKey);
return tl !== tKey ? tl : val;
};
return (
<div key={key} className={`clone-cat ${many ? 'clone-cat--select' : 'clone-cat--chips'}`}>
<div className="label-row label-row--sm">
{t(`clone.cat_${key}`)}
<span className="clone-slider-kicker">
{vdStates[key] === 'Auto' ? t('clone.auto_kicker') : `· ${optLabel(vdStates[key])}`}
</span>
</div>
{many ? (
<select
className="input-base"
value={vdStates[key]}
onChange={e => setVdStates({ ...vdStates, [key]: e.target.value })}
>
{options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
) : (
<div className="chip-group" role="radiogroup" aria-label={t(`clone.cat_${key}`)}>
{options.map((opt, i) => {
const optTKey = `clone.opt_${opt.replace(/[ -]/g, '_')}`;
const optTl = t(optTKey);
const optLabel = optTl !== optTKey ? optTl : opt;
const checked = vdStates[key] === opt;
// Roving tabindex: the checked chip is the group's
// single tab stop (first chip if nothing matches).
const roving = checked || (!options.includes(vdStates[key]) && i === 0);
return (
<button
key={opt}
type="button"
role="radio"
aria-checked={checked}
tabIndex={roving ? 0 : -1}
className={`chip ${checked ? 'active' : ''}`}
onClick={() => setVdStates({ ...vdStates, [key]: opt })}
onKeyDown={e => onChipKeyDown(e, key, options)}
>
{opt === 'Auto'
? <span className="chip-auto"><FALLBACK_VOICE_ICON size={11} /> {stripVoiceEmoji(t('clone.opt_Auto'))}</span>
: optLabel}
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
{/* Save the current design as a reusable profile (0005): the
backend renders a deterministic identity sample (seed 42)
and stores the slider picks for later re-editing. */}
<div className="clone-save-profile">
{!showSaveProfile ? (
<Button
variant="subtle"
size="sm"
onClick={() => setShowSaveProfile(true)}
leading={<Save size={12} />}
>
{t('clone.save_design_as_profile', { defaultValue: 'Save design as profile' })}
</Button>
) : (
<div className="clone-save-profile__row">
<Input
size="sm"
placeholder={t('clone.profile_name')}
value={profileName}
onChange={e => setProfileName(e.target.value)}
/>
<Button variant="subtle" size="sm"
onClick={() => handleSaveDesignProfile(vdStates, buildDesignInstruct(vdStates, instruct).instruct, language)}>
{t('clone.save')}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowSaveProfile(false)}>{t('clone.cancel')}</Button>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
{/* ═══ ACTION BAR — pinned to the column bottom (10x §1.1): generation
parameters live WITH the button; SYNTHESIZE never scrolls away.
Overrides expand upward, above the controls row. ═══ */}
<div className="studio-action-bar clone-panel--overflow-visible">
{showOverrides && (
<div className="override-content">
<div className="grid-4">
<div>
<div className="label-row label-row--spread"><span>CFG</span><span className="val-bubble">{cfg}</span></div>
<input type="range" min="1.0" max="4.0" step="0.1" value={cfg} onChange={e => setCfg(Number(e.target.value))} />
</div>
<div>
<div className="label-row label-row--spread"><span>{t('clone.speed')}</span><span className="val-bubble">{speed}x</span></div>
<input type="range" min="0.5" max="2.0" step="0.1" value={speed} onChange={e => setSpeed(Number(e.target.value))} />
</div>
<div>
<div className="label-row label-row--spread"><span>{t('clone.tshift')}</span><span className="val-bubble">{tShift}</span></div>
<input type="range" min="0" max="1.0" step="0.05" value={tShift} onChange={e => setTShift(Number(e.target.value))} />
</div>
<div>
<div className="label-row label-row--spread"><span>{t('clone.pos_temp')}</span><span className="val-bubble">{posTemp}</span></div>
<input type="range" min="0" max="10" step="0.5" value={posTemp} onChange={e => setPosTemp(Number(e.target.value))} />
</div>
<div>
<div className="label-row label-row--spread"><span>{t('clone.class_temp')}</span><span className="val-bubble">{classTemp}</span></div>
<input type="range" min="0" max="2" step="0.1" value={classTemp} onChange={e => setClassTemp(Number(e.target.value))} />
</div>
<div>
<div className="label-row label-row--spread"><span>{t('clone.layer_pen')}</span><span className="val-bubble">{layerPenalty}</span></div>
<input type="range" min="0" max="10" step="0.5" value={layerPenalty} onChange={e => setLayerPenalty(Number(e.target.value))} />
</div>
<div>
<div className="label-row"><span>{t('clone.duration')}</span></div>
<input type="text" className="input-base clone-duration-input" value={duration} onChange={e => setDuration(e.target.value)} placeholder={t('clone.auto')} />
</div>
<div className="clone-prod-col">
<label className="clone-prod-check">
<input type="checkbox" checked={denoise} onChange={e => setDenoise(e.target.checked)} /> {t('clone.denoise')}
</label>
<label className="clone-prod-check">
<input type="checkbox" checked={postprocess} onChange={e => setPostprocess(e.target.checked)} /> {t('clone.postprocess')}
</label>
</div>
</div>
</div>
)}
{/* Controls row: language · steps · overrides disclosure */}
<div className="studio-action-bar__row">
<div className="studio-action-bar__lang">
<Globe size={12} className="label-icon" />
<SearchableSelect
value={language}
options={ALL_LANGUAGES}
popular={POPULAR_LANGS}
recentsKey="omnivoice.recents.genLang"
onChange={setLanguage}
/>
</div>
<label className="studio-action-bar__steps" title={t('clone.steps')}>
<SlidersHorizontal size={12} className="label-icon" />
<input type="range" min="8" max="64" value={steps} onChange={e => setSteps(Number(e.target.value))} />
<span className="val-bubble">{steps}</span>
</label>
<button
type="button"
className="studio-action-bar__overrides"
onClick={() => setShowOverrides(!showOverrides)}
aria-expanded={showOverrides}
>
<Settings2 size={13} /> {t('clone.production_overrides')}
{showOverrides ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
</button>
</div>
{showHearDemo ? (
<>
<Button
variant="primary"
block
onClick={playDemoOutput}
leading={<Play size={14} />}
className="clone-footer-cta"
>
{demoAudioPlaying ? t('demo.stop_demo') : t('demo.hear_demo')}
</Button>
<div className="clone-hear-demo-chip">
{t('demo.prerendered_chip')}
</div>
<audio
ref={demoAudioRef}
onEnded={() => {
setDemoAudioPlaying(false);
demoReleaseRef.current?.();
demoReleaseRef.current = null;
}}
preload="none"
/>
</>
) : outputPlaying && !isGenerating ? (
/* Synthesized output is playing — the CTA becomes a Stop button
(#316) so playback can be halted immediately. */
<Button
variant="primary"
block
onClick={stopActivePlayback}
leading={<Square size={14} />}
className="clone-footer-cta"
>
{t('clone.stop_playback')}
</Button>
) : (
<Button
variant="primary"
block
loading={isGenerating}
onClick={handleGenerate}
leading={!isGenerating && <Play size={14} />}
className="clone-footer-cta"
>
{isGenerating ? t('clone.synthesizing', { seconds: generationTime }) : t('clone.synthesize')}
</Button>
)}
{isGenerating && (
<Progress
value={Math.min((generationTime / 8) * 100, 95)}
tone="brand"
size="sm"
className="clone-footer-cta"
<DesignMethodPanel
t={t}
describeText={describeText}
onDescribeChange={onDescribeChange}
describeMatchedAny={describeMatchedAny}
describeUnmatched={describeUnmatched}
chipPersonalities={chipPersonalities}
activePersonality={activePersonality}
applyPersonality={applyPersonality}
applyPreset={applyPreset}
identityOpen={identityOpen}
setIdentityOpen={setIdentityOpen}
identityRecipe={identityRecipe}
vdStates={vdStates}
setVdStates={setVdStates}
onChipKeyDown={onChipKeyDown}
showSaveProfile={showSaveProfile}
setShowSaveProfile={setShowSaveProfile}
profileName={profileName}
setProfileName={setProfileName}
handleSaveDesignProfile={handleSaveDesignProfile}
instruct={instruct}
language={language}
/>
)}
{/* 10x P4 a11y (spec §3): persistent polite live region — screen
readers hear generation start AND finish in-workspace, without
relying on the FloatingPill. sr-only keeps it out of the
action-bar flex flow; static text avoids per-second re-announces
from the ticking "Synthesizing… (Ns)" button label. */}
<div className="sr-only" role="status" aria-live="polite">
{isGenerating
? t('clone.generating_status', { defaultValue: 'Generating audio…' })
: wasGeneratingRef.current
? t('clone.generating_done_status', { defaultValue: 'Generation finished' })
: null}
</div>
</div>
</div>
);
}
function MicButton({ isCleaning, isRecording, recordingTime, onStart, onStop }) {
const { t } = useTranslation();
if (isCleaning) {
return (
<div className="mic-btn mic-btn--cleaning">
<Sparkles size={18} className="spinner" />
<span>{t('clone.cleaning')}</span>
</div>
</div>
);
}
if (isRecording) {
return (
<button type="button" onClick={onStop} className="mic-btn mic-btn--recording">
<Square size={18} fill="currentColor" />
<span>{recordingTime}s</span>
</button>
);
}
return (
<button type="button" onClick={onStart} className="mic-btn mic-btn--idle" title={t('clone.record')}>
<Mic size={18} />
<span>{t('clone.record')}</span>
</button>
</div>
{/* ═══ ACTION BAR — pinned to the column bottom ═══ */}
<ActionBar
t={t}
showOverrides={showOverrides}
setShowOverrides={setShowOverrides}
cfg={cfg} setCfg={setCfg}
speed={speed} setSpeed={setSpeed}
tShift={tShift} setTShift={setTShift}
posTemp={posTemp} setPosTemp={setPosTemp}
classTemp={classTemp} setClassTemp={setClassTemp}
layerPenalty={layerPenalty} setLayerPenalty={setLayerPenalty}
duration={duration} setDuration={setDuration}
denoise={denoise} setDenoise={setDenoise}
postprocess={postprocess} setPostprocess={setPostprocess}
language={language} setLanguage={setLanguage}
steps={steps} setSteps={setSteps}
showHearDemo={showHearDemo}
playDemoOutput={playDemoOutput}
demoAudioPlaying={demoAudioPlaying}
demoAudioRef={demoAudioRef}
demoReleaseRef={demoReleaseRef}
setDemoAudioPlaying={setDemoAudioPlaying}
outputPlaying={outputPlaying}
isGenerating={isGenerating}
handleGenerate={handleGenerate}
generationTime={generationTime}
wasGeneratingRef={wasGeneratingRef}
/>
</div>
);
}
+6 -569
View File
@@ -4,56 +4,20 @@
// facet filters to explore hundreds. (core.archetypes / /archetypes API)
// • My Imports — a neutral importer: paste any URL you have the rights to, or
// upload a file, trim it, save it. The project ships no celebrity catalog.
import React, { useState, useMemo, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Search, Download, Play, Pause, Trash2, X, Loader, Star,
Wand2, UserPlus, Sparkles, RotateCcw, Grid, List, Upload, Scissors, Store, Send, Package,
} from 'lucide-react';
import { Button, Input } from '../ui';
import { useArchetypeCategories, useArchetypes, useGalleryVoices, useCommunityItems } from '../api/hooks';
import { Sparkles, Store, Upload } from 'lucide-react';
import { archetypePreviewUrl, useArchetypeAsProfile } from '../api/archetypes';
import { addCommunityItem, communitySubmitUrl } from '../api/community';
import { importPersona } from '../api/profiles';
import { openExternal } from '../api/external';
import {
searchYoutube, downloadYoutubeClip, deleteGalleryVoice,
saveVoiceAsProfile, uploadVoiceClip, previewVoiceUrl,
} from '../api/gallery';
import AudioTrimmer from '../components/AudioTrimmer';
import { previewVoiceUrl } from '../api/gallery';
import { useAppStore } from '../store';
import { apiUrl } from '../api/client';
import { isTauri } from '../utils/media';
import { claimPlayback, stopActivePlayback } from '../utils/playback';
import { askConfirm } from '../utils/dialog';
import { ArchetypeAvatar, ArchetypeIcon, AccentFlag, NowPlaying, USE_CASE_COLOR } from '../utils/archetypeIcons';
import ArchetypesZone from '../components/gallery/ArchetypesZone';
import CommunityZone from '../components/gallery/CommunityZone';
import ImportsZone from '../components/gallery/ImportsZone';
import './VoiceGallery.css';
const BROWSE_PAGE = 60;
// Facet vocabularies — values must match the backend taxonomy tokens exactly.
const FACETS = {
gender: ['male', 'female'],
age: ['child', 'teenager', 'young adult', 'middle-aged', 'elderly'],
pitch: ['very low pitch', 'low pitch', 'moderate pitch', 'high pitch', 'very high pitch'],
accent: [
'american accent', 'british accent', 'australian accent', 'canadian accent',
'indian accent', 'chinese accent', 'japanese accent', 'korean accent',
'portuguese accent', 'russian accent',
],
// English + Chinese come from the generated catalog; the rest are curated
// multilingual designed voices. Values must match the archetype `language`
// field (a languages.json entry) exactly — that drives the backend filter.
lang: [
'English', 'Chinese', 'Spanish', 'French', 'German', 'Italian',
'Portuguese', 'Russian', 'Hindi', 'Japanese', 'Korean',
],
};
const titleCase = (s) => (s ? String(s).replace(/\b\w/g, (c) => c.toUpperCase()) : s);
const facetLabel = (v) => titleCase(String(v).replace(' pitch', '').replace(' accent', ''));
const hasActiveFilters = (f) => Object.values(f).some((v) => v !== null && v !== '');
export default function VoiceGallery() {
const { t } = useTranslation();
@@ -239,530 +203,3 @@ export default function VoiceGallery() {
</div>
);
}
// ── Archetypes zone ─────────────────────────────────────────────────────────
function ArchetypesZone({
t, filters, setFilter, resetFilters, favorites, toggleFavorite,
viewMode, setViewMode, playingId, loadingPreviewId, onPreview, onUse, onDesign,
}) {
const [favOnly, setFavOnly] = useState(false);
const [offset, setOffset] = useState(0);
useEffect(() => { setOffset(0); }, [filters]);
const cleanFilters = useMemo(() => {
const out = {};
Object.entries(filters).forEach(([k, v]) => { if (v !== null && v !== '') out[k] = v; });
return out;
}, [filters]);
// The Featured strip shows only when nothing is filtered; in that case Browse
// excludes featured to avoid duplicating it. Once any filter is active the
// Featured strip is hidden (see below), so Browse must include featured too —
// otherwise the curated multilingual languages (Spanish/French/…), which have
// *only* featured archetypes, would filter down to an empty list.
const showFeatured = !hasActiveFilters(filters) && !favOnly;
const categoriesQ = useArchetypeCategories();
const featuredQ = useArchetypes({ featured: true, limit: 100 });
const browseQ = useArchetypes({
...cleanFilters,
...(showFeatured ? { featured: false } : {}),
limit: BROWSE_PAGE,
offset,
});
const categories = categoriesQ.data || [];
const featured = featuredQ.data?.items || [];
const browse = browseQ.data?.items || [];
const total = browseQ.data?.total ?? 0;
const favSet = useMemo(() => new Set(favorites), [favorites]);
const applyFav = (list) => (favOnly ? list.filter((a) => favSet.has(a.id)) : list);
// NOTE: no `key` here — React keys must be passed directly on the element,
// not spread in (spreading a `key` prop triggers a dev warning + is ignored).
const cardProps = (a) => ({
a, t, viewMode,
isFavorite: favSet.has(a.id),
isPlaying: playingId === a.id,
isLoadingPreview: loadingPreviewId === a.id,
onPreview, onUse, onDesign, onToggleFavorite: toggleFavorite,
});
return (
<div className="gallery-content gallery-scroll">
<div className="facet-bar">
{/* Three filter lanes (categories · facets · toggles), each its own
horizontally-scrollable portion; the view toggle is pinned right. */}
<div className="facet-group facet-group--cats use-case-chips">
<button className={`category-chip ${!filters.use_case ? 'selected' : ''}`} onClick={() => setFilter('use_case', null)}>
{t('gallery.all', { defaultValue: 'All' })}
</button>
{categories.map((c) => (
<button
key={c.id}
className={`category-chip ${filters.use_case === c.id ? 'selected' : ''}`}
onClick={() => setFilter('use_case', filters.use_case === c.id ? null : c.id)}
title={c.name}
>
<ArchetypeIcon name={c.icon} size={13} />
{t(`archetypes.use_${c.id}`, { defaultValue: c.name })}
</button>
))}
</div>
<div className="facet-group facet-group--facets facet-selects">
{['gender', 'age', 'pitch', 'accent', 'lang'].map((dim) => (
<select
key={dim}
className="facet-select"
value={filters[dim] ?? ''}
onChange={(e) => setFilter(dim, e.target.value || null)}
>
<option value="">{t(`archetypes.facet_${dim}`, { defaultValue: titleCase(dim) })}</option>
{FACETS[dim].map((opt) => <option key={opt} value={opt}>{facetLabel(opt)}</option>)}
</select>
))}
</div>
<div className="facet-group facet-group--toggles">
<label className="facet-toggle">
<input
type="checkbox"
checked={filters.whisper === true}
onChange={(e) => setFilter('whisper', e.target.checked ? true : null)}
/>
{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}
</label>
<label className="facet-toggle">
<input type="checkbox" checked={favOnly} onChange={(e) => setFavOnly(e.target.checked)} />
<Star size={12} /> {t('gallery.favorites', { defaultValue: 'Favorites' })}
</label>
<button className="facet-reset" onClick={() => { resetFilters(); setFavOnly(false); }}>
<RotateCcw size={12} /> {t('gallery.reset', { defaultValue: 'Reset' })}
</button>
</div>
<div className="view-toggle">
<button className={viewMode === 'grid' ? 'active' : ''} onClick={() => setViewMode('grid')} title="Grid"><Grid size={14} /></button>
<button className={viewMode === 'list' ? 'active' : ''} onClick={() => setViewMode('list')} title="List"><List size={14} /></button>
</div>
</div>
{showFeatured && (
<section className="archetype-section">
<div className="content-header"><div className="content-title">{t('archetypes.featured', { defaultValue: 'Featured' })}</div></div>
<div className={`archetype-grid ${viewMode}`}>
{applyFav(featured).map((a) => <ArchetypeCard key={a.id} {...cardProps(a)} />)}
</div>
</section>
)}
<section className="archetype-section">
<div className="content-header">
<div className="content-title">
{t('archetypes.browse_all', { defaultValue: 'Browse all' })}
<span className="count-badge">{total}</span>
</div>
</div>
{browseQ.isLoading ? (
<div className="loading"><Loader className="spin" size={18} /></div>
) : (
<>
<div className={`archetype-grid ${viewMode}`}>
{applyFav(browse).map((a) => <ArchetypeCard key={a.id} {...cardProps(a)} />)}
</div>
{applyFav(browse).length === 0 && (
<div className="empty">{t('gallery.no_matches', { defaultValue: 'No voices match these filters.' })}</div>
)}
{offset + BROWSE_PAGE < total && !favOnly && (
<div className="load-more">
<Button variant="ghost" onClick={() => setOffset(offset + BROWSE_PAGE)} disabled={browseQ.isFetching}>
{browseQ.isFetching ? <Loader className="spin" size={14} /> : null}
{t('gallery.load_more', { defaultValue: 'Load more' })}
</Button>
</div>
)}
</>
)}
</section>
</div>
);
}
// ── Archetype card ───────────────────────────────────────────────────────────
function ArchetypeCard({
a, t, viewMode, isFavorite, isPlaying, isLoadingPreview,
onPreview, onUse, onDesign, onToggleFavorite,
}) {
const color = USE_CASE_COLOR[a.use_case] || '#83a598';
const sub = [a.facets.gender, a.facets.age, a.facets.pitch]
.filter(Boolean).map(facetLabel).join(' · ');
const dialect = a.attrs?.ChineseDialect && a.attrs.ChineseDialect !== 'Auto' ? a.attrs.ChineseDialect : null;
const accentLabel = a.facets.accent
? facetLabel(a.facets.accent)
: (dialect || (a.language === 'Chinese' ? 'Chinese' : null));
return (
<div className={`archetype-card ${viewMode} ${isPlaying ? 'playing' : ''}`} style={{ '--card-accent': color }}>
<div className="arch-head">
<ArchetypeAvatar item={a} />
<div className="arch-title">
<div className="archetype-name">{a.name}</div>
{sub && <div className="archetype-sub">{sub}</div>}
</div>
<button
className={`fav-btn ${isFavorite ? 'on' : ''}`}
onClick={() => onToggleFavorite(a.id)}
title={t('gallery.favorite', { defaultValue: 'Favorite' })}
>
<Star size={15} fill={isFavorite ? 'currentColor' : 'none'} />
</button>
</div>
{/* Always render the chip row (even when empty) so every card shares the
same height and the action rows align across the grid. */}
<div className="archetype-chips">
{accentLabel && (
<span className="facet-chip with-flag">
<AccentFlag accent={a.facets.accent} lang={a.language} size={14} />
{accentLabel}
</span>
)}
{a.facets.whisper && (
<span className="facet-chip">{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}</span>
)}
</div>
<div className="arch-foot">
<button className="preview-btn" onClick={() => onPreview(a)} title={t('gallery.preview', { defaultValue: 'Preview' })}>
{isLoadingPreview ? <Loader className="spin" size={15} /> : isPlaying ? <NowPlaying color={color} /> : <Play size={15} />}
<span>{t('gallery.preview', { defaultValue: 'Preview' })}</span>
</button>
<button className="use-btn" onClick={() => onUse(a)}>
<UserPlus size={14} /> {t('gallery.use_voice', { defaultValue: 'Use voice' })}
</button>
<button className="designer-btn" onClick={() => onDesign(a)} title={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}>
<Wand2 size={14} />
</button>
</div>
</div>
);
}
// ── Community zone (marketplace) ─────────────────────────────────────────────
function CommunityZone({ t, playingId, loadingPreviewId, favorites, toggleFavorite, onPlayAudio, flash, onDesign }) {
const itemsQ = useCommunityItems({ limit: 100 });
const items = itemsQ.data?.items || [];
const favSet = useMemo(() => new Set(favorites), [favorites]);
const submit = async (type) => {
try {
const { url } = await communitySubmitUrl(type);
await openExternal(url);
} catch {
flash(t('gallery.submit_failed', { defaultValue: 'Could not open the submission form.' }));
}
};
return (
<div className="gallery-content gallery-scroll">
<div className="import-explainer community-explainer">
<span>{t('gallery.community_explainer', { defaultValue: 'Designed presets and recorded voices shared by the community, loaded from the omnivoice-gallery.' })}</span>
<div className="submit-actions">
<button className="submit-btn" onClick={() => submit('preset')}>
<Send size={13} /> {t('gallery.submit_preset', { defaultValue: 'Submit a preset' })}
</button>
<button className="submit-btn" onClick={() => submit('voice')}>
<Send size={13} /> {t('gallery.submit_voice', { defaultValue: 'Submit a voice' })}
</button>
</div>
</div>
{itemsQ.isLoading ? (
<div className="loading"><Loader className="spin" size={18} /></div>
) : items.length === 0 ? (
<div className="empty">
{t('gallery.community_empty', { defaultValue: 'No community voices loaded yet — connect to the internet and reopen, or be the first to submit one.' })}
</div>
) : (
<div className="archetype-grid grid">
{items.map((it) => (
<ArchetypeCard
key={it.id}
a={it}
t={t}
viewMode="grid"
isFavorite={favSet.has(it.id)}
isPlaying={playingId === it.id}
isLoadingPreview={loadingPreviewId === it.id}
onToggleFavorite={toggleFavorite}
onPreview={(item) => (item.audio?.url
? onPlayAudio(item.audio.url, item.id)
: flash(t('gallery.no_preview', { defaultValue: 'No preview — add it with "Use voice" to hear it.' })))}
onUse={async (item) => {
try {
const r = await addCommunityItem(item.id, item.name);
flash(t('gallery.saved_as_profile', { defaultValue: 'Added "{{name}}" to your voices.', name: r.name }));
} catch {
flash(t('gallery.use_failed', { defaultValue: 'Could not add that voice.' }));
}
}}
onDesign={(item) => (item.instruct
? onDesign(item.instruct)
: flash(t('gallery.no_designer', { defaultValue: 'Recorded voice — use "Use voice" instead.' })))}
/>
))}
</div>
)}
</div>
);
}
// ── My Imports zone (neutral importer) ───────────────────────────────────────
function ImportsZone({ t, playingId, loadingPreviewId, onPlayGallery, flash }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [trimming, setTrimming] = useState(null); // { voice, file }
const fileRef = useRef(null);
const personaRef = useRef(null);
const [importingPersona, setImportingPersona] = useState(false);
const voicesQ = useGalleryVoices();
const voices = voicesQ.data || [];
const reload = () => voicesQ.refetch();
// Import a portable .ovsvoice (or legacy .omnivoice) persona bundle (#29).
const handlePersonaImport = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setImportingPersona(true);
try {
const fd = new FormData();
fd.append('file', file);
const res = await importPersona(fd);
reload();
flash(t('gallery.persona_imported', {
defaultValue: 'Imported "{{name}}"{{unverified}}.', name: res.name,
unverified: res.verified_own_voice ? '' : t('gallery.persona_unverified_suffix', { defaultValue: ' (unverified)' }),
}));
} catch (err) {
const code = String(err?.message || err);
const msg = code.includes('413')
? t('gallery.persona_too_large', { defaultValue: 'That bundle is too large (max 100 MB).' })
: t('gallery.persona_import_failed', { defaultValue: 'Could not import that persona bundle.' });
flash(msg);
} finally {
setImportingPersona(false);
if (personaRef.current) personaRef.current.value = '';
}
};
const isUrl = /^https?:\/\//i.test(query.trim());
const handleSearch = async () => {
const q = query.trim();
if (!q) return;
if (isUrl) {
setIsDownloading(true);
try {
await downloadYoutubeClip({
video_url: q, start_time: 0, duration: 15,
character_name: t('gallery.imported_clip', { defaultValue: 'Imported clip' }),
category: 'import', description: q,
});
reload();
setQuery('');
} catch (e) {
flash(t('gallery.download_failed', { defaultValue: 'Download failed: {{msg}}', msg: e.message }));
} finally {
setIsDownloading(false);
}
return;
}
setIsSearching(true);
try {
const r = await searchYoutube(q, 'import', 10);
setResults(r.results || []);
} catch (e) {
flash(t('gallery.search_failed', { defaultValue: 'Search failed.' }));
} finally {
setIsSearching(false);
}
};
const handleDownload = async (info) => {
setIsDownloading(true);
try {
await downloadYoutubeClip({
video_url: `https://youtube.com/watch?v=${info.video_id}`,
start_time: 0,
duration: Math.min(parseFloat(info.duration) || 15, 30),
character_name: (info.title || '').substring(0, 40),
category: 'import',
description: info.title,
});
reload();
setResults([]);
} catch (e) {
flash(t('gallery.download_failed', { defaultValue: 'Download failed: {{msg}}', msg: e.message }));
} finally {
setIsDownloading(false);
}
};
const handleUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const fd = new FormData();
fd.append('name', file.name.replace(/\.[^.]+$/, ''));
fd.append('category', 'import');
fd.append('audio', file);
try {
await uploadVoiceClip(fd);
reload();
} catch (err) {
flash(t('gallery.upload_failed', { defaultValue: 'Upload failed.' }));
} finally {
if (fileRef.current) fileRef.current.value = '';
}
};
const handleSaveProfile = async (v) => {
try {
await saveVoiceAsProfile(v.id, v.name);
flash(t('gallery.saved_as_profile', { defaultValue: 'Added "{{name}}" to your voices.', name: v.name }));
} catch (e) {
flash(t('gallery.save_failed', { defaultValue: 'Could not save profile.' }));
}
};
const handleDelete = async (v) => {
if (!(await askConfirm(t('gallery.confirm_delete', { defaultValue: 'Delete "{{name}}"?', name: v.name })))) return;
try { await deleteGalleryVoice(v.id); reload(); } catch { /* noop */ }
};
const handleTrimClick = async (v) => {
try {
const resp = await fetch(apiUrl(previewVoiceUrl(v.id)));
if (!resp.ok) throw new Error('fetch failed');
const blob = await resp.blob();
const file = new File([blob], `${v.name}.wav`, { type: 'audio/wav' });
setTrimming({ voice: v, file });
} catch (e) {
flash(t('gallery.trim_load_failed', { defaultValue: 'Could not load audio for trimming.' }));
}
};
const handleConfirmTrim = async (trimmedFile) => {
if (!trimming) return;
const { voice } = trimming;
const fd = new FormData();
fd.append('name', `${voice.name} (Cropped)`);
fd.append('character', voice.character || '');
fd.append('category', 'import');
fd.append('description', voice.description || '');
fd.append('audio', trimmedFile);
try { await uploadVoiceClip(fd); reload(); setTrimming(null); } catch (e) {
flash(t('gallery.upload_failed', { defaultValue: 'Upload failed.' }));
}
};
return (
<div className="gallery-content">
<div className="import-explainer">
{t('gallery.import_explainer', {
defaultValue: 'Paste a URL you have the rights to (or upload a file), trim the part you need, and save it as a voice. You are responsible for the licensing of anything you import.',
})}
</div>
<div className="gallery-search">
<div className="search-row">
<Input
placeholder={t('gallery.import_placeholder', { defaultValue: 'Paste a video/audio URL, or type to search…' })}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
/>
<Button onClick={handleSearch} disabled={isSearching || isDownloading} size="sm">
{isSearching || isDownloading ? <Loader size={14} className="spin" /> : isUrl ? <Download size={14} /> : <Search size={14} />}
</Button>
<input ref={fileRef} type="file" accept="audio/*,video/*" hidden onChange={handleUpload} />
<Button variant="ghost" size="sm" onClick={() => fileRef.current?.click()} title={t('gallery.upload', { defaultValue: 'Upload file' })}>
<Upload size={14} />
</Button>
<input ref={personaRef} type="file" accept=".ovsvoice,.omnivoice" hidden onChange={handlePersonaImport} />
<Button variant="ghost" size="sm" disabled={importingPersona}
onClick={() => personaRef.current?.click()}
title={t('gallery.import_persona', { defaultValue: 'Import a .ovsvoice persona bundle' })}>
{importingPersona ? <Loader size={14} className="spin" /> : <Package size={14} />}
</Button>
</div>
</div>
{results.length > 0 && (
<div className="search-results-panel">
<div className="panel-header">
<span>{t('gallery.search_results', { defaultValue: '{{count}} results', count: results.length })}</span>
<button className="close-btn" onClick={() => setResults([])}><X size={14} /></button>
</div>
<div className="results-list">
{results.map((r, i) => (
<div key={i} className="result-row">
<div className="result-info">
<span className="result-title">{r.title}</span>
<span className="result-meta">{r.duration || '?'}s</span>
</div>
<Button size="sm" onClick={() => handleDownload(r)} disabled={isDownloading}>
<Download size={12} /> {t('gallery.import', { defaultValue: 'Import' })}
</Button>
</div>
))}
</div>
</div>
)}
<div className="content-header">
<div className="content-title">
{t('gallery.my_imports', { defaultValue: 'My Imports' })}<span className="count-badge">{voices.length}</span>
</div>
</div>
{voicesQ.isLoading ? (
<div className="loading"><Loader className="spin" size={18} /></div>
) : voices.length === 0 ? (
<div className="empty">{t('gallery.no_imports', { defaultValue: 'Nothing imported yet. Paste a URL above to get started.' })}</div>
) : (
<div className="voice-list">
{voices.map((v) => (
<div key={v.id} className="voice-card">
<button className="voice-play" onClick={() => onPlayGallery(v)}>
{loadingPreviewId === v.id ? <Loader className="spin" size={16} /> : playingId === v.id ? <Pause size={16} /> : <Play size={16} />}
</button>
<div className="voice-info">
<span className="voice-name">{v.name}</span>
<span className="voice-meta">{Math.round(v.duration || 0)}s</span>
</div>
<div className="voice-actions">
<button className="action-btn" onClick={() => handleTrimClick(v)} title={t('gallery.trim', { defaultValue: 'Trim' })}><Scissors size={14} /></button>
<button className="action-btn" onClick={() => handleSaveProfile(v)} title={t('gallery.use_voice', { defaultValue: 'Use voice' })}><UserPlus size={14} /></button>
<button className="action-btn danger" onClick={() => handleDelete(v)} title={t('gallery.delete', { defaultValue: 'Delete' })}><Trash2 size={14} /></button>
</div>
</div>
))}
</div>
)}
{trimming && (
<AudioTrimmer
file={trimming.file}
maxSeconds={60}
onConfirm={handleConfirmTrim}
onCancel={() => setTrimming(null)}
/>
)}
</div>
);
}
+49 -277
View File
@@ -2,12 +2,8 @@ import React, { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-hot-toast';
import { toastErrorWithReport } from '../utils/errorToast';
import {
ArrowLeft, Fingerprint, Wand2, Lock, Unlock, Trash2, Play, Save,
FolderOpen, Volume2, Clock, Pencil, Check, X, Sparkles, ShieldCheck, Mic, Square,
Download,
} from 'lucide-react';
import { Panel, Button, Input, Textarea, Field, Badge, Segmented, Progress } from '../ui';
import { ArrowLeft, Fingerprint, Wand2, Sparkles } from 'lucide-react';
import { Button } from '../ui';
import {
getProfile, getProfileUsage, updateProfile, deleteProfile, unlockProfile,
recordConsent, revokeConsent, exportPersona,
@@ -15,8 +11,10 @@ import {
import useRecording from '../hooks/useRecording';
import { generateSpeech } from '../api/generate';
import { API } from '../api/client';
import WaveformPlayer from '../components/WaveformPlayer';
import { useAppStore } from '../store';
import ProfileHeader from '../components/profile/ProfileHeader';
import ProfileDetails from '../components/profile/ProfileDetails';
import ProfileActivity from '../components/profile/ProfileActivity';
import './VoiceProfile.css';
import { askConfirm } from '../utils/dialog';
@@ -240,276 +238,50 @@ export default function VoiceProfile({ voiceId, onBack, onOpenProject, onDeleted
return (
<div className="voice-profile">
{/* Toolbar */}
<div className="voice-profile__bar">
<Button variant="ghost" size="sm" onClick={onBack} leading={<ArrowLeft size={12} />}>
{t('common.back')}
</Button>
<span className="voice-profile__crumb">
<TypeIcon size={12} /> {isDesign ? t('voice_profile.designed') : t('voice_profile.cloned')} voice
</span>
<div className="voice-profile__bar-spacer" />
{!editing && (
<Button variant="subtle" size="sm" onClick={() => setEditing(true)} leading={<Pencil size={12} />}>
{t('voice_profile.edit')}
</Button>
)}
{!editing && (
<label
className="voice-profile__persona-privacy"
title={t('voice_profile.persona_include_ref_hint', { defaultValue: 'Include the raw reference clip. Off = share only a watermarked preview (recommended).' })}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11 }}
>
<input type="checkbox" checked={includeReference} onChange={(e) => setIncludeReference(e.target.checked)} />
{t('voice_profile.persona_include_ref', { defaultValue: 'Include voice clip' })}
</label>
)}
{!editing && (
<Button variant="subtle" size="sm" onClick={onExportPersona} loading={exporting}
leading={!exporting && <Download size={12} />}>
{t('voice_profile.persona_export', { defaultValue: 'Export persona' })}
</Button>
)}
<Button variant="danger" size="sm" onClick={onDelete} leading={<Trash2 size={12} />}>
{t('common.delete')}
</Button>
</div>
{/* Hero */}
<Panel variant="glass" padding="md" className="voice-profile__hero">
<div className="voice-profile__hero-left">
<div className="voice-profile__icon-badge" data-kind={isDesign ? 'design' : 'clone'}>
<TypeIcon size={22} />
</div>
<div className="voice-profile__hero-title">
{editing ? (
<Input
size="lg"
value={draft.name}
onChange={e => setDraft({ ...draft, name: e.target.value })}
placeholder={t('voice_profile.name_placeholder')}
autoFocus
/>
) : (
<h1>{profile.name}</h1>
)}
<div className="voice-profile__badges">
{!!profile.verified_own_voice && (
<Badge tone="success" dot><ShieldCheck size={10} /> {t('voice_profile.verified')}</Badge>
)}
{profile.is_locked
? <Badge tone="warn" dot><Lock size={10} /> {t('voice_profile.locked')}</Badge>
: <Badge tone="neutral">{t('voice_profile.free')}</Badge>}
{profile.language && profile.language !== 'Auto' && (
<Badge tone="info">{profile.language}</Badge>
)}
<Badge tone="neutral" size="xs">
<Clock size={9} /> {createdDate}
</Badge>
{profile.seed != null && (
<Badge tone="violet" size="xs">seed {profile.seed}</Badge>
)}
</div>
</div>
</div>
{(profile.ref_audio_path || profile.locked_audio_path) && (
<div className="voice-profile__audio">
<div className="voice-profile__audio-label">
<Volume2 size={11} /> {profile.is_locked ? t('voice_profile.locked_ref') : t('voice_profile.ref_audio')}
</div>
<WaveformPlayer src={audioUrl} source="profile-ref" className="voice-profile__audio-el" />
</div>
)}
</Panel>
{/* Editable details */}
<Panel
variant="flat"
padding="md"
title={<>{t('voice_profile.details')}</>}
actions={editing ? (
<>
<Button variant="ghost" size="sm" onClick={cancelEdits} leading={<X size={12} />}>{t('common.cancel')}</Button>
<Button variant="primary" size="sm" onClick={saveEdits} loading={saving} leading={!saving && <Check size={12} />}>{t('common.save')}</Button>
</>
) : null}
>
<div className="voice-profile__grid-2">
<Field label={t('voice_profile.style_instruct')}>
{editing ? (
<Textarea
rows={2}
value={draft.instruct}
onChange={e => setDraft({ ...draft, instruct: e.target.value })}
placeholder={t('voice_profile.style_placeholder')}
/>
) : (
<div className="voice-profile__readonly">
{profile.instruct || <em> none </em>}
</div>
)}
</Field>
<Field label={t('voice_profile.language')}>
{editing ? (
<Input
value={draft.language}
onChange={e => setDraft({ ...draft, language: e.target.value })}
placeholder={t('clone.auto')}
/>
) : (
<div className="voice-profile__readonly">{profile.language || 'Auto'}</div>
)}
</Field>
</div>
<Field label={t('voice_profile.ref_transcript')} hint={t('voice_profile.ref_help')}>
{editing ? (
<Textarea
rows={2}
value={draft.ref_text}
onChange={e => setDraft({ ...draft, ref_text: e.target.value })}
placeholder={t('clone.optional')}
/>
) : (
<div className="voice-profile__readonly voice-profile__readonly--transcript">
{profile.ref_text || <em> none </em>}
</div>
)}
</Field>
{profile.is_locked && !editing && (
<div className="voice-profile__lock-row">
<Badge tone="warn" dot><Lock size={10} /> {t('voice_profile.locked')}</Badge>
<span className="voice-profile__lock-hint">
{t('voice_profile.locked_explain')}
</span>
<Button variant="subtle" size="sm" onClick={onUnlock} leading={<Unlock size={12} />}>{t('voice_profile.unlock')}</Button>
</div>
)}
</Panel>
{/* Consent lock (Wave 0.2) — verify this is your own voice */}
<Panel
variant="flat"
padding="md"
title={<><ShieldCheck size={12} /> {t('voice_profile.consent_title')}</>}
>
{profile.verified_own_voice ? (
<div className="voice-profile__lock-row">
<Badge tone="success" dot><ShieldCheck size={10} /> {t('voice_profile.verified')}</Badge>
<span className="voice-profile__lock-hint">
{t('voice_profile.consent_verified_explain', {
date: profile.consent_recorded_at
? new Date(profile.consent_recorded_at * 1000).toLocaleDateString()
: '',
})}
</span>
<Button variant="subtle" size="sm" onClick={onRevokeConsent} leading={<X size={12} />}>
{t('voice_profile.consent_revoke')}
</Button>
</div>
) : (
<>
<p className="voice-profile__readonly">{t('voice_profile.consent_explain')}</p>
<blockquote className="voice-profile__readonly voice-profile__readonly--transcript">
{consentStatement}
</blockquote>
{consentRec.isRecording ? (
<Button variant="danger" size="sm" onClick={consentRec.stopRecording} leading={<Square size={12} />}>
{t('voice_profile.consent_stop')} ({consentRec.recordingTime}s)
</Button>
) : (
<Button
variant="primary"
size="sm"
onClick={consentRec.startRecording}
loading={consentSubmitting || consentRec.isCleaning}
leading={!(consentSubmitting || consentRec.isCleaning) && <Mic size={12} />}
>
{t('voice_profile.consent_record')}
</Button>
)}
</>
)}
</Panel>
{/* Try-it */}
<Panel
variant="flat"
padding="md"
title={<><Play size={13} /> {t('voice_profile.try_voice')}</>}
>
<Field
label={t('voice_profile.test_phrase')}
hint={t('voice_profile.test_help')}
>
<Textarea
rows={2}
value={testText}
onChange={e => setTestText(e.target.value)}
placeholder={t('voice_profile.test_placeholder')}
/>
</Field>
<div className="voice-profile__tryit-actions">
<Button
variant="primary"
size="sm"
loading={testGenerating}
onClick={runTest}
disabled={!testText.trim()}
leading={!testGenerating && <Sparkles size={12} />}
>
{testGenerating ? t('voice_profile.generating') : t('voice_profile.gen_preview')}
</Button>
{testAudioUrl && (
<WaveformPlayer
src={testAudioUrl}
source="profile-test"
autoPlay={autoPlayPreview}
className="voice-profile__tryit-audio"
/>
)}
</div>
</Panel>
{/* Usage */}
<Panel variant="flat" padding="md" title={<>{t('voice_profile.used_title')}</>}>
{!usage || (!usage.synth_total && !usage.projects?.length) ? (
<div className="voice-profile__usage-empty">
{t('voice_profile.used_empty')}
</div>
) : (
<>
<div className="voice-profile__usage-counts">
<Badge tone="brand">
{t('voice_profile.synth_clips', { count: usage.synth_total })}
</Badge>
<Badge tone="info">
{t('voice_profile.projects_count', { count: usage.projects.length })}
</Badge>
<Badge tone="success">
{t('voice_profile.dubbed_segments', { count: usage.project_total_segments })}
</Badge>
</div>
{usage.projects.length > 0 && (
<ul className="voice-profile__usage-list">
{usage.projects.slice(0, 10).map(p => (
<li key={p.project_id}>
<button
type="button"
onClick={() => onOpenProject?.(p.project_id)}
className="voice-profile__usage-link"
>
<FolderOpen size={11} />
<span className="voice-profile__usage-name">{p.project_name}</span>
<span className="voice-profile__usage-count">{p.segment_count} segs</span>
</button>
</li>
))}
</ul>
)}
</>
)}
</Panel>
<ProfileHeader
profile={profile}
isDesign={isDesign}
TypeIcon={TypeIcon}
onBack={onBack}
editing={editing}
setEditing={setEditing}
includeReference={includeReference}
setIncludeReference={setIncludeReference}
onExportPersona={onExportPersona}
exporting={exporting}
onDelete={onDelete}
draft={draft}
setDraft={setDraft}
createdDate={createdDate}
audioUrl={audioUrl}
t={t}
/>
<ProfileDetails
profile={profile}
editing={editing}
draft={draft}
setDraft={setDraft}
saving={saving}
cancelEdits={cancelEdits}
saveEdits={saveEdits}
onUnlock={onUnlock}
onRevokeConsent={onRevokeConsent}
consentStatement={consentStatement}
consentRec={consentRec}
consentSubmitting={consentSubmitting}
t={t}
/>
<ProfileActivity
t={t}
testText={testText}
setTestText={setTestText}
testGenerating={testGenerating}
runTest={runTest}
testAudioUrl={testAudioUrl}
autoPlayPreview={autoPlayPreview}
usage={usage}
onOpenProject={onOpenProject}
/>
</div>
);
}