From 31abee069552f678c0564a5c40623f736331b3ea Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:13:46 -0400 Subject: [PATCH] Redesign the study side: Outline, Study, Script and Export stages Studies now move through a stage bar in the reader's themes. Outline splits chunks by clicking between verses (join merges notes) and suggests chunks from section headings. Study is three panes: outline, passage with an Observe / Interpret / Apply accordion, and commentary / word study / cross-refs / people & places for the chunk; original words are added by clicking them in the interlinear. Script drafts an outline from notes; Export holds files and the share link. Home, Import and the Pronunciation guide are restyled. Study projects are BSB only. Co-Authored-By: Claude Sonnet 5.5 --- src/App.jsx | 8 +- src/App.test.jsx | 193 +++- src/components/AudioPlayer.jsx | 26 +- src/components/AuthStatus.jsx | 14 +- src/components/ProjectHeader.jsx | 228 ---- src/components/StudyPanel.jsx | 2 +- src/components/study/CommentaryChunkTab.jsx | 56 + src/components/study/CrossRefsTab.jsx | 53 + src/components/study/NotesPane.jsx | 103 ++ src/components/study/OutlineRail.jsx | 43 + src/components/study/PageShell.jsx | 31 + src/components/study/PeopleChunkTab.jsx | 30 + src/components/study/ResourcePane.jsx | 50 + src/components/study/ScripturePane.jsx | 46 + src/components/study/StudioShell.jsx | 60 + src/components/study/SuggestWordsSheet.jsx | 50 + src/components/study/WordStudyTab.jsx | 93 ++ src/hooks/useAvailableTranslations.js | 25 - src/hooks/useChunkBuilder.js | 166 ++- src/hooks/useChunkBuilder.test.js | 109 ++ src/hooks/useWordStudy.js | 38 + src/pages/ExportPage.jsx | 63 + src/pages/HomePage.jsx | 607 +++------- src/pages/ImportPage.jsx | 123 +- src/pages/PronunciationPage.jsx | 154 +-- src/pages/ScriptPage.jsx | 141 +++ src/pages/SetupPage.jsx | 587 +++------- src/pages/StudyPage.jsx | 1151 ++----------------- src/utils/bibleUtils.jsx | 59 - src/utils/scriptDraft.js | 29 + src/utils/scriptDraft.test.js | 33 + 31 files changed, 1911 insertions(+), 2460 deletions(-) delete mode 100644 src/components/ProjectHeader.jsx create mode 100644 src/components/study/CommentaryChunkTab.jsx create mode 100644 src/components/study/CrossRefsTab.jsx create mode 100644 src/components/study/NotesPane.jsx create mode 100644 src/components/study/OutlineRail.jsx create mode 100644 src/components/study/PageShell.jsx create mode 100644 src/components/study/PeopleChunkTab.jsx create mode 100644 src/components/study/ResourcePane.jsx create mode 100644 src/components/study/ScripturePane.jsx create mode 100644 src/components/study/StudioShell.jsx create mode 100644 src/components/study/SuggestWordsSheet.jsx create mode 100644 src/components/study/WordStudyTab.jsx delete mode 100644 src/hooks/useAvailableTranslations.js create mode 100644 src/hooks/useChunkBuilder.test.js create mode 100644 src/pages/ExportPage.jsx create mode 100644 src/pages/ScriptPage.jsx create mode 100644 src/utils/scriptDraft.js create mode 100644 src/utils/scriptDraft.test.js diff --git a/src/App.jsx b/src/App.jsx index f3b2197..e00e5f1 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -23,6 +23,8 @@ const ReaderPage = lazy(() => import('./pages/ReaderPage.jsx')); const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx')); const SetupPage = lazy(() => import('./pages/SetupPage.jsx')); const StudyPage = lazy(() => import('./pages/StudyPage.jsx')); +const ScriptPage = lazy(() => import('./pages/ScriptPage.jsx')); +const ExportPage = lazy(() => import('./pages/ExportPage.jsx')); const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx')); const ReadingPlansPage = lazy(() => import('./pages/ReadingPlansPage.jsx')); @@ -32,7 +34,7 @@ const ReadingPlansPage = lazy(() => import('./pages/ReadingPlansPage.jsx')); const App = () => { const [project, setProject] = useState(null); - // 'home' | 'setup' | 'study' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' | 'reading-plans' + // 'home' | 'setup' | 'study' | 'script' | 'export' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' | 'reading-plans' const [currentPage, setCurrentPage] = useState('home'); const [projectIndex, setProjectIndex] = useState([]); const [saveStatus, setSaveStatus] = useState(''); @@ -245,7 +247,7 @@ const App = () => { authUser, setAuthUser, signIn, logout, - // Save / sync status (shown in ProjectHeader) + // Save / sync status (shown in the stage bar) saveStatus, setSaveStatus, syncStatus, retrySync, @@ -348,6 +350,8 @@ function AppRouter() { if (currentPage === 'reader') return ; if (currentPage === 'import') return ; if (currentPage === 'study') return ; + if (currentPage === 'script') return ; + if (currentPage === 'export') return ; if (currentPage === 'setup') return ; if (currentPage === 'pronunciation') return ; return ; diff --git a/src/App.test.jsx b/src/App.test.jsx index 03471cc..0d5f39e 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -135,17 +135,18 @@ describe('Initial render', () => { const user = userEvent.setup(); await renderApp(); await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); - expect(await screen.findByText('Project Setup')).toBeInTheDocument(); + expect(await screen.findByText('New study')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument(); }); - test('setup form shows translation, book, and chapter inputs', async () => { + test('setup form shows book and chapter inputs, and uses the BSB', async () => { const user = userEvent.setup(); await renderApp(); await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); - expect(screen.getByText('Translation')).toBeInTheDocument(); + expect(screen.queryByText('Translation')).not.toBeInTheDocument(); expect(screen.getByText('Book')).toBeInTheDocument(); expect(screen.getByText('Chapter')).toBeInTheDocument(); + expect(screen.getByText(/Berean Standard Bible/)).toBeInTheDocument(); }); }); @@ -200,7 +201,7 @@ describe('Chunk management', () => { test('creates a chunk by clicking a verse range', async () => { await loadChapter(); addChunk('Paul, a servant', 'Grace and peace'); - await screen.findByText(/1[-–]3/); + expect((await screen.findAllByText(/1[-–]3/)).length).toBeGreaterThan(0); }); test('chunk count increments after each addition', async () => { @@ -250,7 +251,7 @@ async function goToStudyWith3Chunks() { addChunk('Grace and peace', 'Grace and peace'); await screen.findByText('3 created'); fireEvent.click(screen.getByRole('button', { name: /begin studying/i })); - await screen.findByText(/chunk editor/i); + await findChunkCounter(3, 3); // addChunk always selects the newest chunk; navigate back to chunk 1 // so navigation tests start from a known position. fireEvent.click(screen.getByRole('button', { name: /previous chunk/i })); @@ -264,7 +265,7 @@ describe('Study view navigation', () => { addChunk('Paul, a servant', 'In hope of eternal'); await screen.findByText('1 created'); fireEvent.click(screen.getByRole('button', { name: /begin studying/i })); - await screen.findByText(/chunk editor/i); + await findChunkCounter(1, 1); }); test('"Previous Chunk" is disabled on the first chunk', async () => { @@ -305,8 +306,9 @@ async function goToStudyAndAddGreekWord(fetchMock) { addChunk('Paul, a servant', 'In hope of eternal'); await screen.findByText('1 created'); fireEvent.click(screen.getByRole('button', { name: /begin studying/i })); - await screen.findByText(/chunk editor/i); - fireEvent.click(screen.getByRole('button', { name: /add greek word/i })); + await findChunkCounter(1, 1); + fireEvent.click(screen.getByRole('tab', { name: /word study/i })); + fireEvent.click(screen.getByRole('button', { name: /add word/i })); await screen.findByPlaceholderText(/G4102, H7225, 4102/i); } @@ -507,12 +509,13 @@ describe('Admin page', () => { // Import page (state is local to ImportPage) // --------------------------------------------------------------------------- describe('Import page', () => { - test('opens from Home, lists fetched translations, and goes back', async () => { + test('opens from Home, uses the BSB (no translation choice), and goes back', async () => { const user = userEvent.setup(); await renderApp(); await user.click(screen.getByRole('button', { name: /import session list/i })); expect(await screen.findByRole('heading', { name: /import session list/i })).toBeInTheDocument(); - expect(await screen.findByRole('option', { name: 'KJV' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'KJV' })).not.toBeInTheDocument(); + expect(screen.getByText(/Berean Standard Bible/)).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /back/i })); expect(await screen.findByRole('button', { name: /import session list/i })).toBeInTheDocument(); }); @@ -625,15 +628,17 @@ describe('Home page', () => { }); // --------------------------------------------------------------------------- -// Project header (ProjectHeader + useShareLink) and the shared read-only view +// Export stage (useShareLink) and the shared read-only view // --------------------------------------------------------------------------- describe('Project header and sharing', () => { const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) }); - test('Export → Markdown downloads a file', async () => { + test('Export stage → Markdown downloads a file', async () => { const user = await loadChapter(); - await user.click(screen.getByRole('button', { name: /export/i })); - await user.click(screen.getByRole('button', { name: /export markdown/i })); + addChunk('Paul, a servant', 'Paul, a servant'); + await screen.findByText('1 created'); + await user.click(screen.getByRole('button', { name: /^export$/i })); + await user.click(await screen.findByRole('button', { name: /export markdown/i })); expect(URL.createObjectURL).toHaveBeenCalled(); }); @@ -647,8 +652,10 @@ describe('Project header and sharing', () => { }); vi.stubGlobal('fetch', fetchMock); const user = await loadChapter(); - await user.click(screen.getByRole('button', { name: /share/i })); - await user.click(screen.getByRole('button', { name: /enable sharing/i })); + addChunk('Paul, a servant', 'Paul, a servant'); + await screen.findByText('1 created'); + await user.click(screen.getByRole('button', { name: /^export$/i })); + await user.click(await screen.findByRole('button', { name: /enable sharing/i })); expect(await screen.findByDisplayValue(/\?share=tok123$/)).toBeInTheDocument(); }); @@ -675,6 +682,7 @@ describe('Study editor', () => { test('study-page messages now show as a status toast', async () => { await goToStudyWith3Chunks(); // Titus is a New Testament book, so the Hebrew picker explains why it can't help. + fireEvent.click(screen.getByRole('tab', { name: /word study/i })); fireEvent.click(screen.getByRole('button', { name: /suggest hebrew/i })); expect(await screen.findByRole('status')).toHaveTextContent(/old testament/i); }); @@ -689,6 +697,7 @@ describe('Study editor', () => { test('a typed cross-reference is added, and the input clears on chunk change', async () => { await goToStudyWith3Chunks(); + fireEvent.click(screen.getByRole('tab', { name: /cross-refs/i })); const input = screen.getByPlaceholderText(/john 1:1 or rom 3:23/i); fireEvent.change(input, { target: { value: 'Rom 3:23' } }); fireEvent.keyDown(input, { key: 'Enter' }); @@ -700,6 +709,7 @@ describe('Study editor', () => { test('a tag can be added with Enter', async () => { await goToStudyWith3Chunks(); + fireEvent.click(screen.getByRole('button', { name: /^more/i })); const input = screen.getByPlaceholderText(/add a tag/i); fireEvent.change(input, { target: { value: 'grace' } }); fireEvent.keyDown(input, { key: 'Enter' }); @@ -722,49 +732,152 @@ describe('Chunk builder', () => { test('"Add Chapter" starts on the next chapter and adds it as a tab', async () => { const user = await loadChapter(); - expect(await addSecondChapter(user)).toHaveClass('bg-slate-900'); + expect(await addSecondChapter(user)).toHaveAttribute('aria-current', 'true'); }); - test('"Back to Chunks" reopens the chapter of the chunk being studied', async () => { + test('the Outline stage reopens the chapter of the chunk being studied', async () => { const user = await loadChapter(); await addSecondChapter(user); addChunk('Paul, a servant', 'In hope of eternal'); // chunk in Titus 2 (now selected) await user.click(screen.getByRole('button', { name: 'Titus 1' })); await user.click(screen.getByRole('button', { name: /begin studying/i })); - await screen.findByText(/chunk editor/i); - await user.click(screen.getByRole('button', { name: /back to chunks/i })); - expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveClass('bg-slate-900'); + await findChunkCounter(1, 1); + await user.click(screen.getByRole('button', { name: /^outline$/i })); + expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveAttribute('aria-current', 'true'); }); }); // --------------------------------------------------------------------------- -// Header redesign: Tools menu + one-dropdown-at-a-time +// Stage bar (Outline / Study / Script / Export) // --------------------------------------------------------------------------- -describe('Header menus', () => { - test('Tools is disabled until a chunk exists, then opens the Pronunciation Guide', async () => { - const user = await loadChapter(); - expect(screen.getByRole('button', { name: /tools/i })).toBeDisabled(); +describe('Stage bar', () => { + test('later stages are disabled until a chunk exists', async () => { + await loadChapter(); + for (const name of [/^study$/i, /^script$/i, /^export$/i]) { + expect(screen.getByRole('button', { name })).toBeDisabled(); + } + expect(screen.getByRole('button', { name: /^outline$/i })).not.toBeDisabled(); addChunk('Paul, a servant', 'Paul, a servant'); await screen.findByText('1 created'); - await user.click(screen.getByRole('button', { name: /tools/i })); - await user.click(screen.getByRole('button', { name: /pronunciation guide/i })); - expect(await screen.findByRole('heading', { name: /pronunciation guide/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^study$/i })).not.toBeDisabled(); }); - test('opening one dropdown closes the other; Escape and outside clicks close it', async () => { + test('Script stage opens the Pronunciation Guide, and back returns to Script', async () => { const user = await loadChapter(); - await user.click(screen.getByRole('button', { name: /export/i })); - expect(screen.getByRole('button', { name: /export markdown/i })).toBeInTheDocument(); + addChunk('Paul, a servant', 'Paul, a servant'); + await screen.findByText('1 created'); + await user.click(screen.getByRole('button', { name: /^script$/i })); + await user.click(await screen.findByRole('button', { name: /pronunciation guide/i })); + expect(await screen.findByRole('heading', { name: /pronunciation guide/i })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /back to script/i })); + expect(await screen.findByLabelText(/final script/i)).toBeInTheDocument(); + }); - await user.click(screen.getByRole('button', { name: /share/i })); - expect(screen.queryByRole('button', { name: /export markdown/i })).not.toBeInTheDocument(); - expect(screen.getByText(/read-only share link/i)).toBeInTheDocument(); + test('a script written in the Script stage is kept on the chunk', async () => { + const user = await loadChapter(); + addChunk('Paul, a servant', 'Paul, a servant'); + await screen.findByText('1 created'); + await user.click(screen.getByRole('button', { name: /^script$/i })); + const script = await screen.findByLabelText(/final script/i); + fireEvent.change(script, { target: { value: 'Welcome to the episode.' } }); + await user.click(screen.getByRole('button', { name: /^study$/i })); + await user.click(screen.getByRole('button', { name: /^script$/i })); + expect(await screen.findByDisplayValue('Welcome to the episode.')).toBeInTheDocument(); + }); +}); - await user.keyboard('{Escape}'); - expect(screen.queryByText(/read-only share link/i)).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: /export/i })); - await user.click(screen.getByText(/scripture & chunks/i)); - expect(screen.queryByRole('button', { name: /export markdown/i })).not.toBeInTheDocument(); +// --------------------------------------------------------------------------- +// Study workspace: outline editing, notes accordion, and the resource tabs +// --------------------------------------------------------------------------- +describe('Study workspace', () => { + const okJson = (data) => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(data), headers: { get: () => 'application/json' } }); + // Wraps the default mock so individual tests can answer specific URLs. + const withRoutes = (routes) => { + const base = buildFetchMock(); + return vi.fn((url, opts) => { + for (const [needle, data] of routes) if (String(url).includes(needle)) return okJson(data); + return base(url, opts); + }); + }; + async function studyOneChunk(routes = []) { + if (routes.length) vi.stubGlobal('fetch', withRoutes(routes)); + const user = await loadChapter(); + addChunk('Paul, a servant', 'In hope of eternal'); + await screen.findByText('1 created'); + await user.click(screen.getByRole('button', { name: /begin studying/i })); + await findChunkCounter(1, 1); + return user; + } + + test('outline: clicking between verses ends a chunk; clicking again joins them', async () => { + const user = await loadChapter(); + await user.click(screen.getByRole('button', { name: /end a chunk after verse 1/i })); + await screen.findByText('1 created'); + await user.click(screen.getByRole('button', { name: /end a chunk after verse 2/i })); + await screen.findByText('2 created'); + await user.click(screen.getByRole('button', { name: /join chunks at verse 1/i })); + await screen.findByText('1 created'); + expect((await screen.findAllByText(/1[-–]2/)).length).toBeGreaterThan(0); + }); + + test('outline: "Suggest from headings" fills the chapter, and never touches existing chunks', async () => { + const user = await loadChapter(); + addChunk('Paul, a servant', 'Paul, a servant'); + await screen.findByText('1 created'); + await user.click(screen.getByRole('button', { name: /suggest from headings/i })); + await screen.findByText('2 created'); + expect(screen.getByRole('status')).toHaveTextContent(/added 1 chunk/i); + }); + + test('notes: one section is open at a time, and text is kept when switching', async () => { + const user = await studyOneChunk(); + fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'Paul calls himself a servant.' } }); + await user.click(screen.getByRole('button', { name: /interpret/i })); + expect(screen.queryByRole('textbox', { name: 'Observe' })).not.toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Interpret' })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /observe/i })); + expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('Paul calls himself a servant.'); + }); + + test('notes: the outline dots and word count reflect what you wrote', async () => { + await studyOneChunk(); + fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'one two three' } }); + expect(screen.getByText('3 words')).toBeInTheDocument(); + }); + + test('interlinear: clicking an original word adds it to the word study', async () => { + const user = await studyOneChunk([['/interlinear/TIT.json', { 1: { 1: [{ o: 'Παῦλος', t: 'Paulos', g: 'Paul', s: 'G3972' }] } }]]); + await user.click(await screen.findByRole('button', { name: '1' })); // verse number opens its original words + await user.click(await screen.findByRole('button', { name: /Παῦλος/ })); + expect(await screen.findByRole('status')).toHaveTextContent(/added/i); + expect(screen.getByRole('tab', { name: /word study · 1/i })).toBeInTheDocument(); + expect(within(screen.getByRole('tabpanel')).getByRole('button', { name: /Παῦλος/ })).toBeInTheDocument(); // the new card + }); + + test('commentary: "Add to notes" quotes a paragraph with its citation', async () => { + const user = await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]); + await user.click(await screen.findByRole('button', { name: /add to notes/i })); + expect(await screen.findByRole('status')).toHaveTextContent(/background notes/i); + await user.click(screen.getByRole('button', { name: /^more/i })); + expect(screen.getByLabelText(/background/i)).toHaveValue('Paul was an apostle. (Matthew Henry, Titus 1:1)'); + }); + + test('people & places: lists what is tagged in the chunk', async () => { + const user = await studyOneChunk([['/d/theographic/TIT/1.json', { chapter: { people: [{ id: 'paul_1', name: 'Paul', verses: [1] }, { id: 'x_2', name: 'Elsewhere', verses: [9] }], places: [], events: [] } }]]); + await user.click(screen.getByRole('tab', { name: /people & places/i })); + const panel = screen.getByRole('tabpanel'); + expect(await within(panel).findByRole('button', { name: /paul/i })).toBeInTheDocument(); + expect(within(panel).queryByText('Elsewhere')).not.toBeInTheDocument(); + }); + + test('script: "Build outline from notes" starts the script from your notes', async () => { + const user = await studyOneChunk(); + fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'Paul greets Titus.' } }); + await user.click(screen.getByRole('button', { name: /^script$/i })); + await user.click(await screen.findByRole('button', { name: /build outline from notes/i })); + const script = screen.getByLabelText(/final script/i); + expect(script.value).toContain('WHAT THE TEXT SAYS\nPaul greets Titus.'); + expect(screen.getByText(/\d+ words · about/)).toBeInTheDocument(); }); }); diff --git a/src/components/AudioPlayer.jsx b/src/components/AudioPlayer.jsx index 5cfc3d7..3dcb178 100644 --- a/src/components/AudioPlayer.jsx +++ b/src/components/AudioPlayer.jsx @@ -77,11 +77,11 @@ export default function AudioPlayer({ const progressPct = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0; return ( -
+
-
-

{title}

-

{subtitle}

+
+

{title}

+

{subtitle}

{status === 'error' && errorMessage && (

{errorMessage}

)} @@ -99,11 +99,11 @@ export default function AudioPlayer({ value={Math.min(currentTime, duration || 0)} onChange={(e) => onSeekTo?.(Number(e.target.value))} disabled={!duration} - className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-sky-600 disabled:cursor-default" - style={{ background: `linear-gradient(to right, rgb(2 132 199) ${progressPct}%, rgb(226 232 240) ${progressPct}%)` }} + className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-[var(--r-line,#e2e8f0)] accent-[var(--r-accent,#0284c7)] disabled:cursor-default" + style={{ background: `linear-gradient(to right, var(--r-accent, rgb(2 132 199)) ${progressPct}%, var(--r-line, rgb(226 232 240)) ${progressPct}%)` }} aria-label="Seek" /> -
+
{formatTime(currentTime)} {formatTime(duration)}
@@ -118,7 +118,7 @@ export default function AudioPlayer({ onClick={onSeekBack} title="Rewind 10 seconds" aria-label="Rewind 10 seconds" - className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 hover:bg-slate-100" + className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5" > @@ -126,7 +126,7 @@ export default function AudioPlayer({ type="button" onClick={onTogglePause} aria-label={status === 'paused' ? 'Resume' : 'Pause'} - className="flex h-12 w-12 items-center justify-center rounded-full bg-sky-600 text-white shadow-sm hover:bg-sky-500" + className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--r-accent,#0284c7)] text-[var(--r-on,#ffffff)] hover:opacity-90" > {status === 'paused' ? : } @@ -135,7 +135,7 @@ export default function AudioPlayer({ onClick={onSeekForward} title="Fast-forward 10 seconds" aria-label="Fast-forward 10 seconds" - className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 hover:bg-slate-100" + className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5" > @@ -146,7 +146,7 @@ export default function AudioPlayer({ disabled={nextDisabled} title="Next chapter" aria-label="Next chapter" - className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 hover:bg-slate-100 disabled:opacity-30 disabled:hover:bg-transparent" + className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent" > @@ -156,7 +156,7 @@ export default function AudioPlayer({ onClick={onStop} title="Stop" aria-label="Stop" - className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 hover:bg-slate-100" + className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5" > @@ -165,7 +165,7 @@ export default function AudioPlayer({ @@ -19,14 +23,14 @@ export default function AuthStatus() { diff --git a/src/components/ProjectHeader.jsx b/src/components/ProjectHeader.jsx deleted file mode 100644 index 128aad5..0000000 --- a/src/components/ProjectHeader.jsx +++ /dev/null @@ -1,228 +0,0 @@ -// Header for an open project (Setup and Study pages): navigation, Claude -// prompts, pronunciation guide, export menu, share link, and save/sync status. -import { useEffect, useRef, useState } from 'react'; -import { useApp } from '../context/AppContext.js'; -import { useShareLink } from '../hooks/useShareLink.js'; -import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js'; -import { - exportProjectDocx, - exportProjectHtml, - exportProjectMarkdown, - printProjectPdf, -} from '../utils/projectExport.js'; -import AuthStatus from './AuthStatus.jsx'; - -// loading: true while the Setup page is fetching a chapter. -export default function ProjectHeader({ loading = false }) { - const { - authUser, - currentPage, setCurrentPage, - goHome, - project, - allChunks, - saveStatus, setSaveStatus, - syncStatus, - retrySync, - } = useApp(); - // Only one dropdown open at a time: 'tools' | 'export' | 'share' | null - const [openMenu, setOpenMenu] = useState(null); - const toggleMenu = (name) => setOpenMenu((current) => (current === name ? null : name)); - const closeMenu = () => setOpenMenu(null); - const menusRef = useRef(null); - - // Close the open dropdown on an outside click or Escape. - useEffect(() => { - if (!openMenu) return; - const onPointerDown = (e) => { - if (!menusRef.current?.contains(e.target)) setOpenMenu(null); - }; - const onKeyDown = (e) => { if (e.key === 'Escape') setOpenMenu(null); }; - document.addEventListener('mousedown', onPointerDown); - document.addEventListener('keydown', onKeyDown); - return () => { - document.removeEventListener('mousedown', onPointerDown); - document.removeEventListener('keydown', onKeyDown); - }; - }, [openMenu]); - const share = useShareLink(project?.id); - - // Copies a prompt, then opens claude.ai so it can be pasted straight in. - const copyPrompt = (prompt) => { - navigator.clipboard.writeText(prompt).then(() => { - setSaveStatus('Copied! Opening Claude…'); - window.setTimeout(() => setSaveStatus(''), 3000); - window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer'); - }); - }; - - return ( -
- {currentPage !== 'home' && ( -
- {currentPage !== 'home' && ( - - )} - {project && currentPage === 'study' && ( - - )} -
- )} - {project && ( -
-
- - {openMenu === 'tools' && allChunks.length > 0 && ( -
- - - -
- )} -
-
- - {openMenu === 'export' && ( -
- - - - -
- )} -
-
- - {openMenu === 'share' && ( -
-

Read-only share link

-

- Anyone with this link can view (not edit) this study — no account needed. Good for a co-teacher - or a listener who wants to follow along. -

- {share.shareToken ? ( -
-
- e.target.select()} - className="w-full flex-1 truncate rounded-lg border border-slate-300 bg-slate-50 px-2 py-1.5 text-xs text-slate-600" - /> - -
- -
- ) : ( - - )} -
- )} -
-
- )} -
- {loading ? ( - Loading… - ) : saveStatus ? ( - {saveStatus} - ) : ( -   - )} - {syncStatus === 'syncing' &&
Syncing…
} - {syncStatus === 'synced' &&
Synced ✓
} - {syncStatus === 'error' && ( -
- ⚠ Sync failed - -
- )} -
- -
- ); -} diff --git a/src/components/StudyPanel.jsx b/src/components/StudyPanel.jsx index 9de7f21..1c178b7 100644 --- a/src/components/StudyPanel.jsx +++ b/src/components/StudyPanel.jsx @@ -65,7 +65,7 @@ function PersonDetails({ id }) { ); } -function PersonCard({ person }) { +export function PersonCard({ person }) { const [open, setOpen] = useState(false); return (
  • diff --git a/src/components/study/CommentaryChunkTab.jsx b/src/components/study/CommentaryChunkTab.jsx new file mode 100644 index 0000000..0e779ef --- /dev/null +++ b/src/components/study/CommentaryChunkTab.jsx @@ -0,0 +1,56 @@ +// Commentary for the chunk's verse range, from seven open sources. Some sources +// comment on whole passages, so when nothing starts inside the range we show the +// closest earlier entry. "Add to notes" quotes a paragraph, with its citation, into +// the chunk's background notes. +import { COMMENTARIES } from '../../hooks/useVerseStudy.js'; +import { formatChunkReference } from '../../utils/bibleUtils.jsx'; + +export default function CommentaryChunkTab({ resources, chunk, project, chapterIndex, updateChunk, notify }) { + const { commentarySource, setCommentarySource, commentaryData, commentaryLoading, commentaryError } = resources; + const source = COMMENTARIES.find((c) => c.id === commentarySource) ?? COMMENTARIES[0]; + const items = (commentaryData?.chapter?.content ?? []).filter((i) => i.type === 'verse').sort((a, b) => a.number - b.number); + let entries = items.filter((i) => i.number >= chunk.startVerse && i.number <= chunk.endVerse); + let earlier = false; + if (entries.length === 0) { + const before = [...items].reverse().find((i) => i.number <= chunk.startVerse); + if (before) { entries = [before]; earlier = true; } + } + const ref = formatChunkReference(project, chapterIndex, chunk, '–'); + + const quote = (text, verse) => { + const citation = `${text} (${source.name}, ${ref.split(':')[0]}:${verse})`; + const existing = chunk.generalNotes ?? ''; + updateChunk(chunk.id, { generalNotes: existing ? `${existing}\n\n${citation}` : citation }); + notify('Added to background notes.', 1600); + }; + + return ( +
    + + {commentaryLoading &&

    Loading commentary…

    } + {commentaryError &&

    {commentaryError}

    } + {!commentaryLoading && !commentaryError && commentaryData && entries.length === 0 && ( +

    No commentary found for {ref}.

    + )} + {!commentaryLoading && !commentaryError && entries.map((v) => ( +
    +

    {earlier ? `Notes on the passage starting at verse ${v.number}` : `Verse ${v.number}`}

    + {(v.content ?? []).map((p, i) => { + const text = typeof p === 'string' ? p : ''; + if (!text) return null; + return ( +
    +

    {text}

    + +
    + ); + })} +
    + ))} +

    {source.credit}

    +
    + ); +} diff --git a/src/components/study/CrossRefsTab.jsx b/src/components/study/CrossRefsTab.jsx new file mode 100644 index 0000000..cb8c22d --- /dev/null +++ b/src/components/study/CrossRefsTab.jsx @@ -0,0 +1,53 @@ +// Cross-references for the chunk: typed, suggested from the Open Cross Ref +// dataset, and a preview of each reference's verse text. +import { useState } from 'react'; + +function RefChip({ label, onRemove, loadVerseText }) { + const [text, setText] = useState(null); + const [open, setOpen] = useState(false); + const toggle = () => { + setOpen((o) => !o); + if (text == null) loadVerseText(label).then((t) => setText(t || 'Verse text unavailable.')).catch(() => setText('Verse text unavailable.')); + }; + return ( +
    +
    + + +
    + {open &&

    {text ?? 'Loading…'}

    } +
    + ); +} + +export default function CrossRefsTab({ crossRefs, chunk }) { + const { crossRefInput, setCrossRefInput, suggestingCrossRefs, crossRefSuggestions, addCrossRef, removeCrossRef, + suggestCrossRefsForChunk, addSuggestedCrossRef, loadVerseText } = crossRefs; + const refs = chunk.crossReferences ?? []; + return ( +
    +
    + setCrossRefInput(e.target.value)} placeholder="e.g. John 1:1 or Rom 3:23" + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCrossRef(chunk.id); } }} + className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" /> + +
    + + {crossRefSuggestions.length > 0 && ( +
    + {crossRefSuggestions.map((ref) => ( + + ))} +
    + )} +
    + {refs.map((ref) => removeCrossRef(chunk.id, ref)} loadVerseText={loadVerseText} />)} + {refs.length === 0 &&

    No cross-references yet.

    } +
    +
    + ); +} diff --git a/src/components/study/NotesPane.jsx b/src/components/study/NotesPane.jsx new file mode 100644 index 0000000..6f45ea7 --- /dev/null +++ b/src/components/study/NotesPane.jsx @@ -0,0 +1,103 @@ +// Observe / Interpret / Apply as an accordion (one open at a time), plus a "More" +// area for background notes and tags so the page stays quiet. +import { useEffect, useState } from 'react'; + +const SECTIONS = [ + { field: 'observation', label: 'Observe', hint: 'What does the text say?', + placeholder: "What does the text actually say?\n• Who is speaking, and to whom?\n• What key words or phrases repeat?\n• What's the tone, structure, or literary style?" }, + { field: 'interpretation', label: 'Interpret', hint: 'What does it mean?', + placeholder: "What did this mean to its original audience?\n• What's the historical/cultural context?\n• How does it fit the surrounding argument?\n• What does it reveal about God's character?" }, + { field: 'application', label: 'Apply', hint: 'What do I do with it?', + placeholder: 'How should this shape your life today?\n• What attitude or action does this call for?\n• Is there a promise to trust or a warning to heed?\n• Who could you share this with?' }, +]; + +const areaCls = 'w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3.5 py-3 font-serif text-[15px] leading-7 text-[var(--r-text)] placeholder:font-sans placeholder:text-[13px] placeholder:leading-5 placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]'; +const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length; + +export default function NotesPane({ chunk, updateChunk, allTags }) { + const [open, setOpen] = useState(() => { + try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; } + }); + const [more, setMore] = useState(false); + const [tagInput, setTagInput] = useState(''); + useEffect(() => { setTagInput(''); }, [chunk.id]); + useEffect(() => { try { localStorage.setItem('studyNotesOpen', open || ''); } catch { /* storage unavailable */ } }, [open]); + + const tags = chunk.tags ?? []; + const addTag = (tag) => { + const t = tag.trim(); + if (!t || tags.some((x) => x.toLowerCase() === t.toLowerCase())) { setTagInput(''); return; } + updateChunk(chunk.id, { tags: [...tags, t] }); + setTagInput(''); + }; + const suggestions = allTags.filter((t) => !tags.some((x) => x.toLowerCase() === t.toLowerCase())); + + return ( +
    + {SECTIONS.map(({ field, label, hint, placeholder }) => { + const isOpen = open === field; + const n = words(chunk[field]); + return ( +
    + + {isOpen && ( +
    +