Read entire bible
This commit is contained in:
+13
-96
@@ -1,103 +1,20 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import CandleCanvas from './Candle'
|
||||
import { daySeed, loadKJV, msUntilRollover, pickForDay, type Verse } from './verse'
|
||||
|
||||
type Phase = 'sealed' | 'revealing' | 'revealed'
|
||||
|
||||
function formatCountdown(ms: number): string {
|
||||
const s = Math.max(0, Math.ceil(ms / 1000))
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
const sec = s % 60
|
||||
return `${String(h).padStart(2, '0')}h ${String(m).padStart(2, '0')}m ${String(sec).padStart(2, '0')}s`
|
||||
}
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import Home from './Home'
|
||||
import Reader from './Reader'
|
||||
import SearchPage from './SearchPage'
|
||||
|
||||
export default function App() {
|
||||
const [phase, setPhase] = useState<Phase>('sealed')
|
||||
const [verse, setVerse] = useState<Verse | null>(null)
|
||||
const [countdown, setCountdown] = useState('')
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setCountdown(formatCountdown(msUntilRollover()))
|
||||
update()
|
||||
const id = window.setInterval(update, 1000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const seek = useCallback(async () => {
|
||||
if (phase !== 'sealed') return
|
||||
setPhase('revealing')
|
||||
try {
|
||||
const verses = await loadKJV()
|
||||
setVerse(pickForDay(verses, daySeed()))
|
||||
setPhase('revealed')
|
||||
} catch {
|
||||
setError(true)
|
||||
setPhase('sealed')
|
||||
}
|
||||
}, [phase])
|
||||
|
||||
const today = new Date().toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<CandleCanvas />
|
||||
<>
|
||||
<div className="noise" aria-hidden="true" />
|
||||
<div className="vignette" aria-hidden="true" />
|
||||
|
||||
<header className="app-header">
|
||||
<h1 className="title">Walk By Faith</h1>
|
||||
<p className="tagline">
|
||||
“For we walk by faith, not by sight.” <span className="tagline-ref">— 2 Corinthians 5:7</span>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
{phase !== 'revealed' && (
|
||||
<section className="sealed" aria-label="Today's verse is sealed">
|
||||
<div className="seal">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M12 1.8C12 1.8 4.6 9.4 4.6 15a7.4 7.4 0 0 0 14.8 0c0-5.6-7.4-13.2-7.4-13.2z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
/>
|
||||
<path
|
||||
d="M12 8.4c0 0-3 4.2-3 7a3 3 0 0 0 6 0c0-2.8-3-7-3-7z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="seal-caption">The verse for this day is sealed</p>
|
||||
<button className="seek-btn" onClick={seek} disabled={phase === 'revealing'}>
|
||||
{error ? 'Relight the lamp' : phase === 'revealing' ? 'Lighting the lamp…' : 'Seek the verse'}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{phase === 'revealed' && verse && (
|
||||
<section className="revealed" aria-live="polite">
|
||||
<p className="reveal-label">The verse for {today}</p>
|
||||
<div className="verse">
|
||||
<p className="verse-text">{verse.text}</p>
|
||||
<p className="verse-ref">— {verse.reference}</p>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="app-footer">
|
||||
<p className="countdown">
|
||||
A new verse is appointed in <strong>{countdown}</strong>
|
||||
</p>
|
||||
<p className="foot-note">King James Version · Public Domain · The New Testament</p>
|
||||
</footer>
|
||||
</div>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/read" element={<Navigate to="/read/Genesis/1" replace />} />
|
||||
<Route path="/read/:book/:chapter" element={<Reader />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import CandleCanvas from './Candle'
|
||||
import { type Verse } from './bible'
|
||||
import { daySeed, loadKJV, msUntilRollover, pickForDay } from './verse'
|
||||
|
||||
type Phase = 'sealed' | 'revealing' | 'revealed'
|
||||
|
||||
function formatCountdown(ms: number): string {
|
||||
const s = Math.max(0, Math.ceil(ms / 1000))
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
const sec = s % 60
|
||||
return `${String(h).padStart(2, '0')}h ${String(m).padStart(2, '0')}m ${String(sec).padStart(2, '0')}s`
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [phase, setPhase] = useState<Phase>('sealed')
|
||||
const [verse, setVerse] = useState<Verse | null>(null)
|
||||
const [countdown, setCountdown] = useState('')
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setCountdown(formatCountdown(msUntilRollover()))
|
||||
update()
|
||||
const id = window.setInterval(update, 1000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const seek = useCallback(async () => {
|
||||
if (phase !== 'sealed') return
|
||||
setPhase('revealing')
|
||||
try {
|
||||
const verses = await loadKJV()
|
||||
setVerse(pickForDay(verses, daySeed()))
|
||||
setPhase('revealed')
|
||||
} catch {
|
||||
setError(true)
|
||||
setPhase('sealed')
|
||||
}
|
||||
}, [phase])
|
||||
|
||||
const today = new Date().toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<CandleCanvas />
|
||||
|
||||
<nav className="home-nav" aria-label="Site navigation">
|
||||
<Link to="/read/Genesis/1">Read</Link>
|
||||
<Link to="/search">Search</Link>
|
||||
</nav>
|
||||
|
||||
<header className="app-header">
|
||||
<h1 className="title">Walk By Faith</h1>
|
||||
<p className="tagline">
|
||||
“For we walk by faith, not by sight.” <span className="tagline-ref">— 2 Corinthians 5:7</span>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
{phase !== 'revealed' && (
|
||||
<section className="sealed" aria-label="Today's verse is sealed">
|
||||
<div className="seal">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M12 1.8C12 1.8 4.6 9.4 4.6 15a7.4 7.4 0 0 0 14.8 0c0-5.6-7.4-13.2-7.4-13.2z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
/>
|
||||
<path
|
||||
d="M12 8.4c0 0-3 4.2-3 7a3 3 0 0 0 6 0c0-2.8-3-7-3-7z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="seal-caption">The verse for this day is sealed</p>
|
||||
<button className="seek-btn" onClick={seek} disabled={phase === 'revealing'}>
|
||||
{error ? 'Relight the lamp' : phase === 'revealing' ? 'Lighting the lamp…' : 'Seek the verse'}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{phase === 'revealed' && verse && (
|
||||
<section className="revealed" aria-live="polite">
|
||||
<p className="reveal-label">The verse for {today}</p>
|
||||
<div className="verse">
|
||||
<p className="verse-text">{verse.text}</p>
|
||||
<p className="verse-ref">
|
||||
— <Link to={`/read/${verse.reference.split(' ')[0]}/1`}>{verse.reference}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="app-footer">
|
||||
<p className="countdown">
|
||||
A new verse is appointed in <strong>{countdown}</strong>
|
||||
</p>
|
||||
<p className="foot-note">King James Version · Public Domain · The New Testament</p>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link, Navigate, useLocation, useParams } from 'react-router-dom'
|
||||
import { findBook, getBooks, loadBible, type KJVBookData } from './bible'
|
||||
import TopBar from './TopBar'
|
||||
|
||||
const FONT_KEY = 'wbf-font-size'
|
||||
const FONT_MIN = 0.85
|
||||
const FONT_MAX = 1.35
|
||||
const FONT_STEP = 0.05
|
||||
|
||||
function readStoredFontSize(): number {
|
||||
const raw = Number(localStorage.getItem(FONT_KEY))
|
||||
if (!Number.isFinite(raw)) return 1
|
||||
return Math.min(FONT_MAX, Math.max(FONT_MIN, raw))
|
||||
}
|
||||
|
||||
export default function Reader() {
|
||||
const { book = '', chapter = '' } = useParams()
|
||||
const location = useLocation()
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [drawer, setDrawer] = useState(false)
|
||||
const [fontSize, setFontSize] = useState(readStoredFontSize)
|
||||
|
||||
const books = useMemo(() => getBooks(), [loaded])
|
||||
|
||||
const bookData = useMemo<KJVBookData | undefined>(() => (loaded ? findBook(book) : undefined), [loaded, book])
|
||||
const chapterNum = Number(chapter)
|
||||
const valid =
|
||||
bookData !== undefined && Number.isInteger(chapterNum) && chapterNum >= 1 && chapterNum <= bookData.chapters.length
|
||||
const chapterData = valid && bookData ? bookData.chapters[chapterNum - 1] : undefined
|
||||
|
||||
useEffect(() => {
|
||||
loadBible().then(() => setLoaded(true))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(FONT_KEY, String(fontSize))
|
||||
}, [fontSize])
|
||||
|
||||
const versesRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const hash = location.hash.slice(1)
|
||||
if (hash) {
|
||||
const el = document.getElementById(hash)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
el.classList.add('flash')
|
||||
const t = window.setTimeout(() => el.classList.remove('flash'), 2000)
|
||||
return () => window.clearTimeout(t)
|
||||
}
|
||||
} else {
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
}, [location.key, chapterData])
|
||||
|
||||
const closeDrawer = () => setDrawer(false)
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="reader-layout">
|
||||
<TopBar />
|
||||
<div className="reader-loading">Opening the book…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
const fallback = bookData ? `/read/${encodeURIComponent(bookData.book)}/1` : '/read/Genesis/1'
|
||||
return <Navigate to={fallback} replace />
|
||||
}
|
||||
|
||||
const ot = books.slice(0, 39)
|
||||
const nt = books.slice(39)
|
||||
|
||||
const active = (b: KJVBookData) => b.book === bookData.book
|
||||
|
||||
const prev =
|
||||
chapterNum > 1
|
||||
? { book: bookData.book, chapter: chapterNum - 1, label: `Chapter ${chapterNum - 1}` }
|
||||
: (() => {
|
||||
const idx = books.findIndex((b) => b.book === bookData.book)
|
||||
if (idx > 0) {
|
||||
const pb = books[idx - 1]
|
||||
return { book: pb.book, chapter: pb.chapters.length, label: pb.book }
|
||||
}
|
||||
return null
|
||||
})()
|
||||
|
||||
const next =
|
||||
chapterNum < bookData.chapters.length
|
||||
? { book: bookData.book, chapter: chapterNum + 1, label: `Chapter ${chapterNum + 1}` }
|
||||
: (() => {
|
||||
const idx = books.findIndex((b) => b.book === bookData.book)
|
||||
if (idx < books.length - 1) {
|
||||
const nb = books[idx + 1]
|
||||
return { book: nb.book, chapter: 1, label: nb.book }
|
||||
}
|
||||
return null
|
||||
})()
|
||||
|
||||
const bookLink = (b: KJVBookData, ch = 1) => `/read/${encodeURIComponent(b.book)}/${ch}`
|
||||
|
||||
return (
|
||||
<div className="reader-layout">
|
||||
<TopBar />
|
||||
|
||||
<aside className={drawer ? 'sidebar open' : 'sidebar'} aria-label="Books of the Bible">
|
||||
<p className="sidebar-title">The Bible</p>
|
||||
<h3>Old Testament</h3>
|
||||
{ot.map((b) => (
|
||||
<Link key={b.book} to={bookLink(b)} className={active(b) ? 'active' : ''} onClick={closeDrawer}>
|
||||
{b.book}
|
||||
</Link>
|
||||
))}
|
||||
<h3>New Testament</h3>
|
||||
{nt.map((b) => (
|
||||
<Link key={b.book} to={bookLink(b)} className={active(b) ? 'active' : ''} onClick={closeDrawer}>
|
||||
{b.book}
|
||||
</Link>
|
||||
))}
|
||||
</aside>
|
||||
{drawer && <div className="drawer-backdrop" onClick={closeDrawer} aria-hidden="true" />}
|
||||
|
||||
<main className="reader-panel">
|
||||
<div className="reader-toolbar">
|
||||
<button className="hamburger" onClick={() => setDrawer(true)} aria-label="Open books list">
|
||||
<span className="hamburger-lines" />
|
||||
Books
|
||||
</button>
|
||||
<div className="font-controls" aria-label="Font size">
|
||||
<button
|
||||
onClick={() => setFontSize((f) => Math.max(FONT_MIN, f - FONT_STEP))}
|
||||
disabled={fontSize <= FONT_MIN}
|
||||
aria-label="Decrease font size"
|
||||
>
|
||||
A−
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFontSize((f) => Math.min(FONT_MAX, f + FONT_STEP))}
|
||||
disabled={fontSize >= FONT_MAX}
|
||||
aria-label="Increase font size"
|
||||
>
|
||||
A+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="reader-book-title">{bookData.book}</h1>
|
||||
|
||||
<nav className="chapter-strip" aria-label="Chapters">
|
||||
{Array.from({ length: bookData.chapters.length }, (_, i) => i + 1).map((c) => (
|
||||
<Link key={c} to={`/read/${encodeURIComponent(bookData.book)}/${c}`} className={c === chapterNum ? 'active' : ''}>
|
||||
{c}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="verses" ref={versesRef} style={{ fontSize: `${fontSize}em` }}>
|
||||
{chapterData?.verses.map((v) => (
|
||||
<p className="verse-line" id={`v${v.verse}`} key={v.verse}>
|
||||
<sup>{v.verse}</sup>
|
||||
{v.text}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<nav className="chapter-nav" aria-label="Chapter navigation">
|
||||
{prev ? (
|
||||
<Link to={`/read/${encodeURIComponent(prev.book)}/${prev.chapter}`}>
|
||||
<span aria-hidden="true">←</span> {prev.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{next ? (
|
||||
<Link to={`/read/${encodeURIComponent(next.book)}/${next.chapter}`}>
|
||||
{next.label} <span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</nav>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { loadBible, searchBible, type SearchResult } from './bible'
|
||||
import TopBar from './TopBar'
|
||||
|
||||
function highlight(text: string, query: string): ReactNode {
|
||||
const ql = query.trim().toLowerCase()
|
||||
if (!ql) return text
|
||||
const lower = text.toLowerCase()
|
||||
const parts: ReactNode[] = []
|
||||
let i = 0
|
||||
let idx = lower.indexOf(ql, i)
|
||||
while (idx !== -1) {
|
||||
if (idx > i) parts.push(text.slice(i, idx))
|
||||
parts.push(<mark key={`${idx}-${i}`}>{text.slice(idx, idx + ql.length)}</mark>)
|
||||
i = idx + ql.length
|
||||
idx = lower.indexOf(ql, i)
|
||||
}
|
||||
if (i < text.length) parts.push(text.slice(i))
|
||||
return parts.length ? parts : text
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [debounced, setDebounced] = useState('')
|
||||
const [ready, setReady] = useState(false)
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
loadBible().then(() => setReady(true))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setDebounced(query), 250)
|
||||
return () => window.clearTimeout(id)
|
||||
}, [query])
|
||||
|
||||
const outcome = useMemo(() => searchBible(debounced), [debounced])
|
||||
|
||||
useEffect(() => {
|
||||
setResults(outcome.results)
|
||||
setTotal(outcome.total)
|
||||
}, [outcome])
|
||||
|
||||
const searching = query.trim().length > 0 && query.trim() !== debounced.trim()
|
||||
|
||||
return (
|
||||
<div className="search-page">
|
||||
<TopBar />
|
||||
|
||||
<h1 className="search-title">Search the Scripture</h1>
|
||||
<div className="search-input-wrap">
|
||||
<input
|
||||
className="search-input"
|
||||
type="search"
|
||||
placeholder="Search the King James Bible…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
aria-label="Search the Bible"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!ready && <p className="search-meta">Preparing the pages…</p>}
|
||||
|
||||
{ready && searching && <p className="search-meta">Searching…</p>}
|
||||
|
||||
{ready && !searching && query.trim() === '' && (
|
||||
<p className="search-meta">Enter a word or phrase to search all 31,102 verses.</p>
|
||||
)}
|
||||
|
||||
{ready && !searching && query.trim() !== '' && (
|
||||
<p className="search-meta" aria-live="polite">
|
||||
{total > 0 ? `${total} verse${total === 1 ? '' : 's'} found` : 'No verses found.'}
|
||||
{total > results.length && ` · showing the first ${results.length}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="search-results">
|
||||
{results.map((r) => (
|
||||
<Link
|
||||
key={`${r.book}-${r.chapter}-${r.verse}`}
|
||||
className="search-result"
|
||||
to={`/read/${encodeURIComponent(r.book)}/${r.chapter}#v${r.verse}`}
|
||||
>
|
||||
<span className="search-result-ref">{r.reference}</span>
|
||||
<span className="search-result-text">{highlight(r.text, query)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
|
||||
export default function TopBar() {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<NavLink to="/" className="topbar-brand">
|
||||
Walk By Faith
|
||||
</NavLink>
|
||||
<nav className="topbar-nav" aria-label="Site navigation">
|
||||
<NavLink to="/read/Genesis/1">Read</NavLink>
|
||||
<NavLink to="/search">Search</NavLink>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
export interface KJVVerseData {
|
||||
verse: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface KJVChapterData {
|
||||
chapter: string
|
||||
verses: KJVVerseData[]
|
||||
}
|
||||
|
||||
export interface KJVBookData {
|
||||
book: string
|
||||
chapters: KJVChapterData[]
|
||||
}
|
||||
|
||||
export interface KJVData {
|
||||
books: KJVBookData[]
|
||||
}
|
||||
|
||||
export interface Verse {
|
||||
text: string
|
||||
reference: string
|
||||
}
|
||||
|
||||
export interface SearchResult extends Verse {
|
||||
book: string
|
||||
chapter: number
|
||||
verse: number
|
||||
}
|
||||
|
||||
export interface SearchOutcome {
|
||||
results: SearchResult[]
|
||||
total: number
|
||||
}
|
||||
|
||||
interface IndexEntry {
|
||||
book: string
|
||||
chapter: number
|
||||
verse: number
|
||||
text: string
|
||||
lower: string
|
||||
}
|
||||
|
||||
let loaded = false
|
||||
let books: KJVBookData[] = []
|
||||
let index: IndexEntry[] = []
|
||||
let promise: Promise<void> | null = null
|
||||
|
||||
export function loadBible(): Promise<void> {
|
||||
if (loaded) return Promise.resolve()
|
||||
if (!promise) {
|
||||
promise = fetch('/kjv.json')
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`Failed to load scripture (${r.status})`)
|
||||
return r.json() as Promise<KJVData>
|
||||
})
|
||||
.then((data) => {
|
||||
books = data.books
|
||||
index = []
|
||||
for (const b of data.books) {
|
||||
for (const ch of b.chapters) {
|
||||
const cn = Number(ch.chapter)
|
||||
for (const v of ch.verses) {
|
||||
index.push({ book: b.book, chapter: cn, verse: v.verse, text: v.text, lower: v.text.toLowerCase() })
|
||||
}
|
||||
}
|
||||
}
|
||||
loaded = true
|
||||
})
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
export function getBooks(): KJVBookData[] {
|
||||
return books
|
||||
}
|
||||
|
||||
export function findBook(name: string): KJVBookData | undefined {
|
||||
const q = name.trim().toLowerCase()
|
||||
return books.find((b) => b.book.toLowerCase() === q)
|
||||
}
|
||||
|
||||
export function getChapter(book: KJVBookData, chapter: number): KJVChapterData | undefined {
|
||||
if (chapter < 1 || chapter > book.chapters.length) return undefined
|
||||
return book.chapters[chapter - 1]
|
||||
}
|
||||
|
||||
function displayName(bookName: string): string {
|
||||
return bookName === 'Psalms' ? 'Psalm' : bookName
|
||||
}
|
||||
|
||||
export function referenceFor(book: KJVBookData, chapter: number, verse: number): string {
|
||||
return `${displayName(book.book)} ${chapter}:${verse}`
|
||||
}
|
||||
|
||||
export function searchBible(query: string, limit = 60): SearchOutcome {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return { results: [], total: 0 }
|
||||
const results: SearchResult[] = []
|
||||
let total = 0
|
||||
for (const e of index) {
|
||||
if (e.lower.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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
return { results, total }
|
||||
}
|
||||
+4
-1
@@ -1,5 +1,6 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import '@fontsource/eb-garamond/latin-400.css'
|
||||
import '@fontsource/eb-garamond/latin-400-italic.css'
|
||||
import '@fontsource/eb-garamond/latin-500.css'
|
||||
@@ -10,6 +11,8 @@ import App from './App'
|
||||
|
||||
createRoot(document.getElementById('app')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
+477
-2
@@ -49,7 +49,7 @@ body {
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(ellipse at 50% 40%, transparent 32%, rgba(0, 0, 0, 0.5) 72%, rgba(0, 0, 0, 0.86) 100%);
|
||||
background: radial-gradient(ellipse at 50% 40%, transparent 40%, rgba(0, 0, 0, 0.38) 80%, rgba(0, 0, 0, 0.72) 100%);
|
||||
}
|
||||
|
||||
.app {
|
||||
@@ -290,7 +290,482 @@ body {
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.seek-btn,
|
||||
.verse {
|
||||
.verse,
|
||||
.verse-line.flash {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.home-nav {
|
||||
position: absolute;
|
||||
top: clamp(1.25rem, 4vh, 2.5rem);
|
||||
right: clamp(1.25rem, 5vw, 3rem);
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
gap: 1.4rem;
|
||||
}
|
||||
|
||||
.home-nav a {
|
||||
font-family: var(--display);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
font-size: 0.7rem;
|
||||
color: var(--ink-dim);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.home-nav a:hover {
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem clamp(1.25rem, 5vw, 3rem);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.topbar a {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.topbar-brand {
|
||||
font-family: var(--display);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3em;
|
||||
text-indent: 0.3em;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.topbar-brand:hover {
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.topbar-nav {
|
||||
display: flex;
|
||||
gap: 1.4rem;
|
||||
}
|
||||
|
||||
.topbar-nav a {
|
||||
font-family: var(--display);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
font-size: 0.7rem;
|
||||
color: var(--ink-dim);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.topbar-nav a:hover,
|
||||
.topbar-nav a.active {
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.reader-layout {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
padding-top: 3.25rem;
|
||||
}
|
||||
|
||||
.reader-loading {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 4rem 1rem;
|
||||
color: var(--ink-dim);
|
||||
font-style: italic;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex: none;
|
||||
width: 240px;
|
||||
max-height: calc(100vh - 3.25rem);
|
||||
position: sticky;
|
||||
top: 3.25rem;
|
||||
overflow-y: auto;
|
||||
background: rgba(16, 11, 7, 0.72);
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 1.2rem 0.8rem 3rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(240, 169, 79, 0.35) transparent;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
margin: 0 0 0.6rem;
|
||||
font-family: var(--display);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.28em;
|
||||
text-indent: 0.28em;
|
||||
font-size: 0.72rem;
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.sidebar h3 {
|
||||
margin: 1.4rem 0 0.45rem;
|
||||
font-family: var(--display);
|
||||
font-weight: 400;
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.24em;
|
||||
text-indent: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.sidebar a {
|
||||
display: block;
|
||||
padding: 0.28rem 0.6rem;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.3;
|
||||
color: var(--ink-dim);
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar a:hover {
|
||||
color: var(--accent-soft);
|
||||
background: rgba(240, 169, 79, 0.07);
|
||||
}
|
||||
|
||||
.sidebar a.active {
|
||||
color: var(--accent-soft);
|
||||
background: rgba(240, 169, 79, 0.12);
|
||||
}
|
||||
|
||||
.reader-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0 auto;
|
||||
max-width: 58rem;
|
||||
padding: 1.5rem clamp(1.25rem, 4vw, 3rem) 4rem;
|
||||
}
|
||||
|
||||
.reader-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 2.1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
font-family: var(--display);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 0.68rem;
|
||||
color: var(--ink-dim);
|
||||
background: transparent;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hamburger:hover {
|
||||
color: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.hamburger-lines {
|
||||
width: 0.85rem;
|
||||
height: 1px;
|
||||
background: currentColor;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hamburger-lines::before,
|
||||
.hamburger-lines::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.hamburger-lines::before {
|
||||
top: -4px;
|
||||
}
|
||||
|
||||
.hamburger-lines::after {
|
||||
top: 4px;
|
||||
}
|
||||
|
||||
.font-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.font-controls button {
|
||||
font-family: var(--display);
|
||||
font-size: 0.72rem;
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
border-radius: 50%;
|
||||
color: var(--ink-dim);
|
||||
background: transparent;
|
||||
border: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
transition: color 0.3s, border-color 0.3s;
|
||||
}
|
||||
|
||||
.font-controls button:hover:not(:disabled) {
|
||||
color: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.font-controls button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.reader-book-title {
|
||||
font-family: var(--display);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3em;
|
||||
text-indent: 0.3em;
|
||||
text-align: center;
|
||||
font-size: clamp(1.35rem, 3vw, 1.9rem);
|
||||
color: var(--accent-soft);
|
||||
margin: 0.4rem 0 1.1rem;
|
||||
}
|
||||
|
||||
.chapter-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 2.2rem;
|
||||
}
|
||||
|
||||
.chapter-strip a {
|
||||
min-width: 1.85rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
color: var(--ink-dim);
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
transition: color 0.25s, border-color 0.25s, background 0.25s;
|
||||
}
|
||||
|
||||
.chapter-strip a:hover {
|
||||
color: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chapter-strip a.active {
|
||||
color: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
background: rgba(240, 169, 79, 0.12);
|
||||
}
|
||||
|
||||
.verses {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
font-size: 1.05em;
|
||||
line-height: 1.75;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.verse-line {
|
||||
margin: 0 0 0.7em;
|
||||
}
|
||||
|
||||
.verse-line sup {
|
||||
font-family: var(--display);
|
||||
font-size: 0.6em;
|
||||
color: var(--accent);
|
||||
margin-right: 0.45em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.verse-line.flash {
|
||||
animation: verse-flash 2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes verse-flash {
|
||||
0% {
|
||||
background: rgba(240, 169, 79, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
100% {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.chapter-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
max-width: 46rem;
|
||||
margin: 2.6rem auto 0;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.chapter-nav a {
|
||||
font-family: var(--display);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.74rem;
|
||||
color: var(--accent-soft);
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--line);
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 999px;
|
||||
transition: border-color 0.3s, background 0.3s;
|
||||
}
|
||||
|
||||
.chapter-nav a:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(240, 169, 79, 0.06);
|
||||
}
|
||||
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 25;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.search-page {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
min-height: 100vh;
|
||||
padding: 5.5rem clamp(1.25rem, 5vw, 3rem) 4rem;
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-family: var(--display);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.32em;
|
||||
text-indent: 0.32em;
|
||||
text-align: center;
|
||||
font-size: clamp(1.1rem, 2.6vw, 1.5rem);
|
||||
color: var(--accent-soft);
|
||||
margin: 0 0 1.6rem;
|
||||
}
|
||||
|
||||
.search-input-wrap {
|
||||
max-width: 40rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.95rem 1.7rem;
|
||||
font-family: var(--serif);
|
||||
font-size: 1.05rem;
|
||||
font-style: italic;
|
||||
color: var(--ink);
|
||||
background: rgba(16, 11, 7, 0.8);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
outline: none;
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(240, 169, 79, 0.15), 0 0 30px rgba(240, 169, 79, 0.12);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.search-meta {
|
||||
text-align: center;
|
||||
color: var(--ink-dim);
|
||||
font-size: 0.88rem;
|
||||
font-style: italic;
|
||||
margin: 1.1rem 0 2rem;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
display: block;
|
||||
padding: 0.85rem 1.1rem;
|
||||
margin-bottom: 0.65rem;
|
||||
border: 1px solid rgba(240, 169, 79, 0.14);
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
transition: border-color 0.3s, background 0.3s, transform 0.3s;
|
||||
}
|
||||
|
||||
.search-result:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(240, 169, 79, 0.05);
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.search-result-ref {
|
||||
display: block;
|
||||
font-family: var(--display);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-indent: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.search-result-text {
|
||||
font-size: 1rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.search-result mark {
|
||||
background: rgba(240, 169, 79, 0.35);
|
||||
color: var(--accent-soft);
|
||||
padding: 0 0.1em;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.hamburger {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
max-height: none;
|
||||
width: 280px;
|
||||
transform: translateX(-105%);
|
||||
transition: transform 0.28s ease;
|
||||
box-shadow: 0 0 60px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.reader-panel {
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
}
|
||||
+9
-33
@@ -1,31 +1,9 @@
|
||||
export interface KJVVerseData {
|
||||
verse: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface KJVChapterData {
|
||||
chapter: string
|
||||
verses: KJVVerseData[]
|
||||
}
|
||||
|
||||
export interface KJVBookData {
|
||||
book: string
|
||||
chapters: KJVChapterData[]
|
||||
}
|
||||
|
||||
export interface KJVData {
|
||||
books: KJVBookData[]
|
||||
}
|
||||
|
||||
export interface Verse {
|
||||
text: string
|
||||
reference: string
|
||||
}
|
||||
|
||||
let flat: Verse[] | null = null
|
||||
import { getBooks, loadBible, type Verse } from './bible'
|
||||
|
||||
const ROLLOVER_LOCAL_HOUR = 2
|
||||
|
||||
let flatNT: Verse[] | null = null
|
||||
|
||||
export function daySeed(now: Date = new Date()): number {
|
||||
const shifted = new Date(now.getTime() - ROLLOVER_LOCAL_HOUR * 3600_000)
|
||||
return Math.floor(Date.UTC(shifted.getFullYear(), shifted.getMonth(), shifted.getDate()) / 86_400_000)
|
||||
@@ -55,22 +33,20 @@ export function pickForDay(verses: Verse[], seed: number): Verse {
|
||||
}
|
||||
|
||||
export async function loadKJV(): Promise<Verse[]> {
|
||||
if (flat) return flat
|
||||
const res = await fetch('/kjv.json')
|
||||
if (!res.ok) throw new Error(`Failed to load scripture (${res.status})`)
|
||||
const data = (await res.json()) as KJVData
|
||||
flat = []
|
||||
if (flatNT) return flatNT
|
||||
await loadBible()
|
||||
flatNT = []
|
||||
let inNewTestament = false
|
||||
for (const book of data.books) {
|
||||
for (const book of getBooks()) {
|
||||
if (book.book === 'Matthew') inNewTestament = true
|
||||
if (!inNewTestament) continue
|
||||
const name = book.book === 'Psalms' ? 'Psalm' : book.book
|
||||
for (const chapter of book.chapters) {
|
||||
const chapterNumber = Number(chapter.chapter)
|
||||
for (const v of chapter.verses) {
|
||||
flat.push({ text: v.text, reference: `${name} ${chapterNumber}:${v.verse}` })
|
||||
flatNT.push({ text: v.text, reference: `${name} ${chapterNumber}:${v.verse}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
return flat
|
||||
return flatNT
|
||||
}
|
||||
Reference in New Issue
Block a user