diff --git a/frontend/src/components/audiobook/BookDetails.jsx b/frontend/src/components/audiobook/BookDetails.jsx new file mode 100644 index 00000000..156b1d4d --- /dev/null +++ b/frontend/src/components/audiobook/BookDetails.jsx @@ -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 ( +
+
+ {coverPreview ? ( + <> + {t('audiobook.cover')} + + + ) : ( + + )} +
+
+ {META_FIELDS.map((k) => ( + + ))} + +
+
+ ); +} diff --git a/frontend/src/components/audiobook/LexiconEditor.jsx b/frontend/src/components/audiobook/LexiconEditor.jsx new file mode 100644 index 00000000..690dbb44 --- /dev/null +++ b/frontend/src/components/audiobook/LexiconEditor.jsx @@ -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) => ( +
+ + + +
+ ))} + + + ); +} diff --git a/frontend/src/components/audiobook/Section.jsx b/frontend/src/components/audiobook/Section.jsx new file mode 100644 index 00000000..d4ea264a --- /dev/null +++ b/frontend/src/components/audiobook/Section.jsx @@ -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 `
/` 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 ( +
+ + {icon} + {title} + +
{children}
+
+ ); +} diff --git a/frontend/src/i18n/locales/ar.json b/frontend/src/i18n/locales/ar.json index 3eb2ca53..62f120f3 100644 --- a/frontend/src/i18n/locales/ar.json +++ b/frontend/src/i18n/locales/ar.json @@ -2067,6 +2067,7 @@ "export_error": "تعذر تصدير القاموس." }, "audiobook": { + "output": "الإخراج", "title": "كتاب مسموع", "subtitle": "قم بتحويل البرنامج النصي المحدد بفصل إلى كتاب صوتي m4b مقسم.", "script": "البرنامج النصي", diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 73498164..d6580e79 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 43861be3..1c6904c9 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 49131d55..d617ce5d 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -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", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 889de698..4dfc1748 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -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", diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index 52e6acca..ae1db80d 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -2067,6 +2067,7 @@ "export_error": "शब्दकोश निर्यात नहीं हो सका।" }, "audiobook": { + "output": "आउटपुट", "title": "ऑडियोबुक", "subtitle": "एक अध्याय-सीमांकित स्क्रिप्ट को एक अध्यायबद्ध m4b ऑडियोबुक में बदलें।", "script": "स्क्रिप्ट", diff --git a/frontend/src/i18n/locales/id.json b/frontend/src/i18n/locales/id.json index 357fd405..3403c75c 100644 --- a/frontend/src/i18n/locales/id.json +++ b/frontend/src/i18n/locales/id.json @@ -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", diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index 6ea6c01f..e0963193 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -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", diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index 073a2aab..26b27c8a 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -2067,6 +2067,7 @@ "export_error": "辞書をエクスポートできませんでした。" }, "audiobook": { + "output": "出力", "title": "オーディオブック", "subtitle": "チャプター区切りのスクリプトをチャプター化された m4b オーディオブックに変換します。", "script": "スクリプト", diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index d86c5e87..94b68238 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -2067,6 +2067,7 @@ "export_error": "사전을 내보낼 수 없습니다." }, "audiobook": { + "output": "출력", "title": "Audiobook", "subtitle": "장으로 구분된 스크립트를 장으로 구분된 m4b 오디오북으로 바꿔보세요.", "script": "스크립트", diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 38e95bac..ce3293eb 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -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", diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index 9d3e8f4a..cc78cd36 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -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", diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index dc216bd6..32331697 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -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", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 991900fd..f186b104 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -2067,6 +2067,7 @@ "export_error": "Не удалось экспортировать словарь." }, "audiobook": { + "output": "Вывод", "title": "Аудиокнига", "subtitle": "Превратите сценарий с разделением глав в аудиокнигу m4b с разделением на главы.", "script": "Скрипт", diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index efb2d887..8a25a30d 100644 --- a/frontend/src/i18n/locales/sv.json +++ b/frontend/src/i18n/locales/sv.json @@ -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", diff --git a/frontend/src/i18n/locales/th.json b/frontend/src/i18n/locales/th.json index 5f1cc49f..3e8d9644 100644 --- a/frontend/src/i18n/locales/th.json +++ b/frontend/src/i18n/locales/th.json @@ -2067,6 +2067,7 @@ "export_error": "ไม่สามารถส่งออกพจนานุกรมได้" }, "audiobook": { + "output": "เอาต์พุต", "title": "หนังสือเสียง", "subtitle": "เปลี่ยนสคริปต์ที่คั่นด้วยบทให้เป็นหนังสือเสียงแบบแบ่งบท m4b", "script": "สคริปต์", diff --git a/frontend/src/i18n/locales/tr.json b/frontend/src/i18n/locales/tr.json index ae2ec809..3b0c42c8 100644 --- a/frontend/src/i18n/locales/tr.json +++ b/frontend/src/i18n/locales/tr.json @@ -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", diff --git a/frontend/src/i18n/locales/uk.json b/frontend/src/i18n/locales/uk.json index dad115eb..f9af5340 100644 --- a/frontend/src/i18n/locales/uk.json +++ b/frontend/src/i18n/locales/uk.json @@ -2067,6 +2067,7 @@ "export_error": "Не вдалося експортувати словник." }, "audiobook": { + "output": "Вивід", "title": "Аудіокнига", "subtitle": "Перетворіть розділений розділами сценарій на розділену на розділи аудіокнигу m4b.", "script": "Сценарій", diff --git a/frontend/src/i18n/locales/vi.json b/frontend/src/i18n/locales/vi.json index f2a876be..880baa3d 100644 --- a/frontend/src/i18n/locales/vi.json +++ b/frontend/src/i18n/locales/vi.json @@ -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", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index f7a039d7..e60a12f1 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2074,6 +2074,7 @@ "export_error": "无法导出词典。" }, "audiobook": { + "output": "输出", "title": "有声读物", "subtitle": "将章节分隔的脚本转换为章节化的 m4b 有声读物。", "script": "脚本", diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index 14b99636..4adedd95 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -2067,6 +2067,7 @@ "export_error": "無法匯出字典。" }, "audiobook": { + "output": "輸出", "title": "有聲書", "subtitle": "將章節分隔的腳本轉換為章節化的 m4b 有聲書。", "script": "腳本", diff --git a/frontend/src/pages/AudiobookTab.jsx b/frontend/src/pages/AudiobookTab.jsx index a0452fc3..537b9c3d 100644 --- a/frontend/src/pages/AudiobookTab.jsx +++ b/frontend/src/pages/AudiobookTab.jsx @@ -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} /> -
-
- - -
-
- - -
-
- - {/* Cover + metadata */} -
- -
-
- {coverPreview ? ( - <> - {t('audiobook.cover')} - - - ) : ( - - )} -
-
- } defaultOpen> +
+
+ + - - - - -
-
-
- - {/* Pronunciation lexicon */} -
- - {lex.map((row, i) => ( -
- - - + + +
- ))} - -
+
+ + +
+
+ - {/* Markup quick reference */} -
- - {t('audiobook.markup_help')} - -

+ {/* Book details — cover + embedded metadata. Collapsed by default. */} +

}> + +
+ + {/* Pronunciation lexicon — collapsed by default. */} +
}> + +
+ + {/* Markup quick reference — collapsed by default. */} +
}> +

{t('audiobook.markup_hint')}

-
+ {error && (
diff --git a/frontend/src/test/AudiobookTabLayout.test.jsx b/frontend/src/test/AudiobookTabLayout.test.jsx new file mode 100644 index 00000000..f29b55a7 --- /dev/null +++ b/frontend/src/test/AudiobookTabLayout.test.jsx @@ -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) => {node}; +// The
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()); + // 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()); + 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()); + 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()); + 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(); + }); +});