feat(workspaces): refine voice story and audiobook flows
This commit is contained in:
+20
-18
@@ -283,7 +283,7 @@ function App() {
|
||||
mode === 'stories' ||
|
||||
mode === 'audiobook' ||
|
||||
// Voice (studio) and Dub workspaces moved their saved voices /
|
||||
// projects + history into right-side panels; left sidebar dissolved.
|
||||
// projects + history into workspace rails; global sidebar dissolved.
|
||||
mode === 'studio' ||
|
||||
mode === 'dub';
|
||||
const availableSidebarTabs = [];
|
||||
@@ -1134,7 +1134,7 @@ function App() {
|
||||
};
|
||||
|
||||
const deleteHistory = async (id, type) => {
|
||||
if (!(await askConfirm('Delete this history item?'))) return;
|
||||
if (!(await askConfirm(i18n.t('history.delete_confirm')))) return;
|
||||
try {
|
||||
const endpoint = type === 'dub' ? `${API}/dub/history/${id}` : `${API}/history/${id}`;
|
||||
await apiFetch(endpoint, { method: 'DELETE' });
|
||||
@@ -1554,6 +1554,24 @@ function App() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="studio-with-history">
|
||||
<div className="studio-voices">
|
||||
<WorkspaceVoices
|
||||
defineMethod={defineMethod}
|
||||
profiles={profiles}
|
||||
selectedProfile={selectedProfile}
|
||||
setSelectedProfile={setSelectedProfile}
|
||||
previewLoading={previewLoading}
|
||||
handleSelectProfile={handleSelectProfile}
|
||||
handleDeleteProfile={handleDeleteProfile}
|
||||
handlePreviewVoice={handlePreviewVoice}
|
||||
handleUnlockProfile={handleUnlockProfile}
|
||||
openVoiceProfile={openVoiceProfile}
|
||||
onOpenVoicePreview={(profileId) => {
|
||||
setVoicePreviewProfileId(profileId || '');
|
||||
setIsVoicePreviewOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="studio-with-history__main">
|
||||
<ErrorBoundary name="clone-design">
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
@@ -1627,22 +1645,6 @@ function App() {
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<div className="studio-right">
|
||||
<WorkspaceVoices
|
||||
defineMethod={defineMethod}
|
||||
profiles={profiles}
|
||||
selectedProfile={selectedProfile}
|
||||
setSelectedProfile={setSelectedProfile}
|
||||
previewLoading={previewLoading}
|
||||
handleSelectProfile={handleSelectProfile}
|
||||
handleDeleteProfile={handleDeleteProfile}
|
||||
handlePreviewVoice={handlePreviewVoice}
|
||||
handleUnlockProfile={handleUnlockProfile}
|
||||
openVoiceProfile={openVoiceProfile}
|
||||
onOpenVoicePreview={(profileId) => {
|
||||
setVoicePreviewProfileId(profileId || '');
|
||||
setIsVoicePreviewOpen(true);
|
||||
}}
|
||||
/>
|
||||
<WorkspaceHistory
|
||||
history={history}
|
||||
handleSaveHistoryAsProfile={handleSaveHistoryAsProfile}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,7 @@ import { useAppStore } from '../store';
|
||||
* @param {()=>void} [onCreateVoice] render an inline "create voice" button
|
||||
* @param {string} [recentsKey=''] persist recents under this key (real ids only)
|
||||
* @param {string} [placeholder] trigger placeholder when nothing resolves
|
||||
* @param {string} [ariaLabel] accessible label for the select trigger
|
||||
* @param {boolean} [menuPortal=false] portal the dropdown to <body> (needed
|
||||
* inside clipping ancestors: overflow:auto panels / react-window rows — #1220)
|
||||
*/
|
||||
@@ -84,6 +85,7 @@ export default function VoiceSelector({
|
||||
onCreateVoice,
|
||||
recentsKey = '',
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
disabled = false,
|
||||
size = 'md',
|
||||
buttonClassName,
|
||||
@@ -299,6 +301,7 @@ export default function VoiceSelector({
|
||||
disabled={disabled || materializing}
|
||||
size={size}
|
||||
buttonClassName={buttonClassName}
|
||||
ariaLabel={ariaLabel}
|
||||
menuPortal={menuPortal}
|
||||
onOpenChange={setOpen}
|
||||
onQueryChange={setRawQuery}
|
||||
|
||||
@@ -179,7 +179,6 @@ export default function WorkspaceHistory({
|
||||
<div
|
||||
key={`dub-${item.id}`}
|
||||
className="history-item history-item--dub"
|
||||
onClick={() => restoreDubHistory(item)}
|
||||
>
|
||||
<div className="flex min-w-0 gap-[8px]">
|
||||
<DubMediaPreview item={item} inputType={inputType} />
|
||||
@@ -282,22 +281,30 @@ export default function WorkspaceHistory({
|
||||
className="history-kind"
|
||||
style={{ color: accent, background: `${accent}22` }}
|
||||
>
|
||||
<KindIcon size={9} /> {item.mode || 'synth'}
|
||||
<KindIcon size={9} aria-hidden="true" />{' '}
|
||||
{item.mode === 'clone' ? t('history.mode_clone') : t('history.mode_synth')}
|
||||
</span>
|
||||
<span className="history-meta">
|
||||
{item.language && item.language !== 'Auto' ? `${item.language} · ` : ''}
|
||||
{item.generation_time ? `${item.generation_time}s` : ''}
|
||||
{item.generation_time
|
||||
? t('history.generation_seconds', { duration: item.generation_time })
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={`history-title history-title--clamp ${expanded === item.id ? 'history-title--expanded' : ''}`}
|
||||
title={item.text}
|
||||
onClick={() => setExpanded((e) => (e === item.id ? null : item.id))}
|
||||
aria-expanded={expanded === item.id}
|
||||
aria-label={t(expanded === item.id ? 'history.collapse_text' : 'history.expand_text')}
|
||||
>
|
||||
{displayTitle(item.text)}
|
||||
</div>
|
||||
</button>
|
||||
{item.seed != null && String(item.seed) !== '' ? (
|
||||
<div className="history-subtitle history-subtitle--seed">seed {item.seed}</div>
|
||||
<div className="history-subtitle history-subtitle--seed">
|
||||
{t('history.seed', { seed: item.seed })}
|
||||
</div>
|
||||
) : null}
|
||||
{item.audio_path ? (
|
||||
<LazyWaveform
|
||||
@@ -312,6 +319,7 @@ export default function WorkspaceHistory({
|
||||
<div className="history-actions">
|
||||
{toggleStarHistory ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`history-action-btn history-action-icon ${item.starred ? 'accent' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -324,12 +332,18 @@ export default function WorkspaceHistory({
|
||||
? t('history.unstar_take', { defaultValue: 'Unstar — allow cleanup' })
|
||||
: t('history.star_take', { defaultValue: 'Star — keep this take' })
|
||||
}
|
||||
aria-label={
|
||||
item.starred
|
||||
? t('history.unstar_take', { defaultValue: 'Unstar — allow cleanup' })
|
||||
: t('history.star_take', { defaultValue: 'Star — keep this take' })
|
||||
}
|
||||
>
|
||||
<Star size={10} fill={item.starred ? 'currentColor' : 'none'} />
|
||||
<Star size={10} fill={item.starred ? 'currentColor' : 'none'} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
{playTakeAsOutput ? (
|
||||
<button
|
||||
type="button"
|
||||
className="history-action-btn accent history-action-icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -339,59 +353,71 @@ export default function WorkspaceHistory({
|
||||
title={t('history.play_take', {
|
||||
defaultValue: 'Load as active output',
|
||||
})}
|
||||
aria-label={t('history.play_take', {
|
||||
defaultValue: 'Load as active output',
|
||||
})}
|
||||
>
|
||||
<Play size={10} />
|
||||
<Play size={10} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="history-action-btn accent"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSaveHistoryAsProfile(item);
|
||||
}}
|
||||
>
|
||||
<Save size={10} /> {t('sidebar.save_label')}
|
||||
<Save size={10} aria-hidden="true" /> {t('sidebar.save_label')}
|
||||
</button>
|
||||
{item.profile_id ? (
|
||||
<button
|
||||
type="button"
|
||||
className="history-action-btn accent history-action-icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleLockProfile(item.profile_id, item.id, item.seed);
|
||||
}}
|
||||
title={t('sidebar.lock_identity')}
|
||||
aria-label={t('sidebar.lock_identity')}
|
||||
>
|
||||
<Lock size={10} />
|
||||
<Lock size={10} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="history-action-btn history-action-icon"
|
||||
onClick={(e) =>
|
||||
handleNativeExport(e, item.audio_path, item.audio_path, item.mode)
|
||||
}
|
||||
title="Export"
|
||||
title={t('dub.export')}
|
||||
aria-label={t('dub.export')}
|
||||
>
|
||||
<DownloadIcon size={10} />
|
||||
<DownloadIcon size={10} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="history-action-btn history-action-icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
restoreHistory(item);
|
||||
}}
|
||||
title="Load config"
|
||||
title={t('history.load_config')}
|
||||
aria-label={t('history.load_config')}
|
||||
>
|
||||
<FolderOpen size={10} />
|
||||
<FolderOpen size={10} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="history-action-btn danger history-action-icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteHistory(item.id, 'synth');
|
||||
}}
|
||||
title="Delete"
|
||||
title={t('common.delete')}
|
||||
aria-label={t('common.delete')}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
<Trash2 size={10} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -79,6 +79,18 @@ describe('WorkspaceHistory takes actions', () => {
|
||||
expect(playTakeAsOutput.mock.calls[0][0].id).toBe('bb2');
|
||||
});
|
||||
|
||||
it('exposes text expansion and icon actions to keyboard and assistive technology', () => {
|
||||
renderRail();
|
||||
|
||||
const expand = screen.getAllByRole('button', { name: 'Expand text' })[0];
|
||||
expect(expand).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(expand);
|
||||
expect(expand).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getAllByRole('button', { name: 'Export' })).toHaveLength(2);
|
||||
expect(screen.getAllByRole('button', { name: 'Load settings' })).toHaveLength(2);
|
||||
expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('starred filter narrows the rail to starred takes only', () => {
|
||||
renderRail();
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* WorkspaceProjects — the right-side "Dub projects" panel.
|
||||
* WorkspaceProjects — the compact left-side "Dub projects" panel.
|
||||
*
|
||||
* Relocates the saved-dub-project list (and the Save-project button) out of the
|
||||
* left Sidebar so the Dub workspace can dissolve its sidebar, mirroring the
|
||||
* Voice workspace's WorkspaceVoices. Card markup + actions mirror the former
|
||||
* left Sidebar so the Dub workspace can dissolve its global sidebar, mirroring
|
||||
* the Voice workspace's WorkspaceVoices rail. Card markup + actions mirror the former
|
||||
* Sidebar section 1:1 (open/load, delete).
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* WorkspaceVoices — the right-side "Saved voices" panel.
|
||||
* WorkspaceVoices — the left-side "Saved voices" panel.
|
||||
*
|
||||
* Relocates the saved-profile list that used to live in the left Sidebar
|
||||
* (the "Designed voices" / "Voice clones" section) to the right column, so
|
||||
* (the "Designed voices" / "Voice clones" section) to the workspace rail, so
|
||||
* the Voice workspace can dissolve the left sidebar entirely. Profiles are
|
||||
* scoped by define-method: 'audio' shows reference-audio profiles
|
||||
* (no instruct), 'design' shows designed profiles (have instruct).
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { BookMarked, BookOpen, FileUp, ListTree, Loader, Sparkles, Square } from 'lucide-react';
|
||||
|
||||
import { buttonVariants } from '@/components/ui/button.tsx';
|
||||
import { Button } from '../../ui';
|
||||
|
||||
/** The inviting front door for the long-form workflow: context, path, and actions. */
|
||||
export default function AudiobookHero({
|
||||
t,
|
||||
busy,
|
||||
importing,
|
||||
planLoading,
|
||||
generating,
|
||||
canRun,
|
||||
onImport,
|
||||
onLoadSample,
|
||||
onPreview,
|
||||
onCreate,
|
||||
onStop,
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-[12px] border border-transparent bg-[var(--color-bg-elev-2)] px-[12px] py-[9px]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-[10px]">
|
||||
<div className="flex min-w-0 items-center gap-[9px]">
|
||||
<div
|
||||
className="relative flex h-[34px] w-[28px] shrink-0 items-center justify-center rounded-[5px_8px_8px_5px] bg-primary/[0.13] text-primary shadow-[inset_3px_0_0_color-mix(in_srgb,var(--color-brand)_25%,transparent)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<BookMarked size={15} strokeWidth={1.8} />
|
||||
</div>
|
||||
<h2
|
||||
className="m-0 [font-family:var(--font-serif)] text-[var(--text-lg)] font-semibold text-fg"
|
||||
>
|
||||
{t('audiobook.title')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-[4px]">
|
||||
<label
|
||||
title={t('audiobook.import')}
|
||||
className={buttonVariants({
|
||||
variant: 'subtle',
|
||||
size: 'omniSm',
|
||||
className: busy ? 'cursor-default opacity-50' : 'cursor-pointer',
|
||||
})}
|
||||
>
|
||||
{importing ? <Loader className="animate-spin" /> : <FileUp />}
|
||||
<span className="leading-none">{t('audiobook.import')}</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".txt,.md,.epub,.pdf"
|
||||
onChange={onImport}
|
||||
disabled={busy}
|
||||
aria-label={t('audiobook.import')}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={onLoadSample}
|
||||
disabled={busy}
|
||||
title={t('audiobook.load_sample_hint')}
|
||||
aria-label={t('audiobook.load_sample')}
|
||||
leading={<BookOpen />}
|
||||
>
|
||||
{t('audiobook.load_sample')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={onPreview}
|
||||
disabled={!canRun}
|
||||
loading={planLoading}
|
||||
title={t('audiobook.preview_plan')}
|
||||
aria-label={t('audiobook.preview_plan')}
|
||||
leading={<ListTree />}
|
||||
>
|
||||
{t('audiobook.preview_plan')}
|
||||
</Button>
|
||||
{generating ? (
|
||||
<Button variant="danger" size="sm" onClick={onStop} leading={<Square />}>
|
||||
{t('audiobook.stop')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onCreate}
|
||||
disabled={!canRun}
|
||||
leading={<Sparkles />}
|
||||
>
|
||||
{t('audiobook.create')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
BookText,
|
||||
Code,
|
||||
Languages,
|
||||
Mic2,
|
||||
SlidersHorizontal,
|
||||
SpellCheck,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
import VoiceSelector from '../VoiceSelector';
|
||||
import SearchableSelect from '../SearchableSelect';
|
||||
import AudiobookOverrides from './AudiobookOverrides';
|
||||
import BookDetails from './BookDetails';
|
||||
import CastPanel from './CastPanel';
|
||||
import LexiconEditor from './LexiconEditor';
|
||||
import ALL_LANGUAGES from '../../languages.json';
|
||||
import { POPULAR_LANGS } from '../../utils/constants';
|
||||
|
||||
const FIELD_LABEL =
|
||||
'flex items-center gap-[5px] [font-family:var(--chrome-font-mono)] [font-size:var(--chrome-label-size)] font-semibold [letter-spacing:var(--chrome-label-track)] uppercase text-fg-muted';
|
||||
|
||||
const TOOL_BUTTON =
|
||||
'relative flex h-[46px] min-w-0 flex-col items-center justify-center gap-[3px] rounded-[8px] border border-transparent bg-transparent px-[6px] text-[0.62rem] text-fg-muted cursor-pointer transition-[background,color,box-shadow] duration-[120ms] hover:bg-[var(--chrome-hover-bg)] hover:text-fg focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:-2px] aria-pressed:bg-primary/[0.12] aria-pressed:text-primary aria-pressed:shadow-[inset_0_-2px_0_var(--color-brand)]';
|
||||
|
||||
function ToolButton({ active, badge = 0, icon, label, onClick }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={TOOL_BUTTON}
|
||||
aria-pressed={active}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
<span className="w-full truncate text-center">{label}</span>
|
||||
{badge > 0 && (
|
||||
<span
|
||||
className="absolute right-[5px] top-[4px] min-w-[15px] rounded-full bg-[var(--chrome-hover-bg)] px-[4px] text-center text-[0.55rem] leading-[15px] text-fg [font-variant-numeric:tabular-nums]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact right-rail property inspector with one optional tool panel at a time. */
|
||||
export default function AudiobookInspector({
|
||||
t,
|
||||
profiles,
|
||||
defaultVoice,
|
||||
setDefaultVoice,
|
||||
language,
|
||||
setLanguage,
|
||||
format,
|
||||
setFormat,
|
||||
loudness,
|
||||
setLoudness,
|
||||
castNames,
|
||||
voiceCast,
|
||||
setVoiceCast,
|
||||
overrides,
|
||||
setOverrides,
|
||||
emotionSupported,
|
||||
coverPreview,
|
||||
onCoverPick,
|
||||
clearCover,
|
||||
meta,
|
||||
setMetaField,
|
||||
lex,
|
||||
setLexRow,
|
||||
addLexRow,
|
||||
removeLexRow,
|
||||
}) {
|
||||
// `undefined` means the user has not chosen a panel yet: Cast becomes the
|
||||
// useful default as soon as the script contains [voice:…] tags. Once the user
|
||||
// switches or closes a panel, their explicit choice wins.
|
||||
const [activePanel, setActivePanel] = useState(undefined);
|
||||
const panel =
|
||||
activePanel === undefined
|
||||
? castNames.length > 0
|
||||
? 'cast'
|
||||
: null
|
||||
: activePanel === 'cast' && castNames.length === 0
|
||||
? null
|
||||
: activePanel;
|
||||
const togglePanel = (next) => setActivePanel(panel === next ? null : next);
|
||||
const detailCount =
|
||||
Object.values(meta).filter((value) => value?.trim()).length + (coverPreview ? 1 : 0);
|
||||
const lexiconCount = lex.filter((row) => row.word.trim() || row.say.trim()).length;
|
||||
const outputCount =
|
||||
(loudness !== 'off' ? 1 : 0) +
|
||||
(Object.values(overrides).some((value) => value !== null && value !== false && value !== '')
|
||||
? 1
|
||||
: 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[9px] [container-type:inline-size] [container-name:audiobook-inspector]">
|
||||
<div className="grid grid-cols-1 gap-[8px] rounded-[11px] bg-[var(--chrome-bg)] p-[10px] @min-[360px]/audiobook-inspector:grid-cols-2 @min-[560px]/audiobook-inspector:[grid-template-columns:minmax(170px,1.35fr)_minmax(120px,0.9fr)_minmax(100px,0.7fr)]">
|
||||
<div className="flex min-w-0 flex-col gap-[4px] @min-[360px]/audiobook-inspector:col-span-2 @min-[560px]/audiobook-inspector:col-span-1">
|
||||
<label className={FIELD_LABEL}>
|
||||
<Mic2 size={11} aria-hidden="true" /> {t('audiobook.default_voice')}
|
||||
</label>
|
||||
<VoiceSelector
|
||||
value={defaultVoice}
|
||||
onChange={setDefaultVoice}
|
||||
profiles={profiles}
|
||||
defaultLabel={t('audiobook.engine_default')}
|
||||
ariaLabel={t('audiobook.default_voice')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-[4px]">
|
||||
<label className={FIELD_LABEL}>
|
||||
<Languages size={11} aria-hidden="true" /> {t('audiobook.language')}
|
||||
</label>
|
||||
<SearchableSelect
|
||||
value={language}
|
||||
options={ALL_LANGUAGES}
|
||||
popular={POPULAR_LANGS}
|
||||
recentsKey="omnivoice.recents.audiobookLang"
|
||||
onChange={setLanguage}
|
||||
ariaLabel={t('audiobook.language')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-[4px]">
|
||||
<label className={FIELD_LABEL}>{t('audiobook.format')}</label>
|
||||
<select
|
||||
className="input-base"
|
||||
name="audiobook-format"
|
||||
value={format}
|
||||
onChange={(event) => setFormat(event.target.value)}
|
||||
aria-label={t('audiobook.format')}
|
||||
>
|
||||
<option value="m4b">{t('audiobook.format_m4b')}</option>
|
||||
<option value="mp3">{t('audiobook.format_mp3')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[11px] bg-[var(--chrome-bg)] shadow-[inset_0_0_0_1px_var(--chrome-border)]">
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(68px,1fr))] gap-[2px] p-[4px]"
|
||||
role="toolbar"
|
||||
aria-label={t('audiobook.title')}
|
||||
>
|
||||
{castNames.length > 0 && (
|
||||
<ToolButton
|
||||
active={panel === 'cast'}
|
||||
badge={castNames.length}
|
||||
icon={<Users size={13} aria-hidden="true" />}
|
||||
label={t('audiobook.cast')}
|
||||
onClick={() => togglePanel('cast')}
|
||||
/>
|
||||
)}
|
||||
<ToolButton
|
||||
active={panel === 'output'}
|
||||
badge={outputCount}
|
||||
icon={<SlidersHorizontal size={13} aria-hidden="true" />}
|
||||
label={t('audiobook.output')}
|
||||
onClick={() => togglePanel('output')}
|
||||
/>
|
||||
<ToolButton
|
||||
active={panel === 'details'}
|
||||
badge={detailCount}
|
||||
icon={<BookText size={13} aria-hidden="true" />}
|
||||
label={t('audiobook.details')}
|
||||
onClick={() => togglePanel('details')}
|
||||
/>
|
||||
<ToolButton
|
||||
active={panel === 'lexicon'}
|
||||
badge={lexiconCount}
|
||||
icon={<SpellCheck size={13} aria-hidden="true" />}
|
||||
label={t('audiobook.lexicon')}
|
||||
onClick={() => togglePanel('lexicon')}
|
||||
/>
|
||||
<ToolButton
|
||||
active={panel === 'markup'}
|
||||
icon={<Code size={13} aria-hidden="true" />}
|
||||
label={t('audiobook.markup_help')}
|
||||
onClick={() => togglePanel('markup')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{panel && (
|
||||
<div className="border-t border-transparent p-[11px]">
|
||||
{panel === 'cast' && (
|
||||
<CastPanel
|
||||
t={t}
|
||||
castNames={castNames}
|
||||
voiceCast={voiceCast}
|
||||
setVoiceCast={setVoiceCast}
|
||||
profiles={profiles}
|
||||
/>
|
||||
)}
|
||||
{panel === 'output' && (
|
||||
<div className="flex flex-col gap-[8px]">
|
||||
<div className="flex flex-col gap-[4px]">
|
||||
<label className={FIELD_LABEL}>{t('audiobook.loudness')}</label>
|
||||
<select
|
||||
className="input-base"
|
||||
name="audiobook-loudness"
|
||||
value={loudness}
|
||||
onChange={(event) => setLoudness(event.target.value)}
|
||||
aria-label={t('audiobook.loudness')}
|
||||
>
|
||||
<option value="off">{t('audiobook.loudness_off')}</option>
|
||||
<option value="acx">{t('audiobook.loudness_acx')}</option>
|
||||
<option value="podcast">{t('audiobook.loudness_podcast')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<AudiobookOverrides
|
||||
t={t}
|
||||
overrides={overrides}
|
||||
onChange={setOverrides}
|
||||
emotionSupported={emotionSupported}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{panel === 'details' && (
|
||||
<BookDetails
|
||||
t={t}
|
||||
coverPreview={coverPreview}
|
||||
onCoverPick={onCoverPick}
|
||||
clearCover={clearCover}
|
||||
meta={meta}
|
||||
setMetaField={setMetaField}
|
||||
/>
|
||||
)}
|
||||
{panel === 'lexicon' && (
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
<LexiconEditor
|
||||
t={t}
|
||||
lex={lex}
|
||||
setLexRow={setLexRow}
|
||||
addLexRow={addLexRow}
|
||||
removeLexRow={removeLexRow}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{panel === 'markup' && (
|
||||
<p className="m-0 text-[0.68rem] leading-[1.55] text-fg-muted">
|
||||
{t('audiobook.markup_hint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,14 +20,16 @@ export default function BookDetails({
|
||||
setMetaField,
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-[12px] items-start">
|
||||
<div style={{ position: 'relative', width: 96, height: 96, flexShrink: 0 }}>
|
||||
<div className="flex items-start gap-[9px]">
|
||||
<div className="relative size-[64px] shrink-0">
|
||||
{coverPreview ? (
|
||||
<>
|
||||
<img
|
||||
src={coverPreview}
|
||||
alt={t('audiobook.cover')}
|
||||
style={{ width: 96, height: 96, objectFit: 'cover', borderRadius: 6 }}
|
||||
width="64"
|
||||
height="64"
|
||||
className="size-[64px] rounded-[7px] object-cover"
|
||||
/>
|
||||
<Button
|
||||
variant="icon"
|
||||
@@ -43,8 +45,8 @@ export default function BookDetails({
|
||||
<label
|
||||
className={buttonVariants({ variant: 'subtle', size: 'omniMd' })}
|
||||
style={{
|
||||
width: 96,
|
||||
height: 96,
|
||||
width: 64,
|
||||
height: 64,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
@@ -53,8 +55,8 @@ export default function BookDetails({
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ImageIcon size={20} />
|
||||
<span style={{ fontSize: '0.65rem' }}>{t('audiobook.cover_add')}</span>
|
||||
<ImageIcon size={17} />
|
||||
<span className="text-[0.58rem]">{t('audiobook.cover_add')}</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg"
|
||||
@@ -64,11 +66,13 @@ export default function BookDetails({
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_1fr] gap-[8px] flex-1 min-w-0">
|
||||
<div className="grid min-w-0 flex-1 grid-cols-2 gap-[6px]">
|
||||
{META_FIELDS.map((k) => (
|
||||
<input
|
||||
key={k}
|
||||
className="input-base"
|
||||
name={`audiobook-${k}`}
|
||||
autoComplete="off"
|
||||
placeholder={t(`audiobook.meta_${k}`)}
|
||||
value={meta[k]}
|
||||
onChange={setMetaField(k)}
|
||||
@@ -77,6 +81,8 @@ export default function BookDetails({
|
||||
))}
|
||||
<input
|
||||
className="input-base"
|
||||
name="audiobook-description"
|
||||
autoComplete="off"
|
||||
placeholder={t('audiobook.meta_description')}
|
||||
value={meta.description}
|
||||
onChange={setMetaField('description')}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import VoiceSelector from '../VoiceSelector';
|
||||
|
||||
/**
|
||||
@@ -30,27 +29,23 @@ export default function CastPanel({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-[10px]">
|
||||
<p className="muted text-[0.72rem] leading-[1.5] m-0 text-fg-muted">
|
||||
{t('audiobook.cast_hint')}
|
||||
</p>
|
||||
<div className="flex flex-col gap-[7px]">
|
||||
{castNames.map((name) => {
|
||||
const mapped = voiceCast[name] || '';
|
||||
return (
|
||||
<div key={name} className="flex flex-col gap-[4px]">
|
||||
<div className="flex items-center justify-between gap-[8px]">
|
||||
<code className="text-[0.72rem] text-fg break-all">[voice:{name}]</code>
|
||||
{!mapped && (
|
||||
<span className="muted text-[0.68rem] text-fg-muted whitespace-nowrap">
|
||||
{t('audiobook.cast_uses_default')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
key={name}
|
||||
className="grid grid-cols-[minmax(72px,0.7fr)_minmax(0,1.3fr)] items-center gap-[7px]"
|
||||
>
|
||||
<code className="truncate text-[0.68rem] text-fg" title={`[voice:${name}]`}>
|
||||
{name}
|
||||
</code>
|
||||
<VoiceSelector
|
||||
value={mapped}
|
||||
onChange={(v) => setVoiceCast(name, v || null)}
|
||||
profiles={profiles}
|
||||
defaultLabel={t('audiobook.engine_default')}
|
||||
ariaLabel={`${t('audiobook.cast')}: ${name}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,8 @@ export default function LexiconEditor({ t, lex, setLexRow, addLexRow, removeLexR
|
||||
<div key={i} className="flex gap-[6px]">
|
||||
<input
|
||||
className="input-base"
|
||||
name={`lexicon-word-${i}`}
|
||||
autoComplete="off"
|
||||
placeholder={t('audiobook.lex_word')}
|
||||
value={row.word}
|
||||
onChange={setLexRow(i, 'word')}
|
||||
@@ -23,6 +25,8 @@ export default function LexiconEditor({ t, lex, setLexRow, addLexRow, removeLexR
|
||||
/>
|
||||
<input
|
||||
className="input-base"
|
||||
name={`lexicon-pronunciation-${i}`}
|
||||
autoComplete="off"
|
||||
placeholder={t('audiobook.lex_say')}
|
||||
value={row.say}
|
||||
onChange={setLexRow(i, 'say')}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown, Smile } from 'lucide-react';
|
||||
import {
|
||||
AudioLines,
|
||||
Bold,
|
||||
ChevronDown,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Pause,
|
||||
Smile,
|
||||
SpellCheck,
|
||||
} from 'lucide-react';
|
||||
import { TAGS } from '../../utils/constants';
|
||||
|
||||
// Compact chrome pill button — same visual language as the clone Insert menu.
|
||||
const PILL =
|
||||
'inline-flex items-center gap-[4px] border border-transparent bg-[var(--chrome-bg)] text-[var(--chrome-fg-muted)] px-[8px] py-[3px] rounded-[var(--chrome-radius-pill)] [font-family:var(--chrome-font-mono)] text-[0.66rem] whitespace-nowrap cursor-pointer transition-colors duration-[120ms] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)] focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]';
|
||||
'inline-flex h-[26px] items-center justify-center gap-[4px] border border-transparent bg-[var(--chrome-bg)] text-[var(--chrome-fg-muted)] px-[7px] py-0 rounded-[var(--chrome-radius-pill)] [font-family:var(--chrome-font-mono)] text-[0.62rem] whitespace-nowrap cursor-pointer transition-colors duration-[120ms] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)] focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]';
|
||||
const TAG_BTN =
|
||||
'border border-transparent bg-transparent text-[var(--chrome-fg-muted)] px-[9px] py-[3px] rounded-[var(--chrome-radius-pill)] [font-family:var(--chrome-font-mono)] font-medium text-[0.66rem] whitespace-nowrap cursor-pointer transition-colors duration-[120ms] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]';
|
||||
|
||||
@@ -78,39 +87,82 @@ export default function MarkupToolbar({ t, textareaRef, text, setText }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-[6px] relative"
|
||||
className="relative flex flex-wrap items-center gap-[4px]"
|
||||
role="toolbar"
|
||||
aria-label={t('audiobook.markup_toolbar')}
|
||||
>
|
||||
<button type="button" className={PILL} onClick={() => insert('[pause 500ms]')}>
|
||||
{t('audiobook.insert_pause')}
|
||||
<button
|
||||
type="button"
|
||||
className={PILL}
|
||||
onClick={() => insert('[pause 500ms]')}
|
||||
aria-label={t('audiobook.insert_pause')}
|
||||
title={t('audiobook.insert_pause')}
|
||||
>
|
||||
<Pause size={11} /> {t('audiobook.insert_pause')}
|
||||
</button>
|
||||
<button type="button" className={PILL} onClick={insertVoice}>
|
||||
{t('audiobook.insert_voice')}
|
||||
<button
|
||||
type="button"
|
||||
className={PILL}
|
||||
onClick={insertVoice}
|
||||
aria-label={t('audiobook.insert_voice')}
|
||||
title={t('audiobook.insert_voice')}
|
||||
>
|
||||
<AudioLines size={11} /> {t('audiobook.insert_voice')}
|
||||
</button>
|
||||
<button type="button" className={PILL} onClick={() => wrap('[slow]', '[/slow]')}>
|
||||
{t('audiobook.insert_slow')}
|
||||
<button
|
||||
type="button"
|
||||
className={PILL}
|
||||
onClick={() => wrap('[slow]', '[/slow]')}
|
||||
aria-label={t('audiobook.insert_slow')}
|
||||
title={t('audiobook.insert_slow')}
|
||||
>
|
||||
<ChevronsLeft size={11} /> {t('audiobook.insert_slow')}
|
||||
</button>
|
||||
<button type="button" className={PILL} onClick={() => wrap('[fast]', '[/fast]')}>
|
||||
{t('audiobook.insert_fast')}
|
||||
<button
|
||||
type="button"
|
||||
className={PILL}
|
||||
onClick={() => wrap('[fast]', '[/fast]')}
|
||||
aria-label={t('audiobook.insert_fast')}
|
||||
title={t('audiobook.insert_fast')}
|
||||
>
|
||||
<ChevronsRight size={11} /> {t('audiobook.insert_fast')}
|
||||
</button>
|
||||
<button type="button" className={PILL} onClick={() => wrap('[emphasis]', '[/emphasis]')}>
|
||||
{t('audiobook.insert_emphasis')}
|
||||
<button
|
||||
type="button"
|
||||
className={PILL}
|
||||
onClick={() => wrap('[emphasis]', '[/emphasis]')}
|
||||
aria-label={t('audiobook.insert_emphasis')}
|
||||
title={t('audiobook.insert_emphasis')}
|
||||
>
|
||||
<Bold size={11} /> {t('audiobook.insert_emphasis')}
|
||||
</button>
|
||||
<button type="button" className={PILL} onClick={() => wrap('[spell]', '[/spell]')}>
|
||||
{t('audiobook.insert_spell')}
|
||||
<button
|
||||
type="button"
|
||||
className={PILL}
|
||||
onClick={() => wrap('[spell]', '[/spell]')}
|
||||
aria-label={t('audiobook.insert_spell')}
|
||||
title={t('audiobook.insert_spell')}
|
||||
>
|
||||
<SpellCheck size={11} /> {t('audiobook.insert_spell')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={PILL}
|
||||
onClick={() => setReactionsOpen((o) => !o)}
|
||||
aria-expanded={reactionsOpen}
|
||||
aria-label={t('audiobook.insert_reactions')}
|
||||
title={t('audiobook.insert_reactions')}
|
||||
>
|
||||
<Smile size={11} /> {t('audiobook.insert_reactions')} <ChevronDown size={10} />
|
||||
<Smile size={11} /> {t('audiobook.insert_reactions')} <ChevronDown size={8} />
|
||||
</button>
|
||||
{reactionsOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[19]" onClick={() => setReactionsOpen(false)} />
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-0 z-[19] cursor-default border-0 bg-transparent p-0"
|
||||
onClick={() => setReactionsOpen(false)}
|
||||
aria-label={t('common.close')}
|
||||
/>
|
||||
<div
|
||||
className="absolute left-0 top-[calc(100%+6px)] z-20 flex flex-wrap gap-1 max-w-[min(360px,calc(100vw-16px))] max-h-[min(280px,calc(100vh-120px))] overflow-y-auto overscroll-contain p-2 bg-[var(--chrome-bg)] border border-transparent rounded-[10px] shadow-[0_8px_24px_rgba(0,0,0,0.45)]"
|
||||
role="menu"
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function ScriptPanel({
|
||||
<div className="relative flex-1 flex flex-col min-h-0">
|
||||
<textarea
|
||||
ref={textAreaRef}
|
||||
className="input-base flex-[0_1_auto] resize-y min-h-[160px] mb-[6px]"
|
||||
className="input-base studio-script-input flex-[0_1_auto] resize-y mb-[6px]"
|
||||
placeholder={
|
||||
defineMethod === 'audio'
|
||||
? t('clone.prompt_placeholder')
|
||||
|
||||
@@ -32,14 +32,12 @@ export default function ArchetypeCard({
|
||||
: dialect || (a.language === 'Chinese' ? 'Chinese' : null);
|
||||
const hasChips = Boolean(accentLabel || a.facets.whisper);
|
||||
|
||||
// Borderless by direction: the card keeps a transparent border only to reserve
|
||||
// the box width; the playing state is conveyed by an accent ring (box-shadow)
|
||||
// + lift, never a literal border.
|
||||
const cardBase =
|
||||
'group relative flex flex-col gap-[11px] p-[14px] rounded-[13px] border border-transparent ' +
|
||||
'bg-[linear-gradient(180deg,rgba(255,255,255,0.038),rgba(255,255,255,0.012))] ' +
|
||||
'transition-[transform,box-shadow] duration-150 ' +
|
||||
'hover:-translate-y-[2px] hover:shadow-[0_6px_22px_rgba(0,0,0,0.4)] ' +
|
||||
'group relative flex min-h-[168px] flex-col gap-[9px] p-[13px] rounded-[10px] ' +
|
||||
'border border-[rgba(255,255,255,0.075)] bg-[rgba(255,255,255,0.026)] ' +
|
||||
'transition-[transform,box-shadow,border-color,background-color] duration-150 ' +
|
||||
'hover:-translate-y-px hover:border-[rgba(255,255,255,0.15)] ' +
|
||||
'hover:bg-[rgba(255,255,255,0.042)] hover:shadow-[0_8px_24px_rgba(0,0,0,0.32)] ' +
|
||||
'motion-reduce:transition-none motion-reduce:hover:translate-y-0';
|
||||
const cardState = isPlaying
|
||||
? 'shadow-[0_0_0_1px_var(--card-accent),0_6px_22px_rgba(0,0,0,0.4)]'
|
||||
@@ -48,10 +46,10 @@ export default function ArchetypeCard({
|
||||
return (
|
||||
<div className={`${cardBase} ${cardState}`} style={{ '--card-accent': color }}>
|
||||
{/* Header — the name is the focal point; metadata recedes (smaller, muted). */}
|
||||
<div className="flex items-center gap-[11px]">
|
||||
<ArchetypeAvatar item={a} />
|
||||
<div className="flex items-start gap-[10px]">
|
||||
<ArchetypeAvatar item={a} size={40} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[0.86rem] font-semibold leading-tight text-[var(--color-fg)] truncate">
|
||||
<div className="text-[0.82rem] font-semibold leading-tight text-[var(--color-fg)] truncate">
|
||||
{a.name}
|
||||
</div>
|
||||
{sub && (
|
||||
@@ -61,6 +59,7 @@ export default function ArchetypeCard({
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-shrink-0 flex items-center justify-center w-[26px] h-[26px] rounded-[7px] cursor-pointer transition-[color,background-color,opacity] hover:bg-[var(--chrome-hover-bg)] ${
|
||||
isFavorite
|
||||
? 'text-[#fabd2f]'
|
||||
@@ -68,8 +67,10 @@ export default function ArchetypeCard({
|
||||
}`}
|
||||
onClick={() => onToggleFavorite(a.id)}
|
||||
title={t('gallery.favorite', { defaultValue: 'Favorite' })}
|
||||
aria-label={t('gallery.favorite', { defaultValue: 'Favorite' })}
|
||||
aria-pressed={isFavorite}
|
||||
>
|
||||
<Star size={15} fill={isFavorite ? 'currentColor' : 'none'} />
|
||||
<Star size={15} fill={isFavorite ? 'currentColor' : 'none'} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -94,33 +95,38 @@ export default function ArchetypeCard({
|
||||
|
||||
{/* Actions — quiet Preview (ghost, token hover), confident accent Use voice
|
||||
(tinted → solid accent with inverse text), subtle magic-wand icon. */}
|
||||
<div className="flex items-center gap-[6px] mt-auto">
|
||||
<div className="flex items-center gap-[6px] mt-auto pt-[9px] border-t border-[rgba(255,255,255,0.055)]">
|
||||
<button
|
||||
className="inline-flex items-center gap-[6px] px-[11px] py-[6px] rounded-[8px] bg-transparent text-[var(--color-fg-muted)] text-[0.7rem] cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--color-fg)]"
|
||||
type="button"
|
||||
className="inline-flex items-center gap-[6px] px-[9px] py-[6px] rounded-[6px] bg-transparent text-[var(--color-fg-muted)] text-[0.68rem] cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--color-fg)]"
|
||||
onClick={() => onPreview(a)}
|
||||
title={t('gallery.preview', { defaultValue: 'Preview' })}
|
||||
>
|
||||
{isLoadingPreview ? (
|
||||
<Loader className="spin" size={15} />
|
||||
<Loader className="spin" size={15} aria-hidden="true" />
|
||||
) : isPlaying ? (
|
||||
<NowPlaying color={color} />
|
||||
) : (
|
||||
<Play size={15} />
|
||||
<Play size={15} aria-hidden="true" />
|
||||
)}
|
||||
<span>{t('gallery.preview', { defaultValue: 'Preview' })}</span>
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 inline-flex items-center justify-center gap-[6px] px-[10px] py-[6px] rounded-[8px] bg-[color-mix(in_srgb,var(--card-accent)_15%,transparent)] text-[var(--card-accent)] text-[0.72rem] font-semibold cursor-pointer transition-colors hover:bg-[var(--card-accent)] hover:text-[var(--color-fg-inverse)] focus-visible:bg-[var(--card-accent)] focus-visible:text-[var(--color-fg-inverse)]"
|
||||
type="button"
|
||||
className="flex-1 inline-flex items-center justify-center gap-[6px] px-[10px] py-[6px] rounded-[6px] bg-[color-mix(in_srgb,var(--card-accent)_13%,transparent)] text-[var(--card-accent)] text-[0.7rem] font-semibold cursor-pointer transition-colors hover:bg-[var(--card-accent)] hover:text-[var(--color-fg-inverse)] focus-visible:bg-[var(--card-accent)] focus-visible:text-[var(--color-fg-inverse)]"
|
||||
onClick={() => onUse(a)}
|
||||
>
|
||||
<UserPlus size={14} /> {t('gallery.use_voice', { defaultValue: 'Use voice' })}
|
||||
<UserPlus size={14} aria-hidden="true" />{' '}
|
||||
{t('gallery.use_voice', { defaultValue: 'Use voice' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center w-[30px] h-[30px] flex-shrink-0 rounded-[8px] bg-transparent text-[var(--color-fg-muted)] cursor-pointer opacity-50 transition-[opacity,color,background-color] duration-150 group-hover:opacity-100 focus-visible:opacity-100 hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--card-accent)]"
|
||||
onClick={() => onDesign(a)}
|
||||
title={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
|
||||
aria-label={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
|
||||
>
|
||||
<Wand2 size={14} />
|
||||
<Wand2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { Loader, Star, RotateCcw, Grid, List } from 'lucide-react';
|
||||
import { Loader, Star, RotateCcw, Grid, List, SlidersHorizontal } from 'lucide-react';
|
||||
import { Button, Select, Segmented } from '../../ui';
|
||||
import { useArchetypeCategories, useArchetypes } from '../../api/hooks';
|
||||
import { ArchetypeIcon } from '../../utils/archetypeIcons';
|
||||
import { titleCase, facetLabel } from './constants';
|
||||
import ArchetypeCard from './ArchetypeCard';
|
||||
|
||||
@@ -62,6 +61,7 @@ export default function ArchetypesZone({
|
||||
onDesign,
|
||||
}) {
|
||||
const [favOnly, setFavOnly] = useState(false);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [offset, setOffset] = useState(0);
|
||||
useEffect(() => {
|
||||
setOffset(0);
|
||||
@@ -98,6 +98,9 @@ export default function ArchetypesZone({
|
||||
|
||||
const favSet = useMemo(() => new Set(favorites), [favorites]);
|
||||
const applyFav = (list) => (favOnly ? list.filter((a) => favSet.has(a.id)) : list);
|
||||
const advancedFilterCount = ['gender', 'age', 'pitch', 'accent', 'lang', 'whisper'].filter(
|
||||
(key) => filters[key] !== null && filters[key] !== '',
|
||||
).length;
|
||||
|
||||
// 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).
|
||||
@@ -114,8 +117,6 @@ export default function ArchetypesZone({
|
||||
onToggleFavorite: toggleFavorite,
|
||||
});
|
||||
|
||||
const facetGroup =
|
||||
'flex items-center gap-[5px] flex-nowrap min-w-0 overflow-x-auto overflow-y-hidden [scrollbar-width:thin]';
|
||||
const facetToggle =
|
||||
'inline-flex items-center gap-[5px] h-[26px] box-border px-[9px] rounded-[7px] border border-transparent bg-[var(--chrome-hover-bg)] text-[var(--chrome-fg-muted)] text-[0.68rem] whitespace-nowrap cursor-pointer hover:text-[var(--chrome-fg)] hover:border-[color:var(--chrome-border-strong)]';
|
||||
const gridClass =
|
||||
@@ -125,60 +126,39 @@ export default function ArchetypesZone({
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-y-auto">
|
||||
<div className="flex flex-row items-center gap-[10px] flex-nowrap shrink-0 pt-[2px] pb-[10px] mb-[8px] border-b border-transparent">
|
||||
{/* Three filter lanes (categories · facets · toggles), each its own
|
||||
horizontally-scrollable portion; the view toggle is pinned right. */}
|
||||
<div className={`${facetGroup} flex-[2.4_1_0]`}>
|
||||
<Button
|
||||
variant="chip"
|
||||
active={!filters.use_case}
|
||||
onClick={() => setFilter('use_case', null)}
|
||||
<div className="shrink-0 mb-[8px] pb-[8px] border-b border-transparent">
|
||||
<div className="flex items-center gap-[6px] min-w-0">
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-auto min-w-[132px] max-w-[190px] shrink-0"
|
||||
aria-label={t('gallery.zone_archetypes', { defaultValue: 'Archetypes' })}
|
||||
value={filters.use_case ?? ''}
|
||||
onChange={(e) => setFilter('use_case', e.target.value || null)}
|
||||
>
|
||||
{t('gallery.all', { defaultValue: 'All' })}
|
||||
</Button>
|
||||
{categories.map((c) => (
|
||||
<Button
|
||||
key={c.id}
|
||||
variant="chip"
|
||||
active={filters.use_case === c.id}
|
||||
leading={<ArchetypeIcon name={c.icon} size={13} />}
|
||||
onClick={() => setFilter('use_case', filters.use_case === c.id ? null : c.id)}
|
||||
title={c.name}
|
||||
>
|
||||
{t(`archetypes.use_${c.id}`, { defaultValue: c.name })}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={`${facetGroup} flex-[1.6_1_0] pl-[10px] border-l border-transparent`}>
|
||||
{['gender', 'age', 'pitch', 'accent', 'lang'].map((dim) => (
|
||||
<Select
|
||||
key={dim}
|
||||
size="sm"
|
||||
value={filters[dim] ?? ''}
|
||||
onChange={(e) => setFilter(dim, e.target.value || null)}
|
||||
>
|
||||
<option value="">
|
||||
{t(`archetypes.facet_${dim}`, { defaultValue: titleCase(dim) })}
|
||||
<option value="">{t('gallery.all', { defaultValue: 'All' })}</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{t(`archetypes.use_${c.id}`, { defaultValue: c.name })}
|
||||
</option>
|
||||
{FACETS[dim].map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{facetLabel(opt)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={`${facetGroup} flex-[1_1_0] pl-[10px] border-l border-transparent`}>
|
||||
<label className={facetToggle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.whisper === true}
|
||||
onChange={(e) => setFilter('whisper', e.target.checked ? true : null)}
|
||||
/>
|
||||
{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}
|
||||
</label>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
active={filtersOpen}
|
||||
leading={<SlidersHorizontal size={13} />}
|
||||
trailing={
|
||||
advancedFilterCount > 0 ? (
|
||||
<span className="min-w-[16px] rounded-full bg-[var(--accent)] px-[4px] py-px text-center text-[0.58rem] leading-[14px] text-white">
|
||||
{advancedFilterCount}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
aria-expanded={filtersOpen}
|
||||
onClick={() => setFiltersOpen((open) => !open)}
|
||||
>
|
||||
{t('gallery.filters', { defaultValue: 'Filters' })}
|
||||
</Button>
|
||||
<label className={facetToggle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -187,28 +167,64 @@ export default function ArchetypesZone({
|
||||
/>
|
||||
<Star size={12} /> {t('gallery.favorites', { defaultValue: 'Favorites' })}
|
||||
</label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leading={<RotateCcw size={12} />}
|
||||
onClick={() => {
|
||||
resetFilters();
|
||||
setFavOnly(false);
|
||||
}}
|
||||
>
|
||||
{t('gallery.reset', { defaultValue: 'Reset' })}
|
||||
</Button>
|
||||
{hasActiveFilters(filters) || favOnly ? (
|
||||
<Button
|
||||
variant="icon"
|
||||
iconSize="md"
|
||||
onClick={() => {
|
||||
resetFilters();
|
||||
setFavOnly(false);
|
||||
}}
|
||||
title={t('gallery.reset', { defaultValue: 'Reset' })}
|
||||
aria-label={t('gallery.reset', { defaultValue: 'Reset' })}
|
||||
>
|
||||
<RotateCcw size={13} />
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="ml-auto shrink-0">
|
||||
<Segmented
|
||||
size="xs"
|
||||
value={viewMode}
|
||||
onChange={setViewMode}
|
||||
items={[
|
||||
{ value: 'grid', label: <Grid size={14} />, title: 'Grid' },
|
||||
{ value: 'list', label: <List size={14} />, title: 'List' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Segmented
|
||||
size="xs"
|
||||
value={viewMode}
|
||||
onChange={setViewMode}
|
||||
items={[
|
||||
{ value: 'grid', label: <Grid size={14} />, title: 'Grid' },
|
||||
{ value: 'list', label: <List size={14} />, title: 'List' },
|
||||
]}
|
||||
/>
|
||||
{filtersOpen ? (
|
||||
<div className="mt-[6px] flex items-center gap-[6px] overflow-x-auto pb-px [scrollbar-width:thin]">
|
||||
{['gender', 'age', 'pitch', 'accent', 'lang'].map((dim) => (
|
||||
<Select
|
||||
key={dim}
|
||||
size="sm"
|
||||
className="w-auto min-w-[94px] max-w-[132px] shrink-0"
|
||||
aria-label={t(`archetypes.facet_${dim}`, { defaultValue: titleCase(dim) })}
|
||||
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>
|
||||
))}
|
||||
<label className={`${facetToggle} shrink-0`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.whisper === true}
|
||||
onChange={(e) => setFilter('whisper', e.target.checked ? true : null)}
|
||||
/>
|
||||
{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showFeatured && (
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import ArchetypesZone from './ArchetypesZone';
|
||||
import ArchetypeCard from './ArchetypeCard';
|
||||
|
||||
vi.mock('../../api/hooks', () => ({
|
||||
useArchetypeCategories: () => ({
|
||||
data: [
|
||||
{ id: 'narration', name: 'Narration & Story', icon: 'BookOpen' },
|
||||
{ id: 'social', name: 'Social Media', icon: 'Radio' },
|
||||
],
|
||||
}),
|
||||
useArchetypes: (filters) => ({
|
||||
data: filters.featured ? { items: [] } : { items: [], total: 0 },
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const t = (_key, options = {}) => options.defaultValue || _key;
|
||||
|
||||
const baseProps = {
|
||||
t,
|
||||
filters: {
|
||||
use_case: null,
|
||||
gender: null,
|
||||
age: null,
|
||||
pitch: null,
|
||||
accent: null,
|
||||
whisper: null,
|
||||
lang: null,
|
||||
},
|
||||
setFilter: vi.fn(),
|
||||
resetFilters: vi.fn(),
|
||||
favorites: [],
|
||||
toggleFavorite: vi.fn(),
|
||||
viewMode: 'grid',
|
||||
setViewMode: vi.fn(),
|
||||
playingId: null,
|
||||
loadingPreviewId: null,
|
||||
onPreview: vi.fn(),
|
||||
onUse: vi.fn(),
|
||||
onDesign: vi.fn(),
|
||||
};
|
||||
|
||||
describe('ArchetypesZone filter toolbar', () => {
|
||||
it('keeps categories in one menu and reveals advanced filters on demand', () => {
|
||||
const setFilter = vi.fn();
|
||||
render(<ArchetypesZone {...baseProps} setFilter={setFilter} />);
|
||||
|
||||
const categoryMenu = screen.getByRole('combobox', { name: 'Archetypes' });
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(1);
|
||||
|
||||
fireEvent.change(categoryMenu, { target: { value: 'social' } });
|
||||
expect(setFilter).toHaveBeenCalledWith('use_case', 'social');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Filters' }));
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(6);
|
||||
expect(screen.getByRole('checkbox', { name: 'Whisper' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ArchetypeCard accessibility', () => {
|
||||
it('names icon actions and exposes favorite state', () => {
|
||||
render(
|
||||
<ArchetypeCard
|
||||
a={{
|
||||
id: 'narrator',
|
||||
name: 'Narrator',
|
||||
language: 'English',
|
||||
use_case: 'narration',
|
||||
facets: { gender: 'female', age: 'adult', pitch: 'moderate pitch' },
|
||||
attrs: {},
|
||||
}}
|
||||
t={t}
|
||||
isFavorite
|
||||
isPlaying={false}
|
||||
isLoadingPreview={false}
|
||||
onPreview={vi.fn()}
|
||||
onUse={vi.fn()}
|
||||
onDesign={vi.fn()}
|
||||
onToggleFavorite={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Favorite' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Open in Designer' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// Authored demo content for Stories — kept outside interface copy, like the
|
||||
// Audiobook sample. Loading it creates a normal editable, saved project.
|
||||
export const SAMPLE_STORY_NAME = "The Lighthouse at Wits' End";
|
||||
|
||||
export const SAMPLE_STORY_CAST = [
|
||||
{ id: 'narrator', name: 'Narrator', color: '#fabd2f', profileId: null },
|
||||
{ id: 'mara', name: 'Mara', color: '#83a598', profileId: null },
|
||||
{ id: 'cole', name: 'Cole', color: '#d3869b', profileId: null },
|
||||
];
|
||||
|
||||
export const SAMPLE_STORY_LINES = [
|
||||
{ character: 'narrator', text: '# The Lamp' },
|
||||
{
|
||||
character: 'narrator',
|
||||
text: 'The storm had been building for three days, and Mara knew that tonight the old lighthouse would be tested. [pause 0.6s] She climbed the spiral stair, counting each step.',
|
||||
},
|
||||
{
|
||||
character: 'mara',
|
||||
text: 'Not tonight. [pause 0.4s] You have held for forty years. You will hold for one more.',
|
||||
},
|
||||
{
|
||||
character: 'narrator',
|
||||
text: 'The lamp answered with a cough of light, then steadied. [laughter] Mara laughed—a short, surprised sound.',
|
||||
},
|
||||
{ character: 'narrator', text: '# The Radio' },
|
||||
{
|
||||
character: 'narrator',
|
||||
text: 'Below, the radio crackled. A voice came through the static, fast and frightened.',
|
||||
},
|
||||
{
|
||||
character: 'cole',
|
||||
text: 'Lighthouse, this is the Kestrel. We have lost our bearing. [pause 0.3s] Can anyone hear me?',
|
||||
},
|
||||
{
|
||||
character: 'mara',
|
||||
text: 'Kestrel, this is Mara. [pause 0.5s] Steady now. Give me your call sign, letter by letter.',
|
||||
},
|
||||
{
|
||||
character: 'cole',
|
||||
text: 'Kestrel. [pause 0.4s] We cannot see the shore.',
|
||||
},
|
||||
{
|
||||
character: 'mara',
|
||||
text: 'You do not need to see the shore. [pause 0.6s] You need to see me. Look for the light.',
|
||||
},
|
||||
{
|
||||
character: 'narrator',
|
||||
text: 'Out beyond the black water, a small boat turned—slowly, then surely—toward a single, patient beam. [sigh] Some nights, that is the whole job: to be the thing that does not go out.',
|
||||
},
|
||||
];
|
||||
+976
-74
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
BookMarked,
|
||||
BookOpen,
|
||||
BookText,
|
||||
Code,
|
||||
Loader,
|
||||
SlidersHorizontal,
|
||||
SpellCheck,
|
||||
Square,
|
||||
Upload,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
audiobookPlan,
|
||||
audiobookGenerate,
|
||||
@@ -24,26 +11,18 @@ import { audioUrl } from '../api/generate';
|
||||
import { listEngines } from '../api/engines';
|
||||
import { consumeLongformStream } from '../utils/longformStream';
|
||||
import { useAppStore } from '../store';
|
||||
import VoiceSelector from '../components/VoiceSelector';
|
||||
import SearchableSelect from '../components/SearchableSelect';
|
||||
import AudiobookOverrides, { overridesToRequest } from '../components/audiobook/AudiobookOverrides';
|
||||
import Section from '../components/audiobook/Section';
|
||||
import BookDetails from '../components/audiobook/BookDetails';
|
||||
import LexiconEditor from '../components/audiobook/LexiconEditor';
|
||||
import { overridesToRequest } from '../components/audiobook/AudiobookOverrides';
|
||||
import GenerationProgress from '../components/audiobook/GenerationProgress';
|
||||
import PlanList from '../components/audiobook/PlanList';
|
||||
import AudiobookResult from '../components/audiobook/AudiobookResult';
|
||||
import CastPanel from '../components/audiobook/CastPanel';
|
||||
import MarkupToolbar from '../components/audiobook/MarkupToolbar';
|
||||
import StatsBar from '../components/audiobook/StatsBar';
|
||||
import ValidationWarnings from '../components/audiobook/ValidationWarnings';
|
||||
import AudiobookHero from '../components/audiobook/AudiobookHero';
|
||||
import AudiobookInspector from '../components/audiobook/AudiobookInspector';
|
||||
import { useAudiobookLexicon } from '../hooks/useAudiobookLexicon';
|
||||
import { parseCastNames, validateScript } from '../utils/audiobookScript';
|
||||
import { SAMPLE_AUDIOBOOK_SCRIPT } from '../data/sampleAudiobook';
|
||||
import ALL_LANGUAGES from '../languages.json';
|
||||
import { POPULAR_LANGS } from '../utils/constants';
|
||||
import { Button } from '../ui';
|
||||
import { buttonVariants } from '@/components/ui/button.tsx';
|
||||
|
||||
// Chrome-mono uppercase form label (was the scoped `.audiobook-tab .field-label`
|
||||
// rule; `.field-label` has no global styling, so it's reproduced as utilities).
|
||||
@@ -408,196 +387,82 @@ export default function AudiobookTab({ profiles = [] }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="audiobook-tab flex flex-col h-full box-border px-[1.5rem] py-[1.25rem] gap-[12px]">
|
||||
<div className="audiobook-tab__head flex flex-wrap items-start justify-between gap-[16px]">
|
||||
<div>
|
||||
<div
|
||||
role="heading"
|
||||
aria-level={2}
|
||||
className="flex items-center gap-[8px] m-0 [font-family:var(--font-serif)] [font-size:var(--text-xl)] [font-weight:var(--weight-semibold)] text-fg"
|
||||
>
|
||||
<BookMarked size={20} /> {t('audiobook.title')}
|
||||
</div>
|
||||
<p className="muted audiobook-tab__sub mt-[2px] text-[var(--text-sm)] text-fg-muted">
|
||||
{t('audiobook.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="audiobook-tab__actions flex flex-wrap items-center gap-[8px]">
|
||||
{/* All four use the one shadcn button layout (icon in the leading slot,
|
||||
text in a leading-none span) so the row lines up. Import is a <label>
|
||||
(it wraps the file input) styled the same way — the old inline
|
||||
flex/gap override is what made it ragged. */}
|
||||
<label
|
||||
className={buttonVariants({ variant: 'subtle', size: 'omniMd' })}
|
||||
style={{ cursor: busy ? 'default' : 'pointer' }}
|
||||
>
|
||||
{importing ? <Loader size={14} className="spin" /> : <Upload size={14} />}
|
||||
<span className="leading-none">{t('audiobook.import')}</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".txt,.md,.epub,.pdf"
|
||||
onChange={onImport}
|
||||
disabled={busy}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={loadSample}
|
||||
disabled={busy}
|
||||
title={t('audiobook.load_sample_hint')}
|
||||
leading={<BookOpen size={14} />}
|
||||
>
|
||||
{t('audiobook.load_sample')}
|
||||
</Button>
|
||||
<Button variant="subtle" onClick={onPreview} disabled={!canRun} loading={planLoading}>
|
||||
{t('audiobook.preview_plan')}
|
||||
</Button>
|
||||
{generating ? (
|
||||
<Button variant="danger" onClick={onStop} leading={<Square size={14} />}>
|
||||
{t('audiobook.stop')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" onClick={onCreate} disabled={!canRun}>
|
||||
{t('audiobook.create')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="audiobook-tab flex h-full flex-col box-border px-[1.25rem] py-[1rem] gap-[10px] max-[1120px]:overflow-y-auto">
|
||||
<AudiobookHero
|
||||
t={t}
|
||||
busy={busy}
|
||||
importing={importing}
|
||||
planLoading={planLoading}
|
||||
generating={generating}
|
||||
canRun={canRun}
|
||||
onImport={onImport}
|
||||
onLoadSample={loadSample}
|
||||
onPreview={onPreview}
|
||||
onCreate={onCreate}
|
||||
onStop={onStop}
|
||||
/>
|
||||
|
||||
<div className="audiobook-tab__body grid flex-auto grid-cols-[minmax(0,1fr)_minmax(300px,380px)] max-[900px]:grid-cols-1 gap-[16px] min-h-0">
|
||||
<div className="audiobook-tab__body grid flex-auto grid-cols-[minmax(0,1fr)_minmax(440px,500px)] max-[1120px]:grid-cols-1 gap-[14px] min-h-0">
|
||||
{/* Left: script editor fills the height */}
|
||||
<div className="audiobook-tab__script flex flex-col min-h-0 gap-[6px]">
|
||||
<label className={FIELD_LABEL}>{t('audiobook.script')}</label>
|
||||
<MarkupToolbar t={t} textareaRef={textareaRef} text={text} setText={setText} />
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="input-base"
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
if (warningsDismissed) setWarningsDismissed(false);
|
||||
}}
|
||||
onKeyDown={onScriptKeyDown}
|
||||
placeholder={t('audiobook.script_placeholder')}
|
||||
aria-label={t('audiobook.script')}
|
||||
/>
|
||||
{text.trim() ? (
|
||||
<StatsBar t={t} text={text} />
|
||||
) : (
|
||||
<p className="muted text-[var(--text-sm)] text-fg-muted m-0">
|
||||
{t('audiobook.empty_hint')}
|
||||
</p>
|
||||
)}
|
||||
<div className="audiobook-tab__script flex flex-col min-h-0 gap-[7px]">
|
||||
<div className="flex min-h-[18px] items-center justify-between gap-[12px] px-[4px]">
|
||||
<label className={FIELD_LABEL}>{t('audiobook.script')}</label>
|
||||
{text.trim() ? <StatsBar t={t} text={text} /> : null}
|
||||
</div>
|
||||
<div className="audiobook-tab__manuscript flex min-h-0 flex-1 flex-col overflow-hidden rounded-[14px]">
|
||||
<div className="border-b border-transparent px-[10px] py-[7px]">
|
||||
<MarkupToolbar t={t} textareaRef={textareaRef} text={text} setText={setText} />
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="input-base"
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
if (warningsDismissed) setWarningsDismissed(false);
|
||||
}}
|
||||
onKeyDown={onScriptKeyDown}
|
||||
placeholder={t('audiobook.script_placeholder')}
|
||||
aria-label={t('audiobook.script')}
|
||||
/>
|
||||
{!text.trim() && (
|
||||
<p className="m-0 border-t border-transparent px-[14px] py-[9px] text-[var(--text-sm)] text-fg-muted">
|
||||
{t('audiobook.empty_hint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: settings + results, scrolls independently */}
|
||||
<div className="audiobook-tab__side flex flex-col gap-[12px] min-h-0 overflow-y-auto max-[900px]:overflow-visible pr-[4px]">
|
||||
<div className="audiobook-tab__field flex flex-col gap-[4px]">
|
||||
<label className={FIELD_LABEL}>{t('audiobook.default_voice')}</label>
|
||||
<VoiceSelector
|
||||
value={defaultVoice}
|
||||
onChange={setDefaultVoice}
|
||||
profiles={profiles}
|
||||
defaultLabel={t('audiobook.engine_default')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="audiobook-tab__field flex flex-col gap-[4px]">
|
||||
<label className={FIELD_LABEL}>{t('audiobook.language')}</label>
|
||||
<SearchableSelect
|
||||
value={language}
|
||||
options={ALL_LANGUAGES}
|
||||
popular={POPULAR_LANGS}
|
||||
recentsKey="omnivoice.recents.audiobookLang"
|
||||
onChange={setLanguage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cast — one row per distinct [voice:NAME] in the script (#1217).
|
||||
Open by default so the multi-voice mapping is discoverable the
|
||||
moment a script uses [voice:…]; hidden entirely when it doesn't. */}
|
||||
{castNames.length > 0 && (
|
||||
<Section title={t('audiobook.cast')} icon={<Users size={13} />} defaultOpen>
|
||||
<CastPanel
|
||||
t={t}
|
||||
castNames={castNames}
|
||||
voiceCast={voiceCast}
|
||||
setVoiceCast={setVoiceCast}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<AudiobookOverrides
|
||||
<div className="audiobook-tab__side flex flex-col gap-[9px] min-h-0 overflow-y-auto max-[1120px]:overflow-visible rounded-[12px] bg-[var(--color-bg-elev-2)] p-[10px]">
|
||||
<AudiobookInspector
|
||||
t={t}
|
||||
profiles={profiles}
|
||||
defaultVoice={defaultVoice}
|
||||
setDefaultVoice={setDefaultVoice}
|
||||
language={language}
|
||||
setLanguage={setLanguage}
|
||||
format={format}
|
||||
setFormat={setFormat}
|
||||
loudness={loudness}
|
||||
setLoudness={setLoudness}
|
||||
castNames={castNames}
|
||||
voiceCast={voiceCast}
|
||||
setVoiceCast={setVoiceCast}
|
||||
overrides={overrides}
|
||||
onChange={setLongformOverrides}
|
||||
setOverrides={setLongformOverrides}
|
||||
emotionSupported={emotionSupported}
|
||||
coverPreview={coverPreview}
|
||||
onCoverPick={onCoverPick}
|
||||
clearCover={clearCover}
|
||||
meta={meta}
|
||||
setMetaField={setMetaField}
|
||||
lex={lex}
|
||||
setLexRow={setLexRow}
|
||||
addLexRow={addLexRow}
|
||||
removeLexRow={removeLexRow}
|
||||
/>
|
||||
|
||||
{/* Output — format + loudness. Open by default so a first-timer sees a
|
||||
tangible setting next to the always-on voice/language above. */}
|
||||
<Section title={t('audiobook.output')} icon={<SlidersHorizontal size={13} />} defaultOpen>
|
||||
<div className="grid grid-cols-[1fr_1fr] gap-[8px]">
|
||||
<div className="flex flex-col gap-[4px]">
|
||||
<label className={FIELD_LABEL}>{t('audiobook.format')}</label>
|
||||
<select
|
||||
className="input-base"
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value)}
|
||||
aria-label={t('audiobook.format')}
|
||||
>
|
||||
<option value="m4b">{t('audiobook.format_m4b')}</option>
|
||||
<option value="mp3">{t('audiobook.format_mp3')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-[4px]">
|
||||
<label className={FIELD_LABEL}>{t('audiobook.loudness')}</label>
|
||||
<select
|
||||
className="input-base"
|
||||
value={loudness}
|
||||
onChange={(e) => setLoudness(e.target.value)}
|
||||
aria-label={t('audiobook.loudness')}
|
||||
>
|
||||
<option value="off">{t('audiobook.loudness_off')}</option>
|
||||
<option value="acx">{t('audiobook.loudness_acx')}</option>
|
||||
<option value="podcast">{t('audiobook.loudness_podcast')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Book details — cover + embedded metadata. Collapsed by default. */}
|
||||
<Section title={t('audiobook.details')} icon={<BookText size={13} />}>
|
||||
<BookDetails
|
||||
t={t}
|
||||
coverPreview={coverPreview}
|
||||
onCoverPick={onCoverPick}
|
||||
clearCover={clearCover}
|
||||
meta={meta}
|
||||
setMetaField={setMetaField}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Pronunciation lexicon — collapsed by default. */}
|
||||
<Section title={t('audiobook.lexicon')} icon={<SpellCheck size={13} />}>
|
||||
<LexiconEditor
|
||||
t={t}
|
||||
lex={lex}
|
||||
setLexRow={setLexRow}
|
||||
addLexRow={addLexRow}
|
||||
removeLexRow={removeLexRow}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Markup quick reference — collapsed by default. */}
|
||||
<Section title={t('audiobook.markup_help')} icon={<Code size={13} />}>
|
||||
<p className="muted" style={{ fontSize: '0.72rem', lineHeight: 1.6 }}>
|
||||
{t('audiobook.markup_hint')}
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
{!warningsDismissed && !generating && (
|
||||
<ValidationWarnings
|
||||
t={t}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
// Audiobook tab layout — the prod-polish compaction (#1214).
|
||||
//
|
||||
// The right-hand settings column was flattened into consistent collapsible
|
||||
// Sections (Output / Book details / Pronunciation / Markup) with the primary
|
||||
// inputs (script, default voice, language) always visible. This guards that
|
||||
// contract so a future refactor can't silently drop a control or un-collapse
|
||||
// the column: every key control still renders, Output starts open, the long
|
||||
// groups start collapsed, and a collapsed group opens on click.
|
||||
// The right-hand settings column is a compact property inspector: essentials
|
||||
// stay visible and one optional production tool opens at a time.
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import i18n from '../i18n';
|
||||
@@ -40,9 +36,6 @@ const withI18n = (node) => (
|
||||
<I18nextProvider i18n={i18n}>{node}</I18nextProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
// The <details> element that holds a given section header title.
|
||||
const sectionFor = (title) => screen.getByText(title).closest('details');
|
||||
|
||||
describe('AudiobookTab — compact grouped layout (#1214)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
@@ -50,17 +43,23 @@ describe('AudiobookTab — compact grouped layout (#1214)', () => {
|
||||
});
|
||||
|
||||
it('keeps the primary inputs always visible', () => {
|
||||
render(withI18n(<AudiobookTab profiles={[]} />));
|
||||
const { container } = render(withI18n(<AudiobookTab profiles={[]} />));
|
||||
// Script editor, default voice, language — the three always-on controls.
|
||||
expect(screen.getByLabelText(en.audiobook.script)).toBeTruthy();
|
||||
expect(screen.getByText(en.audiobook.default_voice)).toBeTruthy();
|
||||
expect(screen.getByText(en.audiobook.language)).toBeTruthy();
|
||||
// The action bar keeps the new "Load sample" button + Create.
|
||||
expect(screen.getByText(en.audiobook.load_sample)).toBeTruthy();
|
||||
expect(screen.getByLabelText(en.audiobook.format)).toBeTruthy();
|
||||
// Secondary actions stay discoverable through accessible icon labels.
|
||||
expect(screen.getByLabelText(en.audiobook.load_sample)).toBeTruthy();
|
||||
expect(screen.getByLabelText(en.audiobook.import)).toBeTruthy();
|
||||
expect(screen.getByLabelText(en.audiobook.preview_plan)).toBeTruthy();
|
||||
expect(screen.getByText(en.audiobook.create)).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { level: 2, name: en.audiobook.title })).toBeTruthy();
|
||||
expect(container.querySelector('[class*="container-name:audiobook-inspector"]')).toBeTruthy();
|
||||
expect(container.querySelector('[class*="@min-[360px]/audiobook-inspector:grid-cols-2"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('groups the secondary controls into collapsible sections', () => {
|
||||
it('groups optional controls into an icon-led tool strip', () => {
|
||||
render(withI18n(<AudiobookTab profiles={[]} />));
|
||||
for (const title of [
|
||||
en.audiobook.output,
|
||||
@@ -68,26 +67,32 @@ describe('AudiobookTab — compact grouped layout (#1214)', () => {
|
||||
en.audiobook.lexicon,
|
||||
en.audiobook.markup_help,
|
||||
]) {
|
||||
expect(sectionFor(title).tagName).toBe('DETAILS');
|
||||
expect(screen.getByRole('button', { name: title })).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('opens Output by default and collapses the long groups', () => {
|
||||
it('keeps optional panels closed by default', () => {
|
||||
render(withI18n(<AudiobookTab profiles={[]} />));
|
||||
expect(sectionFor(en.audiobook.output).open).toBe(true);
|
||||
expect(sectionFor(en.audiobook.details).open).toBe(false);
|
||||
expect(sectionFor(en.audiobook.lexicon).open).toBe(false);
|
||||
// Output is open, so its format control is reachable right away.
|
||||
expect(screen.getByLabelText(en.audiobook.format)).toBeTruthy();
|
||||
expect(screen.queryByLabelText(en.audiobook.loudness)).toBeNull();
|
||||
expect(screen.queryByLabelText(en.audiobook.meta_title)).toBeNull();
|
||||
});
|
||||
|
||||
it('a collapsed section toggles open on its summary', () => {
|
||||
it('opens Cast by default when the script contains cast tags', () => {
|
||||
useAppStore.getState().setScript('# Chapter\n[voice:Mara] Hello');
|
||||
render(withI18n(<AudiobookTab profiles={[]} />));
|
||||
const details = sectionFor(en.audiobook.details);
|
||||
expect(details.open).toBe(false);
|
||||
fireEvent.click(within(details).getByText(en.audiobook.details));
|
||||
expect(details.open).toBe(true);
|
||||
// Once open, the metadata inputs are present.
|
||||
expect(screen.getByRole('button', { name: en.audiobook.cast })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByLabelText(`${en.audiobook.cast}: Mara`)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows only the selected optional panel', () => {
|
||||
render(withI18n(<AudiobookTab profiles={[]} />));
|
||||
fireEvent.click(screen.getByRole('button', { name: en.audiobook.details }));
|
||||
expect(screen.getByLabelText(en.audiobook.meta_title)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: en.audiobook.output }));
|
||||
expect(screen.queryByLabelText(en.audiobook.meta_title)).toBeNull();
|
||||
expect(screen.getByLabelText(en.audiobook.loudness)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, within, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import '../i18n';
|
||||
|
||||
@@ -63,12 +63,36 @@ describe('StoriesEditor voice pickers (#1220)', () => {
|
||||
|
||||
it('cast picker renders VoiceSelector and stores the character voice', () => {
|
||||
renderEditor();
|
||||
// Open the Cast panel.
|
||||
fireEvent.click(screen.getByRole('button', { name: /Cast/ }));
|
||||
const castRegion = screen.getByRole('region', { name: /Cast/ });
|
||||
const castRegion = screen.getByRole('complementary', { name: /Stories/ });
|
||||
const trigger = within(castRegion).getByRole('button', { name: /Default/ });
|
||||
fireEvent.click(trigger);
|
||||
fireEvent.mouseDown(screen.getByText('Aria'));
|
||||
expect(useAppStore.getState().cast[0].profileId).toBe('p_clone');
|
||||
});
|
||||
|
||||
it('renders a calm writing hierarchy with the project stats and line canvas', () => {
|
||||
renderEditor();
|
||||
expect(screen.getByRole('heading', { level: 1, name: /Untitled story/ })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('1 lines').length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('main')).toHaveClass('stories-manuscript');
|
||||
expect(screen.getByRole('complementary')).toHaveClass('stories-sidebar');
|
||||
expect(screen.getByRole('list')).toHaveClass('stories-track-list');
|
||||
expect(screen.getByRole('listitem')).toHaveClass('stories-line');
|
||||
});
|
||||
|
||||
it('loads a comprehensive working sample by default', async () => {
|
||||
useAppStore.setState({ storyTracks: [], storyProjects: [], currentProjectId: null });
|
||||
renderEditor();
|
||||
|
||||
await waitFor(() => expect(useAppStore.getState().storyTracks).toHaveLength(11));
|
||||
const state = useAppStore.getState();
|
||||
expect(state.cast.map((member) => member.name)).toEqual(['Narrator', 'Mara', 'Cole']);
|
||||
expect(state.cast.every((member) => member.profileId === 'p_clone')).toBe(true);
|
||||
expect(state.storyProjects.at(-1)?.name).toBe("The Lighthouse at Wits' End");
|
||||
expect(
|
||||
screen.getByRole('heading', { name: "The Lighthouse at Wits' End" }),
|
||||
).toBeInTheDocument();
|
||||
expect(state.storyTracks.filter((track) => track.text.startsWith('#'))).toHaveLength(2);
|
||||
expect(state.storyTracks.some((track) => track.text.includes('[pause'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,10 +33,8 @@ describe('CastPanel', () => {
|
||||
profiles={[]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('[voice:Mara]')).toBeInTheDocument();
|
||||
expect(screen.getByText('[voice:Cole]')).toBeInTheDocument();
|
||||
// Both unmapped → both show the "uses Default voice" hint.
|
||||
expect(screen.getAllByText('audiobook.cast_uses_default')).toHaveLength(2);
|
||||
expect(screen.getByText('Mara')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cole')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getAllByTestId('voice-selector')[0], {
|
||||
target: { value: 'pid-1' },
|
||||
@@ -72,7 +70,7 @@ describe('MarkupToolbar', () => {
|
||||
const ta = screen.getByLabelText('script');
|
||||
ta.focus();
|
||||
ta.setSelectionRange(5, 5); // caret right after "hello"
|
||||
fireEvent.click(screen.getByText('audiobook.insert_pause'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'audiobook.insert_pause' }));
|
||||
expect(ta.value).toBe('hello[pause 500ms] world');
|
||||
});
|
||||
|
||||
@@ -81,7 +79,7 @@ describe('MarkupToolbar', () => {
|
||||
const ta = screen.getByLabelText('script');
|
||||
ta.focus();
|
||||
ta.setSelectionRange(0, 11); // select the whole thing
|
||||
fireEvent.click(screen.getByText('audiobook.insert_slow'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'audiobook.insert_slow' }));
|
||||
expect(ta.value).toBe('[slow]hello world[/slow]');
|
||||
});
|
||||
|
||||
@@ -90,9 +88,17 @@ describe('MarkupToolbar', () => {
|
||||
const ta = screen.getByLabelText('script');
|
||||
ta.focus();
|
||||
ta.setSelectionRange(0, 0);
|
||||
fireEvent.click(screen.getByText('audiobook.insert_voice'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'audiobook.insert_voice' }));
|
||||
expect(ta.value).toBe('[voice:NAME]');
|
||||
});
|
||||
|
||||
it('exposes an accessible dismissal control for the reactions menu', () => {
|
||||
render(<ToolbarHarness />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'audiobook.insert_reactions' }));
|
||||
const close = screen.getByRole('button', { name: 'common.close' });
|
||||
fireEvent.click(close);
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StatsBar', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { storyToSpans } from '../utils/storyToSpans';
|
||||
import { SAMPLE_STORY_CAST, SAMPLE_STORY_LINES } from '../data/sampleStory';
|
||||
|
||||
const CAST = [
|
||||
{ id: 'narrator', name: 'Narrator', profileId: 'p_narr' },
|
||||
@@ -7,6 +8,23 @@ const CAST = [
|
||||
];
|
||||
|
||||
describe('storyToSpans', () => {
|
||||
it('compiles the bundled Stories demo through the real render plan', () => {
|
||||
const cast = SAMPLE_STORY_CAST.map((member) => ({
|
||||
...member,
|
||||
profileId: `profile-${member.id}`,
|
||||
}));
|
||||
const chapters = storyToSpans(SAMPLE_STORY_LINES, cast);
|
||||
const spans = chapters.flatMap((chapter) => chapter.spans);
|
||||
|
||||
expect(chapters.map((chapter) => chapter.title)).toEqual(['The Lamp', 'The Radio']);
|
||||
expect(spans.length).toBeGreaterThan(9);
|
||||
expect(spans.every((span) => span.text.trim())).toBe(true);
|
||||
expect(new Set(spans.map((span) => span.voice_id))).toEqual(
|
||||
new Set(['profile-narrator', 'profile-mara', 'profile-cole']),
|
||||
);
|
||||
expect(spans.some((span) => span.pause_ms_after > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves each line to its cast voice', () => {
|
||||
const tracks = [
|
||||
{ character: 'narrator', text: 'Once upon a time.' },
|
||||
|
||||
@@ -46,6 +46,13 @@ describe('workspace narrow-shell reflow (#476 CTA-clipping guard)', () => {
|
||||
expect(css).toMatch(/\.studio-action-bar\s*\{[^}]*position:\s*sticky/s);
|
||||
});
|
||||
|
||||
it('keeps saved voices in the left rail and generation history alone on the right', () => {
|
||||
expect(app).toMatch(
|
||||
/className="studio-voices">\s*<WorkspaceVoices[\s\S]*?<\/div>\s*<div className="studio-with-history__main">/,
|
||||
);
|
||||
expect(app).toMatch(/<div className="studio-right">\s*<WorkspaceHistory\s+history=\{history\}/);
|
||||
});
|
||||
|
||||
it('gives Dub Projects its own narrower rail than Dub History', () => {
|
||||
expect(app).toMatch(/className="studio-projects">\s*<WorkspaceProjects/);
|
||||
expect(css).toMatch(/\.studio-projects\s*\{[^}]*flex:\s*0 0 240px/s);
|
||||
@@ -57,4 +64,10 @@ describe('workspace narrow-shell reflow (#476 CTA-clipping guard)', () => {
|
||||
/dubStep === 'idle'[\s\S]*?className="studio-projects"[\s\S]*?canSave=\{false\}/,
|
||||
);
|
||||
});
|
||||
|
||||
it('gives the Script editor more height without crowding narrow shells', () => {
|
||||
expect(indexRaw).toMatch(/\.studio-script-input\s*\{[^}]*min-height:\s*240px/s);
|
||||
expect(indexRaw).toMatch(/\.shell-narrow\s+\.studio-script-input[^}]*min-height:\s*200px/s);
|
||||
expect(indexRaw).toMatch(/\.shell-mini\s+\.studio-script-input[^}]*min-height:\s*160px/s);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,7 +133,8 @@ export function NowPlaying({ color }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Color-coded icon tile with a small flag badge — the visual anchor of a card. */
|
||||
/** Color-coded icon tile. Accent flags live in the metadata row, where they
|
||||
* remain readable and do not duplicate into a clipped badge under the tile. */
|
||||
export function ArchetypeAvatar({ item, size = 44 }) {
|
||||
const color = USE_CASE_COLOR[item.use_case] || '#83a598';
|
||||
return (
|
||||
@@ -146,9 +147,6 @@ export function ArchetypeAvatar({ item, size = 44 }) {
|
||||
}}
|
||||
>
|
||||
<ArchetypeIcon name={item.icon} size={Math.round(size * 0.46)} color={color} />
|
||||
<span className="arch-avatar-flag">
|
||||
<AccentFlag accent={item.facets?.accent} lang={item.language} size={15} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user