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:
co-authored by
mergetest
Claude Opus 4.8
parent
a7f813b7a9
commit
b9707e0d8f
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user