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
@@ -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 (
|
||||
<div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-start justify-center bg-black/40 px-3 pt-[12vh] font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div role="dialog" aria-modal="true" aria-label="Search and go to" className="w-full max-w-xl overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] shadow-2xl">
|
||||
<div className="flex items-center gap-2 border-b border-[var(--r-line)] px-4 py-3">
|
||||
<span className="text-[var(--r-sub)]"><SearchIcon /></span>
|
||||
<input
|
||||
ref={inputRef} value={query} onChange={(e) => 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)]"
|
||||
/>
|
||||
<kbd className="hidden rounded border border-[var(--r-line)] px-1.5 py-0.5 text-[10px] text-[var(--r-sub)] sm:block">esc</kbd>
|
||||
</div>
|
||||
<ul id="palette-list" role="listbox" ref={listRef} className="max-h-[50vh] overflow-y-auto p-2">
|
||||
{items.length === 0 && <li className="px-3 py-6 text-center text-sm text-[var(--r-sub)]">Nothing found. Try a book and chapter, like “romans 8”.</li>}
|
||||
{items.map((item, i) => {
|
||||
const header = item.group !== lastGroup ? item.group : null;
|
||||
lastGroup = item.group;
|
||||
return (
|
||||
<li key={i} role="presentation">
|
||||
{header && <p className="px-3 pb-1 pt-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{header}</p>}
|
||||
<div id={`palette-item-${i}`} role="option" aria-selected={i === active} onMouseMove={() => setActive(i)} onClick={item.run}
|
||||
className={`flex cursor-pointer items-baseline gap-2 rounded-lg px-3 py-2 text-sm ${i === active ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : ''}`}>
|
||||
{item.color && <span className="h-2.5 w-2.5 shrink-0 translate-y-px rounded-full border border-black/10" style={{ backgroundColor: item.color }} />}
|
||||
<span className="shrink-0 font-medium">{item.label}</span>
|
||||
{item.hint && <span className={`min-w-0 truncate text-xs ${i === active ? 'opacity-80' : 'text-[var(--r-sub)]'}`}>{item.hint}</span>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="border-t border-[var(--r-line)] px-4 py-2 text-[11px] text-[var(--r-sub)]">↑↓ to move · Enter to open · Esc to close</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div role="status" className="rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<span>Saving {job.label}… {job.done} of {job.total}</span>
|
||||
<button type="button" onClick={onCancel} className="text-[var(--r-accent)] hover:underline">Stop</button>
|
||||
</div>
|
||||
<div className="h-1 overflow-hidden rounded-full bg-[var(--r-line)]"><div className="h-full bg-[var(--r-accent)] transition-[width]" style={{ width: `${pct}%` }} /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One book of a translation.
|
||||
export function SaveBookOffline({ offline, translation, book, name }) {
|
||||
if (!canSaveOffline(translation)) {
|
||||
return <p className="mt-4 text-[11px] text-[var(--r-sub)]">Saving for offline works with the BSB and NET. The {translation}’s licence doesn’t allow it.</p>;
|
||||
}
|
||||
if (offline.job) return <div className="mt-4"><DownloadProgress job={offline.job} onCancel={offline.cancel} /></div>;
|
||||
const saved = offline.isSaved(translation, book);
|
||||
return (
|
||||
<div className="mt-4 flex items-center justify-between gap-3 rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm">
|
||||
<span className={saved ? '' : 'text-[var(--r-sub)]'}>{saved ? `✓ ${name} (${translation}) is saved for offline` : `Read ${name} without a connection`}</span>
|
||||
{!saved && (
|
||||
<button type="button" onClick={() => 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</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
<div className="mb-4 last:mb-0">
|
||||
<h3 className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{title}</h3>
|
||||
<dl className="space-y-1.5">
|
||||
{rows.map(([keys, what]) => (
|
||||
<div key={keys} className="flex items-center justify-between gap-4 text-sm">
|
||||
<dt><kbd className="rounded-md border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-0.5 font-mono text-xs">{keys}</kbd></dt>
|
||||
<dd className="text-right text-[var(--r-sub)]">{what}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 px-3 font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div role="dialog" aria-modal="true" aria-label="Keyboard shortcuts" className="max-h-[80vh] w-full max-w-sm overflow-y-auto rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5 text-[var(--r-text)] shadow-2xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-base font-medium">Keyboard shortcuts</h2>
|
||||
<button ref={closeRef} type="button" onClick={onClose} className="rounded-lg px-2 py-1 text-sm text-[var(--r-sub)] hover:bg-black/5">Close</button>
|
||||
</div>
|
||||
{page.length > 0 && section(currentPage === 'reader' ? 'In the Bible' : 'In this study', page)}
|
||||
{section('Everywhere', GLOBAL)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div style={themeVars(theme)} className="pointer-events-none fixed inset-x-0 bottom-0 z-[100] flex flex-col items-center gap-2 px-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] font-sans">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} role={t.tone === 'error' ? 'alert' : 'status'}
|
||||
className={`pointer-events-auto flex max-w-md items-center gap-3 rounded-full px-4 py-2.5 text-sm shadow-lg ${t.tone === 'error' ? 'bg-[var(--r-danger)] text-[var(--r-bg)]' : 'bg-[var(--r-text)] text-[var(--r-bg)]'}`}>
|
||||
<span>{t.message}</span>
|
||||
{t.action && (
|
||||
<button type="button" onClick={() => { 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}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between text-sm">
|
||||
@@ -42,9 +52,21 @@ function ActiveBody({ plan, progress, onClose, actions }) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{upcoming.length > 0 && canSaveOffline(translation) && (
|
||||
<div className="mt-3">
|
||||
{offline.job
|
||||
? <DownloadProgress job={offline.job} onCancel={offline.cancel} />
|
||||
: (
|
||||
<button type="button" onClick={() => 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})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex items-center justify-between text-xs">
|
||||
<button type="button" onClick={() => { 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</button>
|
||||
<button type="button" onClick={() => { if (window.confirm('Remove this plan?')) { actions.remove(progress.id); onClose(); } }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
|
||||
<button type="button" onClick={() => { actions.restart(progress.id); onClose(); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart</button>
|
||||
<button type="button" onClick={() => { actions.remove(progress.id); onClose(); }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -92,7 +114,7 @@ function CatalogBody({ plan, active, completedCount, onClose, actions }) {
|
||||
<button type="button" onClick={() => { onClose(); actions.edit(plan.id); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Edit plan</button>
|
||||
<button type="button" onClick={() => fileRef.current?.click()} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">{plan.image ? 'Change cover' : 'Add cover photo'}</button>
|
||||
{plan.image && <button type="button" onClick={() => actions.updateCover(plan.id, null)} className="text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">Remove cover</button>}
|
||||
<button type="button" onClick={() => { 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</button>
|
||||
<button type="button" onClick={() => { actions.delete(plan.id); onClose(); }} className="ml-auto text-[var(--r-danger,#b42318)] hover:underline">Delete</button>
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
|
||||
{imageError && <p className="w-full text-[var(--r-danger,#b42318)]">{imageError}</p>}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { CloseIcon } from './icons.jsx';
|
||||
|
||||
export default function BookmarksSheet({ open, onClose, entries, onJump, onRemove }) {
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title={`Bookmarks${entries.length ? ` (${entries.length})` : ''}`}>
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-sm text-[var(--r-sub)]">No bookmarks yet. Tap a verse, then the bookmark icon, to save it here.</p>
|
||||
) : entries.map((e) => (
|
||||
<div key={e.key} className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
|
||||
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: e.color }} />
|
||||
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
|
||||
{e.bookName} {e.chapter}:{e.verse}
|
||||
</button>
|
||||
<button type="button" onClick={() => onRemove(e.key)} aria-label="Remove bookmark" className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
|
||||
</div>
|
||||
))}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Everything you have marked in the Bible: highlights (grouped and filtered by colour, each colour
|
||||
// named by you — "Promises", "Commands") and bookmarks. Opens from the bookmark button in the top bar.
|
||||
import { useState } from 'react';
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { CloseIcon } from './icons.jsx';
|
||||
import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
|
||||
|
||||
const tabBtn = (active) => `rounded-full border px-3 py-1 text-xs font-medium ${active ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
|
||||
|
||||
export default function MarksSheet({
|
||||
open, onClose, bookmarks, highlights, colorLabels, onSetLabel, onJump, onRemoveBookmark, onRemoveHighlight,
|
||||
}) {
|
||||
const [tab, setTab] = useState('highlights');
|
||||
const [filter, setFilter] = useState(null); // a highlight colour, or null for all
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const counts = Object.fromEntries(HIGHLIGHT_COLORS.map((c) => [c, highlights.filter((h) => h.color === c).length]));
|
||||
const shown = filter ? highlights.filter((h) => h.color === filter) : highlights;
|
||||
// Colours in use that aren't one of the picker's (older data) still get listed
|
||||
const total = highlights.length + bookmarks.length;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} label="Bookmarks and highlights" title={`Bookmarks & highlights${total ? ` (${total})` : ''}`}>
|
||||
<div role="tablist" aria-label="What to show" className="mb-3 flex gap-1.5">
|
||||
<button type="button" role="tab" aria-selected={tab === 'highlights'} onClick={() => setTab('highlights')} className={tabBtn(tab === 'highlights')}>Highlights ({highlights.length})</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'bookmarks'} onClick={() => setTab('bookmarks')} className={tabBtn(tab === 'bookmarks')}>Bookmarks ({bookmarks.length})</button>
|
||||
</div>
|
||||
|
||||
{tab === 'highlights' && (
|
||||
<>
|
||||
{/* Colour key: tap a colour to show only that one; "Name colours" lets you say what each means */}
|
||||
<div className="mb-3 flex flex-wrap items-center gap-1.5" role="group" aria-label="Filter by colour">
|
||||
<button type="button" onClick={() => setFilter(null)} aria-pressed={filter === null} className={tabBtn(filter === null)}>All</button>
|
||||
{HIGHLIGHT_COLORS.map((c) => (
|
||||
<button key={c} type="button" onClick={() => setFilter(filter === c ? null : c)} aria-pressed={filter === c}
|
||||
title={colorTitle(c, colorLabels)}
|
||||
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${filter === c ? 'border-[var(--r-text)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
|
||||
<span className="h-3 w-3 rounded-full border border-black/10" style={{ backgroundColor: c }} />
|
||||
<span>{colorLabels[c] || COLOR_NAMES[c]}</span>
|
||||
<span className="text-[var(--r-sub)]">{counts[c]}</span>
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={() => setEditing((e) => !e)} className="ml-auto text-xs text-[var(--r-accent)] hover:underline">{editing ? 'Done' : 'Name colours'}</button>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div className="mb-3 grid gap-2 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3 sm:grid-cols-2">
|
||||
{HIGHLIGHT_COLORS.map((c) => (
|
||||
<label key={c} className="flex items-center gap-2 text-xs text-[var(--r-sub)]">
|
||||
<span className="h-4 w-4 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: c }} />
|
||||
<span className="sr-only">{COLOR_NAMES[c]} means</span>
|
||||
<input value={colorLabels[c] ?? ''} maxLength={30} onChange={(e) => onSetLabel(c, e.target.value)} placeholder={COLOR_SUGGESTIONS[c]}
|
||||
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-transparent px-2 py-1 text-sm text-[var(--r-text)] outline-none focus:border-[var(--r-accent)]" />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{highlights.length === 0 ? (
|
||||
<p className="text-sm text-[var(--r-sub)]">No highlights yet. Select some words in a verse and pick a colour to save them here.</p>
|
||||
) : shown.length === 0 ? (
|
||||
<p className="text-sm text-[var(--r-sub)]">Nothing highlighted in that colour yet.</p>
|
||||
) : shown.map((h) => (
|
||||
<div key={h.id} className="flex items-start gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
|
||||
<span className="mt-1 h-3 w-3 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: h.color }} title={colorTitle(h.color, colorLabels)} />
|
||||
<button type="button" onClick={() => onJump(h.book, h.chapter, h.verse)} className="min-w-0 flex-1 text-left text-sm">
|
||||
<span className="font-medium">{h.bookName} {h.chapter}:{h.verse}</span>
|
||||
{colorLabels[h.color] && <span className="ml-1.5 text-[11px] text-[var(--r-sub)]">{colorLabels[h.color]}</span>}
|
||||
{h.text && <span className="mt-0.5 block font-serif text-[var(--r-sub)]">“{h.text}”</span>}
|
||||
</button>
|
||||
<button type="button" onClick={() => onRemoveHighlight(h)} aria-label={`Remove highlight on ${h.bookName} ${h.chapter}:${h.verse}`}
|
||||
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'bookmarks' && (
|
||||
bookmarks.length === 0 ? (
|
||||
<p className="text-sm text-[var(--r-sub)]">No bookmarks yet. Tap a verse, then the bookmark icon, to save it here.</p>
|
||||
) : bookmarks.map((e) => (
|
||||
<div key={e.key} className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
|
||||
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: e.color }} />
|
||||
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
|
||||
{e.bookName} {e.chapter}:{e.verse}
|
||||
</button>
|
||||
<button type="button" onClick={() => onRemoveBookmark(e)} aria-label={`Remove bookmark on ${e.bookName} ${e.chapter}:${e.verse}`}
|
||||
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,13 @@
|
||||
import { useState } from 'react';
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { bookOptions, CHAPTER_COUNTS } from '../../utils/bibleUtils.jsx';
|
||||
import { READER_TRANSLATIONS } from '../../hooks/useBibleReader.js';
|
||||
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
|
||||
import { SaveBookOffline } from '../OfflineSave.jsx';
|
||||
|
||||
const NT_START = bookOptions.findIndex((b) => b.abbrev === 'MAT');
|
||||
const chip = 'rounded-lg border px-2 py-2 text-center text-sm transition';
|
||||
|
||||
export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, translation, onTranslation, translationNote, onGo }) {
|
||||
export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, translation, onTranslation, translationNote, onGo, offline }) {
|
||||
const [pickedBook, setPickedBook] = useState(null); // book abbrev whose chapters are showing
|
||||
const close = () => { setPickedBook(null); onClose(); };
|
||||
const book = pickedBook ? bookOptions.find((b) => b.abbrev === pickedBook) : null;
|
||||
@@ -20,7 +21,7 @@ export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, tra
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={close} title={book ? (
|
||||
<Sheet open={open} onClose={close} label="Go to" title={book ? (
|
||||
<button type="button" onClick={() => setPickedBook(null)} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">‹ All books · <span className="text-[var(--r-text)]">{book.name}</span></button>
|
||||
) : 'Go to'}>
|
||||
{!book && (
|
||||
@@ -41,6 +42,7 @@ export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, tra
|
||||
</>
|
||||
)}
|
||||
{book && (
|
||||
<>
|
||||
<div className="grid grid-cols-6 gap-1.5 sm:grid-cols-8">
|
||||
{Array.from({ length: CHAPTER_COUNTS[book.abbrev] ?? 1 }, (_, i) => i + 1).map((n) => (
|
||||
<button key={n} type="button" onClick={() => { onGo(book.abbrev, n); close(); }}
|
||||
@@ -49,6 +51,8 @@ export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, tra
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{offline && <SaveBookOffline offline={offline} translation={translation} book={book.abbrev} name={book.name} />}
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
@@ -2,8 +2,18 @@
|
||||
// serif face. Tap a verse to open the verse sheet; select text to highlight.
|
||||
import { formatCrossRef } from '../../utils/wordStudy.js';
|
||||
|
||||
// How each typeface choice is set. "Easy to read" is a sans face with extra letter and word spacing.
|
||||
const FONT_STYLES = {
|
||||
serif: { fontFamily: undefined },
|
||||
sans: { fontFamily: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif" },
|
||||
easy: { fontFamily: "'Atkinson Hyperlegible', Verdana, 'Trebuchet MS', ui-sans-serif, sans-serif", letterSpacing: '0.02em', wordSpacing: '0.12em' },
|
||||
};
|
||||
const LINE_HEIGHTS = { snug: 1.6, normal: 1.85, airy: 2.15 };
|
||||
|
||||
// `parallel` ({ translation, name, notice, verses, loading, error }) shows a second Bible beside the first.
|
||||
export default function ReaderText({
|
||||
title, translation, notice, verses, loading, error, layout, fontSize, wide,
|
||||
title, translation, translationId, notice, verses, loading, error, layout, fontSize, wide,
|
||||
font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null,
|
||||
bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower,
|
||||
crossRefs, showCrossRefs, onSelectionEnd, footer,
|
||||
}) {
|
||||
@@ -20,7 +30,12 @@ export default function ReaderText({
|
||||
id: `reader-verse-${v.number}`,
|
||||
'data-verse-num': v.number,
|
||||
onClick: () => handleTap(v.number),
|
||||
className: 'cursor-pointer rounded-sm transition-colors',
|
||||
// Verses can be reached and chosen from the keyboard too
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
'aria-pressed': selected,
|
||||
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleTap(v.number); } },
|
||||
className: 'cursor-pointer rounded-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--r-accent)]',
|
||||
style: selected
|
||||
? { backgroundColor: 'var(--r-hl)' }
|
||||
: bm ? { backgroundColor: `${bm}66`, boxShadow: `0 0 0 2px ${bm}66` } : undefined,
|
||||
@@ -35,25 +50,71 @@ export default function ReaderText({
|
||||
</span>
|
||||
);
|
||||
|
||||
const num = (n) => <sup className="mr-1 select-none font-sans text-[0.6em] text-[var(--r-sub)]">{n}</sup>;
|
||||
const num = (n) => (showVerseNumbers ? <sup className="mr-1 select-none font-sans text-[0.6em] text-[var(--r-sub)]">{n}</sup> : null);
|
||||
|
||||
// Both Bibles by verse number, so a verse one translation leaves out still lines up with the other.
|
||||
const parallelRows = parallel && !parallel.error ? (() => {
|
||||
const second = new Map(parallel.verses.map((v) => [v.number, v]));
|
||||
const first = new Map(verses.map((v) => [v.number, v]));
|
||||
return [...new Set([...first.keys(), ...second.keys()])].sort((a, b) => a - b)
|
||||
.map((n) => ({ number: n, first: first.get(n), second: second.get(n) }));
|
||||
})() : null;
|
||||
const sideBySide = !!parallel;
|
||||
const bodyStyle = {
|
||||
...FONT_STYLES[font],
|
||||
lineHeight: LINE_HEIGHTS[spacing] ?? LINE_HEIGHTS.normal,
|
||||
};
|
||||
// The page grows with the screen: type is 18px on a phone, ~20px on an iPad and ~24px on a big
|
||||
// monitor (times your text-size choice), and the column is measured in the type's own width (em), so
|
||||
// the line length stays comfortable (~70 characters) instead of becoming a narrow strip on a wide
|
||||
// screen. Two columns / side-by-side take more room.
|
||||
const pageStyle = {
|
||||
fontSize: `calc(clamp(1.125rem, 0.9rem + 0.7vw, 1.55rem) * ${fontSize})`,
|
||||
maxWidth: wide || sideBySide ? 'min(100%, 72em)' : 'min(100%, 40em)',
|
||||
};
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`mx-auto px-5 pb-32 pt-6 text-[var(--r-text)] ${wide ? 'max-w-5xl' : 'max-w-2xl'}`}
|
||||
className="mx-auto px-5 pb-32 pt-6 text-[var(--r-text)] md:px-8"
|
||||
style={pageStyle}
|
||||
onPointerUp={onSelectionEnd}
|
||||
>
|
||||
<header className="mb-5 text-center">
|
||||
<h1 className="font-serif text-3xl font-normal tracking-tight">{title}</h1>
|
||||
<p className="mt-1 font-sans text-xs text-[var(--r-sub)]">{translation}</p>
|
||||
<p className="mt-1 font-sans text-xs text-[var(--r-sub)]">{translation}{parallel ? ` · beside ${parallel.name}` : ''}</p>
|
||||
</header>
|
||||
|
||||
{loading && <p className="text-center font-sans text-sm text-[var(--r-sub)]">Loading…</p>}
|
||||
{error && <p className="text-center font-sans text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||
{loading && <ChapterSkeleton />}
|
||||
{error && <p role="alert" className="text-center font-sans text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||
{!loading && !error && parallel?.error && (
|
||||
<p role="alert" className="mb-4 text-center font-sans text-sm text-[var(--r-danger,#b42318)]">Couldn’t load {parallel.name}: {parallel.error}</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
{!loading && !error && sideBySide && !parallel.error && (
|
||||
<div style={bodyStyle} className="font-serif">
|
||||
<div className="mb-3 hidden grid-cols-2 gap-x-10 border-b border-[var(--r-line)] pb-1 font-sans text-[11px] uppercase tracking-wide text-[var(--r-sub)] md:grid">
|
||||
<span>{translationId}</span><span>{parallel.translation}</span>
|
||||
</div>
|
||||
{parallelRows.map((row) => (
|
||||
<div key={row.number} className="mb-4 grid gap-x-10 gap-y-1 md:grid-cols-2">
|
||||
<p>
|
||||
{row.first
|
||||
? <span {...verseProps(row.first)}>{num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower)}{chips(row.first)}</span>
|
||||
: <span className="text-[var(--r-sub)]">{num(row.number)}—</span>}
|
||||
</p>
|
||||
<p className="border-l-2 border-[var(--r-line)] pl-3 text-[0.95em] text-[var(--r-sub)] md:border-l-0 md:pl-0 md:text-[var(--r-text)]">
|
||||
<span className="mr-1.5 font-sans text-[10px] uppercase tracking-wide md:hidden">{parallel.translation}</span>
|
||||
{row.second ? <>{num(row.second.number)}{row.second.text}</> : parallel.loading ? <span className="font-sans text-xs" aria-label="Loading">…</span> : '—'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && !sideBySide && (
|
||||
<div
|
||||
className={`font-serif ${wide ? 'md:columns-2 md:gap-x-12' : ''}`}
|
||||
style={{ fontSize: `${1.125 * fontSize}rem`, lineHeight: 1.85 }}
|
||||
style={bodyStyle}
|
||||
>
|
||||
{layout === 'flow' ? (
|
||||
<p className="text-pretty">
|
||||
@@ -73,9 +134,20 @@ export default function ReaderText({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && footer}
|
||||
{!loading && !error && <div style={{ fontSize: '1rem' }}>{footer}</div>}
|
||||
|
||||
{notice && !loading && <p className="mt-10 border-t border-[var(--r-line)] pt-3 font-sans text-[11px] leading-snug text-[var(--r-sub)]">{notice}</p>}
|
||||
{parallel?.notice && !loading && !parallel.loading && <p className="mt-2 font-sans text-[11px] leading-snug text-[var(--r-sub)]">{parallel.notice}</p>}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// Grey bars the shape of a page, so a slow chapter load doesn't just show the word "Loading".
|
||||
function ChapterSkeleton() {
|
||||
return (
|
||||
<div role="status" aria-label="Loading chapter" className="animate-pulse motion-reduce:animate-none space-y-3 pt-2">
|
||||
{[100, 96, 92, 98, 84, 100, 90, 70].map((w, i) => <div key={i} className="h-4 rounded bg-[var(--r-line)]" style={{ width: `${w}%` }} />)}
|
||||
<span className="sr-only">Loading…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +1,72 @@
|
||||
// "Aa" sheet: text size, theme, and how verses are laid out.
|
||||
// "Aa" sheet: text size, typeface, spacing, theme, how verses are laid out, and a second Bible
|
||||
// to read beside the first.
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { READER_THEMES } from './readerThemes.js';
|
||||
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
|
||||
|
||||
const SIZES = [['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25], ['XXL', 1.5]];
|
||||
|
||||
function Segmented({ options, value, onChange }) {
|
||||
function Segmented({ options, value, onChange, label }) {
|
||||
return (
|
||||
<div className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
|
||||
{options.map(([id, label]) => (
|
||||
<button key={id} type="button" onClick={() => onChange(id)}
|
||||
<div role="group" aria-label={label} className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
|
||||
{options.map(([id, text]) => (
|
||||
<button key={String(id)} type="button" onClick={() => onChange(id)} aria-pressed={value === id}
|
||||
className={`flex-1 px-3 py-2 text-sm ${value === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||
{label}
|
||||
{text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsSheet({ open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide }) {
|
||||
export default function SettingsSheet({
|
||||
open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide,
|
||||
font, onFont, spacing, onSpacing, verseNumbers, onVerseNumbers,
|
||||
translation, parallel, onParallel,
|
||||
}) {
|
||||
const row = 'mb-4';
|
||||
const label = 'mb-1.5 block text-xs text-[var(--r-sub)]';
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="Reading settings">
|
||||
<div className={row}>
|
||||
<span className={label}>Text size</span>
|
||||
<Segmented options={SIZES.map(([l, s]) => [s, l])} value={fontSize} onChange={onFontSize} />
|
||||
<Segmented label="Text size" options={SIZES.map(([l, s]) => [s, l])} value={fontSize} onChange={onFontSize} />
|
||||
</div>
|
||||
<div className={row}>
|
||||
<span className={label}>Typeface</span>
|
||||
<Segmented label="Typeface" options={[['serif', 'Serif'], ['sans', 'Sans'], ['easy', 'Easy to read']]} value={font} onChange={onFont} />
|
||||
</div>
|
||||
<div className={row}>
|
||||
<span className={label}>Line spacing</span>
|
||||
<Segmented label="Line spacing" options={[['snug', 'Snug'], ['normal', 'Normal'], ['airy', 'Airy']]} value={spacing} onChange={onSpacing} />
|
||||
</div>
|
||||
<div className={row}>
|
||||
<span className={label}>Theme</span>
|
||||
<Segmented options={Object.entries(READER_THEMES).map(([id, t]) => [id, t.label])} value={theme} onChange={onTheme} />
|
||||
<Segmented label="Theme" options={Object.entries(READER_THEMES).map(([id, t]) => [id, t.label])} value={theme} onChange={onTheme} />
|
||||
</div>
|
||||
<div className={row}>
|
||||
<span className={label}>Verses</span>
|
||||
<Segmented options={[['flow', 'Flowing'], ['verses', 'One per line']]} value={layout} onChange={onLayout} />
|
||||
<Segmented label="Verse layout" options={[['flow', 'Flowing'], ['verses', 'One per line']]} value={layout} onChange={onLayout} />
|
||||
</div>
|
||||
<div className={row}>
|
||||
<span className={label}>Verse numbers</span>
|
||||
<Segmented label="Verse numbers" options={[[true, 'Show'], [false, 'Hide']]} value={verseNumbers} onChange={onVerseNumbers} />
|
||||
</div>
|
||||
<div className={row}>
|
||||
<span className={label}>Width</span>
|
||||
<Segmented options={[[false, 'Column'], [true, 'Two columns']]} value={wide} onChange={onWide} />
|
||||
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={wide} onChange={onWide} />
|
||||
</div>
|
||||
<div className={row}>
|
||||
<span className={label}>Read beside another Bible</span>
|
||||
<div role="group" aria-label="Second translation" className="flex flex-wrap gap-1.5">
|
||||
{[{ id: '', name: 'Off' }, ...READER_TRANSLATIONS.filter((t) => t.id !== translation)].map((t) => (
|
||||
<button key={t.id || 'off'} type="button" onClick={() => onParallel(t.id)} aria-pressed={parallel === t.id} title={t.name}
|
||||
className={`rounded-full border px-3 py-1 text-xs font-medium ${parallel === t.id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||
{t.id || 'Off'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-[var(--r-sub)]">Shows both side by side on wider screens, and stacked on a phone.</p>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
@@ -2,17 +2,51 @@
|
||||
// close on outside tap; non-modal ones (the verse sheet) leave the text
|
||||
// tappable. With `pane`, screens >= lg show it as a right-hand side pane
|
||||
// instead of a bottom sheet (tablet landscape / desktop).
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { CloseIcon } from './icons.jsx';
|
||||
|
||||
export default function Sheet({ open, onClose, title, headerRight, modal = true, pane = false, children }) {
|
||||
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
export default function Sheet({ open, onClose, title, label, headerRight, modal = true, pane = false, children }) {
|
||||
const dialogRef = useRef(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
// Modal sheets behave like dialogs: focus moves in, Tab stays inside, Escape closes, and focus
|
||||
// returns to whatever opened the sheet. (The verse pane is not modal, so it leaves focus alone.)
|
||||
useEffect(() => {
|
||||
if (!open || !modal) return undefined;
|
||||
const opener = document.activeElement;
|
||||
const dialog = dialogRef.current;
|
||||
if (dialog && !dialog.contains(document.activeElement)) dialog.focus({ preventScroll: true });
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); onCloseRef.current?.(); return; }
|
||||
if (e.key !== 'Tab' || !dialog) return;
|
||||
const items = [...dialog.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null || el === document.activeElement);
|
||||
if (items.length === 0) { e.preventDefault(); return; }
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (e.shiftKey && (document.activeElement === first || document.activeElement === dialog)) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
if (opener instanceof HTMLElement && document.contains(opener)) opener.focus({ preventScroll: true });
|
||||
};
|
||||
}, [open, modal]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<>
|
||||
{modal && <div className="fixed inset-0 z-40 bg-black/30" onClick={onClose} aria-hidden="true" />}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-label={typeof title === 'string' ? title : undefined}
|
||||
className={`fixed inset-x-0 bottom-0 z-50 flex max-h-[72vh] flex-col rounded-t-2xl border-t border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] font-sans ${
|
||||
aria-modal={modal ? 'true' : undefined}
|
||||
aria-label={typeof title === 'string' ? title : label}
|
||||
tabIndex={-1}
|
||||
className={`fixed inset-x-0 bottom-0 z-50 flex max-h-[72vh] flex-col rounded-t-2xl focus:outline-none border-t border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] font-sans ${
|
||||
pane ? 'lg:inset-x-auto lg:bottom-0 lg:right-0 lg:top-12 lg:max-h-none lg:w-[24rem] lg:rounded-none lg:border-l lg:border-t-0' : 'sm:left-1/2 sm:right-auto sm:w-[36rem] sm:-translate-x-1/2'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -5,8 +5,9 @@ import Sheet from './Sheet.jsx';
|
||||
import { CommentaryTab, PeoplePlacesTab } from '../StudyPanel.jsx';
|
||||
import { BookmarkIcon, CopyIcon } from '../../utils/bibleUtils.jsx';
|
||||
import { formatCrossRef, speakOriginalWord } from '../../utils/wordStudy.js';
|
||||
import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
|
||||
|
||||
const TABS = [['commentary', 'Commentary'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
|
||||
const TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
|
||||
|
||||
function OriginalTab({ words }) {
|
||||
if (!words) return <p className="text-sm text-[var(--r-sub)]">No original-language data for this verse.</p>;
|
||||
@@ -49,6 +50,42 @@ function RefsTab({ refs, loading, onJump }) {
|
||||
);
|
||||
}
|
||||
|
||||
// This verse in every translation, one under the other. Each Bible loads the chapter it is in
|
||||
// (BSB and NET come from the free source; the rest through the server) and shows this verse.
|
||||
function CompareTab({ book, chapter, verse, current }) {
|
||||
const [rows, setRows] = useState({}); // id -> { text } | { error } | undefined while loading
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setRows({});
|
||||
READER_TRANSLATIONS.forEach((t) => {
|
||||
fetchChapter(t.id, book, chapter, { signal: controller.signal })
|
||||
.then(({ verses }) => {
|
||||
const text = verses.find((v) => v.number === verse)?.text;
|
||||
setRows((r) => ({ ...r, [t.id]: text ? { text } : { error: 'No verse here in this translation.' } }));
|
||||
})
|
||||
.catch((err) => { if (!controller.signal.aborted) setRows((r) => ({ ...r, [t.id]: { error: err.message || 'Unavailable.' } })); });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [book, chapter, verse]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{READER_TRANSLATIONS.map((t) => {
|
||||
const row = rows[t.id];
|
||||
return (
|
||||
<div key={t.id}>
|
||||
<p className="mb-0.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{t.id}{t.id === current ? ' · reading' : ''}</p>
|
||||
{!row ? <p className="text-sm text-[var(--r-sub)]">Loading…</p>
|
||||
: row.error ? <p className="text-sm text-[var(--r-sub)]">{row.error}</p>
|
||||
: <p className="font-serif text-[15px] leading-relaxed">{row.text}</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="border-t border-[var(--r-line)] pt-2 text-[10px] leading-snug text-[var(--r-sub)]">Copyright notices for each translation appear at the foot of the chapter when you read it.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerseSheet({
|
||||
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
|
||||
bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose,
|
||||
@@ -86,15 +123,16 @@ export default function VerseSheet({
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="mb-3 flex gap-1.5 overflow-x-auto pb-1">
|
||||
<div role="tablist" aria-label="About this verse" className="mb-3 flex gap-1.5 overflow-x-auto pb-1">
|
||||
{TABS.map(([id, label]) => (
|
||||
<button key={id} type="button" onClick={() => setTab(id)}
|
||||
<button key={id} type="button" role="tab" aria-selected={tab === id} onClick={() => setTab(id)}
|
||||
className={`shrink-0 rounded-full border px-3 py-1 text-xs ${tab === id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === 'commentary' && <CommentaryTab book={book} chapter={chapter} verse={verse.number} />}
|
||||
{tab === 'compare' && <CompareTab book={book} chapter={chapter} verse={verse.number} current={translation} />}
|
||||
{tab === 'original' && <OriginalTab words={words} />}
|
||||
{tab === 'refs' && <RefsTab refs={crossRefs} loading={crossRefsLoading} onJump={onJump} />}
|
||||
{tab === 'people' && <PeoplePlacesTab book={book} chapter={chapter} verse={verse.number} />}
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
export const READER_THEMES = {
|
||||
paper: {
|
||||
label: 'Paper',
|
||||
vars: { '--r-bg': '#faf7f0', '--r-text': '#2b2620', '--r-sub': '#7d7466', '--r-accent': '#b4532a', '--r-bar': '#f1ece0', '--r-line': '#e0d8c6', '--r-hl': '#f6e7a8', '--r-on': '#ffffff', '--r-danger': '#b42318' },
|
||||
vars: { '--r-bg': '#faf7f0', '--r-text': '#2b2620', '--r-sub': '#6b6458', '--r-accent': '#a84e27', '--r-bar': '#f1ece0', '--r-line': '#e0d8c6', '--r-hl': '#f6e7a8', '--r-on': '#ffffff', '--r-danger': '#b42318' },
|
||||
},
|
||||
sepia: {
|
||||
label: 'Sepia',
|
||||
vars: { '--r-bg': '#f1e4c8', '--r-text': '#3b2f1c', '--r-sub': '#7d6b48', '--r-accent': '#9a4a1e', '--r-bar': '#e8d8b6', '--r-line': '#d5c197', '--r-hl': '#e9cf85', '--r-on': '#ffffff', '--r-danger': '#a1281d' },
|
||||
vars: { '--r-bg': '#f1e4c8', '--r-text': '#3b2f1c', '--r-sub': '#635539', '--r-accent': '#93471d', '--r-bar': '#e8d8b6', '--r-line': '#d5c197', '--r-hl': '#e9cf85', '--r-on': '#ffffff', '--r-danger': '#a1281d' },
|
||||
},
|
||||
dark: {
|
||||
label: 'Dark',
|
||||
vars: { '--r-bg': '#1c1b19', '--r-text': '#e6e1d6', '--r-sub': '#9a9385', '--r-accent': '#e0895c', '--r-bar': '#262421', '--r-line': '#3a3733', '--r-hl': '#4a4224', '--r-on': '#1c1b19', '--r-danger': '#f0857a' },
|
||||
vars: { '--r-bg': '#1c1b19', '--r-text': '#e6e1d6', '--r-sub': '#b8b2a4', '--r-accent': '#e0895c', '--r-bar': '#262421', '--r-line': '#3a3733', '--r-hl': '#4a4224', '--r-on': '#1c1b19', '--r-danger': '#f0857a' },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { READER_THEMES } from './readerThemes.js';
|
||||
|
||||
// WCAG relative luminance and contrast ratio
|
||||
const luminance = (hex) => {
|
||||
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
|
||||
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
const contrast = (a, b) => {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
|
||||
// Small text needs 4.5:1. These are the pairings the app actually draws.
|
||||
describe.each(Object.entries(READER_THEMES))('%s theme contrast', (_id, { vars }) => {
|
||||
const v = (name) => vars[`--r-${name}`];
|
||||
test.each([
|
||||
['text', 'bg'], ['text', 'bar'], ['text', 'hl'],
|
||||
['sub', 'bg'], ['sub', 'bar'], ['sub', 'hl'],
|
||||
['accent', 'bg'], ['accent', 'bar'],
|
||||
['danger', 'bg'], ['danger', 'bar'],
|
||||
['on', 'accent'],
|
||||
])('%s on %s is at least 4.5:1', (fg, bg) => {
|
||||
expect(contrast(v(fg), v(bg))).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// Settings → "Your data": download everything as one file, restore from one, and save Bibles for offline.
|
||||
import { useRef, useState } from 'react';
|
||||
import { useApp } from '../../context/AppContext.js';
|
||||
import { useToast } from '../Toasts.jsx';
|
||||
import { DownloadProgress } from '../OfflineSave.jsx';
|
||||
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
|
||||
import { backupFileName, buildBackup, restoreProjects, validateBackup } from '../../utils/backup.js';
|
||||
import { saveRemoteProject } from '../../syncService.js';
|
||||
|
||||
const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
|
||||
|
||||
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
||||
|
||||
export default function DataSection() {
|
||||
const {
|
||||
authUser, readerBookmarks, readerTextHighlights, readerColorLabels, readerInkByPage, readingPlansState,
|
||||
importReaderMarks, importReadingPlans, refreshProjectIndex,
|
||||
} = useApp();
|
||||
const { toast } = useToast();
|
||||
const offline = useOfflineSave();
|
||||
const fileRef = useRef(null);
|
||||
const [pending, setPending] = useState(null); // { name, backup, summary } waiting for "Restore"
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const download = () => {
|
||||
const backup = buildBackup({
|
||||
email: authUser?.email ?? null,
|
||||
reader: { bookmarks: readerBookmarks, highlights: readerTextHighlights, colorLabels: readerColorLabels, ink: readerInkByPage },
|
||||
readingPlans: readingPlansState,
|
||||
});
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = backupFileName();
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast(`Backup downloaded: ${plural(backup.projects.length, 'study', 'studies')} and your reading marks`);
|
||||
};
|
||||
|
||||
const choose = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
setError('');
|
||||
setPending(null);
|
||||
if (!file) return;
|
||||
let data;
|
||||
try { data = JSON.parse(await file.text()); } catch { setError('That file couldn’t be read as a backup.'); return; }
|
||||
const checked = validateBackup(data);
|
||||
if (!checked.ok) { setError(checked.error); return; }
|
||||
setPending({ name: file.name, ...checked });
|
||||
};
|
||||
|
||||
const restore = async () => {
|
||||
setBusy(true);
|
||||
const { backup } = pending;
|
||||
const written = restoreProjects(backup.projects);
|
||||
importReaderMarks(backup.reader);
|
||||
importReadingPlans(backup.readingPlans);
|
||||
refreshProjectIndex();
|
||||
// Studies restored here also go to the account, so they follow you to other devices
|
||||
if (authUser) await Promise.all(written.map((p) => saveRemoteProject(p)));
|
||||
setBusy(false);
|
||||
setPending(null);
|
||||
toast(`Restored ${plural(written.length, 'study', 'studies')}, plus your bookmarks, highlights and plans`);
|
||||
};
|
||||
|
||||
const s = pending?.summary;
|
||||
return (
|
||||
<section id="backup" aria-label="Your data" className="scroll-mt-16 space-y-5 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
|
||||
<div>
|
||||
<h2 className="text-base font-medium">Your data</h2>
|
||||
<p className="mt-1 text-sm text-[var(--r-sub)]">Everything you’ve made — studies and notes, bookmarks, highlights, ink and reading plans — in one file you keep.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button type="button" onClick={download} className={btn}>Download backup</button>
|
||||
<button type="button" onClick={() => fileRef.current?.click()} className={btn}>Restore from a backup…</button>
|
||||
<input ref={fileRef} type="file" accept="application/json,.json" onChange={choose} className="hidden" aria-label="Backup file" />
|
||||
</div>
|
||||
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||
{pending && (
|
||||
<div className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-4 text-sm">
|
||||
<p className="font-medium">{pending.name}</p>
|
||||
<p className="mt-1 text-[var(--r-sub)]">
|
||||
Contains {plural(s.projects, 'study', 'studies')}, {plural(s.bookmarks, 'bookmark')}, {plural(s.highlights, 'highlight')}, {plural(s.inkPages, 'ink page')} and {plural(s.plans, 'reading plan')}.
|
||||
Restoring adds these to what you have — nothing is deleted, and a study on this device is only replaced if the backup’s copy is newer.
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button type="button" onClick={restore} disabled={busy} 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">{busy ? 'Restoring…' : 'Restore'}</button>
|
||||
<button type="button" onClick={() => setPending(null)} className={btn}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-[var(--r-line)] pt-4">
|
||||
<h3 className="text-sm font-medium">Read offline</h3>
|
||||
<p className="mb-3 mt-1 text-sm text-[var(--r-sub)]">Chapters you read are kept automatically. To read anywhere, save a whole Bible while you’re connected (about 4 MB). The ESV, NLT, CSB and NASB can’t be saved: their licences don’t allow it.</p>
|
||||
{offline.job ? <DownloadProgress job={offline.job} onCancel={offline.cancel} /> : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{['BSB', 'NET'].map((t) => (
|
||||
<button key={t} type="button" onClick={() => offline.saveBible(t)} className={btn}>
|
||||
{(offline.saved[t] ?? []).length >= 66 ? `✓ The whole ${t} is saved` : `Save the whole ${t}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export default function OutlineRail({ project, selectedId, onSelect, onEditOutli
|
||||
return (
|
||||
<nav aria-label="Chunks" className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex items-center justify-between px-3 pb-2 pt-3">
|
||||
<span className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Outline</span>
|
||||
<span className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Chunks</span>
|
||||
<button type="button" onClick={onEditOutline} className="text-[11px] text-[var(--r-accent)] hover:underline">Edit</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-4">
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useApp } from '../../context/AppContext.js';
|
||||
import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
|
||||
import { ChevronLeft } from '../reader/icons.jsx';
|
||||
import { ChevronLeft, SearchIcon } from '../reader/icons.jsx';
|
||||
|
||||
// `short` is what a phone shows; the full label is still the button's accessible name.
|
||||
const NAV = [
|
||||
{ id: 'studies', label: 'Studies' },
|
||||
{ id: 'reader', label: 'Read Bible' },
|
||||
{ id: 'plans', label: 'Reading Plans' },
|
||||
{ id: 'studies', label: 'Studies', short: 'Studies' },
|
||||
{ id: 'reader', label: 'Read Bible', short: 'Bible' },
|
||||
{ id: 'plans', label: 'Reading Plans', short: 'Plans' },
|
||||
];
|
||||
|
||||
// Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out.
|
||||
@@ -61,25 +62,37 @@ function AccountMenu() {
|
||||
}
|
||||
|
||||
export default function PageShell({ title, onBack, actions, active = null, maxWidth = 'max-w-6xl', children }) {
|
||||
const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans } = useApp();
|
||||
const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans, openPalette } = useApp();
|
||||
const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans };
|
||||
return (
|
||||
<div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
|
||||
<a href="#content" className="skip-link">Skip to content</a>
|
||||
<header className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
|
||||
<div className={`mx-auto flex h-12 ${maxWidth} items-center gap-4 px-4`}>
|
||||
<div className={`mx-auto flex h-12 ${maxWidth} items-center gap-2 px-3 sm:gap-4 sm:px-4`}>
|
||||
<button type="button" onClick={goHome} className="flex shrink-0 items-center gap-2" aria-label="Bible Study home">
|
||||
<img src="/logo.svg" alt="" className="h-6 w-6" />
|
||||
<span className="hidden text-sm font-medium sm:inline">Bible Study</span>
|
||||
</button>
|
||||
<nav aria-label="Main" className="flex items-center gap-0.5">
|
||||
{NAV.map((n) => (
|
||||
<button key={n.id} type="button" onClick={go[n.id]} aria-current={active === n.id ? 'page' : undefined}
|
||||
className={`rounded-full px-3 py-1.5 text-sm ${active === n.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{n.label}</button>
|
||||
<button key={n.id} type="button" onClick={go[n.id]} aria-current={active === n.id ? 'page' : undefined} aria-label={n.label}
|
||||
className={`whitespace-nowrap rounded-full px-2.5 py-1.5 text-sm sm:px-3 ${active === n.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||
<span className="sm:hidden" aria-hidden="true">{n.short}</span><span className="hidden sm:inline" aria-hidden="true">{n.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="ml-auto"><AccountMenu /></div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{openPalette && (
|
||||
<button type="button" onClick={openPalette} aria-label="Search and go to"
|
||||
className="flex items-center gap-2 rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-1 text-xs text-[var(--r-sub)] hover:bg-black/5">
|
||||
<SearchIcon /><span className="hidden sm:inline">Search</span><kbd className="hidden rounded border border-[var(--r-line)] px-1 text-[10px] md:inline">⌘K</kbd>
|
||||
</button>
|
||||
)}
|
||||
<AccountMenu />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div id="content" tabIndex={-1} className="focus:outline-none">
|
||||
<div className={`mx-auto flex ${maxWidth} flex-wrap items-center gap-3 px-4 pt-6`}>
|
||||
{onBack && (
|
||||
<button type="button" onClick={onBack} aria-label="Back" className="-ml-2 flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /></button>
|
||||
@@ -88,6 +101,7 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
|
||||
{actions && <div className="ml-auto flex flex-wrap items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,9 @@ import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
|
||||
import { ChevronLeft } from '../reader/icons.jsx';
|
||||
|
||||
export const STAGES = [
|
||||
{ id: 'setup', label: 'Outline' },
|
||||
{ id: 'setup', label: 'Chunks' },
|
||||
{ id: 'study', label: 'Study' },
|
||||
{ id: 'outline', label: 'Outline' },
|
||||
{ id: 'script', label: 'Script' },
|
||||
{ id: 'export', label: 'Export' },
|
||||
];
|
||||
@@ -16,7 +17,7 @@ const THEME_ORDER = ['paper', 'sepia', 'dark'];
|
||||
export default function StudioShell({ stage, loading = false, fixedHeight = false, children }) {
|
||||
const {
|
||||
goHome, setCurrentPage, project, allChunks,
|
||||
saveStatus, syncStatus, retrySync,
|
||||
saveStatus, syncStatus, retrySync, syncConflict, keepMyVersion, useServerVersion,
|
||||
readerTheme, setReaderTheme,
|
||||
} = useApp();
|
||||
const noChunks = allChunks.length === 0;
|
||||
@@ -34,7 +35,7 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
|
||||
const disabled = s.id !== 'setup' && (!project || noChunks);
|
||||
return (
|
||||
<button key={s.id} type="button" disabled={disabled} aria-current={stage === s.id ? 'step' : undefined}
|
||||
title={disabled ? 'Add a chunk in Outline first' : undefined}
|
||||
title={disabled ? 'Add a chunk in Chunks first' : undefined}
|
||||
onClick={() => setCurrentPage(s.id)}
|
||||
className={`rounded-full px-3.5 py-1.5 text-xs ${stage === s.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'} disabled:opacity-40 disabled:hover:bg-transparent`}>
|
||||
{s.label}
|
||||
@@ -54,6 +55,15 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{syncConflict && project && syncConflict.id === project.id && (
|
||||
<div role="alert" className="flex shrink-0 flex-wrap items-center justify-center gap-x-4 gap-y-2 border-b border-[var(--r-line)] bg-[var(--r-hl)] px-4 py-2.5 text-sm">
|
||||
<span>This study was changed on another device. Your edits here are saved on this device, but not yet synced.</span>
|
||||
<span className="flex gap-2">
|
||||
<button type="button" onClick={keepMyVersion} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)] hover:opacity-90">Keep this version</button>
|
||||
<button type="button" onClick={useServerVersion} className="rounded-full border border-[var(--r-text)] px-3 py-1 text-xs font-medium hover:bg-black/5">Use the other version</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user