- Commentary and study aids wait for 15 words of your own Observe notes. - Insights page: this week's own work, themes across studies, words you keep studying, what slipped in recalls, open follow-ups. - Recall can record what slipped; session notes (teach it back, who came, group notes, follow-ups); "Pray about this" from Apply. - Compare any two passages side by side. - Handout layouts: standard, questions only, large print, note-taking. - Search understands "in <book>", "last week/month/year" and a year. - Home shows the last backup age and nudges after two weeks. - Phone: the stage bar scrolls instead of clipping Script and Export. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
428 lines
27 KiB
React
428 lines
27 KiB
React
import { useEffect, useRef, useState } from 'react';
|
||
import { useApp } from '../context/AppContext.js';
|
||
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
|
||
import BookCover from '../components/BookCover.jsx';
|
||
import WeeklyRecall from '../components/WeeklyRecall.jsx';
|
||
import RecallHistory from '../components/RecallHistory.jsx';
|
||
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
|
||
import IqNarratorOptions, { useLazyNarrators } from '../components/iq/IqNarratorOptions.jsx';
|
||
import PageShell from '../components/study/PageShell.jsx';
|
||
import AudioPlayer from '../components/AudioPlayer.jsx';
|
||
import {
|
||
bookOptions,
|
||
formatChunkReference,
|
||
formatRelativeDate,
|
||
loadProjectIndex,
|
||
} from '../utils/bibleUtils.jsx';
|
||
import { backupAge, lastBackupAt } from '../utils/backup.js';
|
||
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,
|
||
sharedStudies,
|
||
projectIndex,
|
||
planProgress,
|
||
readingActivity,
|
||
openReadingPlans,
|
||
openPlanReading,
|
||
studyReadingPlanDay,
|
||
studyBuildBusy,
|
||
readingPlanSyncStatus,
|
||
readingPlanSyncError,
|
||
retryReadingPlanSync,
|
||
audioBook, setAudioBook,
|
||
audioNarrator, setAudioNarrator,
|
||
audioState,
|
||
audioProgress,
|
||
openImportProject,
|
||
setCurrentPage,
|
||
openBackup,
|
||
openNewProject,
|
||
pullLatestFromServer,
|
||
resumeProject,
|
||
renameProjectInStorage,
|
||
setProjectSeries,
|
||
deleteProject,
|
||
handlePlayBookAudio,
|
||
handleStopBookAudio,
|
||
handleToggleBookAudioPause,
|
||
handleSkipBookChapter,
|
||
handleSeekAudio,
|
||
handleSeekAudioTo,
|
||
} = useApp();
|
||
const narrators = useLazyNarrators(audioNarrator);
|
||
const [homeSearch, setHomeSearch] = useState('');
|
||
const [homeSort, setHomeSort] = useState(() => {
|
||
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
|
||
}); // 'recent' | 'title' | 'passage'
|
||
const [homeTags, setHomeTags] = useState([]); // tag chips switched on: a study must have all of them
|
||
const [showAllTags, setShowAllTags] = useState(false);
|
||
const [seriesFilter, setSeriesFilter] = useState(''); // '' = all, '__none' = not in a series, or a series name
|
||
const [seriesEditingId, setSeriesEditingId] = useState(null);
|
||
const [seriesValue, setSeriesValue] = useState('');
|
||
const toggleTag = (tag) => setHomeTags((cur) => (cur.includes(tag) ? cur.filter((t) => t !== tag) : [...cur, tag]));
|
||
const [homeView, setHomeView] = useState(() => {
|
||
try { return localStorage.getItem('homeView') === 'list' ? 'list' : 'grid'; } catch { return 'grid'; }
|
||
}); // 'grid' | 'list'
|
||
useEffect(() => {
|
||
try { localStorage.setItem('homeView', homeView); } catch {}
|
||
}, [homeView]);
|
||
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 || isTagQuery(q)) { 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 tagList = tagCounts(projectIndex);
|
||
const shownTags = showAllTags ? tagList : tagList.slice(0, 12);
|
||
const seriesNames = seriesList(projectIndex);
|
||
const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags, series: seriesFilter })
|
||
.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);
|
||
});
|
||
// With series in use (and no single series chosen) the studies are grouped under their series name
|
||
const sections = seriesFilter === '' && seriesNames.length > 0 ? groupBySeries(visible) : [{ name: '', items: visible, flat: true }];
|
||
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={() => setCurrentPage('insights')} className={linkBtn} title="This week, themes, words, what slipped, follow-ups">Insights</button>
|
||
<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">
|
||
{projectIndex.length > 0 && (() => {
|
||
const age = backupAge(lastBackupAt());
|
||
return (
|
||
<p className={`mb-3 flex flex-wrap items-center gap-2 px-1 text-xs ${age.stale ? 'text-[var(--r-danger,#b42318)]' : 'text-[var(--r-sub)]'}`}>
|
||
{age.text}.
|
||
<button type="button" onClick={openBackup} className="text-[var(--r-accent)] hover:underline">Back up now</button>
|
||
</p>
|
||
);
|
||
})()}
|
||
<WeeklyRecall />
|
||
<RecallHistory />
|
||
<IqDailyCard />
|
||
{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, passage or #tag…"
|
||
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>
|
||
{seriesNames.length > 0 && (
|
||
<label className="text-sm text-[var(--r-sub)]">Series{' '}
|
||
<select value={seriesFilter} onChange={(e) => setSeriesFilter(e.target.value)} className={`${fieldCls} ml-1`}>
|
||
<option value="">All studies</option>
|
||
{seriesNames.map((sr) => <option key={sr.name} value={sr.name}>{sr.name} ({sr.count})</option>)}
|
||
<option value="__none">Not in a series</option>
|
||
</select>
|
||
</label>
|
||
)}
|
||
<div role="group" aria-label="Studies layout" className="ml-auto flex overflow-hidden rounded-xl border border-[var(--r-line)] text-sm">
|
||
{[['grid', 'Grid'], ['list', 'List']].map(([id, label]) => (
|
||
<button key={id} type="button" aria-pressed={homeView === id} onClick={() => setHomeView(id)}
|
||
className={`px-3 py-1.5 ${homeView === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{label}</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<datalist id="series-names">{seriesNames.map((sr) => <option key={sr.name} value={sr.name} />)}</datalist>
|
||
{tagList.length > 0 && (
|
||
<div className="-mt-1 mb-4 flex flex-wrap items-center gap-1.5" role="group" aria-label="Filter by tag">
|
||
<span className="mr-1 text-xs text-[var(--r-sub)]">Tags</span>
|
||
{shownTags.map(({ tag, count }) => {
|
||
const on = homeTags.includes(tag);
|
||
return (
|
||
<button key={tag} type="button" aria-pressed={on} onClick={() => toggleTag(tag)}
|
||
className={`rounded-full border px-2.5 py-0.5 text-xs ${on ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||
{tag} <span className="opacity-70">{count}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
{tagList.length > 12 && (
|
||
<button type="button" onClick={() => setShowAllTags((o) => !o)} className="text-xs text-[var(--r-accent)] hover:underline">{showAllTags ? 'Fewer' : `All ${tagList.length} tags`}</button>
|
||
)}
|
||
{homeTags.length > 0 && <button type="button" onClick={() => setHomeTags([])} className="text-xs text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Clear</button>}
|
||
</div>
|
||
)}
|
||
|
||
{visible.length === 0 && (
|
||
<p role="status" className="mb-4 rounded-xl border border-dashed border-[var(--r-line)] px-4 py-6 text-center text-sm text-[var(--r-sub)]">
|
||
No studies match{homeTags.length ? ` ${homeTags.map((t) => `“${t}”`).join(' + ')}` : ''}{homeSearch.trim() ? ` “${homeSearch.trim()}”` : ''}.{' '}
|
||
<button type="button" onClick={() => { setHomeTags([]); setHomeSearch(''); }} className="text-[var(--r-accent)] hover:underline">Clear filters</button>
|
||
</p>
|
||
)}
|
||
|
||
{/* 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>
|
||
)}
|
||
|
||
{sections.map((section) => (
|
||
<div key={section.name || '__none'} className="mb-5 last:mb-0">
|
||
{!section.flat && (
|
||
<h3 className="mb-2 flex items-baseline gap-2 text-sm font-medium">
|
||
{section.name || 'Not in a series'} <span className="text-xs font-normal text-[var(--r-sub)]">{section.items.length}</span>
|
||
</h3>
|
||
)}
|
||
<div className={homeView === 'list' ? 'flex flex-col gap-2' : 'grid gap-3 sm:grid-cols-2'}>
|
||
{section.items.map((entry) => (
|
||
<div key={entry.id} className={homeView === 'list'
|
||
? 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-3 sm:flex-row sm:items-center sm:gap-4'
|
||
: 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5'}>
|
||
{homeView === 'list'
|
||
? <BookCover summary={entry.chapterSummary} compact className="h-14 w-full sm:w-28 sm:shrink-0" />
|
||
: <BookCover summary={entry.chapterSummary} className="h-24" />}
|
||
<div className="min-w-0 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 gap-2 ${homeView === 'list' ? 'flex-wrap items-baseline' : 'justify-between'}`}>
|
||
<h2 className="text-base font-medium">{entry.title}</h2>
|
||
{sharedStudies?.[entry.id] !== undefined && (
|
||
<span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]" title="Another account invited you to edit this study">Shared{sharedStudies[entry.id] ? ` by ${sharedStudies[entry.id]}` : ''}</span>
|
||
)}
|
||
<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>
|
||
<button type="button" onClick={() => { setSeriesEditingId(entry.id); setSeriesValue(entry.series ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Put this study in a series">{entry.series ? '⧉ Series' : '⧉ Add to series'}</button>
|
||
</div>
|
||
)}
|
||
{seriesEditingId === entry.id && (
|
||
<div className="mt-2 flex items-center gap-2">
|
||
<input type="text" autoFocus list="series-names" aria-label="Series name" placeholder="Series name (blank to remove)" value={seriesValue} onChange={(e) => setSeriesValue(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); } else if (e.key === 'Escape') setSeriesEditingId(null); }}
|
||
className={`${fieldCls} flex-1`} />
|
||
<button type="button" onClick={() => { setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
|
||
<button type="button" onClick={() => setSeriesEditingId(null)} aria-label="Cancel" className="text-sm text-[var(--r-sub)]">×</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) => (
|
||
<button key={tag} type="button" onClick={() => toggleTag(tag)} aria-pressed={homeTags.includes(tag)} title={`Show studies tagged “${tag}”`}
|
||
className={`rounded-full border px-2 py-0.5 text-[11px] ${homeTags.includes(tag) ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>{tag}</button>
|
||
))}
|
||
{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 ${homeView === 'list' ? 'sm:w-56 sm:shrink-0' : ''}`}>
|
||
<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">{sharedStudies?.[entry.id] !== undefined ? 'Leave' : 'Delete'}</button>
|
||
</div>
|
||
</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} {...narrators.selectProps}>
|
||
<option value="david">David</option>
|
||
<option value="hays">Hays</option>
|
||
<option value="souer">Souer</option>
|
||
<IqNarratorOptions enabled={narrators.enabled} />
|
||
</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>
|
||
);
|
||
}
|