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:
1 parent
8bb3b104c8
commit
a7e00a76b5
5 files changed
+291
-178
No files matched your search
+13
-170
@@ -3,6 +3,8 @@ import { AppContext, useApp } from './context/AppContext.js';
|
||||
import { useAudio } from './hooks/useAudio.js';
|
||||
import { useAuth } from './hooks/useAuth.js';
|
||||
import { useBibleReader } from './hooks/useBibleReader.js';
|
||||
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
|
||||
import { useReadingPlan } from './hooks/useReadingPlan.js';
|
||||
import {
|
||||
loadNtGloss,
|
||||
loadNtConcordance,
|
||||
@@ -20,11 +22,9 @@ import {
|
||||
import HomePage from './pages/HomePage.jsx';
|
||||
|
||||
import {
|
||||
CHAPTER_COUNTS,
|
||||
bookOptions,
|
||||
NT_BOOK_NUMBER,
|
||||
makeId,
|
||||
formatChunkReference,
|
||||
getChunkVerseEntries,
|
||||
chunkSpansNextChapter,
|
||||
buildExportHtml,
|
||||
@@ -36,8 +36,6 @@ import {
|
||||
} from './utils/bibleUtils.jsx';
|
||||
import {
|
||||
saveProjectToStorage,
|
||||
deleteProjectFromStorage,
|
||||
loadProjectById,
|
||||
migrateOldStorageKeys,
|
||||
buildChapterSummary,
|
||||
} from './utils/projectStorage.js';
|
||||
@@ -52,9 +50,6 @@ import {
|
||||
|
||||
import {
|
||||
saveRemoteProject,
|
||||
deleteRemoteProject,
|
||||
listRemoteProjects,
|
||||
loadRemoteProject,
|
||||
getShareStatus,
|
||||
enableSharing,
|
||||
disableSharing,
|
||||
@@ -118,25 +113,14 @@ const App = () => {
|
||||
const [typedChunkBulk, setTypedChunkBulk] = useState('');
|
||||
const saveTimerRef = useRef(null);
|
||||
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 [suggestingHebrewForChunkId, setSuggestingHebrewForChunkId] = useState(null);
|
||||
// suggestModal: null | { chunkId, words: [{ strongKey, lexeme, translit, def }] }
|
||||
const [suggestModal, setSuggestModal] = useState(null);
|
||||
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 audio = useAudio(reader.readerBookAbbrev, reader.readerChapter);
|
||||
const readingPlan = useReadingPlan();
|
||||
const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({
|
||||
onSignIn: (user) => {
|
||||
setProjectIndex(switchStorageUser(user.id));
|
||||
@@ -151,6 +135,14 @@ const App = () => {
|
||||
goHome();
|
||||
},
|
||||
});
|
||||
const library = useProjectLibrary({
|
||||
authUser,
|
||||
project,
|
||||
setProject,
|
||||
setProjectIndex,
|
||||
setCurrentPage,
|
||||
setActiveChapterIndex,
|
||||
});
|
||||
const [studyLayout, setStudyLayout] = useState(() => {
|
||||
const saved = localStorage.getItem('studyLayout');
|
||||
return saved === 'stacked' ? 'stacked' : 'split'; // 'annotate' (Draw) was removed
|
||||
@@ -171,38 +163,6 @@ const App = () => {
|
||||
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');
|
||||
@@ -274,44 +234,6 @@ const App = () => {
|
||||
});
|
||||
}, [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(() => {
|
||||
if (!titleEdited) {
|
||||
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 = () => {
|
||||
if (!project) return;
|
||||
const guide = buildPronunciationGuide(project);
|
||||
@@ -1512,47 +1410,6 @@ const App = () => {
|
||||
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 = () => {
|
||||
setProjectIndex(loadProjectIndex());
|
||||
setCurrentPage('home');
|
||||
@@ -1773,25 +1630,11 @@ const deleteProject = (id) => {
|
||||
signIn,
|
||||
// Home
|
||||
projectIndex,
|
||||
autoRestoredCount,
|
||||
staleLocalProjects,
|
||||
homeSearch, setHomeSearch,
|
||||
homeSort, setHomeSort,
|
||||
homeTagFilter, setHomeTagFilter,
|
||||
homeFullTextResults,
|
||||
readingPlan,
|
||||
createReadingPlan,
|
||||
markChapterRead,
|
||||
clearReadingPlan,
|
||||
renamingId, setRenamingId,
|
||||
renameValue, setRenameValue,
|
||||
...library,
|
||||
...readingPlan,
|
||||
openBibleReader,
|
||||
openImportProject,
|
||||
openNewProject,
|
||||
pullLatestFromServer,
|
||||
resumeProject,
|
||||
renameProjectInStorage,
|
||||
deleteProject,
|
||||
// Audio (book player on Home + chapter player in the Reader)
|
||||
...audio,
|
||||
// Reader
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
|
||||
import App from './App';
|
||||
import { resetLexiconCaches } from './utils/wordStudy.js';
|
||||
import { switchStorageUser } from './utils/bibleUtils.jsx';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared mock data
|
||||
@@ -51,6 +52,7 @@ function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetLexiconCaches();
|
||||
switchStorageUser(null); // a signed-in test leaves the storage namespace switched
|
||||
vi.stubGlobal('fetch', buildFetchMock());
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||
@@ -512,3 +514,75 @@ describe('Import page', () => {
|
||||
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'));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -1,6 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
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() {
|
||||
const {
|
||||
@@ -8,18 +15,12 @@ export default function HomePage() {
|
||||
autoRestoredCount,
|
||||
staleLocalProjects,
|
||||
projectIndex,
|
||||
homeSearch, setHomeSearch,
|
||||
homeSort, setHomeSort,
|
||||
homeTagFilter, setHomeTagFilter,
|
||||
homeFullTextResults,
|
||||
readingPlan,
|
||||
createReadingPlan,
|
||||
clearReadingPlan,
|
||||
audioBook, setAudioBook,
|
||||
audioNarrator, setAudioNarrator,
|
||||
audioState,
|
||||
renamingId, setRenamingId,
|
||||
renameValue, setRenameValue,
|
||||
openBibleReader,
|
||||
openImportProject,
|
||||
openNewProject,
|
||||
@@ -31,6 +32,51 @@ export default function HomePage() {
|
||||
handleStopBookAudio,
|
||||
handleToggleBookAudioPause,
|
||||
} = 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 (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
|
||||
Reference in new issue
Block a user