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:
1 parent
1edbd364ea
commit
d13f55faf7
65 files changed
+2947
-208
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user