Build and Push Docker Image / build-and-push (push) Successful in 6m41s
- Words tab: "Where else" lists every verse using a Strong's number, by book (new /api/strongs/:number/occurrences, indexed from the interlinear files) - Handout: fill-in-the-blanks layout with a word bank - Four new study templates: character, topical, word, verse by verse - Finish: series progress (not started / studied / ready / taught) - Background: book at a glance for all 66 books - Chunk comments for co-teachers, merged between copies - TODO: correct BSB audio note, tick co-teacher comments Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
111 lines
7.7 KiB
React
111 lines
7.7 KiB
React
// 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';
|
||
import { useIqAvailable } from '../../hooks/useIq.js';
|
||
import { normalizeStrongs } from '../../utils/iqbible.js';
|
||
import IqLexicon from '../iq/IqLexicon.jsx';
|
||
import GrammarPanel from '../GrammarPanel.jsx';
|
||
import IqDictionary from '../iq/IqDictionary.jsx';
|
||
import WordOccurrences from './WordOccurrences.jsx';
|
||
|
||
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 iq = useIqAvailable();
|
||
const [lexicon, setLexicon] = useState(false);
|
||
const [where, setWhere] = useState(false);
|
||
const strongs = normalizeStrongs(word.strongNumber || word.query);
|
||
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">
|
||
{lexicon && iq && strongs && <IqLexicon strongs={strongs} onClose={() => setLexicon(false)} />}
|
||
{where && strongs && <WordOccurrences strongs={strongs} onClose={() => setWhere(false)} />}
|
||
<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>
|
||
{iq && strongs && <button type="button" onClick={() => setLexicon((o) => !o)} aria-pressed={lexicon} className={btn} title="Strong's, BDB, LSJ and Abbott-Smith entries">Lexicons</button>}
|
||
{strongs && <button type="button" onClick={() => setWhere((o) => !o)} aria-pressed={where} className={btn} title="Every verse that uses this word, by book">Where else</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-[var(--r-danger,#b42318)]`}>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>
|
||
{word.parsing && <GrammarPanel label={word.parsing} />}
|
||
{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;
|
||
const iq = useIqAvailable(); // the dictionaries come from IQ Bible, so they appear only when the server has a key
|
||
return (
|
||
<div>
|
||
{iq && <IqDictionary />}
|
||
<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>
|
||
);
|
||
}
|