Split App.jsx into pages + add service worker for offline support

App.jsx reduced from ~6,500 to ~3,900 lines by extracting each route into its
own page component (AdminPage, AuthPage, HomePage, ImportPage, ReaderPage,
SettingsPage, SetupPage, StudyPage). State and handlers are shared via
AppContext (React Context API); AppRouter handles routing at module level.

Also adds a network-first service worker (public/sw.js) that caches the last-
fetched Bible chapter data from bible.helloao.org and bolls.life so the app
stays usable when those APIs are temporarily unreachable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-08-12 08:06:48 -04:00
parent 8e8e814775
commit 96b6e840a3
12 changed files with 3360 additions and 2869 deletions
+989
View File
@@ -0,0 +1,989 @@
import { createPortal } from 'react-dom';
import DOMPurify from 'dompurify';
import { useApp } from '../context/AppContext.js';
import { STUDY_TABS, COMMENTARY_OPTIONS, NT_BOOK_NUMBER, formatChunkReference, CrossRefChip } from '../App.jsx';
export default function StudyPage() {
const {
headerButtons,
project,
allChunks,
selectedChunk,
selectedChunkChapterIndex,
selectedChunkChapter,
selectedChunkVerses,
selectedChunkGlobalIndex,
studyLayout, setStudyLayout,
activeStudyTab, setActiveStudyTab,
mobileStudyTab, setMobileStudyTab,
collapsedSections, setCollapsedSections,
interlinearData,
interlinearLoading,
interlinearError,
commentarySource, setCommentarySource,
commentaryData,
commentaryLoading,
commentaryError,
crossRefInput, setCrossRefInput,
suggestingCrossRefs,
crossRefSuggestions,
tagInput, setTagInput,
suggestModal, setSuggestModal,
suggestSelection, setSuggestSelection,
suggestingGreekForChunkId,
suggestingHebrewForChunkId,
projectIndex,
goToPreviousChunk,
goToNextChunk,
updateProject,
updateChunk,
addCrossRef,
removeCrossRef,
suggestCrossRefsForChunk,
addSuggestedCrossRef,
addTag,
removeTag,
addGreekWord,
removeGreekWord,
updateChunkWord,
lookupWord,
suggestGreekWordsForChunk,
suggestHebrewWordsForChunk,
confirmSuggestWords,
speakOriginalWord,
externalLookupLinks,
buildGreekDefinitionHtml,
loadVerseText,
importFinalScriptDocx,
formatChunkReference: _ignored,
} = useApp();
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
<div>
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-2 text-2xl font-semibold">{project?.title ?? ''}</h1>
{selectedChunk && selectedChunkChapterIndex >= 0 && (
<p className="mt-1 text-sm text-slate-300">
{formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '')}
</p>
)}
</div>
{headerButtons}
</div>
</header>
<main className="mx-auto max-w-7xl px-4 py-4 sm:py-8 sm:px-6 lg:px-8">
<section className={`grid min-w-0 gap-6 ${studyLayout === 'split' ? '' : 'lg:grid-cols-[260px_1fr]'}`}>
{/* Sidebar */}
<aside className={`min-w-0 rounded-3xl border border-slate-200 bg-white p-4 sm:p-6 shadow-panel max-sm:hidden ${studyLayout === 'split' ? 'hidden' : ''}`}>
<div className="mb-4">
<p className="text-sm font-medium text-slate-500">Chunk Navigation</p>
<h2 className="mt-2 text-xl font-semibold text-slate-900">{allChunks.length} chunks</h2>
</div>
{allChunks.length > 6 && (
<label className="mb-4 block text-xs font-semibold uppercase tracking-wide text-slate-500">
Jump to chunk
<select
value={project?.selectedChunkId ?? ''}
onChange={(e) => updateProject((current) => ({ ...current, selectedChunkId: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm font-normal normal-case text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
{project?.chapters.map((ch, chapterIndex) =>
ch.chunks.map((chunk) => (
<option key={chunk.id} value={chunk.id}>
{formatChunkReference(project, chapterIndex, chunk, '')}
</option>
)),
)}
</select>
</label>
)}
<div className="space-y-6 max-h-[calc(100vh-260px)] overflow-y-auto scrollbar-thin">
{project?.chapters.map((ch, chapterIndex) => (
<div key={`${ch.bookAbbrev}-${ch.chapter}`}>
{project.chapters.length > 1 && (
<p className="mb-2 text-xs font-semibold uppercase tracking-widest text-slate-400">
{ch.book} {ch.chapter}
</p>
)}
<div className="space-y-2">
{ch.chunks.length === 0 ? (
<div className="rounded-3xl border border-dashed border-slate-200 p-3 text-xs text-slate-400">
No chunks in this chapter.
</div>
) : (
ch.chunks.map((chunk) => {
const isSelected = project.selectedChunkId === chunk.id;
const hasContent =
(chunk.observation ?? '').trim() ||
(chunk.interpretation ?? '').trim() ||
(chunk.application ?? '').trim() ||
chunk.greekWords.length > 0;
return (
<button
type="button"
key={chunk.id}
onClick={() => updateProject((current) => ({ ...current, selectedChunkId: chunk.id }))}
className={`group flex w-full flex-col gap-1 rounded-3xl border-l-4 p-3 text-left transition ${
isSelected
? 'border-amber-400 bg-amber-50'
: 'border-transparent border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50'
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold text-slate-900">
{formatChunkReference(project, chapterIndex, chunk, '')}
</span>
{hasContent ? <span className="text-emerald-600"></span> : null}
</div>
<p className="text-xs leading-relaxed text-slate-600 truncate">
{ch.verses.find((v) => v.number === chunk.startVerse)?.text || ''}
</p>
</button>
);
})
)}
</div>
</div>
))}
</div>
</aside>
{/* Chunk editor */}
<div className="min-w-0 rounded-3xl border border-slate-200 bg-white p-4 sm:p-6 shadow-panel">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-medium text-slate-500">Chunk editor</p>
<h2 className="mt-2 text-xl font-semibold text-slate-900">
{selectedChunk
? `Section ${formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '')}`
: 'Select a chunk'}
</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{selectedChunk && (
<>
<button
type="button"
onClick={goToPreviousChunk}
disabled={selectedChunkGlobalIndex <= 0}
className="rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
>
Prev
</button>
<div className="rounded-2xl bg-slate-100 px-3 py-2 text-sm text-slate-700">
{`Chunk ${selectedChunkGlobalIndex + 1} of ${allChunks.length}`}
</div>
<button
type="button"
onClick={goToNextChunk}
disabled={selectedChunkGlobalIndex >= allChunks.length - 1}
className="rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
>
Next
</button>
</>
)}
{!selectedChunk && (
<div className="rounded-2xl bg-slate-100 px-3 py-2 text-sm text-slate-700">
Choose a chunk to study.
</div>
)}
<button
type="button"
onClick={() => setStudyLayout((m) => (m === 'split' ? 'stacked' : 'split'))}
className="max-sm:hidden rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400"
title="Toggle two-pane study layout"
>
{studyLayout === 'split' ? '☰ Stacked View' : '◫ Split View'}
</button>
</div>
</div>
{/* Mobile tab bar */}
<div className="sm:hidden -mx-4 mt-4 flex overflow-x-auto border-b border-slate-200">
{[
{ id: 'scripture', label: 'Scripture' },
{ id: 'notes', label: 'Notes' },
{ id: 'crossRefs', label: 'Refs' },
{ id: 'wordStudy', label: 'Words' },
{ id: 'commentary', label: 'Commentary' },
{ id: 'script', label: 'Script' },
].map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setMobileStudyTab(tab.id)}
className={`shrink-0 border-b-2 px-4 py-2.5 text-sm font-semibold transition ${
mobileStudyTab === tab.id
? 'border-slate-900 text-slate-900'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{tab.label}
</button>
))}
</div>
{selectedChunk ? (
<div className="mt-6 space-y-6">
<div className={`space-y-6 ${studyLayout === 'split' ? 'lg:flex lg:items-start lg:gap-6 lg:space-y-0' : ''}`}>
{/* Scripture */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'scripture' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' ? 'lg:sticky lg:top-6 lg:flex-1 lg:basis-0 lg:min-w-0 lg:self-start' : ''}`}>
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-700">Scripture</p>
<p className="text-xs text-slate-500">Read-only passage for the selected chunk.</p>
</div>
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs uppercase tracking-[0.18em] text-slate-500">
{formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '')}
</span>
</div>
<div className="space-y-3 font-serif text-slate-800">
{selectedChunkVerses
.map((verse) => (
<p key={`${verse.chapter}-${verse.number}`} className="leading-relaxed">
<span className="font-semibold text-slate-700">{verse.chapter}:{verse.number}.</span> {verse.text}
</p>
))}
</div>
{/* Interlinear */}
<div className="mt-4 border-t border-slate-200 pt-4">
<button
type="button"
onClick={() => setCollapsedSections((c) => ({ ...c, interlinear: !c.interlinear }))}
className="mb-2 flex w-full items-center justify-between gap-2 text-left"
>
<h3 className="text-sm font-semibold text-slate-900">Interlinear</h3>
<span className="text-slate-400">{collapsedSections.interlinear ? '▸' : '▾'}</span>
</button>
{!collapsedSections.interlinear && (
<div
className="space-y-2 font-serif text-slate-800"
dir={selectedChunkChapter && !NT_BOOK_NUMBER[selectedChunkChapter.bookAbbrev] ? 'rtl' : 'ltr'}
>
{interlinearLoading && <p className="text-sm font-sans text-slate-500">Loading interlinear text...</p>}
{interlinearError && <p className="text-sm font-sans text-rose-500">{interlinearError}</p>}
{!interlinearLoading && !interlinearError && interlinearData
&& Array.from(
{ length: selectedChunk.endVerse - selectedChunk.startVerse + 1 },
(_, i) => selectedChunk.startVerse + i,
)
.filter((num) => interlinearData[String(num)])
.map((num) => (
<div key={num} className="leading-relaxed">
<span className="font-sans font-semibold text-slate-700">{selectedChunkChapter.chapter}:{num}.</span>{' '}
<span className="mt-1 inline-flex flex-wrap gap-x-3 gap-y-2 align-top">
{interlinearData[String(num)].map((w, i) => (
<span key={i} className="inline-flex flex-col items-center text-center" title={[w.t, w.s, w.p].filter(Boolean).join(' · ')}>
<span className="text-base">{w.o}</span>
<span className="font-sans text-[11px] leading-tight text-slate-500">{w.g || '—'}</span>
</span>
))}
</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Right column (study panels) */}
<div className={`space-y-6 ${studyLayout === 'split' ? 'lg:flex-1 lg:basis-0 lg:min-w-0' : ''}`}>
{studyLayout === 'split' && (
<div className="sticky top-0 z-10 flex flex-wrap gap-2 rounded-3xl border border-slate-200 bg-white p-2 shadow-sm">
{STUDY_TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveStudyTab(tab.id)}
className={`rounded-2xl px-4 py-2 text-sm font-semibold transition ${
activeStudyTab === tab.id
? 'bg-slate-900 text-white'
: 'text-slate-600 hover:bg-slate-100'
}`}
>
{tab.label}
</button>
))}
</div>
)}
{/* Session metadata */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'notes' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' && activeStudyTab !== 'notes' ? 'hidden' : ''}`}>
<h3 className="text-sm font-semibold text-slate-900">Session Info</h3>
<p className="text-xs text-slate-500">
Optional only fill this in if this chunk is part of a numbered series (a podcast episode,
sermon, or class session). Used to label it in exports and in "Prepare for Podcast."
</p>
<div className="mt-3 flex flex-col gap-3 sm:flex-row">
<input
type="text"
value={selectedChunk?.episodeNumber ?? ''}
onChange={(e) => updateChunk(selectedChunk.id, { episodeNumber: e.target.value })}
placeholder="Session #"
className="w-full rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 sm:w-32"
/>
<input
type="text"
value={selectedChunk?.episodeTitle ?? ''}
onChange={(e) => updateChunk(selectedChunk.id, { episodeTitle: e.target.value })}
placeholder="Session title"
className="w-full rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</div>
</div>
{/* Background / general notes */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'notes' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' && activeStudyTab !== 'notes' ? 'hidden' : ''}`}>
<button
type="button"
onClick={() => setCollapsedSections((c) => ({ ...c, generalNotes: !c.generalNotes }))}
className="flex w-full items-center justify-between gap-2 text-left"
>
<div>
<h3 className="text-sm font-semibold text-slate-900">Background / General Notes</h3>
<p className="text-xs text-slate-500">Context, history, authorship, themes for this chunk.</p>
</div>
<span className="text-slate-400">{collapsedSections.generalNotes ? '▸' : '▾'}</span>
</button>
{!collapsedSections.generalNotes && (
<textarea
value={selectedChunk?.generalNotes ?? ''}
onChange={(e) => updateChunk(selectedChunk.id, { generalNotes: e.target.value })}
rows={4}
placeholder="e.g. author, date, audience, historical context, key themes…"
className="mt-4 w-full resize-y rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm leading-6 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
)}
</div>
{/* OIA Notes */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 space-y-4 ${mobileStudyTab !== 'notes' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' && activeStudyTab !== 'notes' ? 'hidden' : ''}`}>
<button
type="button"
onClick={() => setCollapsedSections((c) => ({ ...c, oia: !c.oia }))}
className="flex w-full items-center justify-between gap-2 text-left"
>
<div>
<h3 className="text-sm font-semibold text-slate-900">Study Notes (OIA)</h3>
<p className="text-xs text-slate-500">Observation · Interpretation · Application</p>
</div>
<span className="text-slate-400">{collapsedSections.oia ? '▸' : '▾'}</span>
</button>
{!collapsedSections.oia && [
{
field: 'observation',
label: 'Observation',
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: 'Interpretation',
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: 'Application',
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?',
},
].map(({ field, label, placeholder }) => (
<div key={field}>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wider text-slate-500">
{label}
</label>
<textarea
value={selectedChunk[field] ?? ''}
onChange={(e) => updateChunk(selectedChunk.id, { [field]: e.target.value })}
rows={4}
placeholder={placeholder}
className="w-full resize-y rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm leading-6 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</div>
))}
</div>
{/* Tags */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'notes' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' && activeStudyTab !== 'notes' ? 'hidden' : ''}`}>
<h3 className="text-sm font-semibold text-slate-900">Tags</h3>
<p className="mb-3 text-xs text-slate-500">Label this chunk by topic so you can search across studies later.</p>
<div className="flex flex-wrap items-center gap-2">
{(selectedChunk.tags ?? []).map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold text-indigo-700"
>
{tag}
<button
type="button"
onClick={() => removeTag(selectedChunk.id, tag)}
className="text-indigo-400 transition hover:text-indigo-700"
aria-label={`Remove tag ${tag}`}
>
×
</button>
</span>
))}
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); addTag(selectedChunk.id); }
}}
placeholder="Add a tag and press Enter"
list="tag-suggestions"
className="min-w-[10rem] flex-1 rounded-2xl border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</div>
{(() => {
const used = new Set((selectedChunk.tags ?? []).map((t) => t.toLowerCase()));
const allTags = Array.from(
new Set([
...projectIndex.flatMap((entry) => entry.tags ?? []),
...(project?.chapters ?? []).flatMap((ch) => ch.chunks ?? []).flatMap((c) => c.tags ?? []),
]),
)
.filter((t) => !used.has(t.toLowerCase()))
.sort((a, b) => a.localeCompare(b));
if (allTags.length === 0) return null;
return (
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-xs text-slate-500">Suggestions:</span>
{allTags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => updateChunk(selectedChunk.id, { tags: [...(selectedChunk.tags ?? []), tag] })}
className="rounded-full border border-indigo-200 px-3 py-1 text-xs font-semibold text-indigo-600 transition hover:bg-indigo-50"
>
+ {tag}
</button>
))}
</div>
);
})()}
<datalist id="tag-suggestions">
{Array.from(
new Set([
...projectIndex.flatMap((entry) => entry.tags ?? []),
...(project?.chapters ?? []).flatMap((ch) => ch.chunks ?? []).flatMap((c) => c.tags ?? []),
]),
).map((tag) => (
<option key={tag} value={tag} />
))}
</datalist>
</div>
{/* Cross-references */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'crossRefs' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' && activeStudyTab !== 'crossRefs' ? 'hidden' : ''}`}>
<button
type="button"
onClick={() => setCollapsedSections((c) => ({ ...c, crossRefs: !c.crossRefs }))}
className="mb-3 flex w-full items-center justify-between gap-2 text-left"
>
<h3 className="text-sm font-semibold text-slate-900">Cross-References</h3>
<span className="text-slate-400">{collapsedSections.crossRefs ? '▸' : '▾'}</span>
</button>
{!collapsedSections.crossRefs && <>
<div className="flex gap-2">
<input
type="text"
value={crossRefInput}
onChange={(e) => setCrossRefInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCrossRef(selectedChunk.id); } }}
placeholder="e.g. John 1:1 or Rom 3:23"
className="flex-1 rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
<button
type="button"
onClick={() => addCrossRef(selectedChunk.id)}
className="rounded-2xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-800"
>
Add
</button>
</div>
<button
type="button"
onClick={() => suggestCrossRefsForChunk(selectedChunk.id)}
disabled={suggestingCrossRefs}
className="mt-2 rounded-2xl border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-semibold text-amber-700 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-60"
>
{suggestingCrossRefs ? 'Suggesting...' : '✨ Suggest from passage'}
</button>
{crossRefSuggestions.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{crossRefSuggestions.map((ref) => (
<button
key={ref}
type="button"
onClick={() => addSuggestedCrossRef(selectedChunk.id, ref)}
className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-white px-3 py-1 text-sm text-amber-700 transition hover:bg-amber-100"
>
+ {ref}
</button>
))}
</div>
)}
{(selectedChunk.crossReferences ?? []).length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{selectedChunk.crossReferences.map((ref) => (
<CrossRefChip
key={ref}
label={ref}
onRemove={() => removeCrossRef(selectedChunk.id, ref)}
loadVerseText={loadVerseText}
/>
))}
</div>
)}
</>}
</div>
{/* Greek/Hebrew word studies */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'wordStudy' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' && activeStudyTab !== 'wordStudy' ? 'hidden' : ''}`}>
<button
type="button"
onClick={() => setCollapsedSections((c) => ({ ...c, greek: !c.greek }))}
className="mb-5 flex w-full items-center justify-between gap-4 text-left"
>
<div>
<h3 className="text-sm font-semibold text-slate-900">Word Studies (Greek/Hebrew)</h3>
<p className="text-sm text-slate-500">Add lexical notes, look up Strong's entries, and suggest words from the passage.</p>
</div>
<span className="text-slate-400 shrink-0">{collapsedSections.greek ? '' : ''}</span>
</button>
{!collapsedSections.greek && <>
<div className="mb-5 flex items-center justify-end gap-4">
<div className="flex gap-2">
<button
type="button"
onClick={() => suggestGreekWordsForChunk(selectedChunk.id)}
disabled={suggestingGreekForChunkId === selectedChunk.id}
className="rounded-full bg-amber-100 px-4 py-2 text-sm font-semibold text-amber-800 border border-amber-300 transition hover:bg-amber-200 disabled:opacity-50 disabled:cursor-not-allowed"
title="Auto-populate Greek words that actually appear in this passage"
>
{suggestingGreekForChunkId === selectedChunk.id ? 'Suggesting' : ' Suggest from passage'}
</button>
<button
type="button"
onClick={() => suggestHebrewWordsForChunk(selectedChunk.id)}
disabled={suggestingHebrewForChunkId === selectedChunk.id}
className="rounded-full bg-emerald-100 px-4 py-2 text-sm font-semibold text-emerald-800 border border-emerald-300 transition hover:bg-emerald-200 disabled:opacity-50 disabled:cursor-not-allowed"
title="Suggest Hebrew words for Old Testament passages"
>
{suggestingHebrewForChunkId === selectedChunk.id ? 'Suggesting' : ' Suggest Hebrew'}
</button>
<button
type="button"
onClick={() => addGreekWord(selectedChunk.id)}
className="rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-800"
>
Add Greek Word
</button>
</div>
</div>
<div className="space-y-4">
{selectedChunk.greekWords.length === 0 ? (
<div className="rounded-3xl border border-dashed border-slate-300 bg-white p-5 text-sm text-slate-500">
No words yet. Add one to begin a lookup.
</div>
) : (
selectedChunk.greekWords.map((word) => (
<div key={word.id} className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="grid gap-4 sm:grid-cols-[1.4fr_1fr]">
<label className="text-sm text-slate-600">
Strong's number or English gloss
<input
type="text"
value={word.query}
onChange={(e) => updateChunkWord(selectedChunk.id, word.id, { query: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); lookupWord(selectedChunk.id, word.id, 'greek'); }
}}
placeholder="G4102, H7225, 4102, or a word"
className="mt-2 block w-full rounded-2xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
<div className="flex items-end justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => lookupWord(selectedChunk.id, word.id, 'greek')}
className="inline-flex items-center justify-center rounded-2xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-800"
>
{word.loading ? 'Looking up...' : 'Look Up Greek'}
</button>
<button
type="button"
onClick={() => lookupWord(selectedChunk.id, word.id, 'hebrew')}
className="inline-flex items-center justify-center rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400"
>
{word.loading ? 'Looking up...' : 'Look Up Hebrew'}
</button>
</div>
<button
type="button"
onClick={() => removeGreekWord(selectedChunk.id, word.id)}
className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-semibold text-rose-700 transition hover:bg-rose-100"
>
Delete
</button>
</div>
</div>
{word.strongNumber ? (
<div className="mt-3 flex items-center gap-2">
<span
className="group relative inline-flex cursor-default items-center rounded-full bg-sky-100 px-2.5 py-0.5 text-xs font-semibold text-sky-700"
title={word.shortDefinition || word.englishGloss || ''}
>
{word.strongNumber}
{(word.shortDefinition || word.englishGloss) && (
<span className="pointer-events-none absolute bottom-full left-0 z-20 mb-1 hidden w-max max-w-xs rounded-xl bg-slate-900 px-3 py-2 text-xs font-normal leading-5 text-white shadow-lg group-hover:block">
{word.shortDefinition || word.englishGloss}
</span>
)}
</span>
</div>
) : null}
<div className="mt-4 grid gap-4 sm:grid-cols-3">
<label className="text-sm text-slate-600">
<span className="flex items-center gap-2">
{word.strongNumber?.startsWith('H') ? 'Hebrew word' : 'Greek word'}
{word.lexeme && (
<button
type="button"
onClick={() => speakOriginalWord(word.lexeme, word.strongNumber)}
className="rounded-lg bg-sky-100 px-2 py-0.5 text-[11px] font-medium text-sky-700 hover:bg-sky-200 transition"
title="Hear pronunciation"
>
🔊 Speak
</button>
)}
</span>
<input
type="text"
value={word.lexeme}
onChange={(e) => updateChunkWord(selectedChunk.id, word.id, { lexeme: e.target.value })}
className="mt-2 block w-full rounded-2xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
<label className="text-sm text-slate-600">
Transliteration
<input
type="text"
value={word.transliteration}
onChange={(e) => updateChunkWord(selectedChunk.id, word.id, { transliteration: e.target.value })}
className="mt-2 block w-full rounded-2xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
<label className="text-sm text-slate-600">
Part of speech
<input
type="text"
value={word.partOfSpeech}
onChange={(e) => updateChunkWord(selectedChunk.id, word.id, { partOfSpeech: e.target.value })}
placeholder="e.g. Noun"
className="mt-2 block w-full rounded-2xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<label className="text-sm text-slate-600">
English word
<input
type="text"
value={word.englishGloss ?? ''}
onChange={(e) => updateChunkWord(selectedChunk.id, word.id, { englishGloss: e.target.value })}
placeholder="e.g. grace"
className="mt-2 block w-full rounded-2xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
<label className="text-sm text-slate-600">
Short definition
<input
type="text"
value={word.shortDefinition}
onChange={(e) => updateChunkWord(selectedChunk.id, word.id, { shortDefinition: e.target.value })}
className="mt-2 block w-full rounded-2xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
</div>
{word.definitionHtml ? (
<details className="group mt-4 rounded-3xl border border-slate-200 bg-slate-50 p-4">
<summary className="cursor-pointer text-sm font-semibold text-slate-900 transition hover:text-slate-700">
Extended definition
</summary>
<div
className="mt-3 text-sm leading-6 text-slate-700"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(word.definitionHtml) }}
/>
</details>
) : null}
{(word.shortDefinition === 'No definition found.' || word.shortDefinition === 'Lookup failed.') && !word.lexeme && !word.definitionHtml && word.query.trim() ? (
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-4">
<p className="mb-2 text-xs font-semibold text-amber-800">
Not found in BDBT look it up manually:
</p>
<div className="flex flex-wrap gap-2">
{externalLookupLinks(word.query).map(({ label, url }) => (
<a
key={label}
href={url}
target="_blank"
rel="noreferrer"
className="rounded-lg border border-amber-300 bg-white px-3 py-1 text-xs font-medium text-amber-900 transition hover:bg-amber-100"
>
{label}
</a>
))}
<button
type="button"
onClick={() => navigator.clipboard.writeText(word.query.trim())}
className="rounded-lg border border-amber-300 bg-white px-3 py-1 text-xs font-medium text-amber-900 transition hover:bg-amber-100"
>
Copy "{word.query.trim()}"
</button>
</div>
</div>
) : null}
</div>
))
)}
</div>
</>}
</div>
{/* Commentary */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'commentary' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' && activeStudyTab !== 'commentary' ? 'hidden' : ''}`}>
<button
type="button"
onClick={() => setCollapsedSections((c) => ({ ...c, commentary: !c.commentary }))}
className="mb-3 flex w-full items-center justify-between gap-2 text-left"
>
<h3 className="text-sm font-semibold text-slate-900">Commentary</h3>
<span className="text-slate-400">{collapsedSections.commentary ? '▸' : '▾'}</span>
</button>
{!collapsedSections.commentary && <>
<select
value={commentarySource}
onChange={(e) => setCommentarySource(e.target.value)}
className="mb-3 block w-full rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
{COMMENTARY_OPTIONS.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
{commentaryLoading && <p className="text-sm text-slate-500">Loading commentary...</p>}
{commentaryError && <p className="text-sm text-rose-500">{commentaryError}</p>}
{!commentaryLoading && !commentaryError && commentaryData && (() => {
const verses = (commentaryData.chapter?.content ?? []).filter(
(item) => item.type === 'verse'
&& item.number >= selectedChunk.startVerse
&& item.number <= selectedChunk.endVerse,
);
if (verses.length === 0) {
return <p className="text-sm text-slate-500">No commentary found for this verse range.</p>;
}
return (
<div className="space-y-4">
{verses.map((v) => (
<div key={v.number}>
<p className="text-sm font-semibold text-slate-700">Verse {v.number}</p>
{(v.content ?? []).map((p, i) => (
<p
key={i}
className="mt-1 text-sm text-slate-600 cursor-context-menu"
title="Right-click to add to Background / General Notes"
onContextMenu={(e) => {
e.preventDefault();
const sourceName = COMMENTARY_OPTIONS.find((c) => c.id === commentarySource)?.name ?? commentarySource;
const ref = formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '');
const citation = `${p} (${sourceName}, ${ref}:${v.number})`;
const existing = selectedChunk.generalNotes ?? '';
const next = existing ? `${existing}\n\n${citation}` : citation;
updateChunk(selectedChunk.id, { generalNotes: next });
}}
>
{p}
</p>
))}
</div>
))}
</div>
);
})()}
</>}
</div>
{/* Final episode script */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'script' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' && activeStudyTab !== 'script' ? 'hidden' : ''}`}>
<button
type="button"
onClick={() => setCollapsedSections((c) => ({ ...c, finalScript: !c.finalScript }))}
className="flex w-full items-center justify-between gap-2 text-left"
>
<div>
<h3 className="text-sm font-semibold text-slate-900">Final Script <span className="font-normal text-slate-400">(optional)</span></h3>
<p className="text-xs text-slate-500">Paste the finished script for this chunk once Claude has helped you write it keeps the project as a complete archive. Useful for a podcast, sermon, or any spoken teaching, not required for personal study.</p>
</div>
<span className="text-slate-400">{collapsedSections.finalScript ? '▸' : '▾'}</span>
</button>
{!collapsedSections.finalScript && (
<>
<div className="mt-4 flex items-center gap-3">
<label className="inline-flex cursor-pointer items-center justify-center rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400">
Upload .docx
<input
type="file"
accept=".docx"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
importFinalScriptDocx(selectedChunk.id, file);
e.target.value = '';
}}
/>
</label>
<span className="text-xs text-slate-500">Replaces the text below with the document's text.</span>
</div>
<textarea
value={selectedChunk?.finalScript ?? ''}
onChange={(e) => updateChunk(selectedChunk.id, { finalScript: e.target.value })}
rows={8}
placeholder="Paste the final recorded/recordable script here…"
className="mt-4 w-full resize-y rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm leading-6 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</>
)}
</div>
</div>
</div>
{/* Prev / Next — sticky */}
<div className="sticky bottom-0 z-10 -mx-4 sm:-mx-6 mt-6 flex items-center justify-between gap-3 border-t border-slate-200 bg-white/95 px-4 py-3 backdrop-blur-sm sm:px-6">
<button
type="button"
onClick={goToPreviousChunk}
disabled={selectedChunkGlobalIndex <= 0}
className="rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
>
← Previous Chunk
</button>
<span className="text-xs text-slate-400">
{selectedChunkGlobalIndex + 1} / {allChunks.length}
</span>
<button
type="button"
onClick={goToNextChunk}
disabled={selectedChunkGlobalIndex >= allChunks.length - 1}
className="rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
>
Next Chunk →
</button>
</div>
</div>
) : (
<div className="mt-6 rounded-3xl border border-slate-200 bg-slate-50 p-8 text-center text-slate-500">
<span className="sm:hidden">Use Prev / Next above to pick a chunk.</span>
<span className="hidden sm:inline">Select a chunk from the left panel to edit its notes and Greek studies.</span>
</div>
)}
</div>
</section>
</main>
{/* Greek word picker modal */}
{suggestModal && createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={(e) => { if (e.target === e.currentTarget) { setSuggestModal(null); setSuggestSelection(new Set()); } }}
>
<div className="flex max-h-[80vh] w-full max-w-lg flex-col rounded-3xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
<div>
<h2 className="text-base font-semibold text-slate-900">Select {suggestModal.language === 'hebrew' ? 'Hebrew' : 'Greek'} words to add</h2>
<p className="text-xs text-slate-500 mt-0.5">{suggestModal.helperText || "Content words are pre-checked. Uncheck any you don't need."}</p>
</div>
<button
onClick={() => { setSuggestModal(null); setSuggestSelection(new Set()); }}
className="text-slate-400 hover:text-slate-600 text-xl leading-none"
>×</button>
</div>
<div className="flex items-center gap-3 border-b border-slate-100 px-6 py-2">
<button
onClick={() => setSuggestSelection(new Set(suggestModal.words.map((w) => w.strongKey)))}
className="text-xs text-sky-600 hover:underline"
>Select all</button>
<span className="text-slate-300">·</span>
<button
onClick={() => setSuggestSelection(new Set())}
className="text-xs text-sky-600 hover:underline"
>Select none</button>
<span className="ml-auto text-xs text-slate-400">{suggestSelection.size} selected</span>
</div>
<ul className="flex-1 overflow-y-auto divide-y divide-slate-100 px-4 py-2">
{suggestModal.words.map((w) => (
<li key={w.strongKey}>
<label className="flex cursor-pointer items-center gap-3 py-2.5">
<input
type="checkbox"
checked={suggestSelection.has(w.strongKey)}
onChange={(e) => {
setSuggestSelection((prev) => {
const next = new Set(prev);
e.target.checked ? next.add(w.strongKey) : next.delete(w.strongKey);
return next;
});
}}
className="h-4 w-4 shrink-0 rounded border-slate-300 accent-slate-900"
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="font-semibold text-slate-900">{w.def || w.strongKey}</span>
<span className="text-sm text-slate-500">{w.lexeme}</span>
{w.translit && <span className="text-xs text-slate-400 italic">{w.translit}</span>}
<span className="text-xs font-mono text-slate-300">{w.strongKey}</span>
</div>
{w.entry && (w.entry.strongs_def || w.entry.kjv_def || w.entry.derivation) && (
<details className="mt-1">
<summary
className="cursor-pointer text-xs font-medium text-sky-600 hover:text-sky-700"
onClick={(e) => e.stopPropagation()}
>
Definition
</summary>
<div
className="mt-1 text-xs leading-5 text-slate-600"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(buildGreekDefinitionHtml(w.strongKey, w.entry)) }}
/>
</details>
)}
</div>
</label>
</li>
))}
</ul>
<div className="flex gap-3 border-t border-slate-200 px-6 py-4">
<button
onClick={() => { setSuggestModal(null); setSuggestSelection(new Set()); }}
className="flex-1 rounded-xl border border-slate-200 py-2 text-sm font-semibold text-slate-600 hover:bg-slate-50"
>Cancel</button>
<button
onClick={confirmSuggestWords}
disabled={suggestSelection.size === 0}
className="flex-1 rounded-xl bg-slate-900 py-2 text-sm font-semibold text-white hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed"
>Add {suggestSelection.size > 0 ? `${suggestSelection.size} word${suggestSelection.size > 1 ? 's' : ''}` : 'words'}</button>
</div>
</div>
</div>,
document.body
)}
</div>
);
}