215 lines
14 KiB
React
215 lines
14 KiB
React
// 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>
|
||
);
|
||
}
|