Read entire bible
This commit is contained in:
Generated
+59
-1
@@ -11,7 +11,8 @@
|
|||||||
"@fontsource/cinzel": "^5.3.0",
|
"@fontsource/cinzel": "^5.3.0",
|
||||||
"@fontsource/eb-garamond": "^5.3.0",
|
"@fontsource/eb-garamond": "^5.3.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8"
|
"react-dom": "^19.2.8",
|
||||||
|
"react-router-dom": "^7.18.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.2.18",
|
"@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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -740,6 +754,44 @@
|
|||||||
"react": "^19.2.8"
|
"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": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz",
|
"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==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
+2
-1
@@ -19,6 +19,7 @@
|
|||||||
"@fontsource/cinzel": "^5.3.0",
|
"@fontsource/cinzel": "^5.3.0",
|
||||||
"@fontsource/eb-garamond": "^5.3.0",
|
"@fontsource/eb-garamond": "^5.3.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8"
|
"react-dom": "^19.2.8",
|
||||||
|
"react-router-dom": "^7.18.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-96
@@ -1,103 +1,20 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import CandleCanvas from './Candle'
|
import Home from './Home'
|
||||||
import { daySeed, loadKJV, msUntilRollover, pickForDay, type Verse } from './verse'
|
import Reader from './Reader'
|
||||||
|
import SearchPage from './SearchPage'
|
||||||
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 App() {
|
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 (
|
return (
|
||||||
<div className="app">
|
<>
|
||||||
<CandleCanvas />
|
|
||||||
<div className="noise" aria-hidden="true" />
|
<div className="noise" aria-hidden="true" />
|
||||||
<div className="vignette" aria-hidden="true" />
|
<div className="vignette" aria-hidden="true" />
|
||||||
|
<Routes>
|
||||||
<header className="app-header">
|
<Route path="/" element={<Home />} />
|
||||||
<h1 className="title">Walk By Faith</h1>
|
<Route path="/read" element={<Navigate to="/read/Genesis/1" replace />} />
|
||||||
<p className="tagline">
|
<Route path="/read/:book/:chapter" element={<Reader />} />
|
||||||
“For we walk by faith, not by sight.” <span className="tagline-ref">— 2 Corinthians 5:7</span>
|
<Route path="/search" element={<SearchPage />} />
|
||||||
</p>
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</header>
|
</Routes>
|
||||||
|
</>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+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 }
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
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.css'
|
||||||
import '@fontsource/eb-garamond/latin-400-italic.css'
|
import '@fontsource/eb-garamond/latin-400-italic.css'
|
||||||
import '@fontsource/eb-garamond/latin-500.css'
|
import '@fontsource/eb-garamond/latin-500.css'
|
||||||
@@ -10,6 +11,8 @@ import App from './App'
|
|||||||
|
|
||||||
createRoot(document.getElementById('app')!).render(
|
createRoot(document.getElementById('app')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
+477
-2
@@ -49,7 +49,7 @@ body {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
pointer-events: none;
|
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 {
|
.app {
|
||||||
@@ -290,7 +290,482 @@ body {
|
|||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.seek-btn,
|
.seek-btn,
|
||||||
.verse {
|
.verse,
|
||||||
|
.verse-line.flash {
|
||||||
animation: none;
|
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 {
|
import { getBooks, loadBible, type Verse } from './bible'
|
||||||
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
|
|
||||||
|
|
||||||
const ROLLOVER_LOCAL_HOUR = 2
|
const ROLLOVER_LOCAL_HOUR = 2
|
||||||
|
|
||||||
|
let flatNT: Verse[] | null = null
|
||||||
|
|
||||||
export function daySeed(now: Date = new Date()): number {
|
export function daySeed(now: Date = new Date()): number {
|
||||||
const shifted = new Date(now.getTime() - ROLLOVER_LOCAL_HOUR * 3600_000)
|
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)
|
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[]> {
|
export async function loadKJV(): Promise<Verse[]> {
|
||||||
if (flat) return flat
|
if (flatNT) return flatNT
|
||||||
const res = await fetch('/kjv.json')
|
await loadBible()
|
||||||
if (!res.ok) throw new Error(`Failed to load scripture (${res.status})`)
|
flatNT = []
|
||||||
const data = (await res.json()) as KJVData
|
|
||||||
flat = []
|
|
||||||
let inNewTestament = false
|
let inNewTestament = false
|
||||||
for (const book of data.books) {
|
for (const book of getBooks()) {
|
||||||
if (book.book === 'Matthew') inNewTestament = true
|
if (book.book === 'Matthew') inNewTestament = true
|
||||||
if (!inNewTestament) continue
|
if (!inNewTestament) continue
|
||||||
const name = book.book === 'Psalms' ? 'Psalm' : book.book
|
const name = book.book === 'Psalms' ? 'Psalm' : book.book
|
||||||
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) {
|
||||||
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