From 902fb5da0272cc3f04cc1500f12435ffb073ddc5 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Thu, 20 Aug 2026 10:15:45 -0400 Subject: [PATCH] Add Apple Pencil support, reading plan, full-text search, and inline ink - Apple Pencil hover cursor: previews brush size/shape before contact - Tilt shading: tilted pen strokes widen and reduce thinning via tiltX/Y - Two-finger undo in draw mode (non-stylus two-finger tap) - Copy & open Claude buttons: copies study/podcast prompt then opens claude.ai/new - Inline ink canvas in study notes (DrawCanvas embedded in Notes tab) - Full-text search across all project notes with 300ms debounce - Reading plan: set a book + week goal, mark chapters read from the reader Co-Authored-By: Claude Sonnet 4.6 --- .claude/launch.json | 7 ++ src/App.jsx | 96 +++++++++++++++++++++++-- src/pages/DrawCanvas.jsx | 72 ++++++++++++++++++- src/pages/HomePage.jsx | 148 ++++++++++++++++++++++++++++++++++++++- src/pages/ReaderPage.jsx | 15 ++++ src/pages/StudyPage.jsx | 23 ++++++ src/utils/inkRender.js | 27 +++---- 7 files changed, 367 insertions(+), 21 deletions(-) diff --git a/.claude/launch.json b/.claude/launch.json index 9b23a15..adc1a3e 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -8,6 +8,13 @@ "port": 5173, "autoPort": false }, + { + "name": "study-app-alt", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--", "--port", "5174"], + "port": 5174, + "autoPort": false + }, { "name": "study-app-server", "runtimeExecutable": "npm", diff --git a/src/App.jsx b/src/App.jsx index f6ab340..76b79bb 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -71,6 +71,18 @@ export const STUDY_TABS = [ { id: 'script', label: 'Script' }, ]; +export const CHAPTER_COUNTS = { + GEN:50,EXO:40,LEV:27,NUM:36,DEU:34,JOS:24,JDG:21,RUT:4, + '1SA':31,'2SA':24,'1KI':22,'2KI':25,'1CH':29,'2CH':36, + EZR:10,NEH:13,EST:10,JOB:42,PSA:150,PRO:31,ECC:12,SNG:8, + ISA:66,JER:52,LAM:5,EZK:48,DAN:12,HOS:14,JOL:3,AMO:9, + OBA:1,JON:4,MIC:7,NAM:3,HAB:3,ZEP:3,HAG:2,ZEC:14,MAL:4, + MAT:28,MRK:16,LUK:24,JHN:21,ACT:28,ROM:16,'1CO':16,'2CO':13, + GAL:6,EPH:6,PHP:4,COL:4,'1TH':5,'2TH':3,'1TI':6,'2TI':4, + TIT:3,PHM:1,HEB:13,JAS:5,'1PE':5,'2PE':3,'1JN':5,'2JN':1, + '3JN':1,JUD:1,REV:22, +}; + export const bookOptions = [ { name: 'Genesis', abbrev: 'GEN' }, { name: 'Exodus', abbrev: 'EXO' }, @@ -1207,6 +1219,11 @@ const App = () => { const [homeSearch, setHomeSearch] = useState(''); const [homeSort, setHomeSort] = useState('recent'); // 'recent' | 'title' | 'passage' const [homeTagFilter, setHomeTagFilter] = useState(''); + const [homeFullTextResults, setHomeFullTextResults] = useState(null); // null=idle, []=no match, [...]= results + const fullTextTimerRef = useRef(null); + const [readingPlan, setReadingPlan] = useState(() => { + try { return JSON.parse(localStorage.getItem('readingPlan') || 'null'); } catch { return null; } + }); const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(''); const [importBookAbbrev, setImportBookAbbrev] = useState(bookOptions[0].abbrev); @@ -1390,6 +1407,39 @@ const App = () => { useEffect(() => { localStorage.setItem('activeStudyTab', activeStudyTab); }, [activeStudyTab]); + + // Full-text search across all project notes (debounced, synchronous localStorage read) + useEffect(() => { + clearTimeout(fullTextTimerRef.current); + const q = homeSearch.trim().toLowerCase(); + if (q.length < 3) { setHomeFullTextResults(null); return; } + fullTextTimerRef.current = window.setTimeout(() => { + const index = loadProjectIndex(); + const results = []; + for (const entry of index) { + const proj = loadProjectById(entry.id); + if (!proj) continue; + const matches = []; + for (const [chIdx, ch] of (proj.chapters ?? []).entries()) { + for (const chunk of (ch.chunks ?? [])) { + for (const field of ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript']) { + const text = (chunk[field] ?? '').toLowerCase(); + if (!text.includes(q)) continue; + const idx = text.indexOf(q); + const raw = chunk[field] ?? ''; + const start = Math.max(0, idx - 40); + const snippet = (start > 0 ? '…' : '') + raw.slice(start, idx + q.length + 60) + (idx + q.length + 60 < raw.length ? '…' : ''); + matches.push({ chunkId: chunk.id, ref: formatChunkReference(proj, chIdx, chunk, '–'), field, snippet }); + break; // one match per chunk + } + } + } + if (matches.length > 0) results.push({ projectId: entry.id, projectTitle: entry.title, matches }); + } + setHomeFullTextResults(results); + }, 300); + }, [homeSearch]); + const [verseSearch, setVerseSearch] = useState(''); const [collapsedSections, setCollapsedSections] = useState({}); const [commentarySource, setCommentarySource] = useState('matthew-henry'); @@ -3328,8 +3378,9 @@ const App = () => { if (!project) return; const prompt = buildClaudePrompt(project); navigator.clipboard.writeText(prompt).then(() => { - setSaveStatus('Copied for Claude!'); - window.setTimeout(() => setSaveStatus(''), 2000); + setSaveStatus('Copied! Opening Claude…'); + window.setTimeout(() => setSaveStatus(''), 3000); + window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer'); }); }; @@ -3337,11 +3388,36 @@ const App = () => { if (!project) return; const prompt = buildPodcastPrompt(project, authUser?.podcastName); navigator.clipboard.writeText(prompt).then(() => { - setSaveStatus('Copied podcast prep!'); - window.setTimeout(() => setSaveStatus(''), 2000); + setSaveStatus('Copied! Opening Claude…'); + window.setTimeout(() => setSaveStatus(''), 3000); + window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer'); }); }; + const createReadingPlan = (bookAbbrev, bookName, weeks) => { + const totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? 1; + const plan = { bookAbbrev, bookName, totalChapters, startDate: Date.now(), targetDate: Date.now() + weeks * 7 * 24 * 60 * 60 * 1000, chaptersRead: [] }; + setReadingPlan(plan); + try { localStorage.setItem('readingPlan', JSON.stringify(plan)); } catch {} + }; + + const markChapterRead = (chapter) => { + setReadingPlan((prev) => { + if (!prev) return prev; + const chaptersRead = prev.chaptersRead.includes(chapter) + ? prev.chaptersRead.filter((c) => c !== chapter) + : [...prev.chaptersRead, chapter].sort((a, b) => a - b); + const next = { ...prev, chaptersRead }; + try { localStorage.setItem('readingPlan', JSON.stringify(next)); } catch {} + return next; + }); + }; + + const clearReadingPlan = () => { + setReadingPlan(null); + try { localStorage.removeItem('readingPlan'); } catch {} + }; + const copyPronunciationGuide = () => { if (!project) return; const guide = buildPronunciationGuide(project); @@ -3629,17 +3705,18 @@ const deleteProject = (id) => { onClick={copyForClaude} disabled={allChunks.length === 0} className="rounded-md bg-violet-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-violet-400 disabled:cursor-not-allowed disabled:bg-slate-500" + title="Copies your study notes as a Claude prompt, then opens claude.ai so you can paste and go" > - Prepare for Claude + Study Guide → Claude ↗ +
+ {matches.map(({ chunkId, ref, field, snippet }) => ( +
+ {ref} · {field} +

{snippet}

+
+ ))} +
+ + ))} + + + )} + + {/* Reading plan card */} + +

Listen to BSB Audio

@@ -317,3 +357,109 @@ export default function HomePage() {
); } + +function ReadingPlanCard({ readingPlan, createReadingPlan, clearReadingPlan, openBibleReader }) { + const [showForm, setShowForm] = useState(false); + const [planBook, setPlanBook] = useState(bookOptions[0].abbrev); + const [planWeeks, setPlanWeeks] = useState(4); + + if (readingPlan) { + const pct = readingPlan.totalChapters > 0 + ? Math.round((readingPlan.chaptersRead.length / readingPlan.totalChapters) * 100) + : 0; + const daysLeft = Math.max(0, Math.ceil((readingPlan.targetDate - Date.now()) / 86400000)); + const chapLeft = readingPlan.totalChapters - readingPlan.chaptersRead.length; + const paceNeeded = daysLeft > 0 ? (chapLeft / daysLeft).toFixed(1) : '—'; + return ( +
+
+
+

Reading Plan

+

{readingPlan.bookName}

+

+ {readingPlan.chaptersRead.length} / {readingPlan.totalChapters} chapters · {daysLeft} day{daysLeft !== 1 ? 's' : ''} left · {paceNeeded} ch/day needed +

+
+
+ + +
+
+
+
+
+

{pct}%

+ {readingPlan.chaptersRead.length > 0 && ( +

+ Read: ch. {readingPlan.chaptersRead.slice(0, 12).join(', ')}{readingPlan.chaptersRead.length > 12 ? '…' : ''} +

+ )} +
+ ); + } + + return ( +
+ {showForm ? ( +
+ Read + + in + + + +
+ ) : ( +
+
+

Reading Plan

+

Set a goal to read through a book, track chapters as you go.

+
+ +
+ )} +
+ ); +} diff --git a/src/pages/ReaderPage.jsx b/src/pages/ReaderPage.jsx index 4e20343..1c2a719 100644 --- a/src/pages/ReaderPage.jsx +++ b/src/pages/ReaderPage.jsx @@ -48,6 +48,8 @@ export default function ReaderPage() { readerTextHighlights, addTextHighlight, removeTextHighlight, + readingPlan, + markChapterRead, } = useApp(); const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev); @@ -286,6 +288,19 @@ export default function ReaderPage() {
+ {readingPlan && readingPlan.bookAbbrev === readerBookAbbrev && (() => { + const done = readingPlan.chaptersRead.includes(readerChapter); + return ( + + ); + })()}
+ {/* Inline ink notes — collapsible, always available without switching to annotate mode */} +
+ + {!collapsedSections.inlineInk && ( +
+ updateChunk(selectedChunk.id, { inkStrokes: s })} + /> +
+ )} +
+ {/* Cross-references */}