Files
study-bible-project/src/components/study/NotesPane.jsx
T

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.
// 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, useRef, useState } from 'react';
import SessionNotes from './SessionNotes.jsx';
import FirstDraft from './FirstDraft.jsx';
import ScriptureRefs from '../ScriptureRefs.jsx';
import { usePasteCheck } from './PastePrompt.jsx';
import { listNoteVersions, recordNoteVersion } from '../../utils/noteHistory.js';
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
import { ownShare, ownWords, pastedWords, clearPastes } from '../../utils/ownWork.js';
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
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, templateId, saveTemplate, notify, onPray, focusRequest }) {
const [open, setOpen] = useState(() => {
try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; }
});
const [more, setMore] = useState(false);
// A keyboard jump (1 / 2 / 3 on the Study page): open that note and put the cursor in it.
const areas = useRef({});
const wanted = useRef(null);
useEffect(() => {
if (!focusRequest) return;
wanted.current = focusRequest.field;
setOpen(focusRequest.field);
}, [focusRequest]);
useEffect(() => {
if (wanted.current && wanted.current === open) {
const el = areas.current[open];
if (el) { el.focus(); const end = el.value.length; el.setSelectionRange(end, end); wanted.current = null; }
}
});
const [tplName, setTplName] = useState('');
const [tplOpen, setTplOpen] = 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()));
const share = ownShare(chunk);
const pasteCheck = usePasteCheck(chunk, updateChunk);
// Earlier versions of one note: kept when you start and stop editing it; "History" lists them with Restore.
const [historyField, setHistoryField] = useState(null);
const [versions, setVersions] = useState([]);
useEffect(() => { setHistoryField(null); }, [chunk.id]);
const keepVersion = (field) => {
const list = recordNoteVersion(chunk.id, field, chunk[field]);
if (historyField === field) setVersions(list);
};
const openHistory = (field) => {
if (historyField === field) { setHistoryField(null); return; }
recordNoteVersion(chunk.id, field, chunk[field]);
setVersions(listNoteVersions(chunk.id, field));
setHistoryField(field);
};
const restore = (field, text) => {
recordNoteVersion(chunk.id, field, chunk[field]); // so restoring can itself be undone
updateChunk(chunk.id, { [field]: text });
setVersions(listNoteVersions(chunk.id, field));
};
return (
<section aria-label="Study notes" className="space-y-2">
{share !== null && (
<p className="px-1 text-xs text-[var(--r-sub)]" title="Long pastes into a note box count as pasted, not your own">
{share === 100 ? 'All of these notes are your own words.' : `${share}% of these notes are your own words.`}
</p>
)}
{SECTIONS.map(({ field, label, hint, placeholder }) => {
const isOpen = open === field;
const n = words(chunk[field]);
const pasted = pastedWords(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="flex items-center gap-2 text-xs text-[var(--r-sub)]">
{n > 0 && (pasted > 0
? <span className="rounded-full bg-[var(--r-hl)] px-2 py-0.5 text-[var(--r-text)]">{ownWords(chunk, field) === 0 ? 'Pasted' : `${pasted} pasted`}</span>
: <span className="rounded-full border border-[var(--r-line)] px-2 py-0.5">Mine</span>)}
{n ? `${n} word${n === 1 ? '' : 's'}` : ''}
</span>
</button>
{isOpen && (
<div className="px-4 pb-4">
<textarea ref={(el) => { areas.current[field] = el; }} aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
onFocus={() => keepVersion(field)} onBlur={() => keepVersion(field)} onPaste={pasteCheck.onPaste(field)} onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
<ScriptureRefs text={chunk[field]} />
{pasteCheck.prompt(field)}
<button type="button" onClick={() => openHistory(field)} aria-expanded={historyField === field} className="mt-1.5 mr-3 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]">History</button>
{historyField === field && (
<ul aria-label={`${label} history`} className="mt-1.5 space-y-1.5 rounded-xl border border-[var(--r-line)] p-2 text-xs">
{versions.length === 0 && <li className="text-[var(--r-sub)]">No earlier versions yet. They are saved as you edit.</li>}
{versions.map((v) => (
<li key={v.at} className="flex items-baseline justify-between gap-3">
<span className="min-w-0"><span className="text-[var(--r-sub)]">{new Date(v.at).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} · {words(v.text)} words · </span><span className="font-serif">{v.text.length > 70 ? `${v.text.slice(0, 70)}…` : v.text}</span></span>
<button type="button" onClick={() => restore(field, v.text)} disabled={v.text === chunk[field]} className="shrink-0 text-[var(--r-accent)] hover:underline disabled:opacity-40 disabled:no-underline">Restore</button>
</li>
))}
</ul>
)}
{pasted > 0 && (
<p className="mt-1.5 text-xs text-[var(--r-sub)]">
{pasted} word{pasted === 1 ? '' : 's'} here were pasted, so they don't count as your own work.{' '}
<button type="button" onClick={() => updateChunk(chunk.id, { aiPastes: clearPastes(chunk, field) })} className="text-[var(--r-accent)] hover:underline">That’s mine</button>
</p>
)}
{field === 'application' && onPray && (
<button type="button" onClick={onPray} className="mt-1.5 mr-3 text-xs text-[var(--r-accent)] hover:underline">Pray about this →</button>
)}
<div className="mt-1.5 inline-block"><DictateButton label={`Dictate ${label.toLowerCase()}`} onText={(said) => updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /></div>
</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}`} />
<span className="mt-1 block"><DictateButton label="Dictate background" onText={(said) => updateChunk(chunk.id, { generalNotes: appendSpoken(chunk.generalNotes, said) })} /></span>
<ScriptureRefs text={chunk.generalNotes} />
</label>
<label className="block text-xs text-[var(--r-sub)]">
Discussion questions <span className="opacity-70">(one per line; printed on the lesson handout)</span>
<textarea value={chunk.discussionQuestions ?? ''} rows={4} onChange={(e) => updateChunk(chunk.id, { discussionQuestions: e.target.value })}
placeholder={'What does this passage say about God?\nWhere do you see this in your own life?'} className={`mt-1 ${areaCls}`} />
</label>
<FirstDraft chunk={chunk} />
<SessionNotes chunk={chunk} updateChunk={updateChunk} />
{saveTemplate && (
<div className="rounded-xl border border-dashed border-[var(--r-line)] p-3">
{!tplOpen ? (
<button type="button" onClick={() => setTplOpen(true)} className="text-xs text-[var(--r-accent)] hover:underline">Save this chunk’s notes as a template…</button>
) : (
<div>
<p className="mb-2 text-xs text-[var(--r-sub)]">What’s written in this chunk’s note boxes becomes the starting text of each new chunk in studies made from the template, along with this study’s prompts.</p>
<div className="flex flex-wrap items-center gap-2">
<input type="text" aria-label="Template name" value={tplName} onChange={(e) => setTplName(e.target.value)} maxLength={40} placeholder="Template name"
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm" />
<button type="button" onClick={() => {
const made = templateFromChunk({ name: tplName, chunk, template: templateId });
if (!made) { notify?.('Give it a name, and write something in a note box first.', 2600); return; }
saveTemplate(made); setTplName(''); setTplOpen(false); notify?.(`Saved the template “${made.name}”. Pick it when you start a new study.`, 3200);
}} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Save template</button>
<button type="button" onClick={() => setTplOpen(false)} className="text-xs text-[var(--r-sub)] hover:underline">Cancel</button>
</div>
</div>
)}
</div>
)}
<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-[var(--r-danger,#b42318)]">×</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>
);
}