refactor(audiobook): compact, grouped UI for the audiobook tab (#1214)
Group the sprawling right-hand settings column into consistent collapsible Sections (Output open by default; Book details / Pronunciation / Markup collapsed) built on a small reusable native-<details> Section component, so a first-timer sees script + voice + language + Create without scrolling. Extract the cover+metadata and lexicon blocks into BookDetails / LexiconEditor sub-components (also relieves the max-lines lint). New i18n key audiobook.output added to all 21 locales. Behaviour-identical; adds an AudiobookTab layout test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
93e9a5b8a9
commit
fc2b0d4cb5
@@ -0,0 +1,89 @@
|
||||
import { Image as ImageIcon, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '../../ui';
|
||||
import { buttonVariants } from '@/components/ui/button.tsx';
|
||||
|
||||
const META_FIELDS = ['title', 'author', 'narrator', 'year', 'genre'];
|
||||
|
||||
/**
|
||||
* Cover picker + the embedded-metadata fields for the Audiobook tab. Extracted
|
||||
* from AudiobookTab so the tab stays under the max-lines lint and the block can
|
||||
* live inside a collapsible Section. Behaviour-identical to the inline version:
|
||||
* same inputs, same aria-labels, same store-backed `meta` / `setMetaField`.
|
||||
*/
|
||||
export default function BookDetails({
|
||||
t,
|
||||
coverPreview,
|
||||
onCoverPick,
|
||||
clearCover,
|
||||
meta,
|
||||
setMetaField,
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-[12px] items-start">
|
||||
<div style={{ position: 'relative', width: 96, height: 96, flexShrink: 0 }}>
|
||||
{coverPreview ? (
|
||||
<>
|
||||
<img
|
||||
src={coverPreview}
|
||||
alt={t('audiobook.cover')}
|
||||
style={{ width: 96, height: 96, objectFit: 'cover', borderRadius: 6 }}
|
||||
/>
|
||||
<Button
|
||||
variant="icon"
|
||||
iconSize="sm"
|
||||
onClick={clearCover}
|
||||
aria-label={t('audiobook.cover_remove')}
|
||||
style={{ position: 'absolute', top: 4, right: 4 }}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<label
|
||||
className={buttonVariants({ variant: 'subtle', size: 'omniMd' })}
|
||||
style={{
|
||||
width: 96,
|
||||
height: 96,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ImageIcon size={20} />
|
||||
<span style={{ fontSize: '0.65rem' }}>{t('audiobook.cover_add')}</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg"
|
||||
onChange={onCoverPick}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_1fr] gap-[8px] flex-1 min-w-0">
|
||||
{META_FIELDS.map((k) => (
|
||||
<input
|
||||
key={k}
|
||||
className="input-base"
|
||||
placeholder={t(`audiobook.meta_${k}`)}
|
||||
value={meta[k]}
|
||||
onChange={setMetaField(k)}
|
||||
aria-label={t(`audiobook.meta_${k}`)}
|
||||
/>
|
||||
))}
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder={t('audiobook.meta_description')}
|
||||
value={meta.description}
|
||||
onChange={setMetaField('description')}
|
||||
aria-label={t('audiobook.meta_description')}
|
||||
style={{ gridColumn: '1 / -1' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Plus, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '../../ui';
|
||||
|
||||
/**
|
||||
* Pronunciation lexicon editor — the editable {word → respelling} rows plus the
|
||||
* Add button. Extracted from AudiobookTab so the block can live inside a
|
||||
* collapsible Section and the tab stays under the max-lines lint. Behaviour is
|
||||
* identical: the parent still owns the `lex` rows and the row mutators.
|
||||
*/
|
||||
export default function LexiconEditor({ t, lex, setLexRow, addLexRow, removeLexRow }) {
|
||||
return (
|
||||
<>
|
||||
{lex.map((row, i) => (
|
||||
<div key={i} className="flex gap-[6px]">
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder={t('audiobook.lex_word')}
|
||||
value={row.word}
|
||||
onChange={setLexRow(i, 'word')}
|
||||
aria-label={t('audiobook.lex_word')}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder={t('audiobook.lex_say')}
|
||||
value={row.say}
|
||||
onChange={setLexRow(i, 'say')}
|
||||
aria-label={t('audiobook.lex_say')}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<Button
|
||||
variant="icon"
|
||||
iconSize="sm"
|
||||
onClick={() => removeLexRow(i)}
|
||||
aria-label={t('audiobook.lex_remove')}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={addLexRow}
|
||||
leading={<Plus size={14} />}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('audiobook.lex_add')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Collapsible settings group for the Audiobook tab — one consistent affordance
|
||||
* for the secondary controls (Output, Book details, Pronunciation, Markup) so
|
||||
* the right-hand column stops sprawling. Native `<details>/<summary>` gives
|
||||
* keyboard operability + a labelled disclosure for free; the chrome mirrors the
|
||||
* AudiobookOverrides header (small muted mono label + a chevron that rotates
|
||||
* when open) so every group header reads as one family.
|
||||
*
|
||||
* `defaultOpen` sets the initial state only (uncontrolled, like the overrides
|
||||
* panel) — the user's toggle is theirs to keep for the session.
|
||||
*/
|
||||
export default function Section({ title, icon = null, defaultOpen = false, children }) {
|
||||
return (
|
||||
<details className="audiobook-tab__section group flex flex-col gap-[8px]" open={defaultOpen}>
|
||||
<summary className="flex items-center gap-[6px] px-[4px] py-[4px] cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden [font-family:var(--chrome-font-mono)] [font-size:var(--chrome-label-size)] font-semibold [letter-spacing:var(--chrome-label-track)] uppercase [color:var(--chrome-fg-muted)] hover:[color:var(--chrome-fg)]">
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className="ml-auto transition-transform group-open:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</summary>
|
||||
<div className="flex flex-col gap-[8px]">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -2067,6 +2067,7 @@
|
||||
"export_error": "تعذر تصدير القاموس."
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "الإخراج",
|
||||
"title": "كتاب مسموع",
|
||||
"subtitle": "قم بتحويل البرنامج النصي المحدد بفصل إلى كتاب صوتي m4b مقسم.",
|
||||
"script": "البرنامج النصي",
|
||||
|
||||
@@ -2067,6 +2067,7 @@
|
||||
"export_error": "Das Wörterbuch konnte nicht exportiert werden."
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "Ausgabe",
|
||||
"title": "Hörbuch",
|
||||
"subtitle": "Verwandeln Sie ein durch Kapitel getrenntes Skript in ein in Kapitel unterteiltes m4b-Hörbuch.",
|
||||
"script": "Skript",
|
||||
|
||||
@@ -155,6 +155,7 @@
|
||||
"flip_rail": "Flip rail side"
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "Output",
|
||||
"title": "Audiobook",
|
||||
"subtitle": "Turn a chapter-delimited script into a chapterized m4b audiobook.",
|
||||
"script": "Script",
|
||||
|
||||
@@ -2067,6 +2067,7 @@
|
||||
"export_error": "No se pudo exportar el diccionario."
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "Salida",
|
||||
"title": "Audiolibro",
|
||||
"subtitle": "Convierta un guión delimitado por capítulos en un audiolibro m4b dividido en capítulos.",
|
||||
"script": "Guión",
|
||||
|
||||
@@ -2067,6 +2067,7 @@
|
||||
"export_error": "Impossible d'exporter le dictionnaire."
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "Sortie",
|
||||
"title": "Livre audio",
|
||||
"subtitle": "Transformez un script délimité par des chapitres en un livre audio m4b chapitré.",
|
||||
"script": "Scénario",
|
||||
|
||||
@@ -2067,6 +2067,7 @@
|
||||
"export_error": "शब्दकोश निर्यात नहीं हो सका।"
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "आउटपुट",
|
||||
"title": "ऑडियोबुक",
|
||||
"subtitle": "एक अध्याय-सीमांकित स्क्रिप्ट को एक अध्यायबद्ध m4b ऑडियोबुक में बदलें।",
|
||||
"script": "स्क्रिप्ट",
|
||||
|
||||
@@ -2067,6 +2067,7 @@
|
||||
"export_error": "Kamus tidak dapat diekspor."
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "Keluaran",
|
||||
"title": "Buku Audio",
|
||||
"subtitle": "Ubah skrip yang dibatasi bab menjadi buku audio m4b yang diberi bab.",
|
||||
"script": "naskah",
|
||||
|
||||
@@ -2067,6 +2067,7 @@
|
||||
"export_error": "Impossibile esportare il dizionario."
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "Uscita",
|
||||
"title": "Audiolibro",
|
||||
"subtitle": "Trasforma uno script delimitato da capitoli in un audiolibro m4b suddiviso in capitoli.",
|
||||
"script": "Copione",
|
||||
|
||||
@@ -2067,6 +2067,7 @@
|
||||
"export_error": "辞書をエクスポートできませんでした。"
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "出力",
|
||||
"title": "オーディオブック",
|
||||
"subtitle": "チャプター区切りのスクリプトをチャプター化された m4b オーディオブックに変換します。",
|
||||
"script": "スクリプト",
|
||||
|
||||
@@ -2067,6 +2067,7 @@
|
||||
"export_error": "사전을 내보낼 수 없습니다."
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "출력",
|
||||
"title": "Audiobook",
|
||||
"subtitle": "장으로 구분된 스크립트를 장으로 구분된 m4b 오디오북으로 바꿔보세요.",
|
||||
"script": "스크립트",
|
||||
|
||||
@@ -2067,6 +2067,7 @@
|
||||
"export_error": "Kan het woordenboek niet exporteren."
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "Uitvoer",
|
||||
"title": "Audioboek",
|
||||
"subtitle": "Verander een door hoofdstukken gescheiden script in een m4b-audioboek in hoofdstukken.",
|
||||
"script": "Script",
|
||||
|
||||
@@ -2067,6 +2067,7 @@
|
||||
"export_error": "Nie udało się wyeksportować słownika."
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "Wyjście",
|
||||
"title": "Książka audio",
|
||||
"subtitle": "Zamień skrypt podzielony na rozdziały w audiobook m4b podzielony na rozdziały.",
|
||||
"script": "Skrypt",
|
||||
|
||||
@@ -2067,6 +2067,7 @@
|
||||
"export_error": "Não foi possível exportar o dicionário."
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "Saída",
|
||||
"title": "Audiolivro",
|
||||
"subtitle": "Transforme um script delimitado por capítulos em um audiolivro m4b com capítulos.",
|
||||
"script": "Roteiro",
|
||||
|
||||
@@ -2067,6 +2067,7 @@
|
||||
"export_error": "Не удалось экспортировать словарь."
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "Вывод",
|
||||
"title": "Аудиокнига",
|
||||
"subtitle": "Превратите сценарий с разделением глав в аудиокнигу m4b с разделением на главы.",
|
||||
"script": "Скрипт",
|
||||
|
||||
@@ -2067,6 +2067,7 @@
|
||||
"export_error": "Kunde inte exportera ordboken."
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "Utdata",
|
||||
"title": "Ljudbok",
|
||||
"subtitle": "Förvandla ett kapitelavgränsat manus till en kapitelinställd m4b-ljudbok.",
|
||||
"script": "Manus",
|
||||
|
||||
@@ -2067,6 +2067,7 @@
|
||||
"export_error": "ไม่สามารถส่งออกพจนานุกรมได้"
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "เอาต์พุต",
|
||||
"title": "หนังสือเสียง",
|
||||
"subtitle": "เปลี่ยนสคริปต์ที่คั่นด้วยบทให้เป็นหนังสือเสียงแบบแบ่งบท m4b",
|
||||
"script": "สคริปต์",
|
||||
|
||||
@@ -2067,6 +2067,7 @@
|
||||
"export_error": "Sözlük dışa aktarılamadı."
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "Çıktı",
|
||||
"title": "Sesli kitap",
|
||||
"subtitle": "Bölümle ayrılmış bir komut dosyasını bölümlere ayrılmış bir m4b sesli kitaba dönüştürün.",
|
||||
"script": "Senaryo",
|
||||
|
||||
@@ -2067,6 +2067,7 @@
|
||||
"export_error": "Не вдалося експортувати словник."
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "Вивід",
|
||||
"title": "Аудіокнига",
|
||||
"subtitle": "Перетворіть розділений розділами сценарій на розділену на розділи аудіокнигу m4b.",
|
||||
"script": "Сценарій",
|
||||
|
||||
@@ -2067,6 +2067,7 @@
|
||||
"export_error": "Không thể xuất từ điển."
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "Đầu ra",
|
||||
"title": "Sách nói",
|
||||
"subtitle": "Biến tập lệnh được phân cách theo chương thành sách nói m4b được chia theo chương.",
|
||||
"script": "kịch bản",
|
||||
|
||||
@@ -2074,6 +2074,7 @@
|
||||
"export_error": "无法导出词典。"
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "输出",
|
||||
"title": "有声读物",
|
||||
"subtitle": "将章节分隔的脚本转换为章节化的 m4b 有声读物。",
|
||||
"script": "脚本",
|
||||
|
||||
@@ -2067,6 +2067,7 @@
|
||||
"export_error": "無法匯出字典。"
|
||||
},
|
||||
"audiobook": {
|
||||
"output": "輸出",
|
||||
"title": "有聲書",
|
||||
"subtitle": "將章節分隔的腳本轉換為章節化的 m4b 有聲書。",
|
||||
"script": "腳本",
|
||||
|
||||
@@ -3,13 +3,14 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
BookMarked,
|
||||
BookOpen,
|
||||
BookText,
|
||||
Code,
|
||||
Download,
|
||||
Loader,
|
||||
Image as ImageIcon,
|
||||
X,
|
||||
Play,
|
||||
SlidersHorizontal,
|
||||
SpellCheck,
|
||||
Upload,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
@@ -26,6 +27,9 @@ 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 { SAMPLE_AUDIOBOOK_SCRIPT } from '../data/sampleAudiobook';
|
||||
import ALL_LANGUAGES from '../languages.json';
|
||||
import { POPULAR_LANGS } from '../utils/constants';
|
||||
@@ -388,187 +392,67 @@ export default function AudiobookTab({ profiles = [] }) {
|
||||
emotionSupported={emotionSupported}
|
||||
/>
|
||||
|
||||
<div className="audiobook-tab__duo grid grid-cols-[1fr_1fr] gap-[8px]">
|
||||
<div className="audiobook-tab__field 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="audiobook-tab__field 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>
|
||||
|
||||
{/* Cover + metadata */}
|
||||
<div className="audiobook-tab__field flex flex-col gap-[4px]">
|
||||
<label className={FIELD_LABEL}>{t('audiobook.details')}</label>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div style={{ position: 'relative', width: 96, height: 96, flexShrink: 0 }}>
|
||||
{coverPreview ? (
|
||||
<>
|
||||
<img
|
||||
src={coverPreview}
|
||||
alt={t('audiobook.cover')}
|
||||
style={{ width: 96, height: 96, objectFit: 'cover', borderRadius: 6 }}
|
||||
/>
|
||||
<Button
|
||||
variant="icon"
|
||||
iconSize="sm"
|
||||
onClick={clearCover}
|
||||
aria-label={t('audiobook.cover_remove')}
|
||||
style={{ position: 'absolute', top: 4, right: 4 }}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<label
|
||||
className={buttonVariants({ variant: 'subtle', size: 'omniMd' })}
|
||||
style={{
|
||||
width: 96,
|
||||
height: 96,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ImageIcon size={20} />
|
||||
<span style={{ fontSize: '0.65rem' }}>{t('audiobook.cover_add')}</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg"
|
||||
onChange={onCoverPick}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 8,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
{/* 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"
|
||||
placeholder={t('audiobook.meta_title')}
|
||||
value={meta.title}
|
||||
onChange={setMetaField('title')}
|
||||
aria-label={t('audiobook.meta_title')}
|
||||
/>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder={t('audiobook.meta_author')}
|
||||
value={meta.author}
|
||||
onChange={setMetaField('author')}
|
||||
aria-label={t('audiobook.meta_author')}
|
||||
/>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder={t('audiobook.meta_narrator')}
|
||||
value={meta.narrator}
|
||||
onChange={setMetaField('narrator')}
|
||||
aria-label={t('audiobook.meta_narrator')}
|
||||
/>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder={t('audiobook.meta_year')}
|
||||
value={meta.year}
|
||||
onChange={setMetaField('year')}
|
||||
aria-label={t('audiobook.meta_year')}
|
||||
/>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder={t('audiobook.meta_genre')}
|
||||
value={meta.genre}
|
||||
onChange={setMetaField('genre')}
|
||||
aria-label={t('audiobook.meta_genre')}
|
||||
/>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder={t('audiobook.meta_description')}
|
||||
value={meta.description}
|
||||
onChange={setMetaField('description')}
|
||||
aria-label={t('audiobook.meta_description')}
|
||||
style={{ gridColumn: '1 / -1' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pronunciation lexicon */}
|
||||
<div className="audiobook-tab__field flex flex-col gap-[4px]">
|
||||
<label className={FIELD_LABEL}>{t('audiobook.lexicon')}</label>
|
||||
{lex.map((row, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder={t('audiobook.lex_word')}
|
||||
value={row.word}
|
||||
onChange={setLexRow(i, 'word')}
|
||||
aria-label={t('audiobook.lex_word')}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder={t('audiobook.lex_say')}
|
||||
value={row.say}
|
||||
onChange={setLexRow(i, 'say')}
|
||||
aria-label={t('audiobook.lex_say')}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<Button
|
||||
variant="icon"
|
||||
iconSize="sm"
|
||||
onClick={() => removeLexRow(i)}
|
||||
aria-label={t('audiobook.lex_remove')}
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value)}
|
||||
aria-label={t('audiobook.format')}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
<option value="m4b">{t('audiobook.format_m4b')}</option>
|
||||
<option value="mp3">{t('audiobook.format_mp3')}</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={addLexRow}
|
||||
leading={<Plus size={14} />}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('audiobook.lex_add')}
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
{/* Markup quick reference */}
|
||||
<details className="audiobook-tab__field flex flex-col gap-[4px]">
|
||||
<summary className={FIELD_LABEL} style={{ cursor: 'pointer' }}>
|
||||
{t('audiobook.markup_help')}
|
||||
</summary>
|
||||
<p className="muted" style={{ fontSize: '0.72rem', lineHeight: 1.6, marginTop: 6 }}>
|
||||
{/* 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>
|
||||
</details>
|
||||
</Section>
|
||||
|
||||
{error && (
|
||||
<div className="error-banner" role="alert">
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// 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.
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
import en from '../i18n/locales/en.json';
|
||||
|
||||
// listEngines runs once on mount to gate the emotion controls — stub it so the
|
||||
// tab renders offline and reports "no emotion support".
|
||||
vi.mock('../api/engines', () => ({
|
||||
listEngines: vi.fn().mockResolvedValue({ tts: { active: 'x', backends: [] } }),
|
||||
}));
|
||||
vi.mock('../api/generate', () => ({ audioUrl: (f) => `http://test.local/audio/${f}` }));
|
||||
vi.mock('../api/audiobook', () => ({
|
||||
audiobookPlan: vi.fn(),
|
||||
audiobookGenerate: vi.fn(),
|
||||
audiobookUploadCover: vi.fn(),
|
||||
audiobookPreviewChapter: vi.fn(),
|
||||
audiobookImport: vi.fn(),
|
||||
}));
|
||||
|
||||
import AudiobookTab from '../pages/AudiobookTab';
|
||||
import { useAppStore } from '../store';
|
||||
|
||||
const withI18n = (node) => <I18nextProvider i18n={i18n}>{node}</I18nextProvider>;
|
||||
// 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();
|
||||
useAppStore.getState().setScript('');
|
||||
});
|
||||
|
||||
it('keeps the primary inputs always visible', () => {
|
||||
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.getByText(en.audiobook.create)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('groups the secondary controls into collapsible sections', () => {
|
||||
render(withI18n(<AudiobookTab profiles={[]} />));
|
||||
for (const title of [
|
||||
en.audiobook.output,
|
||||
en.audiobook.details,
|
||||
en.audiobook.lexicon,
|
||||
en.audiobook.markup_help,
|
||||
]) {
|
||||
expect(sectionFor(title).tagName).toBe('DETAILS');
|
||||
}
|
||||
});
|
||||
|
||||
it('opens Output by default and collapses the long groups', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it('a collapsed section toggles open on its summary', () => {
|
||||
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.getByLabelText(en.audiobook.meta_title)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user