Files
study-bible-project/src/components/CommandPalette.jsx
T
nmemmertandClaude Sonnet 5.5 d13f55faf7 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>
2026-10-01 14:08:08 -04:00

133 lines
7.6 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>
);
}