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>
133 lines
7.6 KiB
React
133 lines
7.6 KiB
React
// 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>
|
||
);
|
||
}
|