Split Home: local UI state, useProjectLibrary, useReadingPlan

- Search, tag filter, full-text note search, and rename state live in
  HomePage. The sort choice is remembered in localStorage.
- useProjectLibrary owns opening/renaming/deleting projects and the
  server reconcile (auto-restore + newer-on-server list).
- useReadingPlan owns the reading plan shared by Home and Reader.
- Tests: note search, rename, delete, sort memory, reading plan. Test
  setup now resets the storage namespace between tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Opus 5.5 committed 2026-09-22 14:52:46 -04:00
1 parent 8bb3b104c8
commit a7e00a76b5
5 files changed
+291 -178

No files matched your search

+13 -170
View File
@@ -3,6 +3,8 @@ import { AppContext, useApp } from './context/AppContext.js';
import { useAudio } from './hooks/useAudio.js'; import { useAudio } from './hooks/useAudio.js';
import { useAuth } from './hooks/useAuth.js'; import { useAuth } from './hooks/useAuth.js';
import { useBibleReader } from './hooks/useBibleReader.js'; import { useBibleReader } from './hooks/useBibleReader.js';
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
import { useReadingPlan } from './hooks/useReadingPlan.js';
import { import {
loadNtGloss, loadNtGloss,
loadNtConcordance, loadNtConcordance,
@@ -20,11 +22,9 @@ import {
import HomePage from './pages/HomePage.jsx'; import HomePage from './pages/HomePage.jsx';
import { import {
CHAPTER_COUNTS,
bookOptions, bookOptions,
NT_BOOK_NUMBER, NT_BOOK_NUMBER,
makeId, makeId,
formatChunkReference,
getChunkVerseEntries, getChunkVerseEntries,
chunkSpansNextChapter, chunkSpansNextChapter,
buildExportHtml, buildExportHtml,
@@ -36,8 +36,6 @@ import {
} from './utils/bibleUtils.jsx'; } from './utils/bibleUtils.jsx';
import { import {
saveProjectToStorage, saveProjectToStorage,
deleteProjectFromStorage,
loadProjectById,
migrateOldStorageKeys, migrateOldStorageKeys,
buildChapterSummary, buildChapterSummary,
} from './utils/projectStorage.js'; } from './utils/projectStorage.js';
@@ -52,9 +50,6 @@ import {
import { import {
saveRemoteProject, saveRemoteProject,
deleteRemoteProject,
listRemoteProjects,
loadRemoteProject,
getShareStatus, getShareStatus,
enableSharing, enableSharing,
disableSharing, disableSharing,
@@ -118,25 +113,14 @@ const App = () => {
const [typedChunkBulk, setTypedChunkBulk] = useState(''); const [typedChunkBulk, setTypedChunkBulk] = useState('');
const saveTimerRef = useRef(null); const saveTimerRef = useRef(null);
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error' const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
const [suggestingGreekForChunkId, setSuggestingGreekForChunkId] = useState(null); const [suggestingGreekForChunkId, setSuggestingGreekForChunkId] = useState(null);
const [suggestingHebrewForChunkId, setSuggestingHebrewForChunkId] = useState(null); const [suggestingHebrewForChunkId, setSuggestingHebrewForChunkId] = useState(null);
// suggestModal: null | { chunkId, words: [{ strongKey, lexeme, translit, def }] } // suggestModal: null | { chunkId, words: [{ strongKey, lexeme, translit, def }] }
const [suggestModal, setSuggestModal] = useState(null); const [suggestModal, setSuggestModal] = useState(null);
const [suggestSelection, setSuggestSelection] = useState(new Set()); const [suggestSelection, setSuggestSelection] = useState(new Set());
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 reader = useBibleReader(currentPage); const reader = useBibleReader(currentPage);
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter); const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter);
const readingPlan = useReadingPlan();
const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({ const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({
onSignIn: (user) => { onSignIn: (user) => {
setProjectIndex(switchStorageUser(user.id)); setProjectIndex(switchStorageUser(user.id));
@@ -151,6 +135,14 @@ const App = () => {
goHome(); goHome();
}, },
}); });
const library = useProjectLibrary({
authUser,
project,
setProject,
setProjectIndex,
setCurrentPage,
setActiveChapterIndex,
});
const [studyLayout, setStudyLayout] = useState(() => { const [studyLayout, setStudyLayout] = useState(() => {
const saved = localStorage.getItem('studyLayout'); const saved = localStorage.getItem('studyLayout');
return saved === 'stacked' ? 'stacked' : 'split'; // 'annotate' (Draw) was removed return saved === 'stacked' ? 'stacked' : 'split'; // 'annotate' (Draw) was removed
@@ -171,38 +163,6 @@ const App = () => {
localStorage.setItem('activeStudyTab', activeStudyTab); localStorage.setItem('activeStudyTab', 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 [verseSearch, setVerseSearch] = useState('');
const [collapsedSections, setCollapsedSections] = useState({}); const [collapsedSections, setCollapsedSections] = useState({});
const [commentarySource, setCommentarySource] = useState('matthew-henry'); const [commentarySource, setCommentarySource] = useState('matthew-henry');
@@ -274,44 +234,6 @@ const App = () => {
}); });
}, [project?.id, currentPage]); }, [project?.id, currentPage]);
// Once we know who's signed in, reconcile the local project index against the server.
// Runs on every authUser change (including logout -> different login) so a previous
// account's stale suggestions never linger after switching users. Projects that exist
// on the server but not on this device (e.g. a brand new browser) are pulled down
// automatically instead of requiring a manual "Restore" click per project.
useEffect(() => {
if (!authUser) {
setStaleLocalProjects([]);
return;
}
let cancelled = false;
const localIndex = loadProjectIndex();
listRemoteProjects().then(async (result) => {
if (!result.ok || cancelled) return;
const localMap = new Map(localIndex.map((e) => [e.id, e]));
const missing = result.data.filter((e) => !localMap.has(e.id));
if (missing.length > 0) {
const fetched = await Promise.all(missing.map((e) => loadRemoteProject(e.id)));
fetched.filter((r) => r.ok).forEach((r) => saveProjectToStorage(r.data));
if (cancelled) return;
const restoredCount = fetched.filter((r) => r.ok).length;
if (restoredCount > 0) {
setProjectIndex(loadProjectIndex());
setAutoRestoredCount(restoredCount);
window.setTimeout(() => setAutoRestoredCount(null), 4000);
}
}
const stale = result.data.filter((e) => {
const local = localMap.get(e.id);
return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0);
});
if (!cancelled) setStaleLocalProjects(stale);
});
return () => { cancelled = true; };
}, [authUser]);
useEffect(() => { useEffect(() => {
if (!titleEdited) { if (!titleEdited) {
setSetup((current) => ({ setSetup((current) => ({
@@ -1458,30 +1380,6 @@ const App = () => {
}); });
}; };
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 = () => { const copyPronunciationGuide = () => {
if (!project) return; if (!project) return;
const guide = buildPronunciationGuide(project); const guide = buildPronunciationGuide(project);
@@ -1512,47 +1410,6 @@ const App = () => {
setCurrentPage('import'); setCurrentPage('import');
}; };
const resumeProject = (id) => {
const loaded = loadProjectById(id);
if (!loaded) return;
setProject(loaded);
setActiveChapterIndex(0);
const loadedChunks = loaded.chapters.flatMap((ch) => ch.chunks);
const hasSelectedChunk = loaded.selectedChunkId
&& loadedChunks.some((c) => c.id === loaded.selectedChunkId);
setCurrentPage(hasSelectedChunk ? 'study' : 'setup');
};
const renameProjectInStorage = (id, title) => {
const loaded = loadProjectById(id);
if (!loaded) return;
const updated = { ...loaded, title, lastEdited: Date.now() };
saveProjectToStorage(updated);
setProjectIndex(loadProjectIndex());
saveRemoteProject(updated);
if (project?.id === id) {
setProject((current) => (current ? { ...current, title } : current));
}
};
const deleteProject = (id) => {
if (!window.confirm('Delete this project? This cannot be undone.')) return;
deleteProjectFromStorage(id);
setProjectIndex(loadProjectIndex());
deleteRemoteProject(id); // fire-and-forget
};
const pullLatestFromServer = async (id) => {
const result = await loadRemoteProject(id);
if (!result.ok) {
alert('Could not pull latest version from server.');
return;
}
saveProjectToStorage(result.data);
setProjectIndex(loadProjectIndex());
setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id));
};
const goHome = () => { const goHome = () => {
setProjectIndex(loadProjectIndex()); setProjectIndex(loadProjectIndex());
setCurrentPage('home'); setCurrentPage('home');
@@ -1773,25 +1630,11 @@ const deleteProject = (id) => {
signIn, signIn,
// Home // Home
projectIndex, projectIndex,
autoRestoredCount, ...library,
staleLocalProjects, ...readingPlan,
homeSearch, setHomeSearch,
homeSort, setHomeSort,
homeTagFilter, setHomeTagFilter,
homeFullTextResults,
readingPlan,
createReadingPlan,
markChapterRead,
clearReadingPlan,
renamingId, setRenamingId,
renameValue, setRenameValue,
openBibleReader, openBibleReader,
openImportProject, openImportProject,
openNewProject, openNewProject,
pullLatestFromServer,
resumeProject,
renameProjectInStorage,
deleteProject,
// Audio (book player on Home + chapter player in the Reader) // Audio (book player on Home + chapter player in the Reader)
...audio, ...audio,
// Reader // Reader
+74
View File
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest'; import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
import App from './App'; import App from './App';
import { resetLexiconCaches } from './utils/wordStudy.js'; import { resetLexiconCaches } from './utils/wordStudy.js';
import { switchStorageUser } from './utils/bibleUtils.jsx';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared mock data // Shared mock data
@@ -51,6 +52,7 @@ function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
resetLexiconCaches(); resetLexiconCaches();
switchStorageUser(null); // a signed-in test leaves the storage namespace switched
vi.stubGlobal('fetch', buildFetchMock()); vi.stubGlobal('fetch', buildFetchMock());
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock'); vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
@@ -512,3 +514,75 @@ describe('Import page', () => {
expect(await screen.findByRole('button', { name: /import session list/i })).toBeInTheDocument(); expect(await screen.findByRole('button', { name: /import session list/i })).toBeInTheDocument();
}); });
}); });
// ---------------------------------------------------------------------------
// Home page (search/sort/rename local to HomePage; library + reading plan hooks)
// ---------------------------------------------------------------------------
describe('Home page', () => {
// Tests run in local-only mode (the mock API is unreachable), which uses the
// pre-account localStorage keys.
function seedProject({ id = 'p1', title = 'Titus Notes', observation = 'Grace abounds to all.' } = {}) {
const project = {
id, title, translation: 'BSB', lastEdited: Date.now(), selectedChunkId: null,
chapters: [{
book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'Paul, a servant of God.' }],
chunks: [{ id: 'c1', startVerse: 1, endVerse: 1, observation, interpretation: '', application: '',
crossReferences: [], greekWords: [], tags: [] }],
}],
};
const index = JSON.parse(localStorage.getItem('bible-study-index') || '[]');
index.push({ id, title, lastEdited: project.lastEdited, chapterSummary: 'Titus 1', tags: [] });
localStorage.setItem(`bible-study-project-${id}`, JSON.stringify(project));
localStorage.setItem('bible-study-index', JSON.stringify(index));
}
test('searching 3+ characters finds matches inside study notes', async () => {
seedProject();
const user = userEvent.setup();
await renderApp();
await user.type(screen.getByPlaceholderText(/search projects/i), 'grace');
expect(await screen.findByText(/notes matching "grace"/i)).toBeInTheDocument();
});
test('renaming a project updates its title and saved index', async () => {
seedProject();
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /rename/i }));
const input = screen.getByDisplayValue('Titus Notes');
await user.clear(input);
await user.type(input, 'Titus Deep Dive');
await user.click(screen.getByRole('button', { name: /^save$/i }));
expect(await screen.findByText('Titus Deep Dive')).toBeInTheDocument();
expect(JSON.parse(localStorage.getItem('bible-study-index'))[0].title).toBe('Titus Deep Dive');
});
test('deleting a project removes it after confirmation', async () => {
seedProject();
window.confirm.mockReturnValue(true);
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /^delete$/i }));
await waitFor(() => expect(screen.queryByText('Titus Notes')).not.toBeInTheDocument());
expect(localStorage.getItem('bible-study-project-p1')).toBeNull();
});
test('sort choice is remembered after leaving Home', async () => {
seedProject();
const user = userEvent.setup();
await renderApp();
await user.selectOptions(screen.getByDisplayValue('Last edited'), 'title');
await user.click(screen.getByRole('button', { name: /read bible/i }));
await user.click(await screen.findByRole('button', { name: /back to studies/i }));
expect(await screen.findByDisplayValue('Title')).toBeInTheDocument();
});
test('a reading plan can be started from Home', async () => {
seedProject(); // the plan card shows once there's at least one project
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /set goal/i }));
await user.click(screen.getByRole('button', { name: /start plan/i }));
await waitFor(() => expect(JSON.parse(localStorage.getItem('readingPlan'))?.bookAbbrev).toBe('GEN'));
});
});
+115
View File
@@ -0,0 +1,115 @@
// The signed-in user's saved projects: opening, renaming, deleting, and keeping
// this device's copies in step with the server.
import { useEffect, useState } from 'react';
import { loadProjectIndex } from '../utils/bibleUtils.jsx';
import {
saveProjectToStorage,
deleteProjectFromStorage,
loadProjectById,
} from '../utils/projectStorage.js';
import {
saveRemoteProject,
deleteRemoteProject,
listRemoteProjects,
loadRemoteProject,
} from '../syncService.js';
export function useProjectLibrary({
authUser,
project,
setProject,
setProjectIndex,
setCurrentPage,
setActiveChapterIndex,
}) {
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
// Once we know who's signed in, reconcile the local project index against the server.
// Runs on every authUser change (including logout -> different login) so a previous
// account's stale suggestions never linger after switching users. Projects that exist
// on the server but not on this device (e.g. a brand new browser) are pulled down
// automatically instead of requiring a manual "Restore" click per project.
useEffect(() => {
if (!authUser) {
setStaleLocalProjects([]);
return;
}
let cancelled = false;
const localIndex = loadProjectIndex();
listRemoteProjects().then(async (result) => {
if (!result.ok || cancelled) return;
const localMap = new Map(localIndex.map((e) => [e.id, e]));
const missing = result.data.filter((e) => !localMap.has(e.id));
if (missing.length > 0) {
const fetched = await Promise.all(missing.map((e) => loadRemoteProject(e.id)));
fetched.filter((r) => r.ok).forEach((r) => saveProjectToStorage(r.data));
if (cancelled) return;
const restoredCount = fetched.filter((r) => r.ok).length;
if (restoredCount > 0) {
setProjectIndex(loadProjectIndex());
setAutoRestoredCount(restoredCount);
window.setTimeout(() => setAutoRestoredCount(null), 4000);
}
}
const stale = result.data.filter((e) => {
const local = localMap.get(e.id);
return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0);
});
if (!cancelled) setStaleLocalProjects(stale);
});
return () => { cancelled = true; };
}, [authUser]);
const resumeProject = (id) => {
const loaded = loadProjectById(id);
if (!loaded) return;
setProject(loaded);
setActiveChapterIndex(0);
const loadedChunks = loaded.chapters.flatMap((ch) => ch.chunks);
const hasSelectedChunk = loaded.selectedChunkId
&& loadedChunks.some((c) => c.id === loaded.selectedChunkId);
setCurrentPage(hasSelectedChunk ? 'study' : 'setup');
};
const renameProjectInStorage = (id, title) => {
const loaded = loadProjectById(id);
if (!loaded) return;
const updated = { ...loaded, title, lastEdited: Date.now() };
saveProjectToStorage(updated);
setProjectIndex(loadProjectIndex());
saveRemoteProject(updated);
if (project?.id === id) {
setProject((current) => (current ? { ...current, title } : current));
}
};
const deleteProject = (id) => {
if (!window.confirm('Delete this project? This cannot be undone.')) return;
deleteProjectFromStorage(id);
setProjectIndex(loadProjectIndex());
deleteRemoteProject(id); // fire-and-forget
};
const pullLatestFromServer = async (id) => {
const result = await loadRemoteProject(id);
if (!result.ok) {
alert('Could not pull latest version from server.');
return;
}
saveProjectToStorage(result.data);
setProjectIndex(loadProjectIndex());
setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id));
};
return {
staleLocalProjects,
autoRestoredCount,
resumeProject,
renameProjectInStorage,
deleteProject,
pullLatestFromServer,
};
}
+35
View File
@@ -0,0 +1,35 @@
// A simple "read this book in N weeks" plan, stored per browser.
import { useState } from 'react';
import { CHAPTER_COUNTS } from '../utils/bibleUtils.jsx';
export function useReadingPlan() {
const [readingPlan, setReadingPlan] = useState(() => {
try { return JSON.parse(localStorage.getItem('readingPlan') || 'null'); } catch { return null; }
});
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 {}
};
return { readingPlan, createReadingPlan, markChapterRead, clearReadingPlan };
}
+54 -8
View File
@@ -1,6 +1,13 @@
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import { bookOptions, CHAPTER_COUNTS, formatRelativeDate } from '../utils/bibleUtils.jsx'; import {
bookOptions,
CHAPTER_COUNTS,
formatChunkReference,
formatRelativeDate,
loadProjectIndex,
} from '../utils/bibleUtils.jsx';
import { loadProjectById } from '../utils/projectStorage.js';
export default function HomePage() { export default function HomePage() {
const { const {
@@ -8,18 +15,12 @@ export default function HomePage() {
autoRestoredCount, autoRestoredCount,
staleLocalProjects, staleLocalProjects,
projectIndex, projectIndex,
homeSearch, setHomeSearch,
homeSort, setHomeSort,
homeTagFilter, setHomeTagFilter,
homeFullTextResults,
readingPlan, readingPlan,
createReadingPlan, createReadingPlan,
clearReadingPlan, clearReadingPlan,
audioBook, setAudioBook, audioBook, setAudioBook,
audioNarrator, setAudioNarrator, audioNarrator, setAudioNarrator,
audioState, audioState,
renamingId, setRenamingId,
renameValue, setRenameValue,
openBibleReader, openBibleReader,
openImportProject, openImportProject,
openNewProject, openNewProject,
@@ -31,6 +32,51 @@ export default function HomePage() {
handleStopBookAudio, handleStopBookAudio,
handleToggleBookAudioPause, handleToggleBookAudioPause,
} = useApp(); } = useApp();
const [homeSearch, setHomeSearch] = useState('');
const [homeSort, setHomeSort] = useState(() => {
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
}); // 'recent' | 'title' | 'passage'
const [homeTagFilter, setHomeTagFilter] = useState('');
const [homeFullTextResults, setHomeFullTextResults] = useState(null); // null=idle, []=no match, [...]= results
const fullTextTimerRef = useRef(null);
const [renamingId, setRenamingId] = useState(null);
const [renameValue, setRenameValue] = useState('');
useEffect(() => {
try { localStorage.setItem('homeSort', homeSort); } catch {}
}, [homeSort]);
// 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]);
return ( return (
<div className="min-h-screen bg-slate-50 text-slate-900"> <div className="min-h-screen bg-slate-50 text-slate-900">