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

+3
View File
@@ -1,6 +1,7 @@
// The Export stage: take the finished study out as a file, or share it read-only.
import { useApp } from '../context/AppContext.js';
import { useShareLink } from '../hooks/useShareLink.js';
import ImportNotes from '../components/ImportNotes.jsx';
import HandoutExport from '../components/HandoutExport.jsx';
import SessionSchedule from '../components/SessionSchedule.jsx';
import VersionHistory from '../components/VersionHistory.jsx';
@@ -63,6 +64,8 @@ export default function ExportPage() {
<HandoutExport project={project} />
<ImportNotes />
<SessionSchedule project={project} />
<CoTeachers projectId={project?.id} sharedBy={project && sharedStudies?.[project.id]} onLeave={() => { deleteProject(project.id); setCurrentPage('home'); }} />
+7 -1
View File
@@ -1,8 +1,10 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
import BookCover from '../components/BookCover.jsx';
import WeeklyRecall from '../components/WeeklyRecall.jsx';
import OfflineBadge from '../components/OfflineBadge.jsx';
import { useOfflineSave } from '../hooks/useOfflineSave.js';
import RecallHistory from '../components/RecallHistory.jsx';
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
import IqNarratorOptions, { useLazyNarrators } from '../components/iq/IqNarratorOptions.jsx';
@@ -55,6 +57,9 @@ export default function HomePage() {
handleSeekAudioTo,
} = useApp();
const narrators = useLazyNarrators(audioNarrator);
const offline = useOfflineSave();
// Each study's books and translation, read once per list change (the badge itself follows what's saved).
const studyBooks = useMemo(() => Object.fromEntries(projectIndex.map((e) => [e.id, loadProjectById(e.id)])), [projectIndex]);
const [homeSearch, setHomeSearch] = useState('');
const [homeSort, setHomeSort] = useState(() => {
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
@@ -320,6 +325,7 @@ export default function HomePage() {
</div>
)}
<p className="mt-1 text-xs text-[var(--r-sub)]">{formatRelativeDate(entry.lastEdited)}</p>
<OfflineBadge project={studyBooks[entry.id]} offline={offline} />
</div>
<div className={`flex items-center gap-2 ${homeView === 'list' ? 'sm:w-56 sm:shrink-0' : ''}`}>
<button type="button" onClick={() => resumeProject(entry.id)} className="flex-1 rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] hover:opacity-90">Resume</button>
+17 -2
View File
@@ -16,6 +16,10 @@ import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.j
import CompareSheet from '../components/study/CompareSheet.jsx';
import SuggestWordsSheet from '../components/study/SuggestWordsSheet.jsx';
// Keyboard-only study: 1 / 2 / 3 jump to Observe / Interpret / Apply; c, w, x, p open the resource tabs; v compares.
const NOTE_KEYS = { 1: 'observation', 2: 'interpretation', 3: 'application' };
const TAB_KEYS = { c: 'commentary', w: 'words', x: 'refs', p: 'people' };
export default function StudyPage() {
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate, startPrayerFor } = useApp();
@@ -47,6 +51,13 @@ export default function StudyPage() {
const [folded, setFolded] = useState(() => {
try { const f = JSON.parse(localStorage.getItem('study-folded')); return { notes: !!f?.notes, resources: !!f?.resources && !f?.notes }; } catch { return { notes: false, resources: false }; }
});
const [focusRequest, setFocusRequest] = useState(null);
const showHalf = (which) => setFolded((cur) => {
if (!cur[which]) return cur;
const next = { ...cur, [which]: false };
try { localStorage.setItem('study-folded', JSON.stringify(next)); } catch { /* storage unavailable */ }
return next;
});
const toggleFold = (which) => setFolded((cur) => {
const other = which === 'notes' ? 'resources' : 'notes';
// folding the last open half opens the other one instead, so the column is never empty
@@ -76,11 +87,15 @@ export default function StudyPage() {
const editable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target?.isContentEditable;
if (event.key === 'Escape') {
if (wordStudy.suggestModal) { wordStudy.setSuggestModal(null); wordStudy.setSuggestSelection(new Set()); }
else if (editable && !comparing) event.target.blur(); // leave a note box and go back to keyboard moves
return;
}
if (editable || wordStudy.suggestModal) return;
if (editable || wordStudy.suggestModal || comparing || event.metaKey || event.ctrlKey || event.altKey) return;
if (event.key === 'ArrowLeft') { event.preventDefault(); goPrev(); }
else if (event.key === 'ArrowRight') { event.preventDefault(); goNext(); }
else if (NOTE_KEYS[event.key]) { event.preventDefault(); showHalf('notes'); setFocusRequest({ field: NOTE_KEYS[event.key], n: Date.now() }); }
else if (TAB_KEYS[event.key.toLowerCase()]) { event.preventDefault(); showHalf('resources'); setTab(TAB_KEYS[event.key.toLowerCase()]); }
else if (event.key.toLowerCase() === 'v') { event.preventDefault(); setComparing(true); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
@@ -154,7 +169,7 @@ export default function StudyPage() {
</button>
</h2>
<div className={`min-h-0 flex-1 overflow-y-auto border-t border-[var(--r-line)] p-3 ${folded.notes ? 'hidden' : ''}`}>
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateDef ?? project?.templateId} saveTemplate={saveUserTemplate} notify={notify}
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateDef ?? project?.templateId} saveTemplate={saveUserTemplate} notify={notify} focusRequest={focusRequest}
onPray={() => startPrayerFor(formatChunkReference(project, chapterIndex, selectedChunk, '–'))} />
</div>
</section>