Initial Libre Study app scaffold

This commit is contained in:
2026-07-11 21:36:30 -05:00
commit 470cde6865
46 changed files with 8226 additions and 0 deletions
+214
View File
@@ -0,0 +1,214 @@
import { useEffect, useMemo, useState } from 'react'
import { addTagToVerse, getLibraryState, searchVerses } from './studyClient'
import type { Tag, Verse } from './types'
import './App.css'
const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7']
function reference(verse: Verse) {
return `${verse.book} ${verse.chapter}:${verse.verse}`
}
function App() {
const [query, setQuery] = useState('')
const [verses, setVerses] = useState<Verse[]>([])
const [tags, setTags] = useState<Tag[]>([])
const [selectedId, setSelectedId] = useState<number | null>(null)
const [tagInput, setTagInput] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
async function loadLibrary() {
setError(null)
const state = await getLibraryState()
setVerses(state.verses)
setTags(state.tags)
setSelectedId((current) => current ?? state.verses[0]?.id ?? null)
}
async function runSearch(nextQuery = query) {
setError(null)
const results = await searchVerses(nextQuery)
setVerses(results)
setSelectedId(results[0]?.id ?? null)
}
async function addTag() {
const label = tagInput.trim()
if (!selectedId || !label) return
const color = tagColors[Math.abs(label.length) % tagColors.length]
const updated = await addTagToVerse(selectedId, label, color)
setVerses((current) =>
current.map((verse) => (verse.id === updated.id ? updated : verse)),
)
setTags((current) => {
const exists = current.some((tag) => tag.label.toLowerCase() === label.toLowerCase())
return exists ? current : [...current, updated.tags[updated.tags.length - 1]]
})
setTagInput('')
}
useEffect(() => {
loadLibrary()
.catch((reason) => setError(String(reason)))
.finally(() => setLoading(false))
}, [])
const selectedVerse = useMemo(
() => verses.find((verse) => verse.id === selectedId) ?? verses[0],
[selectedId, verses],
)
return (
<main className="app-shell">
<aside className="sidebar">
<div className="brand-block">
<span className="brand-mark">LS</span>
<div>
<h1>Libre Study</h1>
<p>KJV with Strong's-linked study data</p>
</div>
</div>
<form
className="search-box"
onSubmit={(event) => {
event.preventDefault()
runSearch().catch((reason) => setError(String(reason)))
}}
>
<label htmlFor="search">Search</label>
<div className="search-row">
<input
id="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder='faith, "kingdom", G4102'
/>
<button type="submit">Go</button>
</div>
</form>
<section className="tag-filter">
<div className="section-title">Tags</div>
<div className="tag-list">
{tags.length === 0 ? (
<span className="empty-text">No tags yet</span>
) : (
tags.map((tag) => (
<button
key={tag.id}
className="tag-chip"
type="button"
style={{ borderColor: tag.color }}
onClick={() => {
setQuery(`tag:${tag.label}`)
runSearch(`tag:${tag.label}`).catch((reason) => setError(String(reason)))
}}
>
{tag.label}
</button>
))
)}
</div>
</section>
<section className="result-list" aria-label="Search results">
<div className="section-title">{verses.length} verses</div>
{verses.map((verse) => (
<button
key={verse.id}
className={verse.id === selectedVerse?.id ? 'result active' : 'result'}
type="button"
onClick={() => setSelectedId(verse.id)}
>
<span>{reference(verse)}</span>
<small>{verse.text}</small>
</button>
))}
</section>
</aside>
<section className="reader">
{loading ? (
<div className="status-panel">Loading local library...</div>
) : error ? (
<div className="status-panel error">{error}</div>
) : selectedVerse ? (
<>
<header className="reader-header">
<div>
<span className="eyebrow">King James Version</span>
<h2>{reference(selectedVerse)}</h2>
</div>
<div className="reader-tags">
{selectedVerse.tags.map((tag) => (
<span key={tag.id} style={{ backgroundColor: tag.color }}>
{tag.label}
</span>
))}
</div>
</header>
<article className="verse-card">
<p>{selectedVerse.text}</p>
</article>
<div className="study-grid">
<section className="panel">
<div className="panel-header">
<h3>Strong's Links</h3>
<span>{selectedVerse.strongs.length}</span>
</div>
<div className="strong-list">
{selectedVerse.strongs.map((entry) => (
<article key={entry.strong} className="strong-entry">
<div>
<strong>{entry.strong}</strong>
<span>{entry.language}</span>
</div>
<h4>{entry.lemma}</h4>
<p className="translit">{entry.transliteration}</p>
<p>{entry.definition}</p>
</article>
))}
</div>
</section>
<section className="panel">
<div className="panel-header">
<h3>Tag This Verse</h3>
</div>
<div className="tag-editor">
<input
value={tagInput}
onChange={(event) => setTagInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
addTag().catch((reason) => setError(String(reason)))
}
}}
placeholder="prophecy, sermon, doctrine"
/>
<button type="button" onClick={() => addTag().catch((reason) => setError(String(reason)))}>
Add
</button>
</div>
<p className="panel-note">
This is backed by the local SQLite database. Notes, highlights, saved
searches, and import tools can sit on this same foundation.
</p>
</section>
</div>
</>
) : (
<div className="status-panel">No verses matched that search.</div>
)}
</section>
</main>
)
}
export default App