Files
study-bible-project/src/components/study/CompareSheet.jsx
T
nmemmertandClaude Sonnet 5.5 648c3206df Highlight translation differences, first-draft snapshot, verse notes, paste check
- Compare translations can highlight the words that differ from the first
  translation (toggle remembered).
- A copy of your notes is kept the first time commentary is shown; "First draft,
  before commentary" under More shows what changed since. Saving it doesn't
  count as editing the chunk.
- Notes on single verses: a pencil next to each verse in Study, and a note line
  under each verse in Compare. They merge per verse across devices.
- Pasting 15+ words into a note asks "Is this from AI?"; "I wrote it" counts the
  words as yours again.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:56:57 -04:00

215 lines
14 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Compare two passages side by side — your notes and the verses — for parallels (the Gospels), repeated themes,
// or the same chapter studied twice. Pick any chunk from any of your studies to set beside the one you're on.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { loadProjectById } from '../../utils/projectStorage.js';
import { formatChunkReference, getChunkVerseEntries } from '../../utils/bibleUtils.jsx';
import { READER_TRANSLATIONS, fetchChapter, translationName } from '../../utils/bibleText.js';
import { alignVerses, differencesFor, markRanges, translationSpans } from '../../utils/compareTranslations.js';
import { ownShare, pastedWords } from '../../utils/ownWork.js';
import { usePasteCheck } from './PastePrompt.jsx';
const FIELDS = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']];
// Every chunk in your studies, as a pick-list entry that remembers where it sits.
function choices(projectIndex, currentProject) {
const projects = projectIndex.map((e) => (e.id === currentProject?.id ? currentProject : loadProjectById(e.id))).filter(Boolean);
return projects.flatMap((project) => project.chapters.flatMap((chapter, chapterIndex) => chapter.chunks.map((chunk) => ({
key: `${project.id}:${chunk.id}`, project, chapterIndex, chunk,
label: `${formatChunkReference(project, chapterIndex, chunk, '–')} · ${project.title}`,
}))));
}
function Column({ entry }) {
if (!entry) return <p className="p-4 text-sm text-[var(--r-sub)]">Pick a passage to compare.</p>;
const verses = getChunkVerseEntries(entry.project, entry.chapterIndex, entry.chunk);
return (
<div className="min-w-0 space-y-3 p-4">
<h3 className="font-medium">{entry.label}</h3>
<div className="max-h-48 space-y-1 overflow-y-auto rounded-xl bg-[var(--r-bar)] p-3 font-serif text-[13px] leading-relaxed">
{verses.map((v) => <p key={v.number}><sup className="mr-1 text-[var(--r-accent)]">{v.number}</sup>{v.text}</p>)}
</div>
{FIELDS.map(([f, label]) => (
<div key={f}>
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{label}</p>
<p className="whitespace-pre-line font-serif text-[14px] leading-relaxed">{entry.chunk[f]?.trim() || <span className="text-[var(--r-sub)]">Nothing written.</span>}</p>
</div>
))}
</div>
);
}
const OIA = [
['observation', 'Observe', 'What does the text say?'],
['interpretation', 'Interpret', 'What does it mean?'],
['application', 'Apply', 'What do I do with it?'],
];
// The chunk's own Observe / Interpret / Apply notes, editable right here while the translations are side by side.
// They are the same notes as on the Study page, and long pastes are tagged the same way.
function OiaBoxes({ chunk, updateChunk }) {
const share = ownShare(chunk);
const pasteCheck = usePasteCheck(chunk, updateChunk);
return (
<section aria-label="Your notes" className="border-t border-[var(--r-line)] px-4 pb-6 pt-4">
<p className="mb-3 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes{share !== null ? ` · ${share}% your own words` : ''}</p>
<div className="grid gap-4 md:grid-cols-3">
{OIA.map(([field, label, hint]) => {
const pasted = pastedWords(chunk, field);
return (
<label key={field} className="flex flex-col text-xs text-[var(--r-sub)]">
<span className="mb-1 flex items-baseline justify-between"><span><span className="text-sm font-medium text-[var(--r-text)]">{label}</span> {hint}</span>{pasted > 0 && <span className="rounded-full bg-[var(--r-hl)] px-2 py-0.5 text-[var(--r-text)]">{pasted} pasted</span>}</span>
<textarea aria-label={label} value={chunk[field] ?? ''} rows={9}
onPaste={pasteCheck.onPaste(field)}
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })}
className="min-h-[10rem] w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 font-serif text-[15px] leading-7 text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
{pasteCheck.prompt(field)}
</label>
);
})}
</div>
</section>
);
}
// One chunk's verses in several translations, a row per verse. Free translations load straight away; licensed ones
// come through the server and may be unavailable (no key set), which shows as a note instead of text.
function TranslationsView({ project, chunkEntry, updateChunk }) {
const [highlight, setHighlight] = useState(() => { try { return localStorage.getItem('compare-highlight') !== 'off'; } catch { return true; } });
const setHl = (on) => { setHighlight(on); try { localStorage.setItem('compare-highlight', on ? 'on' : 'off'); } catch { /* storage unavailable */ } };
const spans = useMemo(() => (chunkEntry ? translationSpans(project, chunkEntry.chapterIndex, chunkEntry.chunk) : []), [project, chunkEntry]);
const spanKey = spans.map((s) => `${s.book}${s.chapter}:${s.from}-${s.to}`).join(',');
const [picked, setPicked] = useState(() => {
try { const saved = JSON.parse(localStorage.getItem('compare-translations')); if (Array.isArray(saved) && saved.length) return saved; } catch { /* storage unavailable */ }
return [project?.translation ?? 'BSB', 'NET'];
});
const [loaded, setLoaded] = useState({}); // id -> { status: 'loading'|'ok'|'error', chapters, copyright, error }
const toggle = (id) => setPicked((cur) => {
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
try { localStorage.setItem('compare-translations', JSON.stringify(next)); } catch { /* storage unavailable */ }
return next;
});
// The sheet is modal, so the passage can't change while it's open; results are only dropped after it closes.
const alive = useRef(true);
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
useEffect(() => {
picked.filter((id) => !loaded[id]).forEach((id) => {
setLoaded((cur) => ({ ...cur, [id]: { status: 'loading' } }));
Promise.all(spans.map((span) => fetchChapter(id, span.bookAbbrev, Number(span.chapter))
.then((res) => [`${span.book}|${span.chapter}`, res])))
.then((results) => alive.current && setLoaded((cur) => ({
...cur,
[id]: { status: 'ok', chapters: Object.fromEntries(results.map(([k, r]) => [k, r.verses])), copyright: results.map(([, r]) => r.copyright).find(Boolean) ?? '' },
})))
.catch((err) => { if (alive.current) setLoaded((cur) => ({ ...cur, [id]: { status: 'error', error: err.message || 'Unable to load.' } })); });
});
}, [picked, spanKey]); // eslint-disable-line react-hooks/exhaustive-deps
const shown = picked.filter((id) => loaded[id]?.status === 'ok');
const rows = alignVerses(spans, Object.fromEntries(shown.map((id) => [id, loaded[id].chapters])));
const cols = { gridTemplateColumns: `repeat(${Math.max(1, shown.length)}, minmax(0, 1fr))` };
return (
<div className="space-y-4 p-4">
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Translations to compare">
{READER_TRANSLATIONS.map((t) => {
const on = picked.includes(t.id);
return (
<button key={t.id} type="button" aria-pressed={on} onClick={() => toggle(t.id)} title={t.name}
className={`rounded-full border px-3 py-1 text-xs ${on ? '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.id}</button>
);
})}
</div>
{shown.length > 1 && (
<label className="flex items-center gap-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={highlight} onChange={(e) => setHl(e.target.checked)} className="accent-[var(--r-accent)]" /> Highlight the words that differ from {shown[0]}</label>
)}
{picked.map((id) => loaded[id]?.status === 'error' && (
<p key={id} role="status" className="text-xs text-[var(--r-sub)]">{id} isn’t available right now: {loaded[id].error}</p>
))}
{picked.some((id) => loaded[id]?.status === 'loading') && <p className="text-xs text-[var(--r-sub)]">Loading…</p>}
{shown.length === 0 && !picked.some((id) => !loaded[id] || loaded[id].status === 'loading') && <p className="text-sm text-[var(--r-sub)]">Pick two or more translations above.</p>}
{shown.length > 0 && (
<div className="space-y-3">
<div className="hidden gap-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)] md:grid" style={cols}>
{shown.map((id) => <span key={id}>{translationName(id)}</span>)}
</div>
{rows.map((row) => {
const diffs = highlight && shown.length > 1 ? differencesFor(row.texts, shown) : {};
const note = chunkEntry.chunk.verseNotes?.[row.noteKey] ?? '';
return (
<div key={row.key} className="border-t border-[var(--r-line)] pt-3">
<div className="grid gap-x-4 gap-y-1 md:[grid-template-columns:var(--cols)]" style={{ '--cols': cols.gridTemplateColumns }}>
{shown.map((id) => (
<p key={id} className="font-serif text-[14px] leading-relaxed">
<sup className="mr-1 font-sans text-[var(--r-accent)]">{row.label}</sup>
<span className="mr-1 font-sans text-[10px] uppercase tracking-wide text-[var(--r-sub)] md:hidden">{id}</span>
{row.texts[id] == null ? <span className="text-[var(--r-sub)]">—</span>
: markRanges(row.texts[id], diffs[id]).map((part, i) => (part.mark ? <mark key={i} className="rounded bg-[var(--r-hl)] px-0.5 text-inherit">{part.text}</mark> : <span key={i}>{part.text}</span>))}
</p>
))}
</div>
<input aria-label={`Note on verse ${row.label}`} value={note} placeholder={`Note on verse ${row.label}`}
onChange={(e) => updateChunk(chunkEntry.chunk.id, { verseNotes: { ...(chunkEntry.chunk.verseNotes ?? {}), [row.noteKey]: e.target.value } })}
className="mt-1.5 w-full rounded-lg border border-transparent bg-transparent px-2 py-1 text-xs text-[var(--r-text)] placeholder:text-[var(--r-sub)] placeholder:opacity-60 hover:border-[var(--r-line)] focus:border-[var(--r-line)] focus:bg-[var(--r-bg)] focus:outline-none" />
</div>
);
})}
<div className="space-y-1 border-t border-[var(--r-line)] pt-3 text-[11px] leading-snug text-[var(--r-sub)]">
{shown.map((id) => {
const t = READER_TRANSLATIONS.find((x) => x.id === id);
return t?.notice || loaded[id].copyright ? <p key={id}>{loaded[id].copyright || t.notice}</p> : null;
})}
</div>
</div>
)}
</div>
);
}
export default function CompareSheet({ currentChunkId, onClose }) {
const { projectIndex, project, updateChunk } = useApp();
// Other studies are read from storage once per list change; the open study comes straight from state so typing stays quick.
const others = useMemo(() => choices(projectIndex.filter((e) => e.id !== project?.id), null), [projectIndex, project?.id]);
const here = useMemo(() => (project?.chapters ?? []).flatMap((chapter, chapterIndex) => chapter.chunks.map((chunk) => ({
key: `${project.id}:${chunk.id}`, project, chapterIndex, chunk, label: `${formatChunkReference(project, chapterIndex, chunk, '–')} · ${project.title}`,
}))), [project]);
const mine = here.find((c) => c.chunk.id === currentChunkId);
const all = useMemo(() => [...here, ...others], [here, others]);
const [mode, setMode] = useState('passage'); // 'passage' | 'translations'
const [otherKey, setOtherKey] = useState('');
const other = all.find((c) => c.key === otherKey);
const tab = (id) => `rounded-full border px-3 py-1 text-xs ${mode === 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'}`;
return (
<div role="dialog" aria-modal="true" aria-label="Compare passages" className="fixed inset-0 z-[70] flex flex-col bg-[var(--r-bg)] text-[var(--r-text)]">
<div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-2.5">
<h2 className="mr-auto text-sm font-medium sm:mr-0">Compare</h2>
<div role="tablist" aria-label="What to compare" className="flex gap-1.5">
<button type="button" role="tab" aria-selected={mode === 'passage'} onClick={() => setMode('passage')} className={tab('passage')}>Another passage</button>
<button type="button" role="tab" aria-selected={mode === 'translations'} onClick={() => setMode('translations')} className={tab('translations')}>Translations</button>
</div>
{mode === 'passage' && (
<select aria-label="Compare with" value={otherKey} onChange={(e) => setOtherKey(e.target.value)}
className="order-last min-w-0 basis-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm sm:order-none sm:basis-0 sm:flex-1">
<option value="">Choose a passage to set beside this one…</option>
{all.filter((c) => c.key !== mine?.key).map((c) => <option key={c.key} value={c.key}>{c.label}</option>)}
</select>
)}
<button type="button" onClick={onClose} className="ml-auto rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5">Close</button>
</div>
{mode === 'passage' ? (
<div className="grid min-h-0 flex-1 overflow-y-auto md:grid-cols-2 md:divide-x md:divide-[var(--r-line)]">
<Column entry={mine} />
<Column entry={other} />
</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto">
<h3 className="px-4 pt-4 font-medium">{mine?.label}</h3>
<TranslationsView project={project} chunkEntry={mine} updateChunk={updateChunk} />
{mine && <OiaBoxes chunk={mine.chunk} updateChunk={updateChunk} />}
</div>
)}
</div>
);
}