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
+8
-3
@@ -1,6 +1,9 @@
|
|||||||
const SHELL = 'bible-shell-v1';
|
const SHELL = 'bible-shell-v1';
|
||||||
const API = 'bible-api-v1';
|
const API = 'bible-api-v1';
|
||||||
const BIBLE_ORIGINS = ['https://bible.helloao.org', 'https://bolls.life'];
|
// Chapter text, interlinear cross-refs, and (BSB) chapter audio all come from
|
||||||
|
// these origins — cache them so the reader, reading plans, and audio all keep
|
||||||
|
// working offline once you've visited a passage before.
|
||||||
|
const BIBLE_ORIGINS = ['https://bible.helloao.org', 'https://bolls.life', 'https://audio.bible.helloao.org'];
|
||||||
|
|
||||||
self.addEventListener('install', (e) => {
|
self.addEventListener('install', (e) => {
|
||||||
e.waitUntil(
|
e.waitUntil(
|
||||||
@@ -27,12 +30,14 @@ self.addEventListener('fetch', (e) => {
|
|||||||
// Never intercept API routes — let Express handle them
|
// Never intercept API routes — let Express handle them
|
||||||
if (new URL(url).pathname.startsWith('/api/')) return;
|
if (new URL(url).pathname.startsWith('/api/')) return;
|
||||||
|
|
||||||
// External Bible API: network-first, fall back to cache
|
// External Bible API (text, cross-refs, audio): network-first, fall back to cache.
|
||||||
|
// Audio elements often issue Range requests, which come back as 206 Partial
|
||||||
|
// Content — the Cache API throws on those, so only cache full (200) responses.
|
||||||
if (BIBLE_ORIGINS.some(o => url.startsWith(o))) {
|
if (BIBLE_ORIGINS.some(o => url.startsWith(o))) {
|
||||||
e.respondWith(
|
e.respondWith(
|
||||||
fetch(request)
|
fetch(request)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (res.ok) caches.open(API).then(c => c.put(request, res.clone()));
|
if (res.status === 200) caches.open(API).then(c => c.put(request, res.clone()));
|
||||||
return res;
|
return res;
|
||||||
})
|
})
|
||||||
.catch(() =>
|
.catch(() =>
|
||||||
|
|||||||
+56
-1
@@ -8,7 +8,9 @@ import { useReadingPlans } from './hooks/useReadingPlans.js';
|
|||||||
import { useSharedView } from './hooks/useSharedView.js';
|
import { useSharedView } from './hooks/useSharedView.js';
|
||||||
import HomePage from './pages/HomePage.jsx';
|
import HomePage from './pages/HomePage.jsx';
|
||||||
import { getRemoteReadingProgress, saveRemoteProject } from './syncService.js';
|
import { getRemoteReadingProgress, saveRemoteProject } from './syncService.js';
|
||||||
import { buildExportHtml, loadProjectIndex, switchStorageUser } from './utils/bibleUtils.jsx';
|
import {
|
||||||
|
bookOptions, buildExportHtml, loadProjectIndex, makeId, parseBibleChapter, switchStorageUser,
|
||||||
|
} from './utils/bibleUtils.jsx';
|
||||||
import { migrateOldStorageKeys, saveProjectToStorage } from './utils/projectStorage.js';
|
import { migrateOldStorageKeys, saveProjectToStorage } from './utils/projectStorage.js';
|
||||||
|
|
||||||
// Less-common pages are split into their own chunks so the first load only
|
// Less-common pages are split into their own chunks so the first load only
|
||||||
@@ -35,6 +37,7 @@ const App = () => {
|
|||||||
const [saveStatus, setSaveStatus] = useState('');
|
const [saveStatus, setSaveStatus] = useState('');
|
||||||
const saveTimerRef = useRef(null);
|
const saveTimerRef = useRef(null);
|
||||||
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
|
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
|
||||||
|
const [studyBuildBusy, setStudyBuildBusy] = useState(false);
|
||||||
const reader = useBibleReader(currentPage);
|
const reader = useBibleReader(currentPage);
|
||||||
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter);
|
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter);
|
||||||
const readingPlans = useReadingPlans();
|
const readingPlans = useReadingPlans();
|
||||||
@@ -123,6 +126,55 @@ const App = () => {
|
|||||||
setCurrentPage('reader');
|
setCurrentPage('reader');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Starts book-mode audio playback at a specific plan day's chapter, then
|
||||||
|
// shows the Home audio player. Uses handlePlayBookAudioFrom (not the reader
|
||||||
|
// player) so this works even without opening the reader first.
|
||||||
|
const listenReadingPlanDay = (bookAbbrev, chapter) => {
|
||||||
|
audio.handlePlayBookAudioFrom(bookAbbrev, chapter);
|
||||||
|
goHome();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Builds a new chunk-builder project seeded with a reading-plan day's
|
||||||
|
// passage — one whole-chapter chunk per chapter in the day — and jumps
|
||||||
|
// straight into the Study page, skipping the manual chapter/chunk setup.
|
||||||
|
const studyReadingPlanDay = async (dayRefs, title) => {
|
||||||
|
setStudyBuildBusy(true);
|
||||||
|
try {
|
||||||
|
const chapters = await Promise.all(dayRefs.map(async ({ book, chapter }) => {
|
||||||
|
const res = await fetch(`https://bible.helloao.org/api/BSB/${book}/${chapter}.json`);
|
||||||
|
if (!res.ok) throw new Error(`Failed to load ${book} ${chapter}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const verses = parseBibleChapter(data);
|
||||||
|
const bookName = bookOptions.find((b) => b.abbrev === book)?.name ?? book;
|
||||||
|
const chunk = {
|
||||||
|
id: makeId(),
|
||||||
|
startVerse: verses[0]?.number ?? 1,
|
||||||
|
endVerse: verses.at(-1)?.number ?? 1,
|
||||||
|
spilloverEndVerse: null,
|
||||||
|
observation: '', interpretation: '', application: '',
|
||||||
|
crossReferences: [], greekWords: [], generalNotes: '',
|
||||||
|
episodeNumber: '', episodeTitle: '', finalScript: '', tags: [],
|
||||||
|
};
|
||||||
|
return { book: bookName, bookAbbrev: book, chapter: String(chapter), verses, chunks: [chunk] };
|
||||||
|
}));
|
||||||
|
const newProject = {
|
||||||
|
id: makeId(),
|
||||||
|
title: title || 'Reading Plan Study',
|
||||||
|
translation: 'BSB',
|
||||||
|
lastEdited: Date.now(),
|
||||||
|
selectedChunkId: chapters[0]?.chunks[0]?.id ?? null,
|
||||||
|
chapters,
|
||||||
|
};
|
||||||
|
setProject(newProject);
|
||||||
|
setCurrentPage('study');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('studyReadingPlanDay error:', err);
|
||||||
|
window.alert("Couldn't load today's passage. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setStudyBuildBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openNewProject = () => {
|
const openNewProject = () => {
|
||||||
setProject(null);
|
setProject(null);
|
||||||
setCurrentPage('setup');
|
setCurrentPage('setup');
|
||||||
@@ -172,6 +224,9 @@ const App = () => {
|
|||||||
openBibleReader,
|
openBibleReader,
|
||||||
openReadingPlans,
|
openReadingPlans,
|
||||||
readReadingPlanDay,
|
readReadingPlanDay,
|
||||||
|
listenReadingPlanDay,
|
||||||
|
studyReadingPlanDay,
|
||||||
|
studyBuildBusy,
|
||||||
openImportProject,
|
openImportProject,
|
||||||
openNewProject,
|
openNewProject,
|
||||||
// Audio (book player on Home + chapter player in the Reader)
|
// Audio (book player on Home + chapter player in the Reader)
|
||||||
|
|||||||
@@ -67,6 +67,20 @@ export function useAudio(readerBookAbbrev, readerChapter) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Starts book-mode playback at an arbitrary chapter (e.g. a reading plan's
|
||||||
|
// "today" chapter) instead of always chapter 1. Explicit args avoid relying
|
||||||
|
// on reader state that may not have re-rendered yet if called right after
|
||||||
|
// navigating there in the same event handler.
|
||||||
|
const handlePlayBookAudioFrom = async (abbrev, startChapter) => {
|
||||||
|
audioModeRef.current = 'book';
|
||||||
|
setAudioBook(abbrev);
|
||||||
|
try {
|
||||||
|
await playAudioChapter(abbrev, startChapter, audioNarrator);
|
||||||
|
} catch {
|
||||||
|
setAudioState({ status: 'error', chapter: 0, total: 0 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleStopBookAudio = () => {
|
const handleStopBookAudio = () => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
audioRef.current.pause();
|
audioRef.current.pause();
|
||||||
@@ -161,6 +175,7 @@ export function useAudio(readerBookAbbrev, readerChapter) {
|
|||||||
audioState,
|
audioState,
|
||||||
audioProgress,
|
audioProgress,
|
||||||
handlePlayBookAudio,
|
handlePlayBookAudio,
|
||||||
|
handlePlayBookAudioFrom,
|
||||||
handleStopBookAudio,
|
handleStopBookAudio,
|
||||||
handleToggleBookAudioPause,
|
handleToggleBookAudioPause,
|
||||||
handleSkipBookChapter,
|
handleSkipBookChapter,
|
||||||
|
|||||||
+84
-54
@@ -21,6 +21,9 @@ export default function HomePage() {
|
|||||||
toggleDay,
|
toggleDay,
|
||||||
openReadingPlans,
|
openReadingPlans,
|
||||||
readReadingPlanDay,
|
readReadingPlanDay,
|
||||||
|
listenReadingPlanDay,
|
||||||
|
studyReadingPlanDay,
|
||||||
|
studyBuildBusy,
|
||||||
audioBook, setAudioBook,
|
audioBook, setAudioBook,
|
||||||
audioNarrator, setAudioNarrator,
|
audioNarrator, setAudioNarrator,
|
||||||
audioState,
|
audioState,
|
||||||
@@ -161,9 +164,61 @@ export default function HomePage() {
|
|||||||
readingActivity={readingActivity}
|
readingActivity={readingActivity}
|
||||||
openReadingPlans={openReadingPlans}
|
openReadingPlans={openReadingPlans}
|
||||||
readReadingPlanDay={readReadingPlanDay}
|
readReadingPlanDay={readReadingPlanDay}
|
||||||
|
listenReadingPlanDay={listenReadingPlanDay}
|
||||||
|
studyReadingPlanDay={studyReadingPlanDay}
|
||||||
|
studyBuildBusy={studyBuildBusy}
|
||||||
toggleDay={toggleDay}
|
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 ? (
|
{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">
|
<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="text-lg font-semibold text-slate-700">No projects yet</p>
|
||||||
@@ -252,52 +307,6 @@ export default function HomePage() {
|
|||||||
</div>
|
</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">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{projectIndex
|
{projectIndex
|
||||||
.slice()
|
.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 active = planProgress.filter((p) => !p.completedAt);
|
||||||
const streak = computeStreak(readingActivity);
|
const streak = computeStreak(readingActivity);
|
||||||
|
|
||||||
@@ -486,13 +497,32 @@ function ReadingPlansSummaryCard({ planProgress, readingActivity, openReadingPla
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{refs && (
|
{refs && (
|
||||||
<button
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => readReadingPlanDay(refs[0].book, refs[0].chapter)}
|
type="button"
|
||||||
className="shrink-0 rounded-xl bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-indigo-500"
|
onClick={() => listenReadingPlanDay(refs[0].book, refs[0].chapter)}
|
||||||
>
|
title="Listen to today's reading"
|
||||||
Read
|
className="rounded-xl border border-indigo-200 px-2.5 py-1.5 text-sm text-indigo-700 hover:bg-indigo-100"
|
||||||
</button>
|
>
|
||||||
|
🎧
|
||||||
|
</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>
|
||||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
<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,
|
removeCustomPlan,
|
||||||
updateCustomPlan,
|
updateCustomPlan,
|
||||||
readReadingPlanDay,
|
readReadingPlanDay,
|
||||||
|
listenReadingPlanDay,
|
||||||
|
studyReadingPlanDay,
|
||||||
|
studyBuildBusy,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
const [builderMode, setBuilderMode] = useState(null); // null | 'new' | a custom plan id to edit
|
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">
|
<main className="mx-auto max-w-5xl space-y-8 px-4 py-8 sm:px-6 lg:px-8">
|
||||||
{activeProgress.length > 0 && (
|
{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 && (
|
{activeProgress.length > 0 && (
|
||||||
@@ -90,6 +100,9 @@ export default function ReadingPlansPage() {
|
|||||||
key={progress.id}
|
key={progress.id}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
onRead={readReadingPlanDay}
|
onRead={readReadingPlanDay}
|
||||||
|
onListen={listenReadingPlanDay}
|
||||||
|
onStudy={studyReadingPlanDay}
|
||||||
|
studyBuildBusy={studyBuildBusy}
|
||||||
onToggleDay={toggleDay}
|
onToggleDay={toggleDay}
|
||||||
onRestart={restartPlan}
|
onRestart={restartPlan}
|
||||||
onRemove={removePlanProgress}
|
onRemove={removePlanProgress}
|
||||||
@@ -220,7 +233,7 @@ export default function ReadingPlansPage() {
|
|||||||
|
|
||||||
// A single combined "what's left to read today, across every active plan" list —
|
// 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.
|
// 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
|
const rows = activeProgress
|
||||||
.map((progress) => {
|
.map((progress) => {
|
||||||
const plan = getPlan(progress.planId);
|
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-sm font-semibold text-slate-900">{plan.title}</p>
|
||||||
<p className="truncate text-xs text-slate-500">{formatDayReadings(refs)}</p>
|
<p className="truncate text-xs text-slate-500">{formatDayReadings(refs)}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => onRead(ref.book, ref.chapter)}
|
type="button"
|
||||||
className="shrink-0 rounded-xl bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-indigo-500"
|
onClick={() => onListen(ref.book, ref.chapter)}
|
||||||
>
|
title="Listen to today's reading"
|
||||||
Read
|
className="rounded-xl border border-indigo-200 px-2.5 py-1.5 text-sm text-indigo-700 hover:bg-indigo-100"
|
||||||
</button>
|
>
|
||||||
|
🎧
|
||||||
|
</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>
|
||||||
))}
|
))}
|
||||||
</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 [expanded, setExpanded] = useState(false);
|
||||||
const plan = getPlan(progress.planId);
|
const plan = getPlan(progress.planId);
|
||||||
if (!plan) return null;
|
if (!plan) return null;
|
||||||
@@ -314,13 +346,32 @@ function ActivePlanCard({ progress, onRead, onToggleDay, onRestart, onRemove, on
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{nextFirstRef && (
|
{nextFirstRef && (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => onRead(nextFirstRef.book, nextFirstRef.chapter)}
|
type="button"
|
||||||
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
|
onClick={() => onListen(nextFirstRef.book, nextFirstRef.chapter)}
|
||||||
>
|
title="Listen to today's reading"
|
||||||
Read
|
className="rounded-xl border border-emerald-300 px-2.5 py-1.5 text-sm text-emerald-700 hover:bg-emerald-100"
|
||||||
</button>
|
>
|
||||||
|
🎧
|
||||||
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
updateProfile,
|
updateProfile,
|
||||||
updateReminderSettings,
|
updateReminderSettings,
|
||||||
} from '../syncService.js';
|
} from '../syncService.js';
|
||||||
|
import { exportJournalDocx, printJournalPdf } from '../utils/journalExport.js';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const {
|
const {
|
||||||
@@ -38,6 +39,8 @@ export default function SettingsPage() {
|
|||||||
const [reminderBusy, setReminderBusy] = useState(false);
|
const [reminderBusy, setReminderBusy] = useState(false);
|
||||||
const [reminderError, setReminderError] = useState('');
|
const [reminderError, setReminderError] = useState('');
|
||||||
const [reminderSaved, setReminderSaved] = useState(false);
|
const [reminderSaved, setReminderSaved] = useState(false);
|
||||||
|
const [journalBusy, setJournalBusy] = useState(''); // '' | 'docx' | 'pdf'
|
||||||
|
const [journalError, setJournalError] = useState('');
|
||||||
|
|
||||||
const startSetup = async () => {
|
const startSetup = async () => {
|
||||||
setMfaSetupError('');
|
setMfaSetupError('');
|
||||||
@@ -132,6 +135,30 @@ export default function SettingsPage() {
|
|||||||
window.setTimeout(() => setReminderSaved(false), 2500);
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
<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">
|
<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>}
|
{podcastNameSaved && <p className="text-sm text-emerald-600">Saved.</p>}
|
||||||
</section>
|
</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">
|
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-slate-900">Reading reminders</h2>
|
<h2 className="text-lg font-semibold text-slate-900">Reading reminders</h2>
|
||||||
|
|||||||
+109
-30
@@ -6,6 +6,7 @@ import {
|
|||||||
HeadingLevel,
|
HeadingLevel,
|
||||||
Packer,
|
Packer,
|
||||||
Paragraph,
|
Paragraph,
|
||||||
|
ShadingType,
|
||||||
Table,
|
Table,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
@@ -26,6 +27,35 @@ const DOCX_ACCENT = 'C9A84C'; // antique gold
|
|||||||
const DOCX_HEADING_FONT = 'Cormorant Garamond';
|
const DOCX_HEADING_FONT = 'Cormorant Garamond';
|
||||||
const DOCX_BODY_FONT = 'Lora';
|
const DOCX_BODY_FONT = 'Lora';
|
||||||
|
|
||||||
|
const DOCX_STYLES = {
|
||||||
|
default: {
|
||||||
|
document: { run: { font: DOCX_BODY_FONT, color: '0A0A08' } },
|
||||||
|
},
|
||||||
|
paragraphStyles: [
|
||||||
|
{
|
||||||
|
id: 'Title',
|
||||||
|
name: 'Title',
|
||||||
|
basedOn: 'Normal',
|
||||||
|
next: 'Normal',
|
||||||
|
run: { font: DOCX_HEADING_FONT, size: 56, bold: true, color: '0A0A08' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Heading1',
|
||||||
|
name: 'Heading 1',
|
||||||
|
basedOn: 'Normal',
|
||||||
|
next: 'Normal',
|
||||||
|
run: { font: DOCX_HEADING_FONT, size: 36, bold: true, color: '0A0A08' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Heading2',
|
||||||
|
name: 'Heading 2',
|
||||||
|
basedOn: 'Normal',
|
||||||
|
next: 'Normal',
|
||||||
|
run: { font: DOCX_HEADING_FONT, size: 30, bold: true, color: '0A0A08' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export function sectionLabel(text) {
|
export function sectionLabel(text) {
|
||||||
return new Paragraph({
|
return new Paragraph({
|
||||||
spacing: { before: 240, after: 80 },
|
spacing: { before: 240, after: 80 },
|
||||||
@@ -182,36 +212,85 @@ export async function buildStudyDocxBlob(project, chapterSummary) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const doc = new Document({
|
const doc = new Document({ styles: DOCX_STYLES, sections: [{ children }] });
|
||||||
styles: {
|
return Packer.toBlob(doc);
|
||||||
default: {
|
}
|
||||||
document: { run: { font: DOCX_BODY_FONT, color: '0A0A08' } },
|
|
||||||
},
|
// A journal of bookmarks, text highlights, and completed reading plans — see
|
||||||
paragraphStyles: [
|
// utils/journalExport.js for how this data is gathered.
|
||||||
{
|
export async function buildJournalDocxBlob({ bookmarks, highlights, completedPlans }) {
|
||||||
id: 'Title',
|
const children = [
|
||||||
name: 'Title',
|
new Paragraph({ text: 'My Reading Journal', heading: HeadingLevel.TITLE }),
|
||||||
basedOn: 'Normal',
|
new Paragraph({
|
||||||
next: 'Normal',
|
children: [new TextRun({ text: `Generated ${new Date().toLocaleDateString()}`, italics: true, color: '7A7060' })],
|
||||||
run: { font: DOCX_HEADING_FONT, size: 56, bold: true, color: '0A0A08' },
|
spacing: { after: 300 },
|
||||||
},
|
}),
|
||||||
{
|
];
|
||||||
id: 'Heading1',
|
|
||||||
name: 'Heading 1',
|
const heading = (text) => new Paragraph({
|
||||||
basedOn: 'Normal',
|
text,
|
||||||
next: 'Normal',
|
heading: HeadingLevel.HEADING_1,
|
||||||
run: { font: DOCX_HEADING_FONT, size: 36, bold: true, color: '0A0A08' },
|
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: DOCX_ACCENT, space: 4 } },
|
||||||
},
|
spacing: { before: 360, after: 160 },
|
||||||
{
|
|
||||||
id: 'Heading2',
|
|
||||||
name: 'Heading 2',
|
|
||||||
basedOn: 'Normal',
|
|
||||||
next: 'Normal',
|
|
||||||
run: { font: DOCX_HEADING_FONT, size: 30, bold: true, color: '0A0A08' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
sections: [{ children }],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (completedPlans.length > 0) {
|
||||||
|
children.push(heading('Reading Plans Completed'));
|
||||||
|
completedPlans.forEach(({ plan, completedAt }) => {
|
||||||
|
children.push(new Paragraph({
|
||||||
|
children: [
|
||||||
|
new TextRun({ text: `${plan.icon ?? '✓'} ${plan.title}`, bold: true, color: DOCX_ACCENT }),
|
||||||
|
new TextRun({ text: ` Completed ${new Date(completedAt).toLocaleDateString()} · ${plan.days} days`, italics: true, color: '7A7060' }),
|
||||||
|
],
|
||||||
|
spacing: { after: 80 },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bookmarks.length > 0) {
|
||||||
|
children.push(heading('Bookmarked Verses'));
|
||||||
|
bookmarks.forEach((b) => {
|
||||||
|
children.push(new Paragraph({
|
||||||
|
children: [new TextRun({ text: `${b.bookName} ${b.chapter}:${b.verse}`, bold: true, color: DOCX_ACCENT })],
|
||||||
|
spacing: { before: 160 },
|
||||||
|
}));
|
||||||
|
if (b.text) {
|
||||||
|
children.push(new Paragraph({ children: [new TextRun({ text: b.text, italics: true })], spacing: { after: 40 } }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (highlights.length > 0) {
|
||||||
|
children.push(heading('Highlighted Passages'));
|
||||||
|
highlights.forEach((h) => {
|
||||||
|
children.push(new Paragraph({
|
||||||
|
children: [new TextRun({ text: `${h.bookName} ${h.chapter}:${h.verse}`, bold: true, color: DOCX_ACCENT })],
|
||||||
|
spacing: { before: 160 },
|
||||||
|
}));
|
||||||
|
if (h.fullText) {
|
||||||
|
children.push(new Paragraph({
|
||||||
|
children: [
|
||||||
|
new TextRun({ text: h.fullText.slice(0, h.startOffset), italics: true }),
|
||||||
|
new TextRun({
|
||||||
|
text: h.excerpt ?? '',
|
||||||
|
italics: true,
|
||||||
|
bold: true,
|
||||||
|
shading: { type: ShadingType.CLEAR, fill: (h.color || '#FFF176').replace('#', '') },
|
||||||
|
}),
|
||||||
|
new TextRun({ text: h.fullText.slice(h.endOffset), italics: true }),
|
||||||
|
],
|
||||||
|
spacing: { after: 40 },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completedPlans.length + bookmarks.length + highlights.length === 0) {
|
||||||
|
children.push(new Paragraph({
|
||||||
|
text: "Nothing saved yet — bookmark a verse, highlight some text, or finish a reading plan to build your journal.",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = new Document({ styles: DOCX_STYLES, sections: [{ children }] });
|
||||||
return Packer.toBlob(doc);
|
return Packer.toBlob(doc);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// "My Reading Journal" export — gathers bookmarks, text highlights, and
|
||||||
|
// completed reading plans into one document. These first two are local-only
|
||||||
|
// (see useBibleReader.js: 'reader-bookmarks' and 'readerTextHighlights' in
|
||||||
|
// localStorage, never synced to the server), so this only ever reflects the
|
||||||
|
// current device. Offered as a .docx (matching the study-project exporter)
|
||||||
|
// and a print-to-PDF page (matching printProjectPdf in projectExport.js).
|
||||||
|
import { bookOptions, parseBibleChapter } from './bibleUtils.jsx';
|
||||||
|
import { getPlan } from './readingPlans.js';
|
||||||
|
|
||||||
|
const BOOK_NAME_BY_ABBREV = Object.fromEntries(bookOptions.map((b) => [b.abbrev, b.name]));
|
||||||
|
const BOOK_INDEX_BY_ABBREV = Object.fromEntries(bookOptions.map((b, i) => [b.abbrev, i]));
|
||||||
|
|
||||||
|
function downloadBlob(blob, filename) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadReadingProgress() {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(localStorage.getItem('readingPlanProgress') || 'null');
|
||||||
|
if (Array.isArray(raw)) return { progress: raw, customPlans: [] };
|
||||||
|
return { progress: raw?.progress ?? [], customPlans: raw?.customPlans ?? [] };
|
||||||
|
} catch {
|
||||||
|
return { progress: [], customPlans: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One fetch per distinct chapter, however many bookmarks/highlights reference it.
|
||||||
|
async function fetchVersesByChapter(refs) {
|
||||||
|
const cache = new Map();
|
||||||
|
await Promise.all(refs.map(async ({ book, chapter }) => {
|
||||||
|
const key = `${book}_${chapter}`;
|
||||||
|
if (cache.has(key)) return;
|
||||||
|
cache.set(key, null); // claim the slot so concurrent dupes don't double-fetch
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://bible.helloao.org/api/BSB/${book}/${chapter}.json`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
cache.set(key, parseBibleChapter(data));
|
||||||
|
} catch {
|
||||||
|
// leave as null — caller falls back to showing just the reference
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads bookmarks/highlights/reading-plan history from local storage and
|
||||||
|
* fetches the verse text they need, ready to hand to buildJournalDocxBlob or
|
||||||
|
* buildJournalHtml.
|
||||||
|
*/
|
||||||
|
export async function gatherJournalData() {
|
||||||
|
const bookmarksRaw = JSON.parse(localStorage.getItem('reader-bookmarks') || '{}');
|
||||||
|
const highlightsRaw = JSON.parse(localStorage.getItem('readerTextHighlights') || '[]');
|
||||||
|
|
||||||
|
const bookmarkEntries = Object.entries(bookmarksRaw).map(([key, color]) => {
|
||||||
|
const [book, chapterStr, verseStr] = key.split('-');
|
||||||
|
return { book, chapter: Number(chapterStr), verse: Number(verseStr), color };
|
||||||
|
});
|
||||||
|
|
||||||
|
const chapterRefs = [
|
||||||
|
...bookmarkEntries.map((b) => ({ book: b.book, chapter: b.chapter })),
|
||||||
|
...highlightsRaw.map((h) => ({ book: h.book, chapter: h.chapter })),
|
||||||
|
];
|
||||||
|
const versesByChapter = await fetchVersesByChapter(chapterRefs);
|
||||||
|
|
||||||
|
const sortByPassage = (a, b) => (
|
||||||
|
(BOOK_INDEX_BY_ABBREV[a.book] ?? 999) - (BOOK_INDEX_BY_ABBREV[b.book] ?? 999)
|
||||||
|
|| a.chapter - b.chapter
|
||||||
|
|| (a.verse ?? 0) - (b.verse ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const bookmarks = bookmarkEntries
|
||||||
|
.map((b) => {
|
||||||
|
const verse = versesByChapter.get(`${b.book}_${b.chapter}`)?.find((v) => v.number === b.verse);
|
||||||
|
return {
|
||||||
|
...b,
|
||||||
|
bookName: BOOK_NAME_BY_ABBREV[b.book] ?? b.book,
|
||||||
|
text: verse?.text ?? null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort(sortByPassage);
|
||||||
|
|
||||||
|
const highlights = highlightsRaw
|
||||||
|
.map((h) => {
|
||||||
|
const verse = versesByChapter.get(`${h.book}_${h.chapter}`)?.find((v) => v.number === h.verse);
|
||||||
|
const fullText = verse?.text ?? null;
|
||||||
|
const excerpt = fullText ? fullText.slice(h.startOffset, h.endOffset) : null;
|
||||||
|
return {
|
||||||
|
...h,
|
||||||
|
bookName: BOOK_NAME_BY_ABBREV[h.book] ?? h.book,
|
||||||
|
fullText,
|
||||||
|
excerpt,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort(sortByPassage);
|
||||||
|
|
||||||
|
const { progress, customPlans } = loadReadingProgress();
|
||||||
|
const completedPlans = progress
|
||||||
|
.filter((p) => p.completedAt)
|
||||||
|
.map((p) => ({ ...p, plan: getPlan(p.planId) }))
|
||||||
|
.filter((p) => p.plan)
|
||||||
|
.sort((a, b) => b.completedAt - a.completedAt);
|
||||||
|
|
||||||
|
return { bookmarks, highlights, completedPlans, customPlansCount: customPlans.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportJournalDocx() {
|
||||||
|
const data = await gatherJournalData();
|
||||||
|
// docx is large, so it's only downloaded when someone actually exports.
|
||||||
|
const { buildJournalDocxBlob } = await import('./docxExport.js');
|
||||||
|
const blob = await buildJournalDocxBlob(data);
|
||||||
|
downloadBlob(blob, `my-reading-journal-${new Date().toISOString().slice(0, 10)}.docx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return String(text ?? '').replace(/[&<>"']/g, (c) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildJournalHtml({ bookmarks, highlights, completedPlans }) {
|
||||||
|
const dateStr = new Date().toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
const section = (title, rows) => (rows.length === 0 ? '' : `
|
||||||
|
<h2>${title}</h2>
|
||||||
|
<div class="entries">${rows.join('')}</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const planRows = completedPlans.map(({ plan, completedAt }) => `
|
||||||
|
<div class="entry">
|
||||||
|
<span class="ref">${plan.icon ?? '✓'} ${escapeHtml(plan.title)}</span>
|
||||||
|
<span class="meta">Completed ${new Date(completedAt).toLocaleDateString()} · ${plan.days} days</span>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const bookmarkRows = bookmarks.map((b) => `
|
||||||
|
<div class="entry">
|
||||||
|
<span class="ref">${escapeHtml(b.bookName)} ${b.chapter}:${b.verse}</span>
|
||||||
|
${b.text ? `<p class="verse">${escapeHtml(b.text)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const highlightRows = highlights.map((h) => `
|
||||||
|
<div class="entry">
|
||||||
|
<span class="ref">${escapeHtml(h.bookName)} ${h.chapter}:${h.verse}</span>
|
||||||
|
<p class="verse">${
|
||||||
|
h.fullText
|
||||||
|
? escapeHtml(h.fullText.slice(0, h.startOffset))
|
||||||
|
+ `<mark style="background:${escapeHtml(h.color)}">${escapeHtml(h.excerpt ?? '')}</mark>`
|
||||||
|
+ escapeHtml(h.fullText.slice(h.endOffset))
|
||||||
|
: '(verse text unavailable)'
|
||||||
|
}</p>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>My Reading Journal</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Georgia, 'Times New Roman', serif; color: #1c1917; max-width: 720px; margin: 40px auto; padding: 0 20px; line-height: 1.5; }
|
||||||
|
h1 { font-size: 28px; margin-bottom: 4px; }
|
||||||
|
.subtitle { color: #78716c; font-style: italic; margin-top: 0; margin-bottom: 32px; }
|
||||||
|
h2 { font-size: 18px; text-transform: uppercase; letter-spacing: 0.06em; color: #a16207; border-bottom: 1px solid #e7e5e4; padding-bottom: 6px; margin-top: 36px; }
|
||||||
|
.entry { padding: 10px 0; border-bottom: 1px solid #f5f5f4; }
|
||||||
|
.ref { font-weight: bold; color: #a16207; }
|
||||||
|
.meta { color: #78716c; font-size: 13px; margin-left: 8px; }
|
||||||
|
.verse { margin: 4px 0 0; }
|
||||||
|
mark { padding: 1px 2px; border-radius: 2px; }
|
||||||
|
@media print { body { margin: 0; max-width: none; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>My Reading Journal</h1>
|
||||||
|
<p class="subtitle">Generated ${dateStr}</p>
|
||||||
|
${section('Reading Plans Completed', planRows)}
|
||||||
|
${section('Bookmarked Verses', bookmarkRows)}
|
||||||
|
${section('Highlighted Passages', highlightRows)}
|
||||||
|
${planRows.length + bookmarkRows.length + highlightRows.length === 0
|
||||||
|
? '<p>Nothing saved yet — bookmark a verse, highlight some text, or finish a reading plan to build your journal.</p>'
|
||||||
|
: ''}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function printJournalPdf() {
|
||||||
|
const data = await gatherJournalData();
|
||||||
|
const html = buildJournalHtml(data);
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
if (!printWindow) {
|
||||||
|
alert('Please allow pop-ups for this site to print or save as PDF.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printWindow.document.write(html);
|
||||||
|
printWindow.document.close();
|
||||||
|
printWindow.onload = () => {
|
||||||
|
printWindow.focus();
|
||||||
|
printWindow.print();
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user