Connect reading plans to study/audio, cache offline audio, add journal export
Build and Push Docker Image / build-and-push (push) Successful in 4m33s
Build and Push Docker Image / build-and-push (push) Successful in 4m33s
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>
This commit is contained in:
1 parent
1dd6aef6a5
commit
787edcbe73
8 files changed
+604
-105
No files matched your search
+84
-54
@@ -21,6 +21,9 @@ export default function HomePage() {
|
||||
toggleDay,
|
||||
openReadingPlans,
|
||||
readReadingPlanDay,
|
||||
listenReadingPlanDay,
|
||||
studyReadingPlanDay,
|
||||
studyBuildBusy,
|
||||
audioBook, setAudioBook,
|
||||
audioNarrator, setAudioNarrator,
|
||||
audioState,
|
||||
@@ -161,9 +164,61 @@ export default function HomePage() {
|
||||
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>
|
||||
@@ -252,52 +307,6 @@ export default function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projectIndex
|
||||
.slice()
|
||||
@@ -410,7 +419,9 @@ export default function HomePage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ReadingPlansSummaryCard({ planProgress, readingActivity, openReadingPlans, readReadingPlanDay, toggleDay }) {
|
||||
function ReadingPlansSummaryCard({
|
||||
planProgress, readingActivity, openReadingPlans, readReadingPlanDay, listenReadingPlanDay, studyReadingPlanDay, studyBuildBusy, toggleDay,
|
||||
}) {
|
||||
const active = planProgress.filter((p) => !p.completedAt);
|
||||
const streak = computeStreak(readingActivity);
|
||||
|
||||
@@ -486,13 +497,32 @@ function ReadingPlansSummaryCard({ planProgress, readingActivity, openReadingPla
|
||||
</p>
|
||||
</div>
|
||||
{refs && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => readReadingPlanDay(refs[0].book, refs[0].chapter)}
|
||||
className="shrink-0 rounded-xl bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-indigo-500"
|
||||
>
|
||||
Read
|
||||
</button>
|
||||
<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">
|
||||
|
||||
@@ -36,6 +36,9 @@ export default function ReadingPlansPage() {
|
||||
removeCustomPlan,
|
||||
updateCustomPlan,
|
||||
readReadingPlanDay,
|
||||
listenReadingPlanDay,
|
||||
studyReadingPlanDay,
|
||||
studyBuildBusy,
|
||||
} = useApp();
|
||||
const [builderMode, setBuilderMode] = useState(null); // null | 'new' | a custom plan id to edit
|
||||
|
||||
@@ -78,7 +81,14 @@ export default function ReadingPlansPage() {
|
||||
|
||||
<main className="mx-auto max-w-5xl space-y-8 px-4 py-8 sm:px-6 lg:px-8">
|
||||
{activeProgress.length > 0 && (
|
||||
<TodayPanel activeProgress={activeProgress} onRead={readReadingPlanDay} onToggleDay={toggleDay} />
|
||||
<TodayPanel
|
||||
activeProgress={activeProgress}
|
||||
onRead={readReadingPlanDay}
|
||||
onListen={listenReadingPlanDay}
|
||||
onStudy={studyReadingPlanDay}
|
||||
studyBuildBusy={studyBuildBusy}
|
||||
onToggleDay={toggleDay}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeProgress.length > 0 && (
|
||||
@@ -90,6 +100,9 @@ export default function ReadingPlansPage() {
|
||||
key={progress.id}
|
||||
progress={progress}
|
||||
onRead={readReadingPlanDay}
|
||||
onListen={listenReadingPlanDay}
|
||||
onStudy={studyReadingPlanDay}
|
||||
studyBuildBusy={studyBuildBusy}
|
||||
onToggleDay={toggleDay}
|
||||
onRestart={restartPlan}
|
||||
onRemove={removePlanProgress}
|
||||
@@ -220,7 +233,7 @@ export default function ReadingPlansPage() {
|
||||
|
||||
// A single combined "what's left to read today, across every active plan" list —
|
||||
// the fast-glance view; ActivePlanCard below still has full detail/expand/restart.
|
||||
function TodayPanel({ activeProgress, onRead, onToggleDay }) {
|
||||
function TodayPanel({ activeProgress, onRead, onListen, onStudy, studyBuildBusy, onToggleDay }) {
|
||||
const rows = activeProgress
|
||||
.map((progress) => {
|
||||
const plan = getPlan(progress.planId);
|
||||
@@ -252,13 +265,32 @@ function TodayPanel({ activeProgress, onRead, onToggleDay }) {
|
||||
<p className="truncate text-sm font-semibold text-slate-900">{plan.title}</p>
|
||||
<p className="truncate text-xs text-slate-500">{formatDayReadings(refs)}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRead(ref.book, ref.chapter)}
|
||||
className="shrink-0 rounded-xl bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-indigo-500"
|
||||
>
|
||||
Read
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onListen(ref.book, ref.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={() => onStudy(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={() => onRead(ref.book, ref.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>
|
||||
@@ -286,7 +318,7 @@ function PlanCover({ plan }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ActivePlanCard({ progress, onRead, onToggleDay, onRestart, onRemove, onRecalibrate }) {
|
||||
function ActivePlanCard({ progress, onRead, onListen, onStudy, studyBuildBusy, onToggleDay, onRestart, onRemove, onRecalibrate }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const plan = getPlan(progress.planId);
|
||||
if (!plan) return null;
|
||||
@@ -314,13 +346,32 @@ function ActivePlanCard({ progress, onRead, onToggleDay, onRestart, onRemove, on
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{nextFirstRef && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRead(nextFirstRef.book, nextFirstRef.chapter)}
|
||||
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
|
||||
>
|
||||
Read
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onListen(nextFirstRef.book, nextFirstRef.chapter)}
|
||||
title="Listen to today's reading"
|
||||
className="rounded-xl border border-emerald-300 px-2.5 py-1.5 text-sm text-emerald-700 hover:bg-emerald-100"
|
||||
>
|
||||
🎧
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={studyBuildBusy}
|
||||
onClick={() => onStudy(nextRefs, `${plan.title} — ${formatDayReadings(nextRefs)}`)}
|
||||
title="Start a study project from this passage"
|
||||
className="rounded-xl border border-emerald-300 px-2.5 py-1.5 text-sm text-emerald-700 hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRead(nextFirstRef.book, nextFirstRef.chapter)}
|
||||
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
|
||||
>
|
||||
Read
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
updateProfile,
|
||||
updateReminderSettings,
|
||||
} from '../syncService.js';
|
||||
import { exportJournalDocx, printJournalPdf } from '../utils/journalExport.js';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const {
|
||||
@@ -38,6 +39,8 @@ export default function SettingsPage() {
|
||||
const [reminderBusy, setReminderBusy] = useState(false);
|
||||
const [reminderError, setReminderError] = useState('');
|
||||
const [reminderSaved, setReminderSaved] = useState(false);
|
||||
const [journalBusy, setJournalBusy] = useState(''); // '' | 'docx' | 'pdf'
|
||||
const [journalError, setJournalError] = useState('');
|
||||
|
||||
const startSetup = async () => {
|
||||
setMfaSetupError('');
|
||||
@@ -132,6 +135,30 @@ export default function SettingsPage() {
|
||||
window.setTimeout(() => setReminderSaved(false), 2500);
|
||||
};
|
||||
|
||||
const downloadJournalDocx = async () => {
|
||||
setJournalError('');
|
||||
setJournalBusy('docx');
|
||||
try {
|
||||
await exportJournalDocx();
|
||||
} catch {
|
||||
setJournalError("Couldn't build the journal. Please try again.");
|
||||
} finally {
|
||||
setJournalBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
const printJournal = async () => {
|
||||
setJournalError('');
|
||||
setJournalBusy('pdf');
|
||||
try {
|
||||
await printJournalPdf();
|
||||
} catch {
|
||||
setJournalError("Couldn't build the journal. Please try again.");
|
||||
} finally {
|
||||
setJournalBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -242,6 +269,36 @@ export default function SettingsPage() {
|
||||
{podcastNameSaved && <p className="text-sm text-emerald-600">Saved.</p>}
|
||||
</section>
|
||||
|
||||
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">My reading journal</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
A document of your bookmarked verses, highlighted passages, and completed reading plans.
|
||||
Bookmarks and highlights only live on this device (they're not synced), so this reflects
|
||||
what's saved here.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadJournalDocx}
|
||||
disabled={journalBusy !== ''}
|
||||
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
>
|
||||
{journalBusy === 'docx' ? 'Building…' : 'Download as Word (.docx)'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={printJournal}
|
||||
disabled={journalBusy !== ''}
|
||||
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{journalBusy === 'pdf' ? 'Building…' : 'Print / Save as PDF'}
|
||||
</button>
|
||||
</div>
|
||||
{journalError && <p className="text-sm text-rose-600">{journalError}</p>}
|
||||
</section>
|
||||
|
||||
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Reading reminders</h2>
|
||||
|
||||
Reference in new issue
Block a user