Files
study-bible-project/src/pages/HomePage.jsx
T
nmemmertandClaude Sonnet 5.5 d13f55faf7 Add Outline stage, parallel reading, offline saves, backup, sync-conflict handling and more
Study
- New Outline stage (hierarchical I./A./1. points, linked to chunks, in all exports);
  the chunk stage is now "Chunks"
- Home cards: passages shown as ranges, tags capped with "+N more"

Reader
- Side-by-side translations and a Compare tab per verse
- Typeface, line spacing, verse-number settings; text and column scale with the screen
- Highlights/bookmarks sheet with named colours (synced); BSB footnote spacing fix
- Cmd-K palette (passages, studies, notes, marks), keyboard shortcuts and "?" help

Flow and safety
- Toasts with Undo replace most confirm()/alert()
- Back/Forward move between pages
- Server refuses saves over a newer copy (409); keep-mine / use-other banner;
  opening a study no longer re-stamps it
- Latest chapter request wins when flipping quickly

Offline and data
- Save books, plan days or a whole BSB/NET for offline
- Service worker fixes: helloao /api/ URLs were skipped and responses cloned too late
- Settings: download/restore a full backup

Accessibility
- Sheets trap focus and close on Escape, skip link, focus ring, reduced motion,
  theme contrast raised to 4.5:1 (with a test)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:08:08 -04:00

330 lines
19 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 PageShell from '../components/study/PageShell.jsx';
import AudioPlayer from '../components/AudioPlayer.jsx';
import {
bookOptions,
formatChunkReference,
formatRelativeDate,
loadProjectIndex,
} from '../utils/bibleUtils.jsx';
import { loadProjectById } from '../utils/projectStorage.js';
import { compactPassages } from '../utils/passageSummary.js';
import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js';
export default function HomePage() {
const {
autoRestoredCount,
staleLocalProjects,
projectIndex,
planProgress,
readingActivity,
openReadingPlans,
openPlanReading,
studyReadingPlanDay,
studyBuildBusy,
readingPlanSyncStatus,
readingPlanSyncError,
retryReadingPlanSync,
audioBook, setAudioBook,
audioNarrator, setAudioNarrator,
audioState,
audioProgress,
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]);
const allTags = Array.from(new Set(projectIndex.flatMap((entry) => entry.tags ?? []))).sort((a, b) => a.localeCompare(b));
const visible = 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);
});
const fieldCls = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const linkBtn = 'rounded-lg px-3 py-1.5 text-sm text-[var(--r-sub)] hover:bg-black/5';
const notice = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm';
return (
<PageShell
title="My Studies"
active="studies"
actions={(
<>
<button type="button" onClick={openImportProject} className={linkBtn} title="Set up a whole series from a session list (.docx)">Import Session List</button>
<button type="button" onClick={openNewProject} className="rounded-full bg-[var(--r-accent)] px-4 py-1.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ New Project</button>
</>
)}
>
<main className="mx-auto grid max-w-6xl gap-6 px-4 py-6 lg:grid-cols-[minmax(0,1fr)_21rem]">
<section className="min-w-0">
{autoRestoredCount !== null && (
<div className={notice}>Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.</div>
)}
{staleLocalProjects.length > 0 && (
<div className={notice}>
<p className="mb-2">{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-full border border-[var(--r-accent)] px-3 py-1 text-xs text-[var(--r-accent)] hover:bg-black/5">Pull latest “{entry.title}”</button>
))}
</div>
</div>
)}
{readingPlanSyncStatus === 'error' && (
<div className={`${notice} flex flex-wrap items-center justify-between gap-3`}>
<span className="text-[var(--r-danger,#b42318)]">Couldn’t sync your reading plans to the server. Changes are only saved on this device until this is fixed.</span>
<button type="button" onClick={retryReadingPlanSync} className="shrink-0 rounded-lg border border-red-300 px-3 py-1 text-xs text-[var(--r-danger,#b42318)] hover:bg-red-50">Retry</button>
</div>
)}
{projectIndex.length === 0 ? (
<div className="mx-auto max-w-xl rounded-2xl border border-dashed border-[var(--r-line)] p-10 text-center">
<p className="text-lg font-medium">No projects yet</p>
<p className="mt-2 text-sm text-[var(--r-sub)]">Start a new Bible study to get going.</p>
<button type="button" onClick={openNewProject} className="mt-6 rounded-full bg-[var(--r-accent)] px-6 py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ New Project</button>
</div>
) : (
<>
<div className="mb-4 flex flex-wrap items-center gap-3">
<input type="text" value={homeSearch} onChange={(e) => setHomeSearch(e.target.value)} placeholder="Search projects by title or passage…"
className={`${fieldCls} w-full max-w-sm`} />
<label className="text-sm text-[var(--r-sub)]">Sort by{' '}
<select value={homeSort} onChange={(e) => setHomeSort(e.target.value)} className={`${fieldCls} ml-1`}>
<option value="recent">Last edited</option>
<option value="title">Title</option>
<option value="passage">Passage</option>
</select>
</label>
{allTags.length > 0 && (
<label className="text-sm text-[var(--r-sub)]">Tag{' '}
<select value={homeTagFilter} onChange={(e) => setHomeTagFilter(e.target.value)} className={`${fieldCls} ml-1`}>
<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-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
<p className="mb-3 text-sm font-medium">
{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' : ''}`}
</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-medium text-[var(--r-accent)] hover:underline">{projectTitle} →</button>
<div className="space-y-1.5">
{matches.map(({ chunkId, ref, field, snippet }) => (
<div key={chunkId} className="rounded-xl bg-[var(--r-bg)] px-3 py-2">
<span className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{ref} · {field}</span>
<p className="mt-0.5 text-sm leading-snug">{snippet}</p>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
{visible.map((entry) => (
<div key={entry.id} className="flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
<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 t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }
else if (e.key === 'Escape') setRenamingId(null);
}}
className={`${fieldCls} flex-1`} />
<button type="button" onClick={() => { const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
<button type="button" onClick={() => setRenamingId(null)} className="text-sm text-[var(--r-sub)]">×</button>
</div>
) : (
<div className="flex items-start justify-between gap-2">
<h2 className="text-base font-medium">{entry.title}</h2>
<button type="button" onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Rename project">✎ Rename</button>
</div>
)}
{entry.chapterSummary && <p className="mt-1 text-sm text-[var(--r-sub)]" title={entry.chapterSummary}>{compactPassages(entry.chapterSummary)}</p>}
{entry.tags?.length > 0 && (
// A few tags and a "+N" for the rest (hover for all; the Tag filter above finds any of them)
<div className="mt-2 flex flex-wrap gap-1" title={entry.tags.length > 3 ? entry.tags.join(', ') : undefined}>
{entry.tags.slice(0, 3).map((tag) => <span key={tag} className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]">{tag}</span>)}
{entry.tags.length > 3 && <span className="rounded-full px-1.5 py-0.5 text-[11px] text-[var(--r-sub)]">+{entry.tags.length - 3} more</span>}
</div>
)}
<p className="mt-1 text-xs text-[var(--r-sub)]">{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-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] hover:opacity-90">Resume</button>
<button type="button" onClick={() => deleteProject(entry.id)} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm text-[var(--r-danger,#b42318)] hover:bg-black/5">Delete</button>
</div>
</div>
))}
</div>
</>
)}
</section>
<aside className="space-y-4">
<TodayCard planProgress={planProgress} readingActivity={readingActivity} openReadingPlans={openReadingPlans}
openPlanReading={openPlanReading} studyReadingPlanDay={studyReadingPlanDay} studyBuildBusy={studyBuildBusy} />
{/* BSB audio player — always shown, so it's easy to find */}
<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={fieldCls}>
{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={fieldCls}>
<option value="david">David</option>
<option value="hays">Hays</option>
<option value="souer">Souer</option>
</select>
</>
)}
/>
</aside>
</main>
</PageShell>
);
}
// Today's reading across your active plans, each opening straight into the Reader.
function TodayCard({ planProgress, readingActivity, openReadingPlans, openPlanReading, studyReadingPlanDay, studyBuildBusy }) {
const active = planProgress.filter((p) => !p.completedAt);
const streak = computeStreak(readingActivity);
const streakText = streak.current > 0 && <span className="text-xs text-[var(--r-accent)]">{streak.current} day{streak.current !== 1 ? 's' : ''} in a row</span>;
if (active.length === 0) {
return (
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
<div className="flex items-center justify-between gap-2"><p className="text-sm font-medium">Reading Plans</p>{streakText}</div>
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">Read through the Bible, the Gospels, Psalms, and more — one day at a time.</p>
<button type="button" onClick={openReadingPlans} className="rounded-full border border-[var(--r-line)] px-4 py-1.5 text-sm hover:bg-black/5">Browse plans</button>
</div>
);
}
const rows = active.map((progress) => {
const plan = getPlan(progress.planId);
if (!plan) return null;
const dayIndex = nextUnreadDayIndex(plan, progress.completedDays);
const refs = dayIndex != null ? getPlanSchedule(progress.planId)[dayIndex] : null;
return { progress, plan, dayIndex, refs, pct: plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0 };
}).filter(Boolean);
return (
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
<div className="mb-3 flex items-center justify-between gap-2">
<h2 className="text-sm font-medium">Today’s Reading</h2>
<span className="flex items-center gap-3">{streakText}<button type="button" onClick={openReadingPlans} className="text-xs text-[var(--r-accent)] hover:underline">All plans →</button></span>
</div>
<div className="space-y-3">
{rows.map(({ progress, plan, dayIndex, refs, pct }) => (
<div key={progress.id}>
<p className="truncate text-sm font-medium">{plan.icon ? `${plan.icon} ` : ''}{plan.title}</p>
<p className="mb-1.5 truncate font-serif text-sm text-[var(--r-sub)]">{refs ? formatDayReadings(refs) : `${progress.completedDays.length} / ${plan.days} days`}</p>
<div className="mb-2 h-1 overflow-hidden rounded-full bg-[var(--r-line)]"><div className="h-full bg-[var(--r-accent)]" style={{ width: `${pct}%` }} /></div>
{refs && (
<div className="flex gap-2">
<button type="button" onClick={() => openPlanReading(progress.id, dayIndex)} className="rounded-full bg-[var(--r-accent)] px-4 py-1.5 text-xs font-medium text-[var(--r-on)] hover:opacity-90">Continue reading</button>
<button type="button" disabled={studyBuildBusy} onClick={() => studyReadingPlanDay(refs, `${plan.title} — ${formatDayReadings(refs)}`)}
className="rounded-full border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50" title="Start a study project from this passage">Study</button>
</div>
)}
</div>
))}
</div>
</div>
);
}