Files
study-bible-project/src/pages/HomePage.jsx
T
nmemmertandClaude Sonnet 5 787edcbe73
Build and Push Docker Image / build-and-push (push) Successful in 4m33s
Connect reading plans to study/audio, cache offline audio, add journal export
Reading plan days now offer "Study this" (seeds a new chunk-builder project
with the day's full passage and jumps straight into the Study editor) and
"Listen" (starts book-mode audio at the right chapter via a new
handlePlayBookAudioFrom, avoiding a stale-closure bug the reader-state
approach would hit). Fixed the "Listen to BSB Audio" player only rendering
when a study project existed, leaving Listen with nowhere to show up.

The service worker now caches audio.bible.helloao.org (the actual host
serving narrator audio, not just the text/cross-ref API) and no longer
tries to cache 206 Partial Content responses, which the Cache API rejects
and was throwing unhandled rejections for every range-requested audio file.

Added a reading journal export (Settings > My reading journal): gathers
bookmarks, text highlights, and completed plans, fetches their verse text,
and offers a .docx download or a print-to-PDF page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 13:41:40 -04:00

537 lines
25 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.
import { useEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import AuthStatus from '../components/AuthStatus.jsx';
import AudioPlayer from '../components/AudioPlayer.jsx';
import {
bookOptions,
formatChunkReference,
formatRelativeDate,
loadProjectIndex,
} from '../utils/bibleUtils.jsx';
import { loadProjectById } from '../utils/projectStorage.js';
import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js';
export default function HomePage() {
const {
autoRestoredCount,
staleLocalProjects,
projectIndex,
planProgress,
readingActivity,
toggleDay,
openReadingPlans,
readReadingPlanDay,
listenReadingPlanDay,
studyReadingPlanDay,
studyBuildBusy,
audioBook, setAudioBook,
audioNarrator, setAudioNarrator,
audioState,
audioProgress,
openBibleReader,
openImportProject,
openNewProject,
pullLatestFromServer,
resumeProject,
renameProjectInStorage,
deleteProject,
handlePlayBookAudio,
handleStopBookAudio,
handleToggleBookAudioPause,
handleSkipBookChapter,
handleSeekAudio,
handleSeekAudioTo,
} = useApp();
const [homeSearch, setHomeSearch] = useState('');
const [homeSort, setHomeSort] = useState(() => {
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
}); // 'recent' | 'title' | 'passage'
const [homeTagFilter, setHomeTagFilter] = useState('');
const [homeFullTextResults, setHomeFullTextResults] = useState(null); // null=idle, []=no match, [...]= results
const fullTextTimerRef = useRef(null);
const [renamingId, setRenamingId] = useState(null);
const [renameValue, setRenameValue] = useState('');
useEffect(() => {
try { localStorage.setItem('homeSort', homeSort); } catch {}
}, [homeSort]);
// Full-text search across all project notes (debounced, synchronous localStorage read)
useEffect(() => {
clearTimeout(fullTextTimerRef.current);
const q = homeSearch.trim().toLowerCase();
if (q.length < 3) { setHomeFullTextResults(null); return; }
fullTextTimerRef.current = window.setTimeout(() => {
const index = loadProjectIndex();
const results = [];
for (const entry of index) {
const proj = loadProjectById(entry.id);
if (!proj) continue;
const matches = [];
for (const [chIdx, ch] of (proj.chapters ?? []).entries()) {
for (const chunk of (ch.chunks ?? [])) {
for (const field of ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript']) {
const text = (chunk[field] ?? '').toLowerCase();
if (!text.includes(q)) continue;
const idx = text.indexOf(q);
const raw = chunk[field] ?? '';
const start = Math.max(0, idx - 40);
const snippet = (start > 0 ? '…' : '') + raw.slice(start, idx + q.length + 60) + (idx + q.length + 60 < raw.length ? '…' : '');
matches.push({ chunkId: chunk.id, ref: formatChunkReference(proj, chIdx, chunk, '–'), field, snippet });
break; // one match per chunk
}
}
}
if (matches.length > 0) results.push({ projectId: entry.id, projectTitle: entry.title, matches });
}
setHomeFullTextResults(results);
}, 300);
}, [homeSearch]);
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-3 px-4 py-4 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-1 text-2xl font-semibold">My Studies</h1>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={openBibleReader}
className="rounded-xl border border-slate-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700"
>
📖 Read Bible
</button>
<button
type="button"
onClick={openReadingPlans}
className="rounded-xl border border-slate-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700"
>
<span className="hidden sm:inline">📅 Reading Plans</span>
<span className="sm:hidden">📅 Plans</span>
</button>
<button
type="button"
onClick={openImportProject}
className="rounded-xl border border-slate-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700"
>
<span className="hidden sm:inline">📥 Import Session List</span>
<span className="sm:hidden">📥 Import</span>
</button>
<button
type="button"
onClick={openNewProject}
className="rounded-xl bg-slate-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-600"
>
+ New Project
</button>
<AuthStatus />
</div>
</div>
</header>
<main className="mx-auto max-w-7xl px-4 py-4 sm:py-8 sm:px-6 lg:px-8">
{autoRestoredCount !== null && (
<div className="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
<p className="text-sm font-semibold text-emerald-800">
📥 Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.
</p>
</div>
)}
{staleLocalProjects.length > 0 && (
<div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-4">
<p className="mb-3 text-sm font-semibold text-amber-800">
☁️ {staleLocalProjects.length} project{staleLocalProjects.length > 1 ? 's have' : ' has'} a newer version on the server:
</p>
<div className="flex flex-wrap gap-2">
{staleLocalProjects.map((entry) => (
<button
key={entry.id}
type="button"
onClick={() => pullLatestFromServer(entry.id)}
className="rounded-xl bg-amber-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-amber-600"
>
Pull latest "{entry.title}"
</button>
))}
</div>
</div>
)}
{/* Reading plans summary — shown regardless of whether there are any study projects yet */}
<ReadingPlansSummaryCard
planProgress={planProgress}
readingActivity={readingActivity}
openReadingPlans={openReadingPlans}
readReadingPlanDay={readReadingPlanDay}
listenReadingPlanDay={listenReadingPlanDay}
studyReadingPlanDay={studyReadingPlanDay}
studyBuildBusy={studyBuildBusy}
toggleDay={toggleDay}
/>
{/* BSB audio player — shown regardless of whether there are any study projects yet,
so "Listen" from a reading plan always has somewhere visible to land. */}
<div className="mb-4">
<AudioPlayer
title="Listen to BSB Audio"
subtitle={
audioState.status === 'idle' || audioState.status === 'error'
? 'Play a full book of the Berean Standard Bible.'
: `Playing ${bookOptions.find((b) => b.abbrev === audioBook)?.name} — chapter ${audioState.chapter} of ${audioState.total}`
}
errorMessage="Couldn't load audio for this book/narrator."
status={audioState.status}
currentTime={audioProgress.currentTime}
duration={audioProgress.duration}
onPlay={handlePlayBookAudio}
onTogglePause={handleToggleBookAudioPause}
onSeekBack={() => handleSeekAudio(-10)}
onSeekForward={() => handleSeekAudio(10)}
onSeekTo={handleSeekAudioTo}
onNext={handleSkipBookChapter}
nextDisabled={audioState.chapter >= audioState.total}
onStop={handleStopBookAudio}
controls={(
<>
<select
value={audioBook}
onChange={(e) => setAudioBook(e.target.value)}
disabled={audioState.status === 'playing' || audioState.status === 'paused'}
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-60"
>
{bookOptions.map((book) => (
<option key={book.abbrev} value={book.abbrev}>{book.name}</option>
))}
</select>
<select
value={audioNarrator}
onChange={(e) => setAudioNarrator(e.target.value)}
disabled={audioState.status === 'playing' || audioState.status === 'paused'}
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-60"
>
<option value="david">David</option>
<option value="hays">Hays</option>
<option value="souer">Souer</option>
</select>
</>
)}
/>
</div>
{projectIndex.length === 0 ? (
<div className="mx-auto max-w-xl rounded-3xl border border-dashed border-slate-300 bg-white p-10 text-center shadow-panel">
<p className="text-lg font-semibold text-slate-700">No projects yet</p>
<p className="mt-2 text-sm text-slate-500">Start a new Bible study to get going.</p>
<button
type="button"
onClick={openNewProject}
className="mt-6 rounded-xl bg-slate-900 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800"
>
+ New Project
</button>
</div>
) : (
<>
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<input
type="text"
value={homeSearch}
onChange={(e) => setHomeSearch(e.target.value)}
placeholder="Search projects by title or passage…"
className="w-full max-w-sm rounded-xl 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"
/>
<label className="text-sm text-slate-600">
Sort by{' '}
<select
value={homeSort}
onChange={(e) => setHomeSort(e.target.value)}
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
<option value="recent">Last edited</option>
<option value="title">Title</option>
<option value="passage">Passage</option>
</select>
</label>
{(() => {
const allTags = Array.from(
new Set(projectIndex.flatMap((entry) => entry.tags ?? [])),
).sort((a, b) => a.localeCompare(b));
if (allTags.length === 0) return null;
return (
<label className="text-sm text-slate-600">
Tag{' '}
<select
value={homeTagFilter}
onChange={(e) => setHomeTagFilter(e.target.value)}
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
<option value="">All tags</option>
{allTags.map((tag) => (
<option key={tag} value={tag}>{tag}</option>
))}
</select>
</label>
);
})()}
</div>
{/* Full-text note search results */}
{homeFullTextResults !== null && (
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-5 shadow-panel">
<p className="mb-3 text-sm font-semibold text-slate-700">
{homeFullTextResults.length === 0
? 'No notes match your search.'
: `Notes matching "${homeSearch.trim()}" — ${homeFullTextResults.reduce((n, r) => n + r.matches.length, 0)} result${homeFullTextResults.reduce((n, r) => n + r.matches.length, 0) !== 1 ? 's' : ''} across ${homeFullTextResults.length} project${homeFullTextResults.length !== 1 ? 's' : ''}`}
</p>
<div className="space-y-4">
{homeFullTextResults.map(({ projectId, projectTitle, matches }) => (
<div key={projectId}>
<button
type="button"
onClick={() => resumeProject(projectId)}
className="mb-1.5 text-sm font-semibold text-violet-700 hover:underline"
>
{projectTitle} →
</button>
<div className="space-y-1.5">
{matches.map(({ chunkId, ref, field, snippet }) => (
<div key={chunkId} className="rounded-2xl bg-slate-50 px-3 py-2">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-400">{ref} · {field}</span>
<p className="mt-0.5 text-sm text-slate-700 leading-snug">{snippet}</p>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projectIndex
.slice()
.filter((entry) => {
if (homeTagFilter && !(entry.tags ?? []).includes(homeTagFilter)) return false;
const q = homeSearch.trim().toLowerCase();
if (!q) return true;
return entry.title?.toLowerCase().includes(q)
|| entry.chapterSummary?.toLowerCase().includes(q);
})
.sort((a, b) => {
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
return (b.lastEdited ?? 0) - (a.lastEdited ?? 0);
})
.map((entry) => (
<div
key={entry.id}
className="flex flex-col gap-4 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel"
>
<div className="flex-1">
{renamingId === entry.id ? (
<div className="flex items-center gap-2">
<input
type="text"
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const trimmed = renameValue.trim();
if (trimmed) renameProjectInStorage(entry.id, trimmed);
setRenamingId(null);
} else if (e.key === 'Escape') {
setRenamingId(null);
}
}}
className="flex-1 rounded-lg border border-slate-300 px-2 py-1 text-base font-semibold text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
<button
type="button"
onClick={() => {
const trimmed = renameValue.trim();
if (trimmed) renameProjectInStorage(entry.id, trimmed);
setRenamingId(null);
}}
className="text-sm font-semibold text-emerald-600 hover:text-emerald-700"
>
Save
</button>
<button
type="button"
onClick={() => setRenamingId(null)}
className="text-sm text-slate-400 hover:text-slate-600"
>
×
</button>
</div>
) : (
<div className="flex items-start justify-between gap-2">
<h2 className="text-base font-semibold text-slate-900">{entry.title}</h2>
<button
type="button"
onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }}
className="shrink-0 text-xs text-slate-400 hover:text-slate-600"
title="Rename project"
>
✎ Rename
</button>
</div>
)}
{entry.chapterSummary && (
<p className="mt-1 text-sm text-slate-500">{entry.chapterSummary}</p>
)}
{entry.tags?.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{entry.tags.map((tag) => (
<span key={tag} className="rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-semibold text-indigo-700">
{tag}
</span>
))}
</div>
)}
<p className="mt-1 text-xs text-slate-400">{formatRelativeDate(entry.lastEdited)}</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => resumeProject(entry.id)}
className="flex-1 rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-800"
>
Resume
</button>
<button
type="button"
onClick={() => deleteProject(entry.id)}
className="rounded-xl 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>
))}
</div>
</>
)}
</main>
</div>
);
}
function ReadingPlansSummaryCard({
planProgress, readingActivity, openReadingPlans, readReadingPlanDay, listenReadingPlanDay, studyReadingPlanDay, studyBuildBusy, toggleDay,
}) {
const active = planProgress.filter((p) => !p.completedAt);
const streak = computeStreak(readingActivity);
const streakBadge = streak.current > 0 && (
<span className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700">
🔥 {streak.current} day{streak.current !== 1 ? 's' : ''}
</span>
);
if (active.length === 0) {
return (
<div className="mb-4 flex items-center justify-between gap-3 rounded-3xl border border-slate-200 bg-white p-5 shadow-panel">
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-slate-700">Reading Plans</p>
{streakBadge}
</div>
<p className="mt-1 text-xs text-slate-500">Read through the Bible, the Gospels, Psalms, and more — one day at a time.</p>
</div>
<button
type="button"
onClick={openReadingPlans}
className="shrink-0 rounded-xl border border-slate-300 px-3 py-1.5 text-sm font-semibold text-slate-700 hover:bg-slate-50"
>
Browse plans
</button>
</div>
);
}
const rows = active
.map((progress) => {
const plan = getPlan(progress.planId);
if (!plan) return null;
const schedule = getPlanSchedule(progress.planId);
const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0;
const dayIndex = nextUnreadDayIndex(plan, progress.completedDays);
const refs = dayIndex != null ? schedule[dayIndex] : null;
return { progress, plan, pct, dayIndex, refs };
})
.filter(Boolean);
return (
<div className="mb-4 rounded-3xl border border-indigo-200 bg-indigo-50 p-5 shadow-panel">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-900">📅 Today's Reading</h3>
{streakBadge}
</div>
<button type="button" onClick={openReadingPlans} className="text-xs font-semibold text-indigo-700 hover:underline">
View all plans →
</button>
</div>
<div className="space-y-1.5">
{rows.map(({ progress, plan, pct, dayIndex, refs }) => (
<div key={progress.id} className="rounded-2xl bg-white px-4 py-2.5 shadow-sm">
<div className="flex flex-wrap items-center gap-3">
{refs ? (
<input
type="checkbox"
checked={false}
onChange={() => toggleDay(progress.id, dayIndex, plan.days)}
className="h-4 w-4 shrink-0 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
/>
) : (
<span className="shrink-0 text-emerald-500">✓</span>
)}
<span className="shrink-0 text-lg leading-none">{plan.icon ?? '📖'}</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900">{plan.title}</p>
<p className="truncate text-xs text-slate-500">
{refs ? formatDayReadings(refs) : `${progress.completedDays.length} / ${plan.days} days`}
</p>
</div>
{refs && (
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={() => listenReadingPlanDay(refs[0].book, refs[0].chapter)}
title="Listen to today's reading"
className="rounded-xl border border-indigo-200 px-2.5 py-1.5 text-sm text-indigo-700 hover:bg-indigo-100"
>
🎧
</button>
<button
type="button"
disabled={studyBuildBusy}
onClick={() => studyReadingPlanDay(refs, `${plan.title} — ${formatDayReadings(refs)}`)}
title="Start a study project from this passage"
className="rounded-xl border border-indigo-200 px-2.5 py-1.5 text-sm text-indigo-700 hover:bg-indigo-100 disabled:cursor-not-allowed disabled:opacity-50"
>
✎
</button>
<button
type="button"
onClick={() => readReadingPlanDay(refs[0].book, refs[0].chapter)}
className="rounded-xl bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-indigo-500"
>
Read
</button>
</div>
)}
</div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
<div className="h-full rounded-full bg-indigo-400 transition-all" style={{ width: `${pct}%` }} />
</div>
</div>
))}
</div>
</div>
);
}