Build and Push Docker Image / build-and-push (push) Failing after 20s
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>
160 lines
9.3 KiB
React
160 lines
9.3 KiB
React
// 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>
|
||
);
|
||
}
|