Read entire bible

This commit is contained in:
h202-wq
2026-08-19 08:20:17 -04:00
parent 1a79f44a0e
commit 35630dd41f
11 changed files with 1080 additions and 134 deletions
+59 -1
View File
@@ -11,7 +11,8 @@
"@fontsource/cinzel": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2"
},
"devDependencies": {
"@types/react": "^19.2.18",
@@ -340,6 +341,19 @@
}
}
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -740,6 +754,44 @@
"react": "^19.2.8"
}
},
"node_modules/react-router": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.2"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/rolldown": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz",
@@ -779,6 +831,12 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+2 -1
View File
@@ -19,6 +19,7 @@
"@fontsource/cinzel": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2"
}
}
+13 -96
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)
}
+94
View File
@@ -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>
)
}
+15
View File
@@ -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
View File
@@ -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 }
}
+3
View File
@@ -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>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
+477 -2
View File
@@ -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
View File
@@ -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
}