App quality: offline badge, per-note history, keyboard study mode, notes import

- Study cards say whether the Bible text works offline, with a button to save
  the missing book (notes are always kept on the device).
- Each note keeps its last 12 versions with Restore (History under each note).
- Keyboard-only study: 1/2/3 write in Observe/Interpret/Apply, C/W/X/P switch
  resource tabs, V compares, Esc leaves a note box; listed under "?".
- Export > Import notes: bring Word/Markdown/text notes into the matching
  chunks without overwriting anything; can mark them as not your own.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-14 13:01:11 -04:00
1 parent 648c3206df
commit 67645cf1df
14 files changed
+421 -7

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
// Export → Import notes: bring notes from a Word or Markdown file into this study, matched to its chunks by passage.
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { useToast } from './Toasts.jsx';
import { applyImportedNotes, parseNotesText } from '../utils/importNotes.js';
export default function ImportNotes() {
const { project, updateProject } = useApp();
const { toast } = useToast();
const [file, setFile] = useState('');
const [sections, setSections] = useState(null); // null until a file is read
const [own, setOwn] = useState(true);
const [error, setError] = useState('');
const choose = async (e) => {
const f = e.target.files?.[0];
e.target.value = '';
setError(''); setSections(null);
if (!f) return;
try {
let text;
if (/\.docx$/i.test(f.name)) {
const { default: mammoth } = await import('mammoth');
text = (await mammoth.extractRawText({ arrayBuffer: await f.arrayBuffer() })).value;
} else text = await f.text();
const found = parseNotesText(text);
setFile(f.name);
setSections(found);
if (found.length === 0) setError('No passage headings with Observe / Interpret / Apply notes were found in that file. Start each section with a heading like “John 3:16–18”.');
} catch { setError('That file couldn’t be read.'); }
};
const preview = sections ? applyImportedNotes(project, sections, { own }) : null;
const add = () => {
updateProject((current) => applyImportedNotes(current, sections, { own }).project);
toast(`Added notes to ${preview.matched.length} chunk${preview.matched.length === 1 ? '' : 's'}`);
setSections(null);
};
return (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Import notes">
<h2 className="text-sm font-medium">Import notes</h2>
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">Bring in notes from a Word (.docx), Markdown or text file. Start each section with a passage heading (“John 3:16–18”), then label the notes “Observe:”, “Interpret:”, “Apply:”, “Background:” or “Questions:”. Notes you’ve already written are kept; imported text is added below them.</p>
<label className="inline-block cursor-pointer rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5">Choose a file
<input type="file" accept=".docx,.md,.markdown,.txt,text/markdown,text/plain" className="hidden" onChange={choose} />
</label>
{error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{error}</p>}
{preview && sections.length > 0 && (
<div className="mt-3 space-y-2 text-sm">
<p className="text-xs text-[var(--r-sub)]">{file}: {sections.length} section{sections.length === 1 ? '' : 's'} found.</p>
<ul className="space-y-1 text-xs">
{preview.matched.map((m) => <li key={m.heading}>✓ {m.heading}: {m.fields.join(', ')}</li>)}
{preview.unmatched.map((h) => <li key={h} className="text-[var(--r-sub)]">No chunk for {h}, skipped</li>)}
</ul>
<label className="flex items-center gap-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={own} onChange={(e) => setOwn(e.target.checked)} className="accent-[var(--r-accent)]" /> These notes are my own writing (untick if they came from AI or another source)</label>
<button type="button" onClick={add} disabled={preview.matched.length === 0} className="rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] disabled:opacity-40">Add to this study</button>
</div>
)}
</section>
);
}
+21
View File
@@ -0,0 +1,21 @@
// On a study card: whether its Bible text works without a connection, and a button to fix that.
import { DownloadProgress } from './OfflineSave.jsx';
import { offlineStatus } from '../utils/offline.js';
export default function OfflineBadge({ project, offline }) {
if (!project) return null;
const status = offlineStatus(project, offline.saved);
const label = 'mt-1 text-xs';
if (offline.job) return <div className="mt-2"><DownloadProgress job={offline.job} onCancel={offline.cancel} /></div>;
if (status.state === 'ready') return <p className={`${label} text-[var(--r-accent)]`} title="Your notes and the Bible text for this study open without a connection">✓ Works offline</p>;
if (status.state === 'licensed') {
return <p className={`${label} text-[var(--r-sub)]`} title={`The ${status.translation} licence doesn’t allow saving its text for offline use`}>Notes work offline; {status.translation} text needs a connection</p>;
}
const next = status.missing[0];
return (
<p className={`${label} flex flex-wrap items-center gap-x-2 text-[var(--r-sub)]`}>
<span>Needs a connection for {status.missing.map((b) => b.name).join(', ')}</span>
<button type="button" onClick={() => offline.saveBook(status.translation, next.abbrev, next.name)} className="text-[var(--r-accent)] hover:underline">Save {next.name} for offline</button>
</p>
);
}
+4
View File
@@ -22,6 +22,10 @@ const BY_PAGE = {
],
study: [
['← →', 'Previous / next chunk'],
['1 2 3', 'Write in Observe / Interpret / Apply'],
['Esc', 'Leave a note box (then keys work again)'],
['C W X P', 'Commentary / Word study / Cross-refs / People & places'],
['V', 'Compare passages or translations'],
],
};
+50 -4
View File
@@ -1,9 +1,10 @@
// 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, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import SessionNotes from './SessionNotes.jsx';
import FirstDraft from './FirstDraft.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';
@@ -20,11 +21,25 @@ const SECTIONS = [
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 }) {
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('');
@@ -42,6 +57,25 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
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">
@@ -72,9 +106,21 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
</button>
{isOpen && (
<div className="px-4 pb-4">
<textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
onPaste={pasteCheck.onPaste(field)} onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
<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} />
{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.{' '}