diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c586b9c7..6750f120 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { ) : (
+
+ { + setVoicePreviewProfileId(profileId || ''); + setIsVoicePreviewOpen(true); + }} + /> +
}> @@ -1627,22 +1645,6 @@ function App() {
- { - setVoicePreviewProfileId(profileId || ''); - setIsVoicePreviewOpen(true); - }} - /> setProjectsOpen((open) => !open), []); + const toggleCast = useCallback(() => setCastOpen((open) => !open), []); + const toggleSplit = useCallback(() => setSplitOpen((open) => !open), []); // Global reading speed (#415): one speed for every line without its own // per-track override. UI preference → persisted in localStorage (survives // restarts; not part of the project state, so no slice migration). @@ -237,6 +242,7 @@ export default function StoriesEditor({ profiles = [] }) { const trackTextRefs = useRef(new Map()); const fileInputRef = useRef(null); const dragId = useRef(null); + const sampleBootstrapRef = useRef(false); const [dragOver, setDragOver] = useState(null); // ── Cast ──────────────────────────────────────────────────────────────── @@ -332,6 +338,63 @@ export default function StoriesEditor({ profiles = [] }) { newProject(); setProjectName(''); }, [newProject]); + const createSampleStory = useCallback(() => { + newProject(); + setCast( + SAMPLE_STORY_CAST.map((member, index) => ({ + ...member, + profileId: profiles.length ? profiles[index % profiles.length].id : null, + })), + ); + setStoryTracks(SAMPLE_STORY_LINES.map((line) => makeTrack(line.character, line.text))); + setProjectName(SAMPLE_STORY_NAME); + saveProject(SAMPLE_STORY_NAME); + setProjectsOpen(false); + setCastOpen(true); + setSplitOpen(false); + }, [newProject, profiles, saveProject, setCast, setStoryTracks]); + const loadSampleStory = useCallback(async () => { + if ( + tracks.some((track) => track.text.trim()) && + !(await askConfirm(t('audiobook.load_sample_confirm'), t('audiobook.load_sample'))) + ) { + return; + } + + createSampleStory(); + toast.success(t('stories.projectSaved')); + }, [createSampleStory, t, tracks]); + + useEffect(() => { + if (sampleBootstrapRef.current) return; + if (currentProjectId || storyProjects.length || tracks.some((track) => track.text.trim())) + return; + sampleBootstrapRef.current = true; + try { + if (localStorage.getItem(DEFAULT_SAMPLE_KEY)) return; + localStorage.setItem(DEFAULT_SAMPLE_KEY, '1'); + } catch { + // Storage can be unavailable in privacy modes; the pristine-state guard + // still prevents duplicate projects during this mounted session. + } + createSampleStory(); + }, [createSampleStory, currentProjectId, storyProjects.length, tracks]); + useEffect(() => { + if ( + currentProject?.name !== SAMPLE_STORY_NAME || + !profiles.length || + cast.some((member) => member.profileId) + ) { + return; + } + setCast( + cast.map((member, index) => ({ + ...member, + profileId: profiles[index % profiles.length].id, + })), + ); + saveProject(SAMPLE_STORY_NAME); + }, [cast, currentProject?.name, profiles, saveProject, setCast]); const openProject = useCallback( (id) => { loadProject(id); @@ -339,6 +402,14 @@ export default function StoriesEditor({ profiles = [] }) { }, [loadProject], ); + const confirmDeleteProject = useCallback( + async (id) => { + if (await askConfirm(t('stories.deleteProject'), t('stories.deleteProject'))) { + deleteProject(id); + } + }, + [deleteProject, t], + ); const addChapter = useCallback(() => { const n = tracks.filter((tk) => isChapterText(tk.text)).length + 1; @@ -600,655 +671,706 @@ export default function StoriesEditor({ profiles = [] }) { }, [tracks, cast, fetchChunkBlob, exporting, deliver, globalSpeed, t]); // ── Stats ───────────────────────────────────────────────────────────────── - const totalChars = tracks.reduce((acc, tk) => acc + tk.text.length, 0); - const usedCharacters = new Set(tracks.map((tk) => tk.character)).size; - const estMinutes = Math.ceil(totalChars / 800); - - const profileName = (id) => (profiles.find((p) => p.id === id) || {}).name; + const { totalChars, usedCharacters, estMinutes } = useMemo(() => { + let chars = 0; + const characters = new Set(); + for (const track of tracks) { + chars += track.text.length; + characters.add(track.character); + } + return { + totalChars: chars, + usedCharacters: characters.size, + estMinutes: chars ? Math.ceil(chars / 800) : 0, + }; + }, [tracks]); + const profileNames = useMemo( + () => new Map(profiles.map((profile) => [profile.id, profile.name])), + [profiles], + ); + const profileName = useCallback((id) => profileNames.get(id), [profileNames]); return (
- {/* Header / toolbar */} -
-
-

- {t('stories.title')} -

-

{t('stories.subtitle')}

+ {/* Production header */} +
+
+ +
+ {t('stories.title')} +

+ {currentProject ? currentProject.name : t('stories.untitled')} +

+
-
- +
+ + {t('stories.lines', { count: tracks.length })} ·{' '} + {t('stories.minutes', { count: estMinutes })} + + + +
+
- {/* Project */} -
- - -
- -
+ + - {/* Projects panel */} - {projectsOpen && ( -
-
- - {t('stories.projects')} - - +
+
+
+
+
+ + + + + +
-
- setProjectName(e.target.value)} - placeholder={t('stories.untitled')} - aria-label={t('stories.projectName')} - /> - -
- {storyProjects.map((p) => ( + + {/* Paste & split */} + {splitOpen && (
- - -
- ))} - {storyProjects.length === 0 && ( -

- {t('stories.noProjects')} -

- )} -
- )} - - {/* Cast panel */} - {castOpen && ( -
-
- - {t('stories.castTitle')} - - -
- {cast.map((c) => ( -
- setSplitText(e.target.value)} + rows={6} + aria-label={t('stories.splitPlaceholder')} + name="story-import-text" + autoComplete="off" /> - upsertCastMember({ ...c, name: e.target.value })} - aria-label={t('stories.characterName')} - /> - {/* Shared gallery-enabled picker (#1220): a gallery pick - materializes to a real profile id, stored exactly like a - clone/designed voice. `|| null` preserves the store's existing - "no voice = null" shape (backward-compatible projects). */} - - setCharacterVoice(c.id, v || null)} - profiles={profiles} - menuPortal - defaultLabel={t('stories.defaultVoice')} - /> - - -
- ))} - {profiles.length === 0 && ( -

- {t('stories.noProfiles')} -

- )} -
- )} - - {/* Paste & split */} - {splitOpen && ( -
-