-
Loading…
+
);
}
@@ -374,13 +480,7 @@ function AppRouter() {
);
}
- if (authUser === undefined && !authServerDown) {
- return (
-
- );
- }
+ if (authUser === undefined && !authServerDown) return
;
if (authUser === null && !authServerDown) return
;
@@ -390,6 +490,7 @@ function AppRouter() {
if (currentPage === 'reader') return
;
if (currentPage === 'import') return
;
if (currentPage === 'study') return
;
+ if (currentPage === 'outline') return
;
if (currentPage === 'script') return
;
if (currentPage === 'export') return
;
if (currentPage === 'setup') return
;
@@ -397,4 +498,11 @@ function AppRouter() {
return
;
}
+// The toast provider sits outside so every page (and App itself) can show toasts.
+const App = () => (
+
+
+
+);
+
export default App;
diff --git a/src/App.test.jsx b/src/App.test.jsx
index 50fc767..52e7f5a 100644
--- a/src/App.test.jsx
+++ b/src/App.test.jsx
@@ -588,14 +588,17 @@ describe('Home page', () => {
expect(JSON.parse(localStorage.getItem('bible-study-index'))[0].title).toBe('Titus Deep Dive');
});
- test('deleting a project removes it after confirmation', async () => {
+ test('deleting a project removes it at once, and Undo brings it back', async () => {
seedProject();
- window.confirm.mockReturnValue(true);
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /^delete$/i }));
await waitFor(() => expect(screen.queryByText('Titus Notes')).not.toBeInTheDocument());
expect(localStorage.getItem('bible-study-project-p1')).toBeNull();
+ expect(window.confirm).not.toHaveBeenCalled();
+ await user.click(await screen.findByRole('button', { name: /undo/i }));
+ expect(await screen.findByText('Titus Notes')).toBeInTheDocument();
+ expect(localStorage.getItem('bible-study-project-p1')).not.toBeNull();
});
test('sort choice is remembered after leaving Home', async () => {
@@ -760,28 +763,28 @@ describe('Chunk builder', () => {
expect(await addSecondChapter(user)).toHaveAttribute('aria-current', 'true');
});
- test('the Outline stage reopens the chapter of the chunk being studied', async () => {
+ test('the Chunks stage reopens the chapter of the chunk being studied', async () => {
const user = await loadChapter();
await addSecondChapter(user);
addChunk('Paul, a servant', 'In hope of eternal'); // chunk in Titus 2 (now selected)
await user.click(screen.getByRole('button', { name: 'Titus 1' }));
await user.click(screen.getByRole('button', { name: /begin studying/i }));
await findChunkCounter(1, 1);
- await user.click(screen.getByRole('button', { name: /^outline$/i }));
+ await user.click(screen.getByRole('button', { name: /^chunks$/i }));
expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveAttribute('aria-current', 'true');
});
});
// ---------------------------------------------------------------------------
-// Stage bar (Outline / Study / Script / Export)
+// Stage bar (Chunks / Study / Outline / Script / Export)
// ---------------------------------------------------------------------------
describe('Stage bar', () => {
test('later stages are disabled until a chunk exists', async () => {
await loadChapter();
- for (const name of [/^study$/i, /^script$/i, /^export$/i]) {
+ for (const name of [/^study$/i, /^outline$/i, /^script$/i, /^export$/i]) {
expect(screen.getByRole('button', { name })).toBeDisabled();
}
- expect(screen.getByRole('button', { name: /^outline$/i })).not.toBeDisabled();
+ expect(screen.getByRole('button', { name: /^chunks$/i })).not.toBeDisabled();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
expect(screen.getByRole('button', { name: /^study$/i })).not.toBeDisabled();
@@ -1240,7 +1243,7 @@ describe('Back to the dashboard', () => {
await dashboard();
});
- test.each(['Outline', 'Study', 'Script', 'Export'])('the %s stage of a study', async (stage) => {
+ test.each(['Chunks', 'Study', 'Outline', 'Script', 'Export'])('the %s stage of a study', async (stage) => {
const u = await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
@@ -1431,7 +1434,7 @@ describe('New pages start at the top', () => {
await screen.findByRole('heading', { name: /^reading plans$/i });
expect(scroller.scrollTop).toBe(0);
scroller.scrollTop = 300;
- await user.click(screen.getByRole('button', { name: /back to home/i }));
+ await user.click(screen.getByRole('button', { name: /back to home|bible study home/i }));
await screen.findByText('My Studies');
expect(scroller.scrollTop).toBe(0);
});
diff --git a/src/components/CommandPalette.jsx b/src/components/CommandPalette.jsx
new file mode 100644
index 0000000..07ed582
--- /dev/null
+++ b/src/components/CommandPalette.jsx
@@ -0,0 +1,132 @@
+// Press ⌘K (Ctrl+K) anywhere: type a passage ("john 3:16") to go straight to it, or a few letters to
+// find a study, a note you wrote, a bookmark or highlight, a page, or to search the Bible's text.
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { useApp } from '../context/AppContext.js';
+import { themeVars } from './reader/readerThemes.js';
+import { SearchIcon } from './reader/icons.jsx';
+import { formatPassage, parsePassageRef } from '../utils/passageRef.js';
+import { searchMarks, searchNotes, searchProjects } from '../utils/personalSearch.js';
+import { loadProjectIndex } from '../utils/bibleUtils.jsx';
+import { loadProjectById } from '../utils/projectStorage.js';
+
+export default function CommandPalette({ onClose }) {
+ const {
+ readerTheme, authUser, authServerDown, projectIndex, readerBookmarks, readerTextHighlights, readerColorLabels,
+ jumpToReaderVerse, setCurrentPage, setProject, resumeProject, openNewProject, goStudies, openReadingPlans,
+ openBibleReader, requestReaderSearch, openBackup,
+ } = useApp();
+ const [query, setQuery] = useState('');
+ const [active, setActive] = useState(0);
+ const listRef = useRef(null);
+ const inputRef = useRef(null);
+ const opener = useRef(document.activeElement);
+
+ useEffect(() => {
+ inputRef.current?.focus();
+ const from = opener.current;
+ return () => { if (from instanceof HTMLElement && document.contains(from)) from.focus({ preventScroll: true }); };
+ }, []);
+
+ const items = useMemo(() => {
+ const out = [];
+ const q = query.trim();
+ const go = (fn) => () => { onClose(); fn(); };
+
+ // 1. A passage typed in
+ for (const r of parsePassageRef(q)) {
+ out.push({ group: 'Go to', label: formatPassage(r), hint: 'Open in the Bible', run: go(() => { jumpToReaderVerse(r.book, r.chapter, r.verse ?? 1); openBibleReader(); }) });
+ }
+
+ // 2. Pages and actions
+ const pages = [
+ ['Studies', 'My Studies', goStudies], ['Read Bible', 'The Bible reader', openBibleReader], ['Reading Plans', 'Plans and today’s reading', openReadingPlans],
+ ['New project', 'Start a Bible study', openNewProject], ['Settings', 'Account and preferences', () => setCurrentPage('settings')],
+ ['Back up my data', 'Download everything as a file', openBackup],
+ ];
+ const lower = q.toLowerCase();
+ for (const [label, hint, fn] of pages) {
+ if (!lower || label.toLowerCase().includes(lower)) out.push({ group: lower ? 'Pages' : 'Go to', label, hint, run: go(fn) });
+ }
+
+ if (q.length >= 2) {
+ // 3. Studies
+ for (const e of searchProjects(projectIndex, q)) {
+ out.push({ group: 'Studies', label: e.title, hint: e.chapterSummary, run: go(() => resumeProject(e.id)) });
+ }
+ // 4. Notes you wrote
+ const projects = loadProjectIndex().map((e) => loadProjectById(e.id)).filter(Boolean);
+ for (const h of searchNotes(projects, q)) {
+ out.push({
+ group: 'Your notes', label: `${h.ref} — ${h.projectTitle}`, hint: h.snippet,
+ run: go(() => {
+ const full = loadProjectById(h.projectId);
+ if (!full) return;
+ setProject({ ...full, selectedChunkId: h.chunkId });
+ setCurrentPage('study');
+ }),
+ });
+ }
+ // 5. Bookmarks and highlights
+ for (const m of searchMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights, labels: readerColorLabels ?? {} }, q)) {
+ out.push({
+ group: 'Bookmarks & highlights', label: `${m.bookName} ${m.chapter}:${m.verse}`, color: m.color,
+ hint: m.kind === 'highlight' ? `${m.label ? `${m.label}: ` : ''}${m.text ?? 'Highlight'}` : 'Bookmark',
+ run: go(() => { jumpToReaderVerse(m.book, m.chapter, m.verse); openBibleReader(); }),
+ });
+ }
+ // 6. The Bible's own text
+ out.push({ group: 'Search', label: `Search the Bible for “${q}”`, hint: 'Whole-Bible search in the reader', run: go(() => { requestReaderSearch(q); openBibleReader(); }) });
+ }
+ return out;
+ }, [query]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => { setActive(0); }, [query]);
+ useEffect(() => { listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: 'nearest' }); }, [active]);
+
+ const onKeyDown = (e) => {
+ if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => Math.min(a + 1, items.length - 1)); }
+ else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
+ else if (e.key === 'Enter') { e.preventDefault(); items[active]?.run(); }
+ else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose(); }
+ else if (e.key === 'Tab') { e.preventDefault(); } // the box and the list are one control
+ };
+
+ if (authUser === null && !authServerDown) return null; // the sign-in screen has nothing to search
+ let lastGroup = null;
+ return (
+
{ if (e.target === e.currentTarget) onClose(); }}>
+
+
+
+ setQuery(e.target.value)} onKeyDown={onKeyDown}
+ role="combobox" aria-expanded="true" aria-controls="palette-list" aria-autocomplete="list"
+ aria-activedescendant={items[active] ? `palette-item-${active}` : undefined}
+ placeholder="Go to a passage, or search your studies and notes…"
+ className="min-w-0 flex-1 bg-transparent text-base outline-none placeholder:text-[var(--r-sub)]"
+ />
+ esc
+
+
+
↑↓ to move · Enter to open · Esc to close
+
+
+ );
+}
diff --git a/src/components/OfflineSave.jsx b/src/components/OfflineSave.jsx
new file mode 100644
index 0000000..48996fa
--- /dev/null
+++ b/src/components/OfflineSave.jsx
@@ -0,0 +1,32 @@
+// The "Save for offline" control: a button, or progress with a Stop button while a download runs.
+import { canSaveOffline } from '../utils/offline.js';
+
+export function DownloadProgress({ job, onCancel }) {
+ const pct = job.total ? Math.round((job.done / job.total) * 100) : 0;
+ return (
+
+
+ Saving {job.label}… {job.done} of {job.total}
+ Stop
+
+
+
+ );
+}
+
+// One book of a translation.
+export function SaveBookOffline({ offline, translation, book, name }) {
+ if (!canSaveOffline(translation)) {
+ return
Saving for offline works with the BSB and NET. The {translation}’s licence doesn’t allow it.
;
+ }
+ if (offline.job) return
;
+ const saved = offline.isSaved(translation, book);
+ return (
+
+ {saved ? `✓ ${name} (${translation}) is saved for offline` : `Read ${name} without a connection`}
+ {!saved && (
+ offline.saveBook(translation, book, name)} className="shrink-0 rounded-full border border-[var(--r-line)] px-3 py-1 text-xs font-medium hover:bg-black/5">Save for offline
+ )}
+
+ );
+}
diff --git a/src/components/ShortcutsHelp.jsx b/src/components/ShortcutsHelp.jsx
new file mode 100644
index 0000000..60bb09d
--- /dev/null
+++ b/src/components/ShortcutsHelp.jsx
@@ -0,0 +1,63 @@
+// The "?" cheat sheet: every keyboard shortcut for the page you are on, plus the ones that work everywhere.
+import { useEffect, useRef } from 'react';
+import { useApp } from '../context/AppContext.js';
+import { themeVars } from './reader/readerThemes.js';
+
+const GLOBAL = [
+ ['⌘K / Ctrl K', 'Search and go to a passage'],
+ ['?', 'Show this list'],
+ ['Esc', 'Close whatever is open'],
+];
+const BY_PAGE = {
+ reader: [
+ ['← →', 'Previous / next chapter'],
+ ['/', 'Search this chapter or the whole Bible'],
+ ['G', 'Pick a book and chapter'],
+ ['B', 'Bookmark the selected verse'],
+ [',', 'Reading settings'],
+ ['F', 'Hide or show the controls'],
+ ],
+ study: [
+ ['← →', 'Previous / next chunk'],
+ ],
+};
+
+export default function ShortcutsHelp({ onClose }) {
+ const { readerTheme, currentPage } = useApp();
+ const closeRef = useRef(null);
+ useEffect(() => {
+ const from = document.activeElement;
+ closeRef.current?.focus();
+ const onKey = (e) => { if (e.key === 'Escape' || e.key === '?') { e.preventDefault(); e.stopPropagation(); onClose(); } };
+ document.addEventListener('keydown', onKey, true);
+ return () => { document.removeEventListener('keydown', onKey, true); if (from instanceof HTMLElement && document.contains(from)) from.focus({ preventScroll: true }); };
+ }, [onClose]);
+
+ const page = BY_PAGE[currentPage] ?? [];
+ const section = (title, rows) => (
+
+
{title}
+
+ {rows.map(([keys, what]) => (
+
+
{keys}
+ {what}
+
+ ))}
+
+
+ );
+
+ return (
+
{ if (e.target === e.currentTarget) onClose(); }}>
+
+
+
Keyboard shortcuts
+ Close
+
+ {page.length > 0 && section(currentPage === 'reader' ? 'In the Bible' : 'In this study', page)}
+ {section('Everywhere', GLOBAL)}
+
+
+ );
+}
diff --git a/src/components/Toasts.jsx b/src/components/Toasts.jsx
new file mode 100644
index 0000000..572e70b
--- /dev/null
+++ b/src/components/Toasts.jsx
@@ -0,0 +1,62 @@
+// App-wide toasts: short messages at the bottom of the screen, optionally with one
+// action button (usually "Undo"). Used instead of alert()/confirm() so a slip is
+// reversible and nothing blocks the page.
+import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
+import { themeVars } from './reader/readerThemes.js';
+
+const ToastContext = createContext(null);
+
+// toast(message, { action: { label, onClick }, ms, tone: 'info' | 'error' }) -> id
+// Outside a provider (a page rendered on its own in a test) it quietly does nothing.
+const NOOP = { toast: () => 0, dismiss: () => {}, setTheme: () => {} };
+export const useToast = () => useContext(ToastContext) ?? NOOP;
+
+let nextId = 1;
+
+export function ToastProvider({ children }) {
+ const [toasts, setToasts] = useState([]);
+ const [theme, setTheme] = useState('paper'); // set by the app so toasts match the reading theme
+ const timers = useRef(new Map());
+
+ const dismiss = useCallback((id) => {
+ window.clearTimeout(timers.current.get(id));
+ timers.current.delete(id);
+ setToasts((list) => list.filter((t) => t.id !== id));
+ }, []);
+
+ const toast = useCallback((message, { action, ms, tone = 'info' } = {}) => {
+ const id = nextId++;
+ // Messages with an action stay long enough to read and react to.
+ const duration = ms ?? (action ? 8000 : tone === 'error' ? 6000 : 3500);
+ setToasts((list) => [...list.slice(-2), { id, message, action, tone }]);
+ timers.current.set(id, window.setTimeout(() => dismiss(id), duration));
+ return id;
+ }, [dismiss]);
+
+ useEffect(() => {
+ const live = timers.current;
+ return () => live.forEach((t) => window.clearTimeout(t));
+ }, []);
+
+ const value = useMemo(() => ({ toast, dismiss, setTheme }), [toast, dismiss]);
+
+ return (
+
+ {children}
+
+ {toasts.map((t) => (
+
+ {t.message}
+ {t.action && (
+ { t.action.onClick(); dismiss(t.id); }}
+ className="shrink-0 rounded-full px-2 py-0.5 text-sm font-semibold underline underline-offset-2 hover:opacity-80">
+ {t.action.label}
+
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/plans/PlanDetailSheet.jsx b/src/components/plans/PlanDetailSheet.jsx
index f34e1f6..950542d 100644
--- a/src/components/plans/PlanDetailSheet.jsx
+++ b/src/components/plans/PlanDetailSheet.jsx
@@ -5,7 +5,10 @@ import { useRef, useState } from 'react';
import Sheet from '../reader/Sheet.jsx';
import TranslationSelect from './TranslationSelect.jsx';
import { resizeImageToDataUrl } from './CustomPlanBuilder.jsx';
-import { formatDayReadings, getPlanSchedule, daysBehindPace } from '../../utils/readingPlans.js';
+import { formatDayReadings, getPlanSchedule, daysBehindPace, nextUnreadDayIndex } from '../../utils/readingPlans.js';
+import { useOfflineSave } from '../../hooks/useOfflineSave.js';
+import { DownloadProgress } from '../OfflineSave.jsx';
+import { canSaveOffline } from '../../utils/offline.js';
const btn = 'rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm hover:bg-black/5';
const primary = 'w-full rounded-xl bg-[var(--r-accent)] py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50';
@@ -14,6 +17,13 @@ function ActiveBody({ plan, progress, onClose, actions }) {
const schedule = getPlanSchedule(plan.id);
const behind = daysBehindPace(plan, progress);
const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0;
+ const offline = useOfflineSave();
+ const translation = progress.translation ?? actions.defaultTranslation;
+ // The next week of readings, so a trip or a bad connection doesn't interrupt the plan
+ const upcoming = (() => {
+ const from = nextUnreadDayIndex(plan, progress.completedDays);
+ return from == null ? [] : schedule.slice(from, from + 7).flat();
+ })();
return (
<>
@@ -42,9 +52,21 @@ function ActiveBody({ plan, progress, onClose, actions }) {
);
})}
+ {upcoming.length > 0 && canSaveOffline(translation) && (
+
+ {offline.job
+ ?
+ : (
+ offline.saveRefs('the next 7 days', translation, upcoming)}
+ className="w-full rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs text-[var(--r-sub)] hover:bg-black/5">
+ Save the next 7 days for offline ({translation})
+
+ )}
+
+ )}
- { if (window.confirm('Restart this plan from day 1?')) { actions.restart(progress.id); onClose(); } }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart
- { if (window.confirm('Remove this plan?')) { actions.remove(progress.id); onClose(); } }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove
+ { actions.restart(progress.id); onClose(); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart
+ { actions.remove(progress.id); onClose(); }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove
>
);
@@ -92,7 +114,7 @@ function CatalogBody({ plan, active, completedCount, onClose, actions }) {
{ onClose(); actions.edit(plan.id); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Edit plan
fileRef.current?.click()} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">{plan.image ? 'Change cover' : 'Add cover photo'}
{plan.image &&
actions.updateCover(plan.id, null)} className="text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">Remove cover }
-
{ if (window.confirm('Delete this plan? Its progress history will be removed too.')) { actions.delete(plan.id); onClose(); } }} className="ml-auto text-[var(--r-danger,#b42318)] hover:underline">Delete
+
{ actions.delete(plan.id); onClose(); }} className="ml-auto text-[var(--r-danger,#b42318)] hover:underline">Delete
{imageError &&
{imageError}
}
diff --git a/src/components/reader/BookmarksSheet.jsx b/src/components/reader/BookmarksSheet.jsx
deleted file mode 100644
index b128651..0000000
--- a/src/components/reader/BookmarksSheet.jsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import Sheet from './Sheet.jsx';
-import { CloseIcon } from './icons.jsx';
-
-export default function BookmarksSheet({ open, onClose, entries, onJump, onRemove }) {
- return (
-