Files
study-bible-project/src/components/reader/SearchSheet.jsx
T
nmemmertandClaude Sonnet 5.5 ecb1c701fa
Build and Push Docker Image / build-and-push (push) Failing after 20s
Rework app bar, notes layout, Settings/Admin/login restyle
App bar: brand, Studies / Read Bible / Reading Plans, and one account menu
(theme, Settings, Admin, Log out). Study stage: notes get their own box above
the resources in the right column. Settings, Admin and the login page use the
reader themes; add a theme-aware danger colour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 10:27:02 -04:00

112 lines
5.9 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.
// Search this chapter, or the whole Bible in the translation being read.
// BSB and NET are downloaded once and searched in the browser; ESV, NLT, CSB and
// NASB are searched through the server (their licences don't allow a bulk copy).
import { useMemo } from 'react';
import Sheet from './Sheet.jsx';
import { SearchIcon } from './icons.jsx';
import { bookOptions } from '../../utils/bibleUtils.jsx';
const nameFor = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Wraps each searched word (quotes ignored) in <mark>.
function highlight(text, query) {
const words = [...new Set(query.toLowerCase().replace(/["“”]/g, ' ').split(/\s+/).filter((w) => w.length > 1))];
if (!words.length) return text;
const re = new RegExp(`(${words.map(escapeRegExp).join('|')})`, 'gi');
return text.split(re).map((part, i) => (i % 2 === 1
? <mark key={i} className="rounded bg-[var(--r-hl)] px-0.5 text-[var(--r-text)]">{part}</mark>
: part));
}
export default function SearchSheet({
open, onClose, query, onQuery, scope, onScope, verses, bookName, chapter, currentBook, translation,
isLocal, indexStatus, onLoadIndex, bibleMatches, deferredBibleQuery, remote, onJump,
}) {
const q = query.trim().toLowerCase();
const chapterMatches = useMemo(
() => (scope === 'chapter' && q ? verses.filter((v) => v.text.toLowerCase().includes(q)) : []),
[scope, q, verses],
);
const scopeBtn = (id, label, onClick) => (
<button type="button" onClick={onClick}
className={`px-3 py-1.5 text-xs ${scope === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{label}</button>
);
const note = (text) => <p className="text-sm text-[var(--r-sub)]">{text}</p>;
const resultRow = (r) => (
<button key={`${r.bookAbbrev}-${r.chapter}-${r.verse}`} type="button" onClick={() => onJump(r.bookAbbrev, r.chapter, r.verse)}
className="block w-full rounded-lg px-2 py-2 text-left text-sm hover:bg-black/5">
<span className="mr-1.5 font-medium">{nameFor(r.bookAbbrev)} {r.chapter}:{r.verse}</span>{highlight(r.text, query)}
</button>
);
return (
<Sheet open={open} onClose={onClose} title="Search">
<div className="mb-3 flex items-center gap-2 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2">
<span className="text-[var(--r-sub)]"><SearchIcon /></span>
<input autoFocus value={query} onChange={(e) => onQuery(e.target.value)}
placeholder={scope === 'bible' ? `Search the whole Bible (${translation})…` : `Search ${bookName} ${chapter}…`}
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--r-text)] outline-none placeholder:text-[var(--r-sub)]" />
</div>
<div className="mb-3 inline-flex overflow-hidden rounded-lg border border-[var(--r-line)]">
{scopeBtn('chapter', 'This chapter', () => onScope('chapter'))}
{scopeBtn('bible', `Whole Bible (${translation})`, () => { onScope('bible'); if (isLocal && indexStatus === 'idle') onLoadIndex(); })}
</div>
{scope === 'chapter' && (
!q ? note('Type a word to find it in this chapter.')
: chapterMatches.length === 0 ? note(`No verses match “${query}”.`)
: chapterMatches.map((v) => (
<button key={v.number} type="button" onClick={() => onJump(currentBook, chapter, v.number)}
className="block w-full rounded-lg px-2 py-2 text-left text-sm hover:bg-black/5">
<span className="mr-1.5 font-medium">{v.number}</span>{highlight(v.text, query)}
</button>
))
)}
{scope === 'bible' && isLocal && (
<>
{indexStatus === 'loading' && note('Loading the full Bible for search. This happens once per visit (about 7 MB).')}
{indexStatus === 'error' && <p className="text-sm text-[var(--r-danger,#b42318)]">Couldn’t load the full Bible. <button type="button" onClick={onLoadIndex} className="underline">Try again</button></p>}
{indexStatus === 'ready' && (!deferredBibleQuery
? note('Type a word to search all 66 books.')
: bibleMatches.length === 0
? note(`No verses match “${query}”.`)
: (
<>
<p className="mb-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
{bibleMatches.length} match{bibleMatches.length === 1 ? '' : 'es'}{bibleMatches.length > 100 ? ' (showing first 100)' : ''}
</p>
{bibleMatches.slice(0, 100).map(resultRow)}
</>
))}
</>
)}
{scope === 'bible' && !isLocal && (
<>
{remote.tooShort && note('Type at least two letters. Use quotes for an exact phrase, like “good shepherd”.')}
{!remote.tooShort && remote.error && <p className="text-sm text-[var(--r-danger,#b42318)]">{remote.error}</p>}
{!remote.tooShort && !remote.error && remote.loading && remote.results.length === 0 && note('Searching…')}
{!remote.tooShort && !remote.error && !remote.loading && remote.total === 0 && remote.page > 0 && note(`No verses match “${query}”.`)}
{remote.results.length > 0 && (
<>
<p className="mb-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
{remote.total} match{remote.total === 1 ? '' : 'es'} in {translation}
</p>
{remote.results.map(resultRow)}
{remote.hasMore && (
<button type="button" onClick={remote.loadMore} disabled={remote.loading}
className="mt-2 w-full rounded-lg border border-[var(--r-line)] py-2 text-sm text-[var(--r-sub)] hover:bg-black/5 disabled:opacity-50">
{remote.loading ? 'Loading…' : 'Show more'}
</button>
)}
</>
)}
</>
)}
</Sheet>
);
}