RED LETTER CODE
This commit is contained in:
+32
-6
@@ -1,8 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { loadBible, searchBible, type SearchResult } from './bible'
|
import { getVerseText, loadBible, searchBible, type SearchResult } from './bible'
|
||||||
|
import { allRedLetter, loadRedLetter } from './redletter'
|
||||||
import TopBar from './TopBar'
|
import TopBar from './TopBar'
|
||||||
|
|
||||||
|
const RED_LETTER_CODE = /^jesusredletter$/i
|
||||||
|
|
||||||
function highlight(text: string, query: string): ReactNode {
|
function highlight(text: string, query: string): ReactNode {
|
||||||
const ql = query.trim().toLowerCase()
|
const ql = query.trim().toLowerCase()
|
||||||
if (!ql) return text
|
if (!ql) return text
|
||||||
@@ -28,7 +31,7 @@ export default function SearchPage() {
|
|||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadBible().then(() => setReady(true))
|
Promise.all([loadBible(), loadRedLetter()]).then(() => setReady(true))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -36,7 +39,22 @@ export default function SearchPage() {
|
|||||||
return () => window.clearTimeout(id)
|
return () => window.clearTimeout(id)
|
||||||
}, [query])
|
}, [query])
|
||||||
|
|
||||||
const outcome = useMemo(() => searchBible(debounced), [debounced])
|
const secret = RED_LETTER_CODE.test(debounced.trim())
|
||||||
|
|
||||||
|
const outcome = useMemo(() => {
|
||||||
|
if (secret) {
|
||||||
|
const results: SearchResult[] = allRedLetter().map((e) => ({
|
||||||
|
text: e.quote ?? getVerseText(e.book, e.chapter, e.verse) ?? '',
|
||||||
|
reference: e.reference,
|
||||||
|
book: e.book,
|
||||||
|
chapter: e.chapter,
|
||||||
|
verse: e.verse,
|
||||||
|
}))
|
||||||
|
return { results, total: results.length, redLetter: true }
|
||||||
|
}
|
||||||
|
const res = searchBible(debounced)
|
||||||
|
return { results: res.results, total: res.total, redLetter: false }
|
||||||
|
}, [secret, debounced, ready])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setResults(outcome.results)
|
setResults(outcome.results)
|
||||||
@@ -70,10 +88,16 @@ export default function SearchPage() {
|
|||||||
<p className="search-meta">Enter a word or phrase to search all 31,102 verses.</p>
|
<p className="search-meta">Enter a word or phrase to search all 31,102 verses.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ready && !searching && query.trim() !== '' && (
|
{ready && !searching && secret && (
|
||||||
|
<div className="redletter-banner" aria-live="polite">
|
||||||
|
<h2>The Words of Christ</h2>
|
||||||
|
<p>{total} verses — the recorded words of Jesus</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ready && !searching && query.trim() !== '' && !secret && (
|
||||||
<p className="search-meta" aria-live="polite">
|
<p className="search-meta" aria-live="polite">
|
||||||
{total > 0 ? `${total} verse${total === 1 ? '' : 's'} found` : 'No verses found.'}
|
{total > 0 ? `${total} verse${total === 1 ? '' : 's'} found` : 'No verses found.'}
|
||||||
{total > results.length && ` · showing the first ${results.length}`}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -85,7 +109,9 @@ export default function SearchPage() {
|
|||||||
to={`/read/${encodeURIComponent(r.book)}/${r.chapter}#v${r.verse}`}
|
to={`/read/${encodeURIComponent(r.book)}/${r.chapter}#v${r.verse}`}
|
||||||
>
|
>
|
||||||
<span className="search-result-ref">{r.reference}</span>
|
<span className="search-result-ref">{r.reference}</span>
|
||||||
<span className="search-result-text">{highlight(r.text, query)}</span>
|
<span className="search-result-text">
|
||||||
|
{outcome.redLetter ? <span className="jesus">{r.text}</span> : highlight(r.text, debounced)}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+7
-7
@@ -65,7 +65,7 @@ export function loadBible(): Promise<void> {
|
|||||||
index.push({
|
index.push({
|
||||||
book: b.book,
|
book: b.book,
|
||||||
chapter: cn,
|
chapter: cn,
|
||||||
verse: v.verse,
|
verse: Number(v.verse),
|
||||||
text: v.text,
|
text: v.text,
|
||||||
lower: v.text.toLowerCase(),
|
lower: v.text.toLowerCase(),
|
||||||
refLower: `${displayName(b.book)} ${cn}:${v.verse}`.toLowerCase(),
|
refLower: `${displayName(b.book)} ${cn}:${v.verse}`.toLowerCase(),
|
||||||
@@ -93,6 +93,10 @@ export function getChapter(book: KJVBookData, chapter: number): KJVChapterData |
|
|||||||
return book.chapters[chapter - 1]
|
return book.chapters[chapter - 1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getVerseText(book: string, chapter: number, verse: number): string | undefined {
|
||||||
|
return index.find((e) => e.book === book && Number(e.chapter) === chapter && Number(e.verse) === verse)?.text
|
||||||
|
}
|
||||||
|
|
||||||
function displayName(bookName: string): string {
|
function displayName(bookName: string): string {
|
||||||
return bookName === 'Psalms' ? 'Psalm' : bookName
|
return bookName === 'Psalms' ? 'Psalm' : bookName
|
||||||
}
|
}
|
||||||
@@ -101,18 +105,14 @@ export function referenceFor(book: KJVBookData, chapter: number, verse: number):
|
|||||||
return `${displayName(book.book)} ${chapter}:${verse}`
|
return `${displayName(book.book)} ${chapter}:${verse}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchBible(query: string, limit = 60): SearchOutcome {
|
export function searchBible(query: string): SearchOutcome {
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
if (!q) return { results: [], total: 0 }
|
if (!q) return { results: [], total: 0 }
|
||||||
const results: SearchResult[] = []
|
const results: SearchResult[] = []
|
||||||
let total = 0
|
|
||||||
for (const e of index) {
|
for (const e of index) {
|
||||||
if (e.lower.includes(q) || e.refLower.includes(q)) {
|
if (e.lower.includes(q) || e.refLower.includes(q)) {
|
||||||
total++
|
|
||||||
if (results.length < limit) {
|
|
||||||
results.push({ text: e.text, reference: `${displayName(e.book)} ${e.chapter}:${e.verse}`, book: e.book, chapter: e.chapter, verse: e.verse })
|
results.push({ text: e.text, reference: `${displayName(e.book)} ${e.chapter}:${e.verse}`, book: e.book, chapter: e.chapter, verse: e.verse })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return { results, total: results.length }
|
||||||
return { results, total }
|
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
export type RedLetterEntry = string | null
|
export type RedLetterEntry = string | null
|
||||||
|
|
||||||
|
export interface RedLetterVerse {
|
||||||
|
book: string
|
||||||
|
chapter: number
|
||||||
|
verse: number
|
||||||
|
reference: string
|
||||||
|
quote: string | null
|
||||||
|
}
|
||||||
|
|
||||||
let loaded = false
|
let loaded = false
|
||||||
let promise: Promise<void> | null = null
|
let promise: Promise<void> | null = null
|
||||||
let map: Map<string, string | null> = new Map()
|
let map: Map<string, string | null> = new Map()
|
||||||
|
let entries: RedLetterVerse[] = []
|
||||||
|
|
||||||
|
const refRe = /^([A-Za-z]+) (\d+):(\d+)$/
|
||||||
|
|
||||||
export function loadRedLetter(): Promise<void> {
|
export function loadRedLetter(): Promise<void> {
|
||||||
if (loaded) return Promise.resolve()
|
if (loaded) return Promise.resolve()
|
||||||
@@ -14,8 +25,19 @@ export function loadRedLetter(): Promise<void> {
|
|||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
map = new Map()
|
map = new Map()
|
||||||
|
entries = []
|
||||||
for (const [ref, value] of Object.entries(data.verses)) {
|
for (const [ref, value] of Object.entries(data.verses)) {
|
||||||
map.set(ref.toLowerCase(), value === 'full' ? null : value)
|
map.set(ref.toLowerCase(), value === 'full' ? null : value)
|
||||||
|
const m = refRe.exec(ref)
|
||||||
|
if (m) {
|
||||||
|
entries.push({
|
||||||
|
book: m[1],
|
||||||
|
chapter: Number(m[2]),
|
||||||
|
verse: Number(m[3]),
|
||||||
|
reference: ref,
|
||||||
|
quote: value === 'full' ? null : value,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
loaded = true
|
loaded = true
|
||||||
})
|
})
|
||||||
@@ -28,6 +50,10 @@ export function redLetterFor(book: string, chapter: number, verse: number): RedL
|
|||||||
return map.get(`${name} ${chapter}:${verse}`.toLowerCase())
|
return map.get(`${name} ${chapter}:${verse}`.toLowerCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function allRedLetter(): RedLetterVerse[] {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
function normalize(s: string): string {
|
function normalize(s: string): string {
|
||||||
let out = ''
|
let out = ''
|
||||||
for (const c of s) {
|
for (const c of s) {
|
||||||
|
|||||||
@@ -719,6 +719,33 @@ a {
|
|||||||
margin: 1.1rem 0 2rem;
|
margin: 1.1rem 0 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.redletter-banner {
|
||||||
|
text-align: center;
|
||||||
|
margin: 1.1rem 0 2rem;
|
||||||
|
padding: 1.4rem 1.5rem;
|
||||||
|
border: 1px solid rgba(208, 95, 90, 0.45);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(208, 95, 90, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.redletter-banner h2 {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
text-indent: 0.3em;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
color: var(--red);
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redletter-banner p {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.search-results {
|
.search-results {
|
||||||
max-width: 46rem;
|
max-width: 46rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|||||||
+3
-3
@@ -51,14 +51,14 @@ export async function loadKJV(): Promise<DailyVerse[]> {
|
|||||||
for (const chapter of book.chapters) {
|
for (const chapter of book.chapters) {
|
||||||
const chapterNumber = Number(chapter.chapter)
|
const chapterNumber = Number(chapter.chapter)
|
||||||
for (const v of chapter.verses) {
|
for (const v of chapter.verses) {
|
||||||
const entry = redLetterFor(book.book, chapterNumber, v.verse)
|
const entry = redLetterFor(book.book, chapterNumber, Number(v.verse))
|
||||||
if (entry === undefined) continue
|
if (entry === undefined) continue
|
||||||
flatRed.push({
|
flatRed.push({
|
||||||
text: entry === null ? v.text : entry,
|
text: entry === null ? v.text : entry,
|
||||||
reference: `${name} ${chapterNumber}:${v.verse}`,
|
reference: `${name} ${chapterNumber}:${Number(v.verse)}`,
|
||||||
book: book.book,
|
book: book.book,
|
||||||
chapter: chapterNumber,
|
chapter: chapterNumber,
|
||||||
verse: v.verse,
|
verse: Number(v.verse),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user