Build and Push Docker Image / build-and-push (push) Failing after 20s
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>
112 lines
5.9 KiB
React
112 lines
5.9 KiB
React
// 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>
|
||
);
|
||
}
|