Files
study-bible-project/src/pages/StudyPage.jsx
T
nmemmertandClaude Sonnet 5.5 7a010fcda8
Build and Push Docker Image / build-and-push (push) Failing after 20s
Resizable right column in the Study stage; start on the chosen page without a flash
The Study stage's right column has a drag handle (mouse, arrow keys, double-click
or Home to reset); the width is remembered per device. The app now starts on the
user's chosen home page from the first render instead of showing My Studies
first. Tests wait on page headings so a lazily loaded page can't be confused
with hidden text from the previous one.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:01:56 -04:00

160 lines
9.3 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The Study stage: the chunk outline on the left, the passage and your
// Observe / Interpret / Apply notes in the middle, and commentary, word study,
// cross-references and people & places on the right — all for the selected chunk.
import { useEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { useCrossRefs } from '../hooks/useCrossRefs.js';
import { useStudyResources } from '../hooks/useStudyResources.js';
import { useWordStudy } from '../hooks/useWordStudy.js';
import { useResizableWidth } from '../hooks/useResizableWidth.js';
import { formatChunkReference, getChunkVerseEntries } from '../utils/bibleUtils.jsx';
import StudioShell from '../components/study/StudioShell.jsx';
import OutlineRail from '../components/study/OutlineRail.jsx';
import ScripturePane from '../components/study/ScripturePane.jsx';
import NotesPane from '../components/study/NotesPane.jsx';
import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.jsx';
import SuggestWordsSheet from '../components/study/SuggestWordsSheet.jsx';
export default function StudyPage() {
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary } = useApp();
// Short-lived status line (word suggestions, lookups, …)
const [statusMessage, setStatusMessage] = useState('');
const statusTimerRef = useRef(null);
const notify = (message, ms = 2000) => {
window.clearTimeout(statusTimerRef.current);
setStatusMessage(message);
statusTimerRef.current = window.setTimeout(() => setStatusMessage(''), ms);
};
useEffect(() => () => window.clearTimeout(statusTimerRef.current), []);
const selectedChunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
const chapterIndex = selectedChunk ? project.chapters.findIndex((ch) => ch.chunks.some((c) => c.id === selectedChunk.id)) : -1;
const chapter = chapterIndex >= 0 ? project.chapters[chapterIndex] : null;
const verses = selectedChunk ? getChunkVerseEntries(project, chapterIndex, selectedChunk) : [];
const globalIndex = allChunks.findIndex((c) => c.id === selectedChunk?.id);
// The right column's width can be dragged (remembered on this device)
const gridRef = useRef(null);
const rightPanelRef = useRef(null);
const rightPanel = useResizableWidth({ storageKey: 'study-right-width', containerRef: gridRef });
const [tab, setTab] = useResourceTab();
const [openWordId, setOpenWordId] = useState(null);
useEffect(() => { setOpenWordId(null); }, [selectedChunk?.id]);
const resources = useStudyResources({
project, selectedChunk, selectedChunkChapterIndex: chapterIndex,
collapsedSections: { commentary: tab !== 'commentary', interlinear: false },
commentarySource: readerCommentary, setCommentarySource: setReaderCommentary,
});
const crossRefs = useCrossRefs({ project, allChunks, selectedChunk, updateChunk, notify });
const wordStudy = useWordStudy({ project, allChunks, updateProject, notify });
const select = (id) => updateProject((current) => ({ ...current, selectedChunkId: id }));
const goPrev = () => { if (globalIndex > 0) select(allChunks[globalIndex - 1].id); };
const goNext = () => { if (globalIndex >= 0 && globalIndex < allChunks.length - 1) select(allChunks[globalIndex + 1].id); };
// Arrow keys move between chunks; Escape closes the word picker
useEffect(() => {
const onKey = (event) => {
const tag = event.target?.tagName;
const editable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target?.isContentEditable;
if (event.key === 'Escape') {
if (wordStudy.suggestModal) { wordStudy.setSuggestModal(null); wordStudy.setSuggestSelection(new Set()); }
return;
}
if (editable || wordStudy.suggestModal) return;
if (event.key === 'ArrowLeft') { event.preventDefault(); goPrev(); }
else if (event.key === 'ArrowRight') { event.preventDefault(); goNext(); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
const allTags = Array.from(new Set([
...projectIndex.flatMap((entry) => entry.tags ?? []),
...(project?.chapters ?? []).flatMap((ch) => ch.chunks ?? []).flatMap((c) => c.tags ?? []),
])).sort((a, b) => a.localeCompare(b));
const addedStrongs = new Set((selectedChunk?.greekWords ?? []).map((w) => (w.strongNumber || '').toUpperCase()).filter(Boolean));
const pickWord = async (w) => {
const id = await wordStudy.addWordFromInterlinear(selectedChunk.id, w);
if (id) { setTab('words'); setOpenWordId(id); }
};
return (
<StudioShell stage="study" fixedHeight>
<div ref={gridRef} style={rightPanel.width ? { '--study-right': `${rightPanel.width}px` } : undefined}
className="flex min-h-0 flex-1 flex-col overflow-y-auto lg:grid lg:grid-cols-[15rem_minmax(0,1fr)_var(--study-right,28rem)] lg:overflow-hidden xl:grid-cols-[16rem_minmax(0,1fr)_var(--study-right,32rem)]">
<aside className="hidden min-h-0 border-r border-[var(--r-line)] bg-[var(--r-bar)] lg:block">
<OutlineRail project={project} selectedId={selectedChunk?.id} onSelect={select} onEditOutline={() => setCurrentPage('setup')} />
</aside>
<main className="min-h-0 lg:overflow-y-auto">
{selectedChunk && chapter ? (
<div className="mx-auto max-w-3xl space-y-4 px-5 py-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-xl font-medium">{formatChunkReference(project, chapterIndex, selectedChunk, '–')}</h1>
<p aria-live="polite" className="mt-0.5 text-xs text-[var(--r-sub)]">{`Chunk ${globalIndex + 1} of ${allChunks.length}`}</p>
{selectedChunk.episodeTitle && (
<p className="text-xs text-[var(--r-accent)]">{selectedChunk.episodeNumber ? `${selectedChunk.episodeNumber}: ` : ''}{selectedChunk.episodeTitle}</p>
)}
</div>
<div className="flex shrink-0 gap-1.5">
<button type="button" onClick={goPrev} disabled={globalIndex <= 0} aria-label="Previous chunk"
className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5 disabled:opacity-40">‹</button>
<button type="button" onClick={goNext} disabled={globalIndex >= allChunks.length - 1} aria-label="Next chunk"
className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5 disabled:opacity-40">›</button>
</div>
</div>
<ScripturePane
key={selectedChunk.id}
verses={verses} chapter={chapter}
interlinear={resources.interlinearData} interlinearLoading={resources.interlinearLoading}
addedStrongs={addedStrongs} onPickWord={pickWord}
/>
</div>
) : (
<p className="p-8 text-center text-sm text-[var(--r-sub)]">Add a chunk in the Outline stage to start studying.</p>
)}
</main>
<aside ref={rightPanelRef} className="relative flex min-h-0 flex-col gap-3 border-t border-[var(--r-line)] bg-[var(--r-bar)] p-3 lg:border-l lg:border-t-0">
{/* Drag handle on the column's left edge (wide screens only) */}
<div {...rightPanel.handleProps(rightPanelRef)}
className="group absolute -left-1.5 top-0 z-20 hidden h-full w-3 cursor-col-resize touch-none select-none focus:outline-none lg:block">
<span className="mx-auto block h-full w-px bg-transparent transition group-hover:w-0.5 group-hover:bg-[var(--r-accent)] group-focus-visible:w-0.5 group-focus-visible:bg-[var(--r-accent)]" />
</div>
{selectedChunk && chapter && (
<>
<section aria-label="Notes" className="flex min-h-[22rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[11]">
<h2 className="shrink-0 border-b border-[var(--r-line)] px-4 py-2.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes</h2>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} />
</div>
</section>
<section aria-label="Resources" className="flex min-h-[24rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[10]">
<ResourcePane
tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project}
updateChunk={updateChunk} notify={notify}
resources={resources} wordStudy={wordStudy} crossRefs={crossRefs}
openWordId={openWordId} setOpenWordId={setOpenWordId}
/>
</section>
</>
)}
</aside>
</div>
<SuggestWordsSheet wordStudy={wordStudy} />
{statusMessage && (
<div role="status" className="fixed bottom-6 left-1/2 z-[60] -translate-x-1/2 rounded-full bg-[var(--r-text)] px-4 py-2 text-sm text-[var(--r-bg)] shadow-lg">
{statusMessage}
</div>
)}
</StudioShell>
);
}