Files
study-bible-project/src/pages/PronunciationPage.jsx
nmemmertandClaude Sonnet 5.5 31abee0695
Build and Push Docker Image / build-and-push (push) Successful in 4m15s
Redesign the study side: Outline, Study, Script and Export stages
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>
2026-09-29 10:13:46 -04:00

118 lines
6.0 KiB
React

// Every Greek and Hebrew word in the study with its transliteration, so a
// speaker can rehearse them. Reached from the Script and Export stages.
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import StudioShell from '../components/study/StudioShell.jsx';
import { buildPronunciationGuide } from '../utils/bibleUtils.jsx';
import { speakOriginalWord } from '../utils/wordStudy.js';
export default function PronunciationPage() {
const { project, setCurrentPage } = useApp();
const [search, setSearch] = useState('');
const [copied, setCopied] = useState(false);
const chapters = Array.isArray(project?.chapters) ? project.chapters : [];
// Collect all words with chapter context, deduplicated by Strong's number
const seen = new Set();
const entries = [];
chapters.forEach((ch) => {
ch.chunks.forEach((chunk) => {
(chunk.greekWords ?? []).forEach((word) => {
if (!word.lexeme && !word.transliteration) return;
const key = word.strongNumber || `${word.lexeme}__${word.transliteration}`;
if (seen.has(key)) return;
seen.add(key);
entries.push({ word, chapterRef: `${ch.book} ${ch.chapter}` });
});
});
});
const q = search.trim().toLowerCase();
const filtered = q
? entries.filter(({ word }) => [word.englishGloss, word.shortDefinition, word.lexeme, word.transliteration, word.strongNumber]
.some((f) => (f ?? '').toLowerCase().includes(q)))
: entries;
const greek = filtered.filter(({ word }) => !word.strongNumber?.startsWith('H'));
const hebrew = filtered.filter(({ word }) => word.strongNumber?.startsWith('H'));
const copyAll = () => {
navigator.clipboard.writeText(buildPronunciationGuide(project)).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5';
return (
<StudioShell stage="script">
<main className="mx-auto max-w-4xl px-4 py-8">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-xl font-medium">Pronunciation Guide</h1>
{project?.title && <p className="mt-1 text-sm text-[var(--r-sub)]">{project.title}</p>}
<p className="mt-1 text-xs text-[var(--r-sub)]">{entries.length} unique word{entries.length !== 1 ? 's' : ''} across all chapters</p>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={copyAll} className={btn}>{copied ? '✓ Copied!' : 'Copy all'}</button>
<button type="button" onClick={() => setCurrentPage('script')} className={btn}>← Back to Script</button>
</div>
</div>
<input type="text" placeholder="Filter by word, gloss, Strong's number…" value={search} onChange={(e) => setSearch(e.target.value)}
className="mb-6 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-2.5 text-sm text-[var(--r-text)] placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
{entries.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--r-line)] p-12 text-center">
<p className="font-medium">No words yet</p>
<p className="mt-1 text-sm text-[var(--r-sub)]">Add Greek or Hebrew words in the Word study tab of the Study stage.</p>
</div>
) : filtered.length === 0 ? (
<p className="py-8 text-center text-sm text-[var(--r-sub)]">No words match “{search}”</p>
) : (
<div className="space-y-8">
{greek.length > 0 && <WordGroup title="Greek (New Testament)" words={greek} onSpeak={speakOriginalWord} />}
{hebrew.length > 0 && <WordGroup title="Hebrew (Old Testament)" words={hebrew} onSpeak={speakOriginalWord} />}
</div>
)}
</main>
</StudioShell>
);
}
function WordGroup({ title, words, onSpeak }) {
return (
<section>
<h2 className="mb-3 text-sm font-medium">{title} <span className="font-normal text-[var(--r-sub)]">· {words.length} word{words.length !== 1 ? 's' : ''}</span></h2>
<div className="grid gap-3 sm:grid-cols-2">
{words.map(({ word, chapterRef }) => <WordCard key={word.id ?? word.lexeme} word={word} chapterRef={chapterRef} onSpeak={onSpeak} />)}
</div>
</section>
);
}
function WordCard({ word, chapterRef, onSpeak }) {
return (
<div className="flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
{word.lexeme && <p className="break-words font-serif text-3xl font-light leading-tight">{word.lexeme}</p>}
{word.transliteration && <p className="mt-1 text-base italic text-[var(--r-sub)]">{word.transliteration}</p>}
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
{word.lexeme && <button type="button" onClick={() => onSpeak(word.lexeme, word.strongNumber)} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5" title="Hear pronunciation">Speak</button>}
{word.strongNumber && <span className="rounded-full border border-[var(--r-line)] px-2.5 py-0.5 text-xs text-[var(--r-sub)]">{word.strongNumber}</span>}
</div>
</div>
<div className="space-y-0.5">
{word.englishGloss && <p className="text-sm font-medium">{word.englishGloss}</p>}
{word.shortDefinition && word.shortDefinition !== word.englishGloss && <p className="text-sm leading-snug text-[var(--r-sub)]">{word.shortDefinition}</p>}
</div>
<div className="flex flex-wrap items-center gap-2 border-t border-[var(--r-line)] pt-3 text-xs text-[var(--r-sub)]">
{word.partOfSpeech && <span className="rounded-full border border-[var(--r-line)] px-2.5 py-0.5">{word.partOfSpeech}</span>}
<span className="ml-auto">{chapterRef}</span>
</div>
</div>
);
}