Redesign the study side: Outline, Study, Script and Export stages
Build and Push Docker Image / build-and-push (push) Successful in 4m15s

Studies now move through a stage bar in the reader's themes. Outline splits
chunks by clicking between verses (join merges notes) and suggests chunks from
section headings. Study is three panes: outline, passage with an Observe /
Interpret / Apply accordion, and commentary / word study / cross-refs / people
& places for the chunk; original words are added by clicking them in the
interlinear. Script drafts an outline from notes; Export holds files and the
share link. Home, Import and the Pronunciation guide are restyled. Study
projects are BSB only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-09-29 10:13:46 -04:00
1 parent b1bc1c8ea4
commit 31abee0695
31 files changed
+1911 -2460

No files matched your search

+13 -13
View File
@@ -77,11 +77,11 @@ export default function AudioPlayer({
const progressPct = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
return (
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-panel sm:p-5">
<div className="rounded-3xl border border-[var(--r-line,#e2e8f0)] bg-[var(--r-bar,#ffffff)] p-4 sm:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-semibold text-slate-900 sm:text-base">{title}</h3>
<p className="mt-0.5 truncate text-xs text-slate-500 sm:text-sm">{subtitle}</p>
<div className="min-w-[10rem] flex-1">
<h3 className="truncate text-sm font-semibold text-[var(--r-text,#0f172a)] sm:text-base">{title}</h3>
<p className="mt-0.5 truncate text-xs text-[var(--r-sub,#64748b)] sm:text-sm">{subtitle}</p>
{status === 'error' && errorMessage && (
<p className="mt-1 text-xs text-rose-600">{errorMessage}</p>
)}
@@ -99,11 +99,11 @@ export default function AudioPlayer({
value={Math.min(currentTime, duration || 0)}
onChange={(e) => onSeekTo?.(Number(e.target.value))}
disabled={!duration}
className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-sky-600 disabled:cursor-default"
style={{ background: `linear-gradient(to right, rgb(2 132 199) ${progressPct}%, rgb(226 232 240) ${progressPct}%)` }}
className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-[var(--r-line,#e2e8f0)] accent-[var(--r-accent,#0284c7)] disabled:cursor-default"
style={{ background: `linear-gradient(to right, var(--r-accent, rgb(2 132 199)) ${progressPct}%, var(--r-line, rgb(226 232 240)) ${progressPct}%)` }}
aria-label="Seek"
/>
<div className="mt-1 flex justify-between text-xs tabular-nums text-slate-400">
<div className="mt-1 flex justify-between text-xs tabular-nums text-[var(--r-sub,#64748b)]">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
@@ -118,7 +118,7 @@ export default function AudioPlayer({
onClick={onSeekBack}
title="Rewind 10 seconds"
aria-label="Rewind 10 seconds"
className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 hover:bg-slate-100"
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
>
<Seek10Icon direction="back" />
</button>
@@ -126,7 +126,7 @@ export default function AudioPlayer({
type="button"
onClick={onTogglePause}
aria-label={status === 'paused' ? 'Resume' : 'Pause'}
className="flex h-12 w-12 items-center justify-center rounded-full bg-sky-600 text-white shadow-sm hover:bg-sky-500"
className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--r-accent,#0284c7)] text-[var(--r-on,#ffffff)] hover:opacity-90"
>
{status === 'paused' ? <PlayIcon /> : <PauseIcon />}
</button>
@@ -135,7 +135,7 @@ export default function AudioPlayer({
onClick={onSeekForward}
title="Fast-forward 10 seconds"
aria-label="Fast-forward 10 seconds"
className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 hover:bg-slate-100"
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
>
<Seek10Icon direction="forward" />
</button>
@@ -146,7 +146,7 @@ export default function AudioPlayer({
disabled={nextDisabled}
title="Next chapter"
aria-label="Next chapter"
className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 hover:bg-slate-100 disabled:opacity-30 disabled:hover:bg-transparent"
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent"
>
<NextTrackIcon />
</button>
@@ -156,7 +156,7 @@ export default function AudioPlayer({
onClick={onStop}
title="Stop"
aria-label="Stop"
className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 hover:bg-slate-100"
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
>
<StopIcon />
</button>
@@ -165,7 +165,7 @@ export default function AudioPlayer({
<button
type="button"
onClick={onPlay}
className="flex h-12 w-12 items-center justify-center rounded-full bg-sky-600 text-white shadow-sm hover:bg-sky-500"
className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--r-accent,#0284c7)] text-[var(--r-on,#ffffff)] hover:opacity-90"
aria-label="Play"
>
<PlayIcon />
+9 -5
View File
@@ -1,17 +1,21 @@
// Signed-in email plus Admin / Settings / Log out buttons for page headers.
import { useApp } from '../context/AppContext.js';
export default function AuthStatus() {
// `themed` swaps the dark-header look for the reader themes' colours.
export default function AuthStatus({ themed = false }) {
const { authUser, setCurrentPage, logout } = useApp();
if (!authUser) return null;
const btn = themed
? 'rounded-lg px-2.5 py-1.5 text-xs text-[var(--r-sub)] transition hover:bg-black/5'
: 'rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15';
return (
<div className="flex items-center gap-2 text-sm text-slate-300">
<div className={`flex items-center gap-2 text-sm ${themed ? 'text-[var(--r-sub)]' : 'text-slate-300'}`}>
<span className="hidden sm:inline">{authUser.email}</span>
{authUser.isAdmin && (
<button
type="button"
onClick={() => setCurrentPage('admin')}
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
className={btn}
>
🛡 Admin
</button>
@@ -19,14 +23,14 @@ export default function AuthStatus() {
<button
type="button"
onClick={() => setCurrentPage('settings')}
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
className={btn}
>
⚙ Settings
</button>
<button
type="button"
onClick={logout}
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
className={btn}
>
Log out
</button>
-228
View File
@@ -1,228 +0,0 @@
// Header for an open project (Setup and Study pages): navigation, Claude
// prompts, pronunciation guide, export menu, share link, and save/sync status.
import { useEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { useShareLink } from '../hooks/useShareLink.js';
import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js';
import {
exportProjectDocx,
exportProjectHtml,
exportProjectMarkdown,
printProjectPdf,
} from '../utils/projectExport.js';
import AuthStatus from './AuthStatus.jsx';
// loading: true while the Setup page is fetching a chapter.
export default function ProjectHeader({ loading = false }) {
const {
authUser,
currentPage, setCurrentPage,
goHome,
project,
allChunks,
saveStatus, setSaveStatus,
syncStatus,
retrySync,
} = useApp();
// Only one dropdown open at a time: 'tools' | 'export' | 'share' | null
const [openMenu, setOpenMenu] = useState(null);
const toggleMenu = (name) => setOpenMenu((current) => (current === name ? null : name));
const closeMenu = () => setOpenMenu(null);
const menusRef = useRef(null);
// Close the open dropdown on an outside click or Escape.
useEffect(() => {
if (!openMenu) return;
const onPointerDown = (e) => {
if (!menusRef.current?.contains(e.target)) setOpenMenu(null);
};
const onKeyDown = (e) => { if (e.key === 'Escape') setOpenMenu(null); };
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [openMenu]);
const share = useShareLink(project?.id);
// Copies a prompt, then opens claude.ai so it can be pasted straight in.
const copyPrompt = (prompt) => {
navigator.clipboard.writeText(prompt).then(() => {
setSaveStatus('Copied! Opening Claude…');
window.setTimeout(() => setSaveStatus(''), 3000);
window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer');
});
};
return (
<div className="flex flex-wrap items-center gap-3">
{currentPage !== 'home' && (
<div className="flex flex-wrap items-center gap-2">
{currentPage !== 'home' && (
<button
type="button"
onClick={goHome}
className="rounded-xl border border-white/15 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/15"
>
← Home
</button>
)}
{project && currentPage === 'study' && (
<button
type="button"
onClick={() => setCurrentPage('setup')}
className="rounded-xl border border-white/15 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/15"
>
← Back to Chunks
</button>
)}
</div>
)}
{project && (
<div ref={menusRef} className="flex flex-wrap items-center gap-3 border-white/10 sm:border-l sm:pl-3">
<div className="relative">
<button
type="button"
onClick={() => toggleMenu('tools')}
aria-expanded={openMenu === 'tools'}
disabled={allChunks.length === 0}
title={allChunks.length === 0 ? 'Add a chunk first to use these tools' : undefined}
className="flex items-center gap-1.5 rounded-md bg-violet-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-violet-400 disabled:cursor-not-allowed disabled:bg-slate-500 disabled:opacity-60 disabled:shadow-none"
>
🧰 Tools
</button>
{openMenu === 'tools' && allChunks.length > 0 && (
<div className="absolute left-0 top-full z-20 mt-2 w-64 overflow-hidden rounded-2xl border border-slate-200 bg-white py-1.5 text-left shadow-lg">
<button
type="button"
onClick={() => { copyPrompt(buildClaudePrompt(project)); closeMenu(); }}
title="Copies your study notes as a Claude prompt, then opens claude.ai so you can paste and go"
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50"
>
Study Guide → Claude ↗
</button>
<button
type="button"
onClick={() => { copyPrompt(buildPodcastPrompt(project, authUser?.podcastName)); closeMenu(); }}
title="Copies a podcast script prompt for Claude, then opens claude.ai — set your show name in Account Settings first"
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50"
>
🎙 Podcast → Claude ↗
</button>
<button
type="button"
onClick={() => { setCurrentPage('pronunciation'); closeMenu(); }}
title="Open the pronunciation guide — all Greek & Hebrew words with transliterations"
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50"
>
🗣 Pronunciation Guide
</button>
</div>
)}
</div>
<div className="relative">
<button
type="button"
onClick={() => toggleMenu('export')}
aria-expanded={openMenu === 'export'}
className="flex items-center gap-1.5 rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400"
>
⬇ Export
</button>
{openMenu === 'export' && (
<div className="absolute left-0 top-full z-20 mt-2 w-56 overflow-hidden rounded-2xl border border-slate-200 bg-white py-1.5 text-left shadow-lg">
<button type="button" onClick={() => { exportProjectHtml(project); closeMenu(); }}
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
Export HTML
</button>
<button type="button" onClick={() => { exportProjectDocx(project); closeMenu(); }}
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
Export DOCX
</button>
<button type="button" onClick={() => { exportProjectMarkdown(project); closeMenu(); }}
title="Handy for Obsidian, Notion, or any Markdown-based notes app"
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
Export Markdown
</button>
<button type="button" onClick={() => { printProjectPdf(project); closeMenu(); }}
title="Opens a print-friendly version in a new tab — choose 'Save as PDF' in the print dialog"
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
🖨 Print / Save PDF
</button>
</div>
)}
</div>
<div className="relative">
<button
type="button"
onClick={() => toggleMenu('share')}
aria-expanded={openMenu === 'share'}
className={`rounded-md px-4 py-2 text-sm font-medium shadow-sm transition ${share.shareToken ? 'bg-teal-600 text-white hover:bg-teal-500' : 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50'}`}
>
🔗 {share.shareToken ? 'Shared' : 'Share'}
</button>
{openMenu === 'share' && (
<div className="absolute right-0 top-full z-20 mt-2 w-80 rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-lg">
<h3 className="text-sm font-semibold text-slate-900">Read-only share link</h3>
<p className="mt-1 text-xs text-slate-500">
Anyone with this link can view (not edit) this study — no account needed. Good for a co-teacher
or a listener who wants to follow along.
</p>
{share.shareToken ? (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2">
<input
readOnly
value={share.shareUrl}
onFocus={(e) => e.target.select()}
className="w-full flex-1 truncate rounded-lg border border-slate-300 bg-slate-50 px-2 py-1.5 text-xs text-slate-600"
/>
<button type="button" onClick={share.copy}
className="shrink-0 rounded-lg bg-sky-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-sky-400">
{share.shareCopied ? 'Copied!' : 'Copy'}
</button>
</div>
<button type="button" onClick={share.revoke} disabled={share.shareBusy}
className="w-full rounded-lg border border-rose-200 px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-50">
{share.shareBusy ? 'Revoking…' : 'Revoke link'}
</button>
</div>
) : (
<button type="button" onClick={share.enable} disabled={share.shareBusy}
className="mt-3 w-full rounded-lg bg-teal-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-teal-500 disabled:cursor-not-allowed disabled:opacity-50">
{share.shareBusy ? 'Enabling…' : 'Enable sharing'}
</button>
)}
</div>
)}
</div>
</div>
)}
<div className="text-right text-sm text-slate-300 space-y-0.5">
{loading ? (
<span>Loading…</span>
) : saveStatus ? (
<span className="text-emerald-300">{saveStatus}</span>
) : (
<span>&nbsp;</span>
)}
{syncStatus === 'syncing' && <div className="text-xs text-slate-400">Syncing…</div>}
{syncStatus === 'synced' && <div className="text-xs text-emerald-400">Synced ✓</div>}
{syncStatus === 'error' && (
<div className="flex items-center gap-2">
<span className="text-xs text-rose-400">⚠ Sync failed</span>
<button
type="button"
onClick={retrySync}
className="text-xs text-slate-300 underline hover:text-white"
>
Retry
</button>
</div>
)}
</div>
<AuthStatus />
</div>
);
}
+1 -1
View File
@@ -65,7 +65,7 @@ function PersonDetails({ id }) {
);
}
function PersonCard({ person }) {
export function PersonCard({ person }) {
const [open, setOpen] = useState(false);
return (
<li className="rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2">
@@ -0,0 +1,56 @@
// Commentary for the chunk's verse range, from seven open sources. Some sources
// comment on whole passages, so when nothing starts inside the range we show the
// closest earlier entry. "Add to notes" quotes a paragraph, with its citation, into
// the chunk's background notes.
import { COMMENTARIES } from '../../hooks/useVerseStudy.js';
import { formatChunkReference } from '../../utils/bibleUtils.jsx';
export default function CommentaryChunkTab({ resources, chunk, project, chapterIndex, updateChunk, notify }) {
const { commentarySource, setCommentarySource, commentaryData, commentaryLoading, commentaryError } = resources;
const source = COMMENTARIES.find((c) => c.id === commentarySource) ?? COMMENTARIES[0];
const items = (commentaryData?.chapter?.content ?? []).filter((i) => i.type === 'verse').sort((a, b) => a.number - b.number);
let entries = items.filter((i) => i.number >= chunk.startVerse && i.number <= chunk.endVerse);
let earlier = false;
if (entries.length === 0) {
const before = [...items].reverse().find((i) => i.number <= chunk.startVerse);
if (before) { entries = [before]; earlier = true; }
}
const ref = formatChunkReference(project, chapterIndex, chunk, '–');
const quote = (text, verse) => {
const citation = `${text} (${source.name}, ${ref.split(':')[0]}:${verse})`;
const existing = chunk.generalNotes ?? '';
updateChunk(chunk.id, { generalNotes: existing ? `${existing}\n\n${citation}` : citation });
notify('Added to background notes.', 1600);
};
return (
<div>
<select aria-label="Commentary source" value={source.id} onChange={(e) => setCommentarySource(e.target.value)}
className="mb-3 w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]">
{COMMENTARIES.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
{commentaryLoading && <p className="text-sm text-[var(--r-sub)]">Loading commentary…</p>}
{commentaryError && <p className="text-sm text-[var(--r-sub)]">{commentaryError}</p>}
{!commentaryLoading && !commentaryError && commentaryData && entries.length === 0 && (
<p className="text-sm text-[var(--r-sub)]">No commentary found for {ref}.</p>
)}
{!commentaryLoading && !commentaryError && entries.map((v) => (
<div key={v.number} className="mb-4">
<p className="mb-1 text-xs font-medium text-[var(--r-sub)]">{earlier ? `Notes on the passage starting at verse ${v.number}` : `Verse ${v.number}`}</p>
{(v.content ?? []).map((p, i) => {
const text = typeof p === 'string' ? p : '';
if (!text) return null;
return (
<div key={i} className="group mb-2">
<p className="whitespace-pre-line text-[13px] leading-relaxed">{text}</p>
<button type="button" onClick={() => quote(text, v.number)} className="mt-0.5 text-[11px] text-[var(--r-accent)] opacity-70 hover:underline hover:opacity-100">Add to notes</button>
</div>
);
})}
</div>
))}
<p className="mt-2 text-[10px] text-[var(--r-sub)]">{source.credit}</p>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
// Cross-references for the chunk: typed, suggested from the Open Cross Ref
// dataset, and a preview of each reference's verse text.
import { useState } from 'react';
function RefChip({ label, onRemove, loadVerseText }) {
const [text, setText] = useState(null);
const [open, setOpen] = useState(false);
const toggle = () => {
setOpen((o) => !o);
if (text == null) loadVerseText(label).then((t) => setText(t || 'Verse text unavailable.')).catch(() => setText('Verse text unavailable.'));
};
return (
<div className="rounded-xl border border-[var(--r-line)]">
<div className="flex items-center justify-between gap-2 px-3 py-1.5">
<button type="button" onClick={toggle} aria-expanded={open} className="flex-1 text-left text-sm">{label}</button>
<button type="button" onClick={onRemove} aria-label={`Remove ${label}`} className="text-[var(--r-sub)] hover:text-red-600">×</button>
</div>
{open && <p className="border-t border-[var(--r-line)] px-3 py-2 font-serif text-[13px] leading-relaxed text-[var(--r-sub)]">{text ?? 'Loading…'}</p>}
</div>
);
}
export default function CrossRefsTab({ crossRefs, chunk }) {
const { crossRefInput, setCrossRefInput, suggestingCrossRefs, crossRefSuggestions, addCrossRef, removeCrossRef,
suggestCrossRefsForChunk, addSuggestedCrossRef, loadVerseText } = crossRefs;
const refs = chunk.crossReferences ?? [];
return (
<div>
<div className="flex gap-2">
<input value={crossRefInput} onChange={(e) => setCrossRefInput(e.target.value)} placeholder="e.g. John 1:1 or Rom 3:23"
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCrossRef(chunk.id); } }}
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
<button type="button" onClick={() => addCrossRef(chunk.id)} className="rounded-lg bg-[var(--r-text)] px-3 py-1.5 text-sm text-[var(--r-bg)]">Add</button>
</div>
<button type="button" onClick={() => suggestCrossRefsForChunk(chunk.id)} disabled={suggestingCrossRefs}
className="mt-2 rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5 disabled:opacity-50">
{suggestingCrossRefs ? 'Suggesting…' : 'Suggest from passage'}
</button>
{crossRefSuggestions.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{crossRefSuggestions.map((ref) => (
<button key={ref} type="button" onClick={() => addSuggestedCrossRef(chunk.id, ref)}
className="rounded-full border border-[var(--r-accent)] px-2.5 py-0.5 text-xs text-[var(--r-accent)] hover:bg-black/5">+ {ref}</button>
))}
</div>
)}
<div className="mt-3 space-y-1.5">
{refs.map((ref) => <RefChip key={ref} label={ref} onRemove={() => removeCrossRef(chunk.id, ref)} loadVerseText={loadVerseText} />)}
{refs.length === 0 && <p className="text-sm text-[var(--r-sub)]">No cross-references yet.</p>}
</div>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
// Observe / Interpret / Apply as an accordion (one open at a time), plus a "More"
// area for background notes and tags so the page stays quiet.
import { useEffect, useState } from 'react';
const SECTIONS = [
{ field: 'observation', label: 'Observe', hint: 'What does the text say?',
placeholder: "What does the text actually say?\n• Who is speaking, and to whom?\n• What key words or phrases repeat?\n• What's the tone, structure, or literary style?" },
{ field: 'interpretation', label: 'Interpret', hint: 'What does it mean?',
placeholder: "What did this mean to its original audience?\n• What's the historical/cultural context?\n• How does it fit the surrounding argument?\n• What does it reveal about God's character?" },
{ field: 'application', label: 'Apply', hint: 'What do I do with it?',
placeholder: 'How should this shape your life today?\n• What attitude or action does this call for?\n• Is there a promise to trust or a warning to heed?\n• Who could you share this with?' },
];
const areaCls = 'w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3.5 py-3 font-serif text-[15px] leading-7 text-[var(--r-text)] placeholder:font-sans placeholder:text-[13px] placeholder:leading-5 placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
export default function NotesPane({ chunk, updateChunk, allTags }) {
const [open, setOpen] = useState(() => {
try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; }
});
const [more, setMore] = useState(false);
const [tagInput, setTagInput] = useState('');
useEffect(() => { setTagInput(''); }, [chunk.id]);
useEffect(() => { try { localStorage.setItem('studyNotesOpen', open || ''); } catch { /* storage unavailable */ } }, [open]);
const tags = chunk.tags ?? [];
const addTag = (tag) => {
const t = tag.trim();
if (!t || tags.some((x) => x.toLowerCase() === t.toLowerCase())) { setTagInput(''); return; }
updateChunk(chunk.id, { tags: [...tags, t] });
setTagInput('');
};
const suggestions = allTags.filter((t) => !tags.some((x) => x.toLowerCase() === t.toLowerCase()));
return (
<section aria-label="Study notes" className="space-y-2">
{SECTIONS.map(({ field, label, hint, placeholder }) => {
const isOpen = open === field;
const n = words(chunk[field]);
return (
<div key={field} className={`overflow-hidden rounded-2xl border ${isOpen ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)]'}`}>
<button type="button" onClick={() => setOpen(isOpen ? null : field)} aria-expanded={isOpen}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left">
<span className="flex items-center gap-2.5">
<span aria-hidden="true" className={`h-2.5 w-2.5 rounded-full border ${n ? 'border-[var(--r-accent)] bg-[var(--r-accent)]' : 'border-[var(--r-sub)]'}`} />
<span className="text-sm font-medium">{label}</span>
<span className="text-xs text-[var(--r-sub)]">{hint}</span>
</span>
<span className="text-xs text-[var(--r-sub)]">{n ? `${n} word${n === 1 ? '' : 's'}` : ''}</span>
</button>
{isOpen && (
<div className="px-4 pb-4">
<textarea aria-label={label} value={chunk[field] ?? ''} rows={8} placeholder={placeholder}
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
</div>
)}
</div>
);
})}
<div className="rounded-2xl border border-[var(--r-line)]">
<button type="button" onClick={() => setMore((m) => !m)} aria-expanded={more}
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm">
<span><span className="font-medium">More</span> <span className="text-xs text-[var(--r-sub)]">background{tags.length ? ` · ${tags.length} tag${tags.length === 1 ? '' : 's'}` : ' and tags'}</span></span>
<span className="text-xs text-[var(--r-sub)]">{more ? '▾' : '▸'}</span>
</button>
{more && (
<div className="space-y-4 px-4 pb-4">
<label className="block text-xs text-[var(--r-sub)]">
Background / general notes
<textarea value={chunk.generalNotes ?? ''} rows={5} onChange={(e) => updateChunk(chunk.id, { generalNotes: e.target.value })}
placeholder="e.g. author, date, audience, historical context, key themes…" className={`mt-1 ${areaCls}`} />
</label>
<div>
<p className="mb-1.5 text-xs text-[var(--r-sub)]">Tags</p>
<div className="flex flex-wrap items-center gap-1.5">
{tags.map((tag) => (
<span key={tag} className="inline-flex items-center gap-1 rounded-full bg-[var(--r-bar)] px-2.5 py-1 text-xs">
{tag}
<button type="button" aria-label={`Remove tag ${tag}`} onClick={() => updateChunk(chunk.id, { tags: tags.filter((t) => t !== tag) })} className="text-[var(--r-sub)] hover:text-red-600">×</button>
</span>
))}
<input value={tagInput} onChange={(e) => setTagInput(e.target.value)} list="study-tag-suggestions"
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addTag(tagInput); } }}
placeholder="Add a tag and press Enter"
className="min-w-[10rem] flex-1 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1 text-xs text-[var(--r-text)] focus:outline-none" />
</div>
{suggestions.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-[var(--r-sub)]">Suggestions:</span>
{suggestions.slice(0, 12).map((t) => (
<button key={t} type="button" onClick={() => addTag(t)} className="rounded-full border border-[var(--r-line)] px-2.5 py-0.5 text-[var(--r-sub)] hover:bg-black/5">+ {t}</button>
))}
</div>
)}
<datalist id="study-tag-suggestions">{allTags.map((t) => <option key={t} value={t} />)}</datalist>
</div>
</div>
)}
</div>
</section>
);
}
+43
View File
@@ -0,0 +1,43 @@
// Left rail of the Study stage: every chunk of the project as an outline, with
// three dots (Observe / Interpret / Apply) showing which notes are written.
import { formatChunkReference } from '../../utils/bibleUtils.jsx';
const filled = (s) => (s ?? '').trim().length > 0;
export default function OutlineRail({ project, selectedId, onSelect, onEditOutline }) {
return (
<nav aria-label="Chunks" className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between px-3 pb-2 pt-3">
<span className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Outline</span>
<button type="button" onClick={onEditOutline} className="text-[11px] text-[var(--r-accent)] hover:underline">Edit</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-4">
{project.chapters.map((ch, chapterIndex) => (
<div key={`${ch.bookAbbrev}-${ch.chapter}`} className="mb-3">
{project.chapters.length > 1 && <p className="px-2 pb-1 text-[11px] text-[var(--r-sub)]">{ch.book} {ch.chapter}</p>}
{ch.chunks.length === 0 && <p className="px-2 text-xs text-[var(--r-sub)]">No chunks in this chapter.</p>}
{ch.chunks.map((chunk) => {
const selected = chunk.id === selectedId;
const first = ch.verses.find((v) => v.number === chunk.startVerse)?.text ?? '';
return (
<button key={chunk.id} type="button" onClick={() => onSelect(chunk.id)} aria-current={selected ? 'true' : undefined}
className={`mb-1 block w-full rounded-xl border px-2.5 py-2 text-left transition ${selected ? 'border-[var(--r-line)] bg-[var(--r-bg)]' : 'border-transparent hover:bg-black/5'}`}>
<span className="flex items-center justify-between gap-2">
<span className="text-[13px] font-medium">{formatChunkReference(project, chapterIndex, chunk, '–')}</span>
<span className="flex gap-1" aria-hidden="true">
{['observation', 'interpretation', 'application'].map((f) => (
<span key={f} className={`h-2 w-2 rounded-full border ${filled(chunk[f]) ? 'border-[var(--r-accent)] bg-[var(--r-accent)]' : 'border-[var(--r-sub)]'}`} />
))}
</span>
</span>
{chunk.episodeTitle && <span className="mt-0.5 block truncate text-[11px] text-[var(--r-accent)]">{chunk.episodeNumber ? `${chunk.episodeNumber} · ` : ''}{chunk.episodeTitle}</span>}
<span className="mt-0.5 block truncate font-serif text-xs text-[var(--r-sub)]">{first}</span>
</button>
);
})}
</div>
))}
</div>
</nav>
);
}
+31
View File
@@ -0,0 +1,31 @@
// Themed page frame for pages that aren't part of a study project (Home, Import):
// slim top bar with a title, optional back button, and actions on the right.
import { useApp } from '../../context/AppContext.js';
import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
import { ChevronLeft } from '../reader/icons.jsx';
import AuthStatus from '../AuthStatus.jsx';
const THEME_ORDER = ['paper', 'sepia', 'dark'];
export default function PageShell({ title, onBack, actions, maxWidth = 'max-w-6xl', children }) {
const { readerTheme, setReaderTheme } = useApp();
const cycle = () => setReaderTheme(THEME_ORDER[(THEME_ORDER.indexOf(readerTheme) + 1) % THEME_ORDER.length]);
return (
<div className="min-h-screen bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
<div className="sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
<div className={`mx-auto flex min-h-[3rem] ${maxWidth} flex-wrap items-center gap-2 px-3 py-1.5`}>
{onBack && (
<button type="button" onClick={onBack} aria-label="Back" className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /></button>
)}
<h1 className="text-base font-medium">{title}</h1>
<div className="ml-auto flex flex-wrap items-center gap-1.5">
{actions}
<button type="button" onClick={cycle} className="rounded-lg px-2 py-1.5 text-xs text-[var(--r-sub)] hover:bg-black/5" title="Change theme">{READER_THEMES[readerTheme]?.label}</button>
<AuthStatus themed />
</div>
</div>
</div>
{children}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
// People, places and events (Theographic data) tied to the chunk's verses.
import { loadTheographic, useAsync } from '../../hooks/useVerseStudy.js';
import { PersonCard } from '../StudyPanel.jsx';
export default function PeopleChunkTab({ book, chapter, chunk }) {
const { status, data } = useAsync(() => loadTheographic(book, chapter), [book, chapter]);
if (status === 'loading') return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
if (status === 'empty') return <p className="text-sm text-[var(--r-sub)]">No people or places data for this chapter.</p>;
const inChunk = (x) => x.verses?.some((n) => n >= chunk.startVerse && n <= chunk.endVerse);
const people = data.people.filter(inChunk);
const places = [...new Map(data.places.filter(inChunk).map((p) => [p.name, p])).values()];
const events = data.events.filter(inChunk);
if (!people.length && !places.length && !events.length) return <p className="text-sm text-[var(--r-sub)]">Nothing tagged in these verses.</p>;
const head = 'mb-1 text-[11px] uppercase tracking-wide text-[var(--r-sub)]';
return (
<div className="space-y-4">
{people.length > 0 && <div><p className={head}>People</p><ul className="space-y-1">{people.map((p) => <PersonCard key={p.id} person={p} />)}</ul></div>}
{places.length > 0 && (
<div><p className={head}>Places</p>
<ul className="space-y-1">{places.map((p) => (
<li key={p.id} className="flex items-center justify-between rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm">
<span>{p.name}{p.featureType && <span className="ml-1 text-xs text-[var(--r-sub)]">{p.featureType}</span>}</span>
{p.latitude != null && <a href={`https://www.openstreetmap.org/?mlat=${p.latitude}&mlon=${p.longitude}#map=8/${p.latitude}/${p.longitude}`} target="_blank" rel="noopener noreferrer" className="text-xs text-[var(--r-accent)] hover:underline">Map ↗</a>}
</li>))}</ul></div>
)}
{events.length > 0 && <div><p className={head}>Events</p><ul className="space-y-1">{events.map((e) => <li key={e.id} className="rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm">{e.name}{e.startDate && <span className="ml-1 text-xs text-[var(--r-sub)]">approx. AD {e.startDate}</span>}</li>)}</ul></div>}
<p className="text-[10px] text-[var(--r-sub)]">People, places and events: Theographic Bible Metadata, CC BY-SA 4.0.</p>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
// Right-hand pane of the Study stage: commentary, word study, cross-references and
// people & places, all driven by the selected chunk.
import { useState } from 'react';
import CommentaryChunkTab from './CommentaryChunkTab.jsx';
import WordStudyTab from './WordStudyTab.jsx';
import CrossRefsTab from './CrossRefsTab.jsx';
import PeopleChunkTab from './PeopleChunkTab.jsx';
export const RESOURCE_TABS = [
{ id: 'commentary', label: 'Commentary' },
{ id: 'words', label: 'Word study' },
{ id: 'refs', label: 'Cross-refs' },
{ id: 'people', label: 'People & places' },
];
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) {
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length };
return (
<div className="flex h-full min-h-0 flex-col">
<div role="tablist" aria-label="Study resources" className="flex shrink-0 flex-wrap gap-1.5 border-b border-[var(--r-line)] px-3 py-2.5">
{RESOURCE_TABS.map((t) => (
<button key={t.id} type="button" role="tab" aria-selected={tab === t.id} onClick={() => setTab(t.id)}
className={`rounded-full border px-3 py-1 text-xs ${tab === t.id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
{t.label}{counts[t.id] ? ` · ${counts[t.id]}` : ''}
</button>
))}
</div>
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{tab === 'commentary' && <CommentaryChunkTab resources={resources} chunk={chunk} project={project} chapterIndex={chapterIndex} updateChunk={updateChunk} notify={notify} />}
{tab === 'words' && <WordStudyTab wordStudy={wordStudy} chunk={chunk} openWordId={openWordId} setOpenWordId={setOpenWordId} />}
{tab === 'refs' && <CrossRefsTab crossRefs={crossRefs} chunk={chunk} />}
{tab === 'people' && <PeopleChunkTab book={chapter.bookAbbrev} chapter={chapter.chapter} chunk={chunk} />}
</div>
</div>
);
}
export function useResourceTab() {
const [tab, setTabState] = useState(() => {
try {
const saved = localStorage.getItem('studyResourceTab');
return RESOURCE_TABS.some((t) => t.id === saved) ? saved : 'commentary';
} catch { return 'commentary'; }
});
const setTab = (id) => {
setTabState(id);
try { localStorage.setItem('studyResourceTab', id); } catch { /* storage unavailable */ }
};
return [tab, setTab];
}
+46
View File
@@ -0,0 +1,46 @@
// The chunk's passage, set like the Reader. Click a verse number to open its
// interlinear words; click an original word to add it to the chunk's word study.
import { useState } from 'react';
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord }) {
const [openVerse, setOpenVerse] = useState(null);
const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev];
return (
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4">
<div className="font-serif text-[1.05rem] leading-[1.85]">
{verses.map((v) => {
const words = v.chapter === chapter?.chapter ? interlinear?.[String(v.number)] : null;
const open = openVerse === `${v.chapter}:${v.number}`;
return (
<div key={`${v.chapter}-${v.number}`} className="mb-1.5">
<button type="button" disabled={!words} onClick={() => setOpenVerse(open ? null : `${v.chapter}:${v.number}`)}
title={words ? 'Show original words' : undefined}
className={`mr-1.5 align-super font-sans text-[0.6em] ${words ? 'text-[var(--r-accent)] hover:underline' : 'cursor-default text-[var(--r-sub)]'} ${open ? 'font-bold' : ''}`}>
{v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number}
</button>
<span>{v.text}</span>
{open && words && (
<div dir={isHebrew ? 'rtl' : 'ltr'} className="mt-2 flex flex-wrap gap-2 rounded-xl bg-[var(--r-bg)] p-2.5 font-sans">
{words.map((w, i) => {
const added = w.s && addedStrongs.has(w.s.toUpperCase());
return (
<button key={i} type="button" disabled={!w.s} onClick={() => onPickWord(w)}
title={[w.t, w.s, w.p].filter(Boolean).join(' · ') + (w.s ? (added ? ' — already in word study' : ' — click to add') : '')}
className={`flex flex-col items-center rounded-lg border px-2 py-1 text-center ${added ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)] hover:bg-black/5'} disabled:cursor-default`}>
<span className={`text-base ${isHebrew ? 'font-serif' : ''}`}>{w.o}</span>
<span className="text-[11px] leading-tight text-[var(--r-sub)]">{w.g || '—'}</span>
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
{interlinearLoading && <p className="mt-2 font-sans text-xs text-[var(--r-sub)]">Loading original words…</p>}
{!interlinearLoading && interlinear && <p className="mt-2 font-sans text-[11px] text-[var(--r-sub)]">Click a verse number to see its original words; click a word to study it.</p>}
</section>
);
}
+60
View File
@@ -0,0 +1,60 @@
// Shared frame for every stage of a study project (Outline, Study, Script,
// Export): themed like the Reader, with a slim bar that carries the project title,
// the stage switcher, and save/sync status.
import { useApp } from '../../context/AppContext.js';
import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
import { ChevronLeft } from '../reader/icons.jsx';
export const STAGES = [
{ id: 'setup', label: 'Outline' },
{ id: 'study', label: 'Study' },
{ id: 'script', label: 'Script' },
{ id: 'export', label: 'Export' },
];
const THEME_ORDER = ['paper', 'sepia', 'dark'];
export default function StudioShell({ stage, loading = false, fixedHeight = false, children }) {
const {
goHome, setCurrentPage, project, allChunks,
saveStatus, syncStatus, retrySync,
readerTheme, setReaderTheme,
} = useApp();
const noChunks = allChunks.length === 0;
const cycleTheme = () => setReaderTheme(THEME_ORDER[(THEME_ORDER.indexOf(readerTheme) + 1) % THEME_ORDER.length]);
return (
<div className={`bg-[var(--r-bg)] font-sans text-[var(--r-text)] ${fixedHeight ? 'flex h-screen flex-col overflow-hidden' : 'min-h-screen'}`} style={themeVars(readerTheme)}>
<div className="z-30 shrink-0 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
<div className="mx-auto flex h-12 max-w-[1600px] items-center gap-3 px-3">
<button type="button" onClick={goHome} aria-label="Back to studies"
className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /></button>
<div className="min-w-0 max-w-[16rem] truncate text-sm font-medium">{project?.title ?? ''}</div>
<nav aria-label="Study stages" className="mx-auto flex gap-0.5">
{STAGES.map((s) => {
const disabled = s.id !== 'setup' && (!project || noChunks);
return (
<button key={s.id} type="button" disabled={disabled} aria-current={stage === s.id ? 'step' : undefined}
title={disabled ? 'Add a chunk in Outline first' : undefined}
onClick={() => setCurrentPage(s.id)}
className={`rounded-full px-3.5 py-1.5 text-xs ${stage === s.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'} disabled:opacity-40 disabled:hover:bg-transparent`}>
{s.label}
</button>
);
})}
</nav>
<div className="flex min-w-[7rem] items-center justify-end gap-2 text-xs text-[var(--r-sub)]" aria-live="polite">
{loading ? <span>Loading…</span>
: saveStatus ? <span className="text-[var(--r-accent)]">{saveStatus}</span>
: syncStatus === 'syncing' ? <span>Syncing…</span>
: syncStatus === 'synced' ? <span>Saved</span>
: syncStatus === 'error' ? (
<span className="text-red-600">Sync failed <button type="button" onClick={retrySync} className="underline">Retry</button></span>
) : null}
<button type="button" onClick={cycleTheme} className="rounded-lg px-2 py-1 hover:bg-black/5" title="Change theme">{READER_THEMES[readerTheme]?.label}</button>
</div>
</div>
</div>
{children}
</div>
);
}
@@ -0,0 +1,50 @@
// Picker for "suggest words from this passage": checkbox list of candidate words.
import DOMPurify from 'dompurify';
import Sheet from '../reader/Sheet.jsx';
import { buildGreekDefinitionHtml } from '../../utils/wordStudy.js';
export default function SuggestWordsSheet({ wordStudy }) {
const { suggestModal, setSuggestModal, suggestSelection, setSuggestSelection, confirmSuggestWords } = wordStudy;
if (!suggestModal) return null;
const close = () => { setSuggestModal(null); setSuggestSelection(new Set()); };
const n = suggestSelection.size;
return (
<Sheet open onClose={close} title={`Select ${suggestModal.language === 'hebrew' ? 'Hebrew' : 'Greek'} words to add`}>
<p className="mb-2 text-xs text-[var(--r-sub)]">{suggestModal.helperText || "Content words are pre-checked. Uncheck any you don't need."}</p>
<div className="mb-2 flex items-center gap-3 text-xs">
<button type="button" onClick={() => setSuggestSelection(new Set(suggestModal.words.map((w) => w.strongKey)))} className="text-[var(--r-accent)] hover:underline">Select all</button>
<button type="button" onClick={() => setSuggestSelection(new Set())} className="text-[var(--r-accent)] hover:underline">Select none</button>
<span className="ml-auto text-[var(--r-sub)]">{n} selected</span>
</div>
<ul className="max-h-[40vh] divide-y divide-[var(--r-line)] overflow-y-auto rounded-xl border border-[var(--r-line)] px-3">
{suggestModal.words.map((w) => (
<li key={w.strongKey}>
<label className="flex cursor-pointer items-center gap-3 py-2.5">
<input type="checkbox" checked={suggestSelection.has(w.strongKey)} className="h-4 w-4 shrink-0 accent-[var(--r-accent)]"
onChange={(e) => setSuggestSelection((prev) => { const next = new Set(prev); if (e.target.checked) next.add(w.strongKey); else next.delete(w.strongKey); return next; })} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-2">
<span className="text-sm font-medium">{w.def || w.strongKey}</span>
<span className="text-sm text-[var(--r-sub)]">{w.lexeme}</span>
{w.translit && <span className="text-xs italic text-[var(--r-sub)]">{w.translit}</span>}
<span className="font-mono text-[10px] text-[var(--r-sub)]">{w.strongKey}</span>
</div>
{w.entry && (w.entry.strongs_def || w.entry.kjv_def || w.entry.derivation) && (
<details className="mt-1">
<summary className="cursor-pointer text-xs text-[var(--r-accent)]" onClick={(e) => e.stopPropagation()}>Definition</summary>
<div className="mt-1 text-xs leading-5" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(buildGreekDefinitionHtml(w.strongKey, w.entry)) }} />
</details>
)}
</div>
</label>
</li>
))}
</ul>
<div className="mt-3 flex gap-2">
<button type="button" onClick={close} className="flex-1 rounded-xl border border-[var(--r-line)] py-2 text-sm hover:bg-black/5">Cancel</button>
<button type="button" onClick={confirmSuggestWords} disabled={n === 0}
className="flex-1 rounded-xl bg-[var(--r-accent)] py-2 text-sm font-medium text-[var(--r-on)] disabled:opacity-40">Add {n > 0 ? `${n} word${n > 1 ? 's' : ''}` : 'words'}</button>
</div>
</Sheet>
);
}
+93
View File
@@ -0,0 +1,93 @@
// Greek/Hebrew word study for the chunk: compact cards (one open for editing at a
// time), Strong's lookup, and suggestions from the passage.
import { useState } from 'react';
import DOMPurify from 'dompurify';
import { externalLookupLinks, speakOriginalWord } from '../../utils/wordStudy.js';
const inputCls = 'mt-1 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
const { updateChunkWord, lookupWord, removeGreekWord } = wordStudy;
const set = (patch) => updateChunkWord(chunkId, word.id, patch);
const field = (label, key, placeholder) => (
<label className="block text-xs text-[var(--r-sub)]">{label}
<input type="text" value={word[key] ?? ''} placeholder={placeholder} onChange={(e) => set({ [key]: e.target.value })} className={inputCls} />
</label>
);
const notFound = (word.shortDefinition === 'No definition found.' || word.shortDefinition === 'Lookup failed.') && !word.lexeme && !word.definitionHtml && word.query.trim();
return (
<div className={`rounded-xl border ${open ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)]'}`}>
<button type="button" onClick={onToggle} aria-expanded={open} className="flex w-full items-baseline gap-2 px-3 py-2 text-left">
<span className="font-serif text-lg leading-tight">{word.lexeme || <span className="font-sans text-sm text-[var(--r-sub)]">New word</span>}</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--r-sub)]">{[word.transliteration, word.englishGloss || word.shortDefinition].filter(Boolean).join(' · ')}</span>
{word.strongNumber && <span className="shrink-0 rounded-full bg-[var(--r-bar)] px-2 py-0.5 text-[10px]">{word.strongNumber}</span>}
</button>
{open && (
<div className="space-y-3 border-t border-[var(--r-line)] px-3 py-3">
<label className="block text-xs text-[var(--r-sub)]">Strong’s number or English gloss
<input type="text" value={word.query} onChange={(e) => set({ query: e.target.value })}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); lookupWord(chunkId, word.id, 'greek'); } }}
placeholder="G4102, H7225, 4102, or a word" className={inputCls} />
</label>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'greek')} className={`${btn} bg-[var(--r-text)] text-[var(--r-bg)] hover:opacity-90`}>{word.loading ? 'Looking up...' : 'Look up Greek'}</button>
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'hebrew')} className={btn}>{word.loading ? 'Looking up...' : 'Look up Hebrew'}</button>
{word.lexeme && <button type="button" onClick={() => speakOriginalWord(word.lexeme, word.strongNumber)} className={btn} title="Hear pronunciation">Speak</button>}
<button type="button" onClick={() => removeGreekWord(chunkId, word.id)} className={`${btn} ml-auto text-red-700`}>Delete</button>
</div>
<div className="grid grid-cols-2 gap-2">
{field(word.strongNumber?.startsWith('H') ? 'Hebrew word' : 'Greek word', 'lexeme')}
{field('Transliteration', 'transliteration')}
{field('Part of speech', 'partOfSpeech', 'e.g. Noun')}
{field('English word', 'englishGloss', 'e.g. grace')}
</div>
{field('Short definition', 'shortDefinition')}
{word.definitionHtml && (
<details className="rounded-lg border border-[var(--r-line)] p-2.5">
<summary className="cursor-pointer text-xs font-medium">Extended definition</summary>
<div className="mt-2 text-[13px] leading-relaxed" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(word.definitionHtml) }} />
</details>
)}
{notFound ? (
<div className="rounded-lg border border-[var(--r-line)] p-2.5">
<p className="mb-1.5 text-xs text-[var(--r-sub)]">Not found in BDBT — look it up manually:</p>
<div className="flex flex-wrap gap-1.5">
{externalLookupLinks(word.query).map(({ label, url }) => (
<a key={label} href={url} target="_blank" rel="noreferrer" className={btn}>{label} ↗</a>
))}
<button type="button" onClick={() => navigator.clipboard.writeText(word.query.trim())} className={btn}>Copy “{word.query.trim()}”</button>
</div>
</div>
) : null}
</div>
)}
</div>
);
}
export default function WordStudyTab({ wordStudy, chunk, openWordId, setOpenWordId }) {
const { suggestingGreekForChunkId, suggestingHebrewForChunkId, suggestGreekWordsForChunk, suggestHebrewWordsForChunk, addGreekWord } = wordStudy;
return (
<div>
<div className="mb-3 flex flex-wrap gap-2">
<button type="button" onClick={() => suggestGreekWordsForChunk(chunk.id)} disabled={suggestingGreekForChunkId === chunk.id} className={btn}
title="Auto-populate Greek words that actually appear in this passage">{suggestingGreekForChunkId === chunk.id ? 'Suggesting…' : 'Suggest Greek'}</button>
<button type="button" onClick={() => suggestHebrewWordsForChunk(chunk.id)} disabled={suggestingHebrewForChunkId === chunk.id} className={btn}
title="Suggest Hebrew words for Old Testament passages">{suggestingHebrewForChunkId === chunk.id ? 'Suggesting…' : 'Suggest Hebrew'}</button>
<button type="button" onClick={() => setOpenWordId(addGreekWord(chunk.id))} className={`${btn} ml-auto bg-[var(--r-text)] text-[var(--r-bg)] hover:opacity-90`}>Add word</button>
</div>
<div className="space-y-2">
{chunk.greekWords.length === 0 && (
<p className="rounded-xl border border-dashed border-[var(--r-line)] p-4 text-sm text-[var(--r-sub)]">
No words yet. Open a verse’s original words in the text and click one, or add a word to look it up.
</p>
)}
{chunk.greekWords.map((w) => (
<WordCard key={w.id} word={w} open={openWordId === w.id} chunkId={chunk.id} wordStudy={wordStudy}
onToggle={() => setOpenWordId(openWordId === w.id ? null : w.id)} />
))}
</div>
</div>
);
}