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>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 14:08:08 -04:00
1 parent 1edbd364ea
commit d13f55faf7
65 files changed
+2947 -208

No files matched your search

+6 -3
View File
@@ -9,6 +9,7 @@ import {
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() {
@@ -215,10 +216,12 @@ export default function HomePage() {
<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)]">{entry.chapterSummary}</p>}
{entry.chapterSummary && <p className="mt-1 text-sm text-[var(--r-sub)]" title={entry.chapterSummary}>{compactPassages(entry.chapterSummary)}</p>}
{entry.tags?.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{entry.tags.map((tag) => <span key={tag} className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]">{tag}</span>)}
// 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>
+205
View File
@@ -0,0 +1,205 @@
// The Outline stage: the shape of the message as a real outline — main points (I.), sub-points (A.),
// and deeper (1., a.) — written after studying the chunks. Each point can carry a passage and link to
// the chunk it comes from. Everything is kept on the project and goes out with the exports.
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { useToast } from '../components/Toasts.jsx';
import StudioShell from '../components/study/StudioShell.jsx';
import { formatChunkReference } from '../utils/bibleUtils.jsx';
import {
addAfter, addChild, addRoot, countNodes, flatten, getNode, indent, insertAt, locate, moveNode, newNode,
outdent, outlineToText, removeNode, seedFromChunks, updateNode,
} from '../utils/outline.js';
const iconBtn = 'flex h-7 w-7 items-center justify-center rounded-md text-sm text-[var(--r-sub)] hover:bg-black/5 hover:text-[var(--r-text)] disabled:opacity-30 disabled:hover:bg-transparent';
const btn = 'rounded-xl border border-[var(--r-line)] px-3.5 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
// A text box that grows with what is typed, so a long point never hides behind a scroll bar.
function PointText({ value, onChange, ...rest }) {
const ref = useRef(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}, [value]);
return (
<textarea ref={ref} rows={1} value={value} onChange={(e) => onChange(e.target.value)}
className="block w-full resize-none overflow-hidden rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-[15px] leading-snug text-[var(--r-text)] placeholder:text-[var(--r-sub)] hover:border-[var(--r-line)] focus:border-[var(--r-line)] focus:bg-[var(--r-bg)] focus:outline-none"
{...rest} />
);
}
export default function OutlinePage() {
const { project, allChunks, updateProject, setCurrentPage } = useApp();
const { toast } = useToast();
const tree = project?.outline ?? [];
const rows = flatten(tree);
const [pendingFocus, setPendingFocus] = useState(null);
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose passage/chunk fields are showing
// The one way every change is made, always from the latest outline (typing is fast).
const edit = (fn) => updateProject((current) => ({ ...current, outline: fn(current.outline ?? []) }));
useEffect(() => {
if (!pendingFocus) return;
document.querySelector(`[data-outline-id="${pendingFocus}"]`)?.focus();
setPendingFocus(null);
}, [pendingFocus, tree]);
// The chunk choices, named by passage
const chunkChoices = [];
(project?.chapters ?? []).forEach((chapter, chapterIndex) => chapter.chunks.forEach((chunk) => {
chunkChoices.push({ id: chunk.id, label: formatChunkReference(project, chapterIndex, chunk, '–') });
}));
const unlinked = seedFromChunks(project, tree).length - tree.length;
const addMain = () => {
const node = newNode();
edit((t) => addRoot(t, node));
setPendingFocus(node.id);
};
const addSub = (id) => {
const node = newNode();
edit((t) => addChild(t, id, node));
setPendingFocus(node.id);
};
const addSibling = (id) => {
const node = newNode();
edit((t) => addAfter(t, id, node));
setPendingFocus(node.id);
};
const remove = (id) => {
const where = locate(tree, id);
const gone = getNode(tree, id);
if (!where || !gone) return;
const index = rows.findIndex((r) => r.node.id === id);
edit((t) => removeNode(t, id));
setPendingFocus(rows[index - 1]?.node.id ?? null);
const count = countNodes([gone]);
toast(count > 1 ? `Deleted a point and its ${count - 1} sub-point${count > 2 ? 's' : ''}` : 'Deleted a point', {
action: { label: 'Undo', onClick: () => edit((t) => insertAt(t, where.parentId, where.index, gone)) },
});
};
const onKeyDown = (e, id, text, hasChildren) => {
if (e.nativeEvent.isComposing) return;
const at = rows.findIndex((r) => r.node.id === id);
const el = e.currentTarget;
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); addSibling(id); }
else if (e.key === 'Tab') { e.preventDefault(); edit((t) => (e.shiftKey ? outdent(t, id) : indent(t, id))); setPendingFocus(id); }
else if (e.key === 'Backspace' && text === '' && !hasChildren) { e.preventDefault(); remove(id); }
else if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { e.preventDefault(); edit((t) => moveNode(t, id, e.key === 'ArrowUp' ? -1 : 1)); setPendingFocus(id); }
else if (e.key === 'ArrowUp' && el.selectionStart === 0 && rows[at - 1]) { e.preventDefault(); setPendingFocus(rows[at - 1].node.id); }
else if (e.key === 'ArrowDown' && el.selectionStart === text.length && rows[at + 1]) { e.preventDefault(); setPendingFocus(rows[at + 1].node.id); }
};
const copy = async () => {
try { await navigator.clipboard.writeText(outlineToText(tree)); toast('Outline copied'); } catch { toast("Couldn't copy. Select the text and copy it instead.", { tone: 'error' }); }
};
const addChunks = () => {
edit((t) => seedFromChunks(project, t));
toast(`Added ${unlinked} point${unlinked === 1 ? '' : 's'} from your chunks`);
};
const openChunk = (chunkId) => {
updateProject((current) => ({ ...current, selectedChunkId: chunkId }));
setCurrentPage('study');
};
const toggleMeta = (id) => setMetaOpen((s) => { const next = new Set(s); if (next.has(id)) next.delete(id); else next.add(id); return next; });
return (
<StudioShell stage="outline">
<main className="mx-auto max-w-3xl px-4 py-8">
<div className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-xl font-medium">Outline</h1>
<p className="mt-1 text-sm text-[var(--r-sub)]">The shape of the message: main points, sub-points under them, and deeper if you need it.</p>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={addMain} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ Main point</button>
<button type="button" onClick={addChunks} disabled={unlinked === 0} className={btn} title="Adds a main point for each chunk that isn't in the outline yet">
Add my chunks{unlinked > 0 ? ` (${unlinked})` : ''}
</button>
<button type="button" onClick={copy} disabled={tree.length === 0} className={btn}>Copy as text</button>
</div>
</div>
{tree.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--r-line)] p-10 text-center">
<p className="text-base font-medium">No outline yet</p>
<p className="mx-auto mt-2 max-w-md text-sm text-[var(--r-sub)]">Start from your chunks to get a main point for each passage you studied, or start blank and write your own.</p>
<div className="mt-5 flex flex-wrap justify-center gap-2">
<button type="button" onClick={addChunks} disabled={unlinked === 0} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">Start from my chunks</button>
<button type="button" onClick={addMain} className={btn}>Start blank</button>
</div>
</div>
) : (
<ul aria-label="Outline" className="space-y-0.5">
{rows.map(({ node, depth, label }, at) => {
const hasChildren = node.children.length > 0;
const showMeta = metaOpen.has(node.id) || (!!node.ref && false);
const linked = chunkChoices.find((c) => c.id === node.chunkId);
const siblings = rows.filter((r) => r.parentId === rows[at].parentId);
const place = siblings.findIndex((r) => r.node.id === node.id);
return (
<li key={node.id} className="group" style={{ paddingLeft: `${depth * 1.75}rem` }}>
<div className="flex items-start gap-1.5">
<span className="w-9 shrink-0 pt-1.5 text-right font-serif text-sm text-[var(--r-sub)]" aria-hidden="true">{label}</span>
<div className="min-w-0 flex-1">
<PointText
value={node.text} data-outline-id={node.id}
aria-label={`Point ${label}`}
placeholder={depth === 0 ? 'Main point' : 'Sub-point'}
onChange={(value) => edit((t) => updateNode(t, node.id, { text: value }))}
onKeyDown={(e) => onKeyDown(e, node.id, node.text, hasChildren)}
/>
{(node.ref || linked) && !showMeta && (
<p className="px-2 text-xs text-[var(--r-sub)]">
{node.ref}
{linked && <>{node.ref ? ' · ' : ''}<button type="button" onClick={() => openChunk(node.chunkId)} className="text-[var(--r-accent)] hover:underline">Open chunk in Study</button></>}
</p>
)}
{showMeta && (
<div className="mb-1 grid gap-2 px-2 pt-1 sm:grid-cols-2">
<label className="text-xs text-[var(--r-sub)]">Passage
<input value={node.ref} onChange={(e) => edit((t) => updateNode(t, node.id, { ref: e.target.value }))} placeholder="e.g. Titus 1:5–9"
className="mt-0.5 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]" />
</label>
<label className="text-xs text-[var(--r-sub)]">Linked chunk
<select value={node.chunkId ?? ''} onChange={(e) => edit((t) => updateNode(t, node.id, {
chunkId: e.target.value || null,
...(e.target.value && !node.ref ? { ref: chunkChoices.find((c) => c.id === e.target.value)?.label ?? '' } : {}),
}))}
className="mt-0.5 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]">
<option value="">None</option>
{chunkChoices.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
</select>
</label>
</div>
)}
</div>
<div className="flex shrink-0 items-center opacity-0 transition focus-within:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100">
<button type="button" className={iconBtn} aria-label={`Move ${label} out a level`} title="Out a level (Shift+Tab)" disabled={depth === 0} onClick={() => { edit((t) => outdent(t, node.id)); }}>←</button>
<button type="button" className={iconBtn} aria-label={`Move ${label} in a level`} title="In a level (Tab)" disabled={place === 0} onClick={() => { edit((t) => indent(t, node.id)); }}>→</button>
<button type="button" className={iconBtn} aria-label={`Move ${label} up`} title="Up (Alt+↑)" disabled={place === 0} onClick={() => edit((t) => moveNode(t, node.id, -1))}>↑</button>
<button type="button" className={iconBtn} aria-label={`Move ${label} down`} title="Down (Alt+↓)" disabled={place === siblings.length - 1} onClick={() => edit((t) => moveNode(t, node.id, 1))}>↓</button>
<button type="button" className={iconBtn} aria-label={`Add a sub-point under ${label}`} title="Add a sub-point" onClick={() => addSub(node.id)}>+</button>
<button type="button" className={iconBtn} aria-label={`Passage and chunk for ${label}`} aria-expanded={showMeta} title="Passage and chunk" onClick={() => toggleMeta(node.id)}>⋯</button>
<button type="button" className={`${iconBtn} hover:!text-[var(--r-danger,#b42318)]`} aria-label={`Delete ${label}`} title="Delete" onClick={() => remove(node.id)}>×</button>
</div>
</div>
</li>
);
})}
</ul>
)}
{tree.length > 0 && (
<p className="mt-6 text-xs text-[var(--r-sub)]">
Enter adds a point · Tab and Shift+Tab change its level · Alt+↑ / ↓ moves it · Backspace on an empty point deletes it.
</p>
)}
</main>
</StudioShell>
);
}
+88 -11
View File
@@ -5,7 +5,9 @@ import { useApp } from '../context/AppContext.js';
import { bookOptions } from '../utils/bibleUtils.jsx';
import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings, nextUnreadDayIndex, computeStreak, nearestReadingDay } from '../utils/readingPlans.js';
import DrawCanvas from './DrawCanvas.jsx';
import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js';
import { READER_TRANSLATIONS } from '../utils/bibleText.js';
import { useParallelChapter } from '../hooks/useParallelChapter.js';
import { useOfflineSave, useOnline } from '../hooks/useOfflineSave.js';
import { useBibleSearch } from '../hooks/useBibleSearch.js';
import { themeVars } from '../components/reader/readerThemes.js';
import { TopBar, BottomBar } from '../components/reader/ReaderBars.jsx';
@@ -14,7 +16,9 @@ import PlanStrip from '../components/reader/PlanStrip.jsx';
import NavigatorSheet from '../components/reader/NavigatorSheet.jsx';
import SettingsSheet from '../components/reader/SettingsSheet.jsx';
import SearchSheet from '../components/reader/SearchSheet.jsx';
import BookmarksSheet from '../components/reader/BookmarksSheet.jsx';
import MarksSheet from '../components/reader/MarksSheet.jsx';
import { useToast } from '../components/Toasts.jsx';
import { HIGHLIGHT_COLORS, colorTitle } from '../utils/highlightColors.js';
import AudioSheet from '../components/reader/AudioSheet.jsx';
import VerseSheet from '../components/reader/VerseSheet.jsx';
import PlanSheet from '../components/reader/PlanSheet.jsx';
@@ -22,7 +26,6 @@ import PlanFooter from '../components/reader/PlanFooter.jsx';
import { FocusIcon } from '../components/reader/icons.jsx';
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
const HIGHLIGHT_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff', '#fed7aa'];
export default function ReaderPage() {
const {
@@ -40,6 +43,10 @@ export default function ReaderPage() {
readerSelectedVerse, setReaderSelectedVerse,
readerFontSize, setReaderFontSize,
readerWide: readerWideLayout, setReaderWide: setReaderWideLayout,
readerFont, setReaderFont,
readerSpacing, setReaderSpacing,
readerVerseNumbers, setReaderVerseNumbers,
readerParallel, setReaderParallel,
readerBookmarks,
readerCrossRefs, setReaderCrossRefs,
readerCrossRefsLoading,
@@ -56,6 +63,7 @@ export default function ReaderPage() {
readerGoToNextChapter,
jumpToReaderVerse,
toggleReaderBookmark,
restoreReaderBookmark,
cycleBookmarkColor,
loadReaderCrossRefs,
loadBibleIndex,
@@ -69,6 +77,8 @@ export default function ReaderPage() {
readerTextHighlights,
addTextHighlight,
removeTextHighlight,
restoreTextHighlight,
readerColorLabels, setReaderColorLabel,
planProgress,
toggleDay,
toggleChapterRead,
@@ -77,10 +87,18 @@ export default function ReaderPage() {
openPlanReading,
studyReadingPlanDay,
studyBuildBusy,
readerRequest, clearReaderRequest,
} = useApp();
const { toast } = useToast();
const offline = useOfflineSave();
const online = useOnline();
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
const readerNotice = READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.notice ?? '';
// A second Bible beside the first (never the same one twice, and not while drawing)
const parallelId = readerParallel && readerParallel !== readerTranslation ? readerParallel : '';
const parallelChapter = useParallelChapter(parallelId, readerBookAbbrev, readerChapter);
const parallelSource = READER_TRANSLATIONS.find((t) => t.id === parallelId);
const [sheet, setSheet] = useState(null); // null | 'nav' | 'settings' | 'search' | 'bookmarks' | 'audio'
const [chromeVisible, setChromeVisible] = useState(true);
@@ -203,6 +221,17 @@ export default function ReaderPage() {
}, [bibleIndex, bibleIndexLower, deferredBibleQuery]);
const remoteSearch = useBibleSearch(readerTranslation, readerSearch, wholeBibleSearching && !localSearch);
// "Search the Bible for …" from the ⌘K palette: open the whole-Bible search with the text filled in.
useEffect(() => {
if (!readerRequest?.search) return;
setReaderSearchScope('bible');
setReaderSearch(readerRequest.search);
setReaderSearchActive(true);
setSheet('search');
if (localSearch && indexStatus === 'idle') loadBibleIndex(readerTranslation);
clearReaderRequest();
}, [readerRequest]); // eslint-disable-line react-hooks/exhaustive-deps
const openSheet = (name) => {
setSheet(name);
if (name === 'search') setReaderSearchActive(true);
@@ -233,9 +262,20 @@ export default function ReaderPage() {
useEffect(() => {
const handleKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === 'ArrowLeft') readerGoToPreviousChapter();
else if (e.key === 'ArrowRight') readerGoToNextChapter();
else if (e.key === 'Escape') { if (sheet) closeSheet(); else if (readerDrawMode) setReaderDrawMode(false); else setReaderSelectedVerse(null); }
else if (!sheet && !readerDrawMode) {
// One-key shortcuts (the "?" list shows them)
if (e.key === '/') { e.preventDefault(); openSheet('search'); }
else if (e.key === 'g' || e.key === 'G') { e.preventDefault(); openSheet('nav'); }
else if (e.key === ',') { e.preventDefault(); openSheet('settings'); }
else if (e.key === 'f' || e.key === 'F') setChromeVisible((v) => !v);
else if ((e.key === 'b' || e.key === 'B') && selectedKey) {
readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey);
}
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
@@ -249,6 +289,17 @@ export default function ReaderPage() {
return () => document.removeEventListener('pointerdown', dismiss);
}, [selectionPicker]);
// Removing a highlight or bookmark is one tap, so it can always be taken back.
const removeHighlight = (id) => {
const gone = (readerTextHighlights || []).find((h) => h.id === id);
removeTextHighlight(id);
if (gone) toast('Highlight removed', { action: { label: 'Undo', onClick: () => restoreTextHighlight(gone) } });
};
const removeBookmark = (key, color) => {
toggleReaderBookmark(key);
toast('Bookmark removed', { action: { label: 'Undo', onClick: () => restoreReaderBookmark?.(key, color) } });
};
// Render verse text with colored highlights and optional search match highlight
const chapterSearch = sheet === 'search' && readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
function renderVerseText(verseNum, text, searchLow) {
@@ -276,7 +327,8 @@ export default function ReaderPage() {
if (s.type === 'hl') {
parts.push(
<mark key={s.id} style={{ backgroundColor: s.color, color: '#2b2620', borderRadius: '2px', cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); removeTextHighlight(s.id); }} title="Tap to remove highlight">
onClick={(e) => { e.stopPropagation(); removeHighlight(s.id); }}
title={`${colorTitle(s.color, readerColorLabels ?? {})} · tap to remove`}>
{text.slice(s.start, s.end)}
</mark>,
);
@@ -307,7 +359,7 @@ export default function ReaderPage() {
const selectedText = sel.toString();
const startOffset = verse.text.indexOf(selectedText);
if (startOffset === -1) return;
setSelectionPicker({ x: (rect.left + rect.right) / 2, y: rect.top, verse: verseNum, startOffset, endOffset: startOffset + selectedText.length });
setSelectionPicker({ x: (rect.left + rect.right) / 2, y: rect.top, verse: verseNum, startOffset, endOffset: startOffset + selectedText.length, text: selectedText });
}, 20);
}
@@ -317,6 +369,12 @@ export default function ReaderPage() {
return { key, color, bookAbbrev: bAbbrev, bookName: bookOptions[bookIndex]?.name ?? bAbbrev, chapter: Number(chapterStr), verse: Number(verseStr), bookIndex };
}).sort((a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse);
// Highlights newest-last in reading order, with the book's name for the list
const highlightEntries = (readerTextHighlights || []).map((h) => {
const bookIndex = bookOptions.findIndex((b) => b.abbrev === h.book);
return { ...h, bookName: bookOptions[bookIndex]?.name ?? h.book, bookIndex };
}).sort((a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse || a.startOffset - b.startOffset);
const selectedVerse = readerSelectedVerse != null ? readerVerses.find((v) => v.number === readerSelectedVerse) : null;
const selectedKey = selectedVerse ? `${readerBookAbbrev}-${readerChapter}-${selectedVerse.number}` : null;
const title = `${readerBook?.name ?? ''} ${readerChapter} · ${readerTranslation}`;
@@ -337,7 +395,7 @@ export default function ReaderPage() {
onOpenNavigator={() => openSheet('nav')}
onSearch={() => openSheet('search')}
onBookmarks={() => openSheet('bookmarks')}
bookmarkCount={bookmarkEntries.length}
bookmarkCount={bookmarkEntries.length + highlightEntries.length}
onSettings={() => openSheet('settings')}
onDoneDrawing={readerDrawMode ? () => setReaderDrawMode(false) : undefined}
/>
@@ -359,11 +417,20 @@ export default function ReaderPage() {
/>
)}
{!online && (
<p role="status" className="border-b border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-1.5 text-center font-sans text-xs text-[var(--r-sub)]">
You’re offline. Chapters you’ve read or saved still open; save a whole book from “Go to” while you’re connected.
</p>
)}
<div className={paneOpen ? 'lg:pr-[24rem]' : ''}>
{!readerDrawMode && (
<ReaderText
title={`${readerBook?.name ?? ''} ${readerChapter}`}
translation={READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.name ?? readerTranslation}
translationId={readerTranslation}
font={readerFont} spacing={readerSpacing} showVerseNumbers={readerVerseNumbers}
parallel={parallelId ? { translation: parallelId, name: parallelSource?.name ?? parallelId, notice: parallelSource?.notice ?? '', ...parallelChapter } : null}
notice={readerNotice}
verses={readerVerses}
loading={readerLoading}
@@ -445,7 +512,7 @@ export default function ReaderPage() {
crossRefsLoading={readerCrossRefsLoading}
onLoadCrossRefs={() => { if (!readerCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }}
bookmarkColor={readerBookmarks[selectedKey]}
onToggleBookmark={() => toggleReaderBookmark(selectedKey)}
onToggleBookmark={() => (readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey))}
onCycleColor={() => cycleBookmarkColor(selectedKey)}
onJump={jump}
onClose={() => setReaderSelectedVerse(null)}
@@ -471,7 +538,7 @@ export default function ReaderPage() {
bookAbbrev={readerBookAbbrev} chapter={readerChapter}
translation={readerTranslation} onTranslation={setReaderTranslation}
translationNote={ctxInfo ? `Reading “${ctxInfo.plan.title}”: this sets the plan’s translation.` : ''}
onGo={goTo}
onGo={goTo} offline={offline}
/>
<SettingsSheet
open={sheet === 'settings'} onClose={closeSheet}
@@ -479,6 +546,10 @@ export default function ReaderPage() {
theme={readerTheme} onTheme={setReaderTheme}
layout={readerLayout} onLayout={setReaderLayout}
wide={readerWideLayout} onWide={setReaderWideLayout}
font={readerFont} onFont={setReaderFont}
spacing={readerSpacing} onSpacing={setReaderSpacing}
verseNumbers={readerVerseNumbers} onVerseNumbers={setReaderVerseNumbers}
translation={readerTranslation} parallel={parallelId} onParallel={setReaderParallel}
/>
<SearchSheet
open={sheet === 'search'} onClose={closeSheet}
@@ -490,9 +561,13 @@ export default function ReaderPage() {
bibleMatches={bibleMatches} deferredBibleQuery={deferredBibleQuery} remote={remoteSearch}
onJump={jump}
/>
<BookmarksSheet
<MarksSheet
open={sheet === 'bookmarks'} onClose={closeSheet}
entries={bookmarkEntries} onJump={jump} onRemove={toggleReaderBookmark}
bookmarks={bookmarkEntries} highlights={highlightEntries}
colorLabels={readerColorLabels ?? {}} onSetLabel={setReaderColorLabel}
onJump={jump}
onRemoveBookmark={(e) => removeBookmark(e.key, e.color)}
onRemoveHighlight={(h) => removeHighlight(h.id)}
/>
<AudioSheet
open={sheet === 'audio'} onClose={closeSheet}
@@ -514,7 +589,8 @@ export default function ReaderPage() {
{HIGHLIGHT_COLORS.map((color) => (
<button
key={color}
aria-label="Highlight"
aria-label={`Highlight ${colorTitle(color, readerColorLabels ?? {}).toLowerCase()}`}
title={colorTitle(color, readerColorLabels ?? {})}
className="h-7 w-7 rounded-full border-2 border-white shadow-sm transition-transform hover:scale-110 active:scale-95"
style={{ backgroundColor: color }}
onPointerDown={(e) => {
@@ -522,6 +598,7 @@ export default function ReaderPage() {
addTextHighlight({
book: readerBookAbbrev, chapter: readerChapter, verse: selectionPicker.verse,
startOffset: selectionPicker.startOffset, endOffset: selectionPicker.endOffset, color,
text: selectionPicker.text,
});
window.getSelection()?.removeAllRanges();
setSelectionPicker(null);
+9 -20
View File
@@ -3,8 +3,7 @@
// the custom-plan builder and everything else open as sheets.
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { themeVars, READER_THEMES } from '../components/reader/readerThemes.js';
import { ChevronLeft } from '../components/reader/icons.jsx';
import PageShell from '../components/study/PageShell.jsx';
import Sheet from '../components/reader/Sheet.jsx';
import PlanDetailSheet from '../components/plans/PlanDetailSheet.jsx';
import CustomPlanBuilder from '../components/plans/CustomPlanBuilder.jsx';
@@ -14,7 +13,6 @@ import {
} from '../utils/readingPlans.js';
const CATEGORY_ORDER = ['Whole Bible', 'Old Testament', 'New Testament', 'Topical'];
const THEME_ORDER = ['paper', 'sepia', 'dark'];
const pill = 'rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[10px] text-[var(--r-sub)]';
const sectionTitle = 'mb-2 mt-7 text-[11px] uppercase tracking-wide text-[var(--r-sub)]';
@@ -34,13 +32,12 @@ function nextOf(progress) {
export default function ReadingPlansPage() {
const {
goHome,
planProgress, customPlans, readingActivity,
startPlan, toggleDay, restartPlan, removePlanProgress, recalibratePace, setPlanTranslation,
addCustomPlan, removeCustomPlan, updateCustomPlan,
openPlanReading, studyReadingPlanDay, studyBuildBusy,
readingPlanSyncStatus, readingPlanSyncError, retryReadingPlanSync,
readerTheme, setReaderTheme, readerTranslation, planTranslationPref,
readerTranslation, planTranslationPref,
} = useApp();
const [detail, setDetail] = useState(null); // { planId, progressId? }
@@ -59,8 +56,6 @@ export default function ReadingPlansPage() {
const categories = ['All', ...CATEGORY_ORDER.filter((c) => READING_PLANS.some((p) => p.category === c)), ...(customPlans.length ? ['My Plans'] : [])];
const shown = catalog.filter((p) => category === 'All' || p.category === category);
const cycleTheme = () => setReaderTheme(THEME_ORDER[(THEME_ORDER.indexOf(readerTheme) + 1) % THEME_ORDER.length]);
const actions = {
defaultTranslation: planTranslationPref && planTranslationPref !== 'same' ? planTranslationPref : readerTranslation,
setTranslation: setPlanTranslation,
@@ -100,17 +95,11 @@ export default function ReadingPlansPage() {
};
return (
<div className="min-h-dvh bg-[var(--r-bg)] pb-16 text-[var(--r-text)]" style={themeVars(readerTheme)}>
<div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
<div className="mx-auto flex h-12 max-w-3xl items-center gap-1 px-2">
<button type="button" onClick={goHome} aria-label="Back to home" className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button>
<h1 className="mx-auto text-sm font-medium">Reading plans</h1>
{streak.current > 0 && <span className="text-xs text-[var(--r-accent)]">{streak.current} day streak</span>}
<button type="button" onClick={cycleTheme} className="ml-2 rounded-lg px-2 py-1.5 text-xs text-[var(--r-sub)] hover:bg-black/5" title="Change theme">{READER_THEMES[readerTheme]?.label}</button>
</div>
</div>
<main className="mx-auto max-w-3xl px-4 pt-5 font-sans">
<PageShell
title="Reading Plans" active="plans" maxWidth="max-w-3xl"
actions={streak.current > 0 ? <span className="text-xs text-[var(--r-accent)]">{streak.current} day streak</span> : null}
>
<main className="mx-auto max-w-3xl px-4 pb-16 pt-5 font-sans">
{readingPlanSyncStatus === 'error' && (
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-red-300 px-4 py-3 text-sm">
<span className="text-[var(--r-danger,#b42318)]">
@@ -217,7 +206,7 @@ export default function ReadingPlansPage() {
</div>
<div className="flex shrink-0 items-center gap-3 text-xs">
<button type="button" onClick={() => restartPlan(p.id)} className="font-medium hover:underline">Start again</button>
<button type="button" onClick={() => { if (window.confirm('Remove this from your history?')) removePlanProgress(p.id); }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
<button type="button" onClick={() => removePlanProgress(p.id)} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
</div>
</div>
);
@@ -240,6 +229,6 @@ export default function ReadingPlansPage() {
<CustomPlanBuilder key={builderMode} initialValues={editingPlan} onSubmit={submitBuilder} onCancel={() => setBuilderMode(null)} />
)}
</Sheet>
</div>
</PageShell>
);
}
+5 -1
View File
@@ -3,6 +3,7 @@
// pronunciation guide live here because this is where they're needed.
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { useToast } from '../components/Toasts.jsx';
import StudioShell from '../components/study/StudioShell.jsx';
import { formatChunkReference } from '../utils/bibleUtils.jsx';
import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js';
@@ -15,6 +16,7 @@ const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:
export default function ScriptPage() {
const { project, allChunks, updateProject, updateChunk, authUser, setSaveStatus, setCurrentPage } = useApp();
const [notice, setNotice] = useState('');
const { toast } = useToast();
const chunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
const chapterIndex = chunk ? project.chapters.findIndex((ch) => ch.chunks.some((c) => c.id === chunk.id)) : -1;
@@ -26,9 +28,11 @@ export default function ScriptPage() {
const set = (patch) => updateChunk(chunk.id, patch);
const append = (text) => set({ finalScript: script.trim() ? `${script.replace(/\s+$/, '')}\n\n${text}` : text });
// Replaces the script with an outline from your notes — and says how to get the old one back.
const draft = () => {
if (has(script) && !window.confirm('Replace the current script with a fresh outline from your notes?')) return;
const before = script;
set({ finalScript: buildScriptDraft(chunk, reference) });
if (has(before)) toast('Script replaced with a fresh outline', { action: { label: 'Undo', onClick: () => set({ finalScript: before }) } });
};
const importDocx = async (file) => {
+13 -2
View File
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx';
import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js';
import { READER_TRANSLATIONS } from '../utils/bibleText.js';
import DataSection from '../components/settings/DataSection.jsx';
import { COMMENTARIES } from '../hooks/useVerseStudy.js';
import { READER_THEMES } from '../components/reader/readerThemes.js';
import {
@@ -55,7 +56,15 @@ export default function SettingsPage() {
readerWide, setReaderWide,
readerCommentary, setReaderCommentary,
audioNarrator, setAudioNarrator,
settingsFocus, clearSettingsFocus,
} = useApp();
// "Back up my data" from the ⌘K palette lands on that section
useEffect(() => {
if (!settingsFocus) return;
document.getElementById(settingsFocus)?.scrollIntoView({ block: 'start' });
clearSettingsFocus();
}, [settingsFocus]); // eslint-disable-line react-hooks/exhaustive-deps
const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress
const [mfaSetupCode, setMfaSetupCode] = useState('');
const [mfaSetupError, setMfaSetupError] = useState('');
@@ -341,6 +350,8 @@ export default function SettingsPage() {
{podcastNameSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>}
</section>
<DataSection />
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div>
<h2 className="text-base font-medium text-[var(--r-text)]">My reading journal</h2>