Add Pronunciation Guide page with word cards and TTS
Build and Push Docker Image / build-and-push (push) Successful in 3m37s
Build and Push Docker Image / build-and-push (push) Successful in 3m37s
Replace the copy-to-clipboard button with a dedicated page that shows all collected Greek/Hebrew words as cards grouped by language. Each card displays the original script large, transliteration, Strong's number, English gloss, definition, part of speech, and a Speak button. Includes a search/filter and a "Copy all" fallback for the old behavior. Move buildPronunciationGuide to bibleUtils.jsx to avoid circular imports. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
7b2920b9e6
commit
85fc566ade
3 files changed
+212
-20
No files matched your search
+5
-20
@@ -9,6 +9,7 @@ import ReaderPage from './pages/ReaderPage.jsx';
|
|||||||
import SettingsPage from './pages/SettingsPage.jsx';
|
import SettingsPage from './pages/SettingsPage.jsx';
|
||||||
import SetupPage from './pages/SetupPage.jsx';
|
import SetupPage from './pages/SetupPage.jsx';
|
||||||
import StudyPage from './pages/StudyPage.jsx';
|
import StudyPage from './pages/StudyPage.jsx';
|
||||||
|
import PronunciationPage from './pages/PronunciationPage.jsx';
|
||||||
import {
|
import {
|
||||||
AlignmentType,
|
AlignmentType,
|
||||||
BorderStyle,
|
BorderStyle,
|
||||||
@@ -48,6 +49,7 @@ import {
|
|||||||
BookmarkIcon,
|
BookmarkIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
CrossRefChip,
|
CrossRefChip,
|
||||||
|
buildPronunciationGuide,
|
||||||
} from './utils/bibleUtils.jsx';
|
} from './utils/bibleUtils.jsx';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -410,24 +412,6 @@ PASSAGE: ${chapterLabel}
|
|||||||
return header + buildChunkBodies(project);
|
return header + buildChunkBodies(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildPronunciationGuide(project) {
|
|
||||||
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
|
||||||
const lines = [];
|
|
||||||
chapters.forEach((ch) => {
|
|
||||||
ch.chunks.forEach((chunk) => {
|
|
||||||
(chunk.greekWords ?? []).forEach((word) => {
|
|
||||||
const gloss = word.shortDefinition || word.englishGloss || '';
|
|
||||||
const lexeme = word.lexeme || '';
|
|
||||||
const translit = word.transliteration || '';
|
|
||||||
if (!lexeme && !translit) return;
|
|
||||||
const label = gloss ? `${gloss} — ` : '';
|
|
||||||
lines.push(`${label}${lexeme}${translit ? ` (${translit})` : ''}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (lines.length === 0) return 'No Greek/Hebrew words collected yet.';
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildPodcastPrompt(project, podcastName) {
|
export function buildPodcastPrompt(project, podcastName) {
|
||||||
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
||||||
@@ -3243,9 +3227,9 @@ const deleteProject = (id) => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={copyPronunciationGuide}
|
onClick={() => setCurrentPage('pronunciation')}
|
||||||
disabled={allChunks.length === 0}
|
disabled={allChunks.length === 0}
|
||||||
title="Copy a quick word + transliteration list to keep open while recording"
|
title="Open the pronunciation guide — all Greek & Hebrew words with transliterations"
|
||||||
className="rounded-md bg-amber-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:bg-slate-500"
|
className="rounded-md bg-amber-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:bg-slate-500"
|
||||||
>
|
>
|
||||||
🗣<span className="hidden sm:inline"> Pronunciation Guide</span>
|
🗣<span className="hidden sm:inline"> Pronunciation Guide</span>
|
||||||
@@ -3639,6 +3623,7 @@ function AppRouter() {
|
|||||||
if (currentPage === 'import') return <ImportPage />;
|
if (currentPage === 'import') return <ImportPage />;
|
||||||
if (currentPage === 'study') return <StudyPage />;
|
if (currentPage === 'study') return <StudyPage />;
|
||||||
if (currentPage === 'setup') return <SetupPage />;
|
if (currentPage === 'setup') return <SetupPage />;
|
||||||
|
if (currentPage === 'pronunciation') return <PronunciationPage />;
|
||||||
return <HomePage />;
|
return <HomePage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { buildPronunciationGuide } from '../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
|
export default function PronunciationPage() {
|
||||||
|
const { project, speakOriginalWord, setCurrentPage } = useApp();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const chapters = Array.isArray(project?.chapters) ? project.chapters : [];
|
||||||
|
|
||||||
|
// Collect all words with chapter/chunk 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 ?? '').toLowerCase().includes(q) ||
|
||||||
|
(word.shortDefinition ?? '').toLowerCase().includes(q) ||
|
||||||
|
(word.lexeme ?? '').toLowerCase().includes(q) ||
|
||||||
|
(word.transliteration ?? '').toLowerCase().includes(q) ||
|
||||||
|
(word.strongNumber ?? '').toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
: entries;
|
||||||
|
|
||||||
|
const greek = filtered.filter(({ word }) => !word.strongNumber?.startsWith('H'));
|
||||||
|
const hebrew = filtered.filter(({ word }) => word.strongNumber?.startsWith('H'));
|
||||||
|
|
||||||
|
const copyAll = () => {
|
||||||
|
const guide = buildPronunciationGuide(project);
|
||||||
|
navigator.clipboard.writeText(guide).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50">
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-8">
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Pronunciation Guide</h1>
|
||||||
|
{project?.title && (
|
||||||
|
<p className="mt-1 text-sm text-slate-500">{project.title}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-xs text-slate-400">
|
||||||
|
{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="rounded-xl border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
|
||||||
|
>
|
||||||
|
{copied ? '✓ Copied!' : '📋 Copy all'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage('study')}
|
||||||
|
className="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-slate-400"
|
||||||
|
>
|
||||||
|
← Back to Study
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter by word, gloss, Strong's number…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<div className="rounded-3xl border border-dashed border-slate-300 bg-white p-12 text-center">
|
||||||
|
<p className="text-2xl">🔤</p>
|
||||||
|
<p className="mt-3 font-semibold text-slate-700">No words yet</p>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Add Greek or Hebrew words in the Word Studies panel on the Study page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-slate-500">No words match "{search}"</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-10">
|
||||||
|
{greek.length > 0 && <WordGroup title="Greek (New Testament)" color="sky" words={greek} onSpeak={speakOriginalWord} />}
|
||||||
|
{hebrew.length > 0 && <WordGroup title="Hebrew (Old Testament)" color="emerald" words={hebrew} onSpeak={speakOriginalWord} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WordGroup({ title, color, words, onSpeak }) {
|
||||||
|
const accent = {
|
||||||
|
sky: { badge: 'bg-sky-100 text-sky-700 border-sky-200', speak: 'bg-sky-100 text-sky-700 hover:bg-sky-200', heading: 'text-sky-800 border-sky-200 bg-sky-50' },
|
||||||
|
emerald: { badge: 'bg-emerald-100 text-emerald-700 border-emerald-200', speak: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200', heading: 'text-emerald-800 border-emerald-200 bg-emerald-50' },
|
||||||
|
}[color];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h2 className={`mb-4 inline-block rounded-full border px-4 py-1.5 text-sm font-semibold ${accent.heading}`}>
|
||||||
|
{title} · {words.length} word{words.length !== 1 ? 's' : ''}
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{words.map(({ word, chapterRef }) => (
|
||||||
|
<WordCard key={word.id ?? word.lexeme} word={word} chapterRef={chapterRef} accent={accent} onSpeak={onSpeak} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WordCard({ word, chapterRef, accent, onSpeak }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-3xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
{/* Top row: lexeme + speak */}
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{word.lexeme && (
|
||||||
|
<p className="text-3xl font-light leading-tight tracking-wide text-slate-900 break-words">
|
||||||
|
{word.lexeme}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{word.transliteration && (
|
||||||
|
<p className="mt-1 text-base font-medium italic text-slate-500">
|
||||||
|
{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-xl border px-3 py-1.5 text-xs font-semibold transition ${accent.speak} border-current/20`}
|
||||||
|
title="Hear pronunciation"
|
||||||
|
>
|
||||||
|
🔊 Speak
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{word.strongNumber && (
|
||||||
|
<span className={`rounded-full border px-2.5 py-0.5 text-xs font-semibold ${accent.badge}`}>
|
||||||
|
{word.strongNumber}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gloss + definition */}
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{word.englishGloss && (
|
||||||
|
<p className="text-sm font-semibold text-slate-800">{word.englishGloss}</p>
|
||||||
|
)}
|
||||||
|
{word.shortDefinition && word.shortDefinition !== word.englishGloss && (
|
||||||
|
<p className="text-sm text-slate-500 leading-snug">{word.shortDefinition}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: part of speech + source passage */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-t border-slate-100 pt-3 text-xs text-slate-400">
|
||||||
|
{word.partOfSpeech && (
|
||||||
|
<span className="rounded-full bg-slate-100 px-2.5 py-0.5 text-slate-600">{word.partOfSpeech}</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto">{chapterRef}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -540,3 +540,22 @@ export function CrossRefChip({ label, onRemove, loadVerseText }) {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildPronunciationGuide(project) {
|
||||||
|
const chapters = Array.isArray(project?.chapters) ? project.chapters : [];
|
||||||
|
const lines = [];
|
||||||
|
chapters.forEach((ch) => {
|
||||||
|
ch.chunks.forEach((chunk) => {
|
||||||
|
(chunk.greekWords ?? []).forEach((word) => {
|
||||||
|
const gloss = word.shortDefinition || word.englishGloss || '';
|
||||||
|
const lexeme = word.lexeme || '';
|
||||||
|
const translit = word.transliteration || '';
|
||||||
|
if (!lexeme && !translit) return;
|
||||||
|
const label = gloss ? `${gloss} — ` : '';
|
||||||
|
lines.push(`${label}${lexeme}${translit ? ` (${translit})` : ''}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (lines.length === 0) return 'No Greek/Hebrew words collected yet.';
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
Reference in new issue
Block a user