Move chunk builder into SetupPage via useChunkBuilder
- The setup form, active chapter tab, verse range selection, typed/bulk/ cross-chapter chunk creation, reorder, and delete move out of App. - Setup now opens on the chapter holding the chunk last studied (e.g. after Back to Chunks), and the Add Chapter form suggests the chapter after the project's last one instead of repeating the current one. - openNewProject/goHome/resume/import no longer reset setup state by hand; SetupPage starts fresh each time it opens. - Tests: add-chapter default, Back to Chunks chapter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bf02e071fa
commit
0f1a9c3d5b
6 files changed
+555
-518
No files matched your search
+1
-510
@@ -9,10 +9,7 @@ import { useSharedView } from './hooks/useSharedView.js';
|
||||
import HomePage from './pages/HomePage.jsx';
|
||||
|
||||
import {
|
||||
bookOptions,
|
||||
makeId,
|
||||
buildExportHtml,
|
||||
parseBibleChapter,
|
||||
loadProjectIndex,
|
||||
switchStorageUser,
|
||||
} from './utils/bibleUtils.jsx';
|
||||
@@ -45,31 +42,11 @@ const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx'));
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const App = () => {
|
||||
const [setup, setSetup] = useState({
|
||||
translation: 'BSB',
|
||||
book: 'Titus',
|
||||
bookAbbrev: 'TIT',
|
||||
chapter: '1',
|
||||
title: 'Titus 1 Study',
|
||||
});
|
||||
const [titleEdited, setTitleEdited] = useState(false);
|
||||
const [project, setProject] = useState(null);
|
||||
// 'home' | 'setup' | 'study' | 'settings'
|
||||
const [currentPage, setCurrentPage] = useState('home');
|
||||
const [projectIndex, setProjectIndex] = useState([]);
|
||||
const [activeChapterIndex, setActiveChapterIndex] = useState(0);
|
||||
const [showAddChapterForm, setShowAddChapterForm] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState('');
|
||||
const [rangeStart, setRangeStart] = useState(null);
|
||||
const [rangeEnd, setRangeEnd] = useState(null);
|
||||
const [loadingChapter, setLoadingChapter] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [typedChunkStart, setTypedChunkStart] = useState('');
|
||||
const [typedChunkEnd, setTypedChunkEnd] = useState('');
|
||||
const [typedChunkNextEnd, setTypedChunkNextEnd] = useState('');
|
||||
const [clickedSpanNextEnd, setClickedSpanNextEnd] = useState('');
|
||||
const [typedChunkBulk, setTypedChunkBulk] = useState('');
|
||||
const saveTimerRef = useRef(null);
|
||||
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
|
||||
const reader = useBibleReader(currentPage);
|
||||
@@ -95,9 +72,7 @@ const App = () => {
|
||||
setProject,
|
||||
setProjectIndex,
|
||||
setCurrentPage,
|
||||
setActiveChapterIndex,
|
||||
});
|
||||
const [verseSearch, setVerseSearch] = useState('');
|
||||
const openBibleReader = () => {
|
||||
setCurrentPage('reader');
|
||||
};
|
||||
@@ -110,17 +85,7 @@ const App = () => {
|
||||
setProjectIndex(loadProjectIndex());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!titleEdited) {
|
||||
setSetup((current) => ({
|
||||
...current,
|
||||
title: `${current.book} ${current.chapter} Study`,
|
||||
}));
|
||||
}
|
||||
}, [setup.book, setup.chapter, titleEdited]);
|
||||
|
||||
const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : [];
|
||||
const activeChapter = project?.chapters[activeChapterIndex] ?? null;
|
||||
// Autosave
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
@@ -147,412 +112,10 @@ const App = () => {
|
||||
};
|
||||
}, [project]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleSetupField = (field, value) => {
|
||||
if (field === 'book') {
|
||||
const selected = bookOptions.find((book) => book.abbrev === value) ?? bookOptions[0];
|
||||
setSetup((current) => ({
|
||||
...current,
|
||||
book: selected.name,
|
||||
bookAbbrev: selected.abbrev,
|
||||
}));
|
||||
} else {
|
||||
setSetup((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadChapter = async () => {
|
||||
if (!setup.bookAbbrev || !setup.chapter) {
|
||||
setErrorMessage('Please choose a book and chapter.');
|
||||
return;
|
||||
}
|
||||
setErrorMessage('');
|
||||
setLoadingChapter(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://bible.helloao.org/api/${setup.translation}/${setup.bookAbbrev}/${setup.chapter}.json`,
|
||||
);
|
||||
if (!response.ok) throw new Error('Unable to load chapter.');
|
||||
const data = await response.json();
|
||||
const verses = parseBibleChapter(data);
|
||||
if (!data || !Array.isArray(verses) || verses.length === 0) {
|
||||
throw new Error('Invalid Bible data returned.');
|
||||
}
|
||||
|
||||
if (project) {
|
||||
// Adding a chapter to an existing project
|
||||
const alreadyExists = project.chapters.some(
|
||||
(ch) => ch.bookAbbrev === setup.bookAbbrev && ch.chapter === setup.chapter,
|
||||
);
|
||||
if (alreadyExists) {
|
||||
setErrorMessage('That chapter is already in this project.');
|
||||
setLoadingChapter(false);
|
||||
return;
|
||||
}
|
||||
const newChapter = {
|
||||
book: setup.book,
|
||||
bookAbbrev: setup.bookAbbrev,
|
||||
chapter: setup.chapter,
|
||||
verses,
|
||||
chunks: [],
|
||||
};
|
||||
updateProject((current) => {
|
||||
const updatedChapters = [...current.chapters, newChapter];
|
||||
return { ...current, chapters: updatedChapters };
|
||||
});
|
||||
setActiveChapterIndex(project.chapters.length);
|
||||
setShowAddChapterForm(false);
|
||||
setErrorMessage('');
|
||||
} else {
|
||||
// Creating a brand-new project
|
||||
const newProject = {
|
||||
id: makeId(),
|
||||
title: setup.title,
|
||||
translation: setup.translation,
|
||||
lastEdited: Date.now(),
|
||||
selectedChunkId: null,
|
||||
chapters: [
|
||||
{
|
||||
book: setup.book,
|
||||
bookAbbrev: setup.bookAbbrev,
|
||||
chapter: setup.chapter,
|
||||
verses,
|
||||
chunks: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
setProject(newProject);
|
||||
setActiveChapterIndex(0);
|
||||
setCurrentPage('setup');
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage(error.message || 'Failed to load chapter.');
|
||||
} finally {
|
||||
setLoadingChapter(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateProject = (updater) => {
|
||||
setProject((current) => (current ? updater(current) : current));
|
||||
};
|
||||
|
||||
const beginStudying = () => {
|
||||
if (!project || allChunks.length === 0) return;
|
||||
updateProject((current) => ({
|
||||
...current,
|
||||
selectedChunkId: current.selectedChunkId || allChunks[0]?.id || null,
|
||||
}));
|
||||
setCurrentPage('study');
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chunk CRUD (operates on activeChapter in setup view, on selectedChunkChapter in study)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const addChunkToChapter = (chapterIndex, start, end, spilloverEndVerse = null) => {
|
||||
updateProject((current) => {
|
||||
const chapters = current.chapters.map((ch, idx) => {
|
||||
if (idx !== chapterIndex) return ch;
|
||||
const overlap = ch.chunks.find(
|
||||
(c) => c.startVerse === start
|
||||
&& c.endVerse === end
|
||||
&& (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null),
|
||||
);
|
||||
if (overlap) return ch;
|
||||
const newChunk = {
|
||||
id: makeId(),
|
||||
startVerse: start,
|
||||
endVerse: end,
|
||||
spilloverEndVerse,
|
||||
observation: '',
|
||||
interpretation: '',
|
||||
application: '',
|
||||
crossReferences: [],
|
||||
greekWords: [],
|
||||
generalNotes: '',
|
||||
episodeNumber: '',
|
||||
episodeTitle: '',
|
||||
finalScript: '',
|
||||
tags: [],
|
||||
};
|
||||
return { ...ch, chunks: [...ch.chunks, newChunk] };
|
||||
});
|
||||
// Find the newly added chunk id
|
||||
const newChunk = chapters[chapterIndex].chunks.at(-1);
|
||||
return { ...current, chapters, selectedChunkId: newChunk?.id ?? current.selectedChunkId };
|
||||
});
|
||||
};
|
||||
|
||||
const addChunk = (start, end, spilloverEndVerse = null) => {
|
||||
if (!project) return;
|
||||
const chapter = activeChapter;
|
||||
if (!chapter) return;
|
||||
const overlap = chapter.chunks.find(
|
||||
(c) => c.startVerse === start
|
||||
&& c.endVerse === end
|
||||
&& (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null),
|
||||
);
|
||||
if (overlap) {
|
||||
setStatusMessage('That chunk already exists.');
|
||||
window.setTimeout(() => setStatusMessage(''), 1800);
|
||||
return;
|
||||
}
|
||||
addChunkToChapter(activeChapterIndex, start, end, spilloverEndVerse);
|
||||
};
|
||||
|
||||
const addChunkRanges = (ranges) => {
|
||||
if (!project || !activeChapter || ranges.length === 0) {
|
||||
return { addedCount: 0, skippedCount: 0 };
|
||||
}
|
||||
|
||||
let addedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let lastAddedChunkId = null;
|
||||
|
||||
updateProject((current) => {
|
||||
const chapters = current.chapters.map((ch, idx) => {
|
||||
if (idx !== activeChapterIndex) return ch;
|
||||
const existing = new Set(
|
||||
ch.chunks.map((c) => `${c.startVerse}-${c.endVerse}-${c.spilloverEndVerse ?? ''}`),
|
||||
);
|
||||
const newChunks = [];
|
||||
|
||||
ranges.forEach(({ start, end, spilloverEndVerse = null }) => {
|
||||
const key = `${start}-${end}-${spilloverEndVerse ?? ''}`;
|
||||
if (existing.has(key)) {
|
||||
skippedCount += 1;
|
||||
return;
|
||||
}
|
||||
const newChunk = {
|
||||
id: makeId(),
|
||||
startVerse: start,
|
||||
endVerse: end,
|
||||
spilloverEndVerse,
|
||||
observation: '',
|
||||
interpretation: '',
|
||||
application: '',
|
||||
crossReferences: [],
|
||||
greekWords: [],
|
||||
tags: [],
|
||||
};
|
||||
existing.add(key);
|
||||
newChunks.push(newChunk);
|
||||
addedCount += 1;
|
||||
lastAddedChunkId = newChunk.id;
|
||||
});
|
||||
|
||||
return newChunks.length > 0
|
||||
? { ...ch, chunks: [...ch.chunks, ...newChunks] }
|
||||
: ch;
|
||||
});
|
||||
|
||||
return {
|
||||
...current,
|
||||
chapters,
|
||||
selectedChunkId: lastAddedChunkId ?? current.selectedChunkId,
|
||||
};
|
||||
});
|
||||
|
||||
return { addedCount, skippedCount };
|
||||
};
|
||||
|
||||
const addTypedChunk = () => {
|
||||
if (!activeChapter) return;
|
||||
const start = Number.parseInt(typedChunkStart, 10);
|
||||
const end = Number.parseInt(typedChunkEnd, 10);
|
||||
const nextEndRaw = typedChunkNextEnd.trim();
|
||||
const nextEnd = nextEndRaw ? Number.parseInt(nextEndRaw, 10) : null;
|
||||
const maxVerse = activeChapter.verses.at(-1)?.number ?? 0;
|
||||
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
|
||||
const nextMaxVerse = nextChapter?.verses?.at(-1)?.number ?? 0;
|
||||
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end)) {
|
||||
setStatusMessage('Type a valid start and end verse.');
|
||||
window.setTimeout(() => setStatusMessage(''), 2200);
|
||||
return;
|
||||
}
|
||||
if (start < 1 || end < 1 || start > maxVerse || end > maxVerse) {
|
||||
setStatusMessage(`Verse range must be between 1 and ${maxVerse}.`);
|
||||
window.setTimeout(() => setStatusMessage(''), 2200);
|
||||
return;
|
||||
}
|
||||
if (start > end) {
|
||||
setStatusMessage('Start verse must be less than or equal to end verse.');
|
||||
window.setTimeout(() => setStatusMessage(''), 2200);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextEndRaw) {
|
||||
if (!nextChapter) {
|
||||
setStatusMessage('Add the next chapter first to span chunks across chapters.');
|
||||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||
return;
|
||||
}
|
||||
if (nextChapter.bookAbbrev !== activeChapter.bookAbbrev) {
|
||||
setStatusMessage('Chapter spanning only works into the next chapter of the same book.');
|
||||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(nextEnd) || nextEnd < 1 || nextEnd > nextMaxVerse) {
|
||||
setStatusMessage(`Next chapter end must be between 1 and ${nextMaxVerse}.`);
|
||||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||
return;
|
||||
}
|
||||
if (end !== maxVerse) {
|
||||
setStatusMessage('To span chapters, set End to the last verse of this chapter.');
|
||||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { addedCount } = addChunkRanges([{ start, end, spilloverEndVerse: nextEnd }]);
|
||||
if (addedCount === 0) {
|
||||
setStatusMessage('That chunk already exists.');
|
||||
window.setTimeout(() => setStatusMessage(''), 2200);
|
||||
return;
|
||||
}
|
||||
|
||||
setTypedChunkStart('');
|
||||
setTypedChunkEnd('');
|
||||
setTypedChunkNextEnd('');
|
||||
};
|
||||
|
||||
const addBulkTypedChunks = () => {
|
||||
if (!activeChapter) return;
|
||||
const raw = typedChunkBulk.trim();
|
||||
if (!raw) {
|
||||
setStatusMessage('Type chunk ranges first. Example: 1-6, 7-12');
|
||||
window.setTimeout(() => setStatusMessage(''), 2200);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxVerse = activeChapter.verses.at(-1)?.number ?? 0;
|
||||
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
|
||||
const nextMaxVerse = nextChapter?.verses?.at(-1)?.number ?? 0;
|
||||
const parts = raw
|
||||
.split(/[\n,;]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const validRanges = [];
|
||||
let invalidCount = 0;
|
||||
|
||||
parts.forEach((part) => {
|
||||
const single = /^(\d+)$/.exec(part);
|
||||
if (single) {
|
||||
const v = Number.parseInt(single[1], 10);
|
||||
if (v >= 1 && v <= maxVerse) {
|
||||
validRanges.push({ start: v, end: v });
|
||||
} else {
|
||||
invalidCount += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const range = /^(\d+)\s*-\s*(\d+)(?:\s*:\s*(\d+))?$/.exec(part);
|
||||
if (!range) {
|
||||
invalidCount += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const start = Number.parseInt(range[1], 10);
|
||||
const end = Number.parseInt(range[2], 10);
|
||||
const spilloverEndVerse = range[3] ? Number.parseInt(range[3], 10) : null;
|
||||
if (start < 1 || end < 1 || start > end || start > maxVerse || end > maxVerse) {
|
||||
invalidCount += 1;
|
||||
return;
|
||||
}
|
||||
if (spilloverEndVerse !== null) {
|
||||
if (!nextChapter
|
||||
|| nextChapter.bookAbbrev !== activeChapter.bookAbbrev
|
||||
|| spilloverEndVerse < 1
|
||||
|| spilloverEndVerse > nextMaxVerse
|
||||
|| end !== maxVerse) {
|
||||
invalidCount += 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
validRanges.push({ start, end, spilloverEndVerse });
|
||||
});
|
||||
|
||||
if (validRanges.length === 0) {
|
||||
setStatusMessage(`No valid ranges found. Use 1-${maxVerse} and format like 1-6, 7-12.`);
|
||||
window.setTimeout(() => setStatusMessage(''), 2600);
|
||||
return;
|
||||
}
|
||||
|
||||
const { addedCount, skippedCount } = addChunkRanges(validRanges);
|
||||
const notes = [];
|
||||
if (addedCount > 0) notes.push(`Added ${addedCount} chunk${addedCount > 1 ? 's' : ''}`);
|
||||
if (skippedCount > 0) notes.push(`skipped ${skippedCount} duplicate${skippedCount > 1 ? 's' : ''}`);
|
||||
if (invalidCount > 0) notes.push(`ignored ${invalidCount} invalid`);
|
||||
setStatusMessage(notes.join(' • '));
|
||||
window.setTimeout(() => setStatusMessage(''), 2800);
|
||||
|
||||
if (addedCount > 0) {
|
||||
setTypedChunkBulk('');
|
||||
}
|
||||
};
|
||||
|
||||
const addClickSpanChunk = () => {
|
||||
if (!activeChapter || rangeStart === null) {
|
||||
setStatusMessage('Click a start verse first, then set next chapter end.');
|
||||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
|
||||
if (!nextChapter) {
|
||||
setStatusMessage('Add the next chapter first to span into it.');
|
||||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||
return;
|
||||
}
|
||||
if (nextChapter.bookAbbrev !== activeChapter.bookAbbrev) {
|
||||
setStatusMessage('Click-based spanning only works into the next chapter of the same book.');
|
||||
window.setTimeout(() => setStatusMessage(''), 2600);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextMaxVerse = nextChapter.verses.at(-1)?.number ?? 0;
|
||||
const spilloverEndVerse = Number.parseInt(clickedSpanNextEnd, 10);
|
||||
if (!Number.isInteger(spilloverEndVerse) || spilloverEndVerse < 1 || spilloverEndVerse > nextMaxVerse) {
|
||||
setStatusMessage(`Next chapter end must be between 1 and ${nextMaxVerse}.`);
|
||||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = rangeStart;
|
||||
const end = activeChapter.verses.at(-1)?.number ?? start;
|
||||
const { addedCount } = addChunkRanges([{ start, end, spilloverEndVerse }]);
|
||||
if (addedCount === 0) {
|
||||
setStatusMessage('That cross-chapter chunk already exists.');
|
||||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||
return;
|
||||
}
|
||||
|
||||
setClickedSpanNextEnd('');
|
||||
setRangeStart(null);
|
||||
setRangeEnd(null);
|
||||
setStatusMessage('Cross-chapter chunk added.');
|
||||
window.setTimeout(() => setStatusMessage(''), 1800);
|
||||
};
|
||||
|
||||
const handleVerseClick = (verseNumber, event) => {
|
||||
if (event.shiftKey && rangeStart !== null) {
|
||||
const start = Math.min(rangeStart, verseNumber);
|
||||
const end = Math.max(rangeStart, verseNumber);
|
||||
addChunk(start, end);
|
||||
setRangeStart(null);
|
||||
setRangeEnd(null);
|
||||
return;
|
||||
}
|
||||
setRangeStart(verseNumber);
|
||||
setRangeEnd(verseNumber);
|
||||
};
|
||||
|
||||
const updateChunk = (chunkId, patch) => {
|
||||
updateProject((current) => ({
|
||||
...current,
|
||||
@@ -563,55 +126,12 @@ const App = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
const moveChunk = (chunkId, direction) => {
|
||||
updateProject((current) => ({
|
||||
...current,
|
||||
chapters: current.chapters.map((ch) => {
|
||||
const index = ch.chunks.findIndex((c) => c.id === chunkId);
|
||||
if (index < 0) return ch;
|
||||
const nextIndex = index + direction;
|
||||
if (nextIndex < 0 || nextIndex >= ch.chunks.length) return ch;
|
||||
const updated = [...ch.chunks];
|
||||
const [removed] = updated.splice(index, 1);
|
||||
updated.splice(nextIndex, 0, removed);
|
||||
return { ...ch, chunks: updated };
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
const deleteChunk = (chunkId) => {
|
||||
updateProject((current) => {
|
||||
const newAllChunks = current.chapters
|
||||
.flatMap((ch) => ch.chunks)
|
||||
.filter((c) => c.id !== chunkId);
|
||||
const nextSelected =
|
||||
current.selectedChunkId === chunkId
|
||||
? newAllChunks[0]?.id ?? null
|
||||
: current.selectedChunkId;
|
||||
return {
|
||||
...current,
|
||||
selectedChunkId: nextSelected,
|
||||
chapters: current.chapters.map((ch) => ({
|
||||
...ch,
|
||||
chunks: ch.chunks.filter((c) => c.id !== chunkId),
|
||||
})),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Home page helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const openNewProject = () => {
|
||||
setProject(null);
|
||||
setActiveChapterIndex(0);
|
||||
setShowAddChapterForm(false);
|
||||
setRangeStart(null);
|
||||
setRangeEnd(null);
|
||||
setErrorMessage('');
|
||||
setStatusMessage('');
|
||||
setTitleEdited(false);
|
||||
setCurrentPage('setup');
|
||||
};
|
||||
|
||||
@@ -624,10 +144,6 @@ const App = () => {
|
||||
setProjectIndex(loadProjectIndex());
|
||||
setCurrentPage('home');
|
||||
setProject(null);
|
||||
setRangeStart(null);
|
||||
setRangeEnd(null);
|
||||
setErrorMessage('');
|
||||
setStatusMessage('');
|
||||
};
|
||||
|
||||
// Manual retry after a failed background sync (shown in the project header).
|
||||
@@ -668,34 +184,9 @@ const App = () => {
|
||||
...audio,
|
||||
// Reader
|
||||
...reader,
|
||||
// Setup
|
||||
// Setup (chunk-builder state lives in SetupPage via useChunkBuilder)
|
||||
project, setProject,
|
||||
setup,
|
||||
titleEdited, setTitleEdited,
|
||||
activeChapterIndex, setActiveChapterIndex,
|
||||
activeChapter,
|
||||
showAddChapterForm, setShowAddChapterForm,
|
||||
rangeStart, setRangeStart,
|
||||
rangeEnd, setRangeEnd,
|
||||
verseSearch, setVerseSearch,
|
||||
typedChunkStart, setTypedChunkStart,
|
||||
typedChunkEnd, setTypedChunkEnd,
|
||||
typedChunkNextEnd, setTypedChunkNextEnd,
|
||||
typedChunkBulk, setTypedChunkBulk,
|
||||
clickedSpanNextEnd, setClickedSpanNextEnd,
|
||||
loadingChapter,
|
||||
errorMessage,
|
||||
statusMessage,
|
||||
allChunks,
|
||||
handleSetupField,
|
||||
handleLoadChapter,
|
||||
beginStudying,
|
||||
handleVerseClick,
|
||||
addTypedChunk,
|
||||
addBulkTypedChunks,
|
||||
addClickSpanChunk,
|
||||
moveChunk,
|
||||
deleteChunk,
|
||||
updateProject,
|
||||
// Study (editor state lives in StudyPage and its hooks)
|
||||
updateChunk,
|
||||
|
||||
@@ -671,3 +671,32 @@ describe('Study editor', () => {
|
||||
expect(input).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chunk builder (useChunkBuilder, local to SetupPage)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Chunk builder', () => {
|
||||
async function addSecondChapter(user) {
|
||||
await user.click(screen.getByRole('button', { name: /add chapter/i }));
|
||||
// The add-chapter form starts on the chapter after the project's last one.
|
||||
expect(screen.getByDisplayValue('2')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /load chapter/i }));
|
||||
return screen.findByRole('button', { name: 'Titus 2' });
|
||||
}
|
||||
|
||||
test('"Add Chapter" starts on the next chapter and adds it as a tab', async () => {
|
||||
const user = await loadChapter();
|
||||
expect(await addSecondChapter(user)).toHaveClass('bg-slate-900');
|
||||
});
|
||||
|
||||
test('"Back to Chunks" reopens the chapter of the chunk being studied', async () => {
|
||||
const user = await loadChapter();
|
||||
await addSecondChapter(user);
|
||||
addChunk('Paul, a servant', 'In hope of eternal'); // chunk in Titus 2 (now selected)
|
||||
await user.click(screen.getByRole('button', { name: 'Titus 1' }));
|
||||
await user.click(screen.getByRole('button', { name: /begin studying/i }));
|
||||
await screen.findByText(/chunk editor/i);
|
||||
await user.click(screen.getByRole('button', { name: /back to chunks/i }));
|
||||
expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveClass('bg-slate-900');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,521 @@
|
||||
// The Setup page's chunk builder: the new-project / add-chapter form, the
|
||||
// active chapter tab, and every way of creating chunks (click, shift-click,
|
||||
// typed range, bulk ranges, cross-chapter spans), plus reorder and delete.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
||||
|
||||
// A new project starts on Titus 1. When adding to an open project, start on
|
||||
// the chapter after its last one (same book and translation).
|
||||
function initialSetupForm(project) {
|
||||
const last = project?.chapters?.at(-1);
|
||||
if (!last) {
|
||||
return { translation: 'BSB', book: 'Titus', bookAbbrev: 'TIT', chapter: '1', title: 'Titus 1 Study' };
|
||||
}
|
||||
const next = Math.min(Number(last.chapter) + 1, CHAPTER_COUNTS[last.bookAbbrev] ?? Number(last.chapter));
|
||||
return {
|
||||
translation: project.translation ?? 'BSB',
|
||||
book: last.book,
|
||||
bookAbbrev: last.bookAbbrev,
|
||||
chapter: String(next),
|
||||
title: project.title,
|
||||
};
|
||||
}
|
||||
|
||||
// Open on the chapter holding the chunk last studied (e.g. after "Back to Chunks").
|
||||
function initialChapterIndex(project) {
|
||||
const index = project?.chapters?.findIndex((ch) => ch.chunks.some((c) => c.id === project.selectedChunkId)) ?? -1;
|
||||
return index >= 0 ? index : 0;
|
||||
}
|
||||
|
||||
export function useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage }) {
|
||||
const [setup, setSetup] = useState(() => initialSetupForm(project));
|
||||
const [titleEdited, setTitleEdited] = useState(false);
|
||||
const [activeChapterIndex, setActiveChapterIndex] = useState(() => initialChapterIndex(project));
|
||||
const [showAddChapterForm, setShowAddChapterForm] = useState(false);
|
||||
const [rangeStart, setRangeStart] = useState(null);
|
||||
const [rangeEnd, setRangeEnd] = useState(null);
|
||||
const [loadingChapter, setLoadingChapter] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [typedChunkStart, setTypedChunkStart] = useState('');
|
||||
const [typedChunkEnd, setTypedChunkEnd] = useState('');
|
||||
const [typedChunkNextEnd, setTypedChunkNextEnd] = useState('');
|
||||
const [clickedSpanNextEnd, setClickedSpanNextEnd] = useState('');
|
||||
const [typedChunkBulk, setTypedChunkBulk] = useState('');
|
||||
const [verseSearch, setVerseSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!titleEdited) {
|
||||
setSetup((current) => ({
|
||||
...current,
|
||||
title: `${current.book} ${current.chapter} Study`,
|
||||
}));
|
||||
}
|
||||
}, [setup.book, setup.chapter, titleEdited]);
|
||||
|
||||
// Each time the add-chapter form opens, suggest the chapter after the last one.
|
||||
useEffect(() => {
|
||||
if (showAddChapterForm) setSetup(initialSetupForm(project));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showAddChapterForm]);
|
||||
|
||||
const activeChapter = project?.chapters[activeChapterIndex] ?? null;
|
||||
|
||||
const handleSetupField = (field, value) => {
|
||||
if (field === 'book') {
|
||||
const selected = bookOptions.find((book) => book.abbrev === value) ?? bookOptions[0];
|
||||
setSetup((current) => ({
|
||||
...current,
|
||||
book: selected.name,
|
||||
bookAbbrev: selected.abbrev,
|
||||
}));
|
||||
} else {
|
||||
setSetup((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadChapter = async () => {
|
||||
if (!setup.bookAbbrev || !setup.chapter) {
|
||||
setErrorMessage('Please choose a book and chapter.');
|
||||
return;
|
||||
}
|
||||
setErrorMessage('');
|
||||
setLoadingChapter(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://bible.helloao.org/api/${setup.translation}/${setup.bookAbbrev}/${setup.chapter}.json`,
|
||||
);
|
||||
if (!response.ok) throw new Error('Unable to load chapter.');
|
||||
const data = await response.json();
|
||||
const verses = parseBibleChapter(data);
|
||||
if (!data || !Array.isArray(verses) || verses.length === 0) {
|
||||
throw new Error('Invalid Bible data returned.');
|
||||
}
|
||||
|
||||
if (project) {
|
||||
// Adding a chapter to an existing project
|
||||
const alreadyExists = project.chapters.some(
|
||||
(ch) => ch.bookAbbrev === setup.bookAbbrev && ch.chapter === setup.chapter,
|
||||
);
|
||||
if (alreadyExists) {
|
||||
setErrorMessage('That chapter is already in this project.');
|
||||
setLoadingChapter(false);
|
||||
return;
|
||||
}
|
||||
const newChapter = {
|
||||
book: setup.book,
|
||||
bookAbbrev: setup.bookAbbrev,
|
||||
chapter: setup.chapter,
|
||||
verses,
|
||||
chunks: [],
|
||||
};
|
||||
updateProject((current) => {
|
||||
const updatedChapters = [...current.chapters, newChapter];
|
||||
return { ...current, chapters: updatedChapters };
|
||||
});
|
||||
setActiveChapterIndex(project.chapters.length);
|
||||
setShowAddChapterForm(false);
|
||||
setErrorMessage('');
|
||||
} else {
|
||||
// Creating a brand-new project
|
||||
const newProject = {
|
||||
id: makeId(),
|
||||
title: setup.title,
|
||||
translation: setup.translation,
|
||||
lastEdited: Date.now(),
|
||||
selectedChunkId: null,
|
||||
chapters: [
|
||||
{
|
||||
book: setup.book,
|
||||
bookAbbrev: setup.bookAbbrev,
|
||||
chapter: setup.chapter,
|
||||
verses,
|
||||
chunks: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
setProject(newProject);
|
||||
setActiveChapterIndex(0);
|
||||
setCurrentPage('setup');
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage(error.message || 'Failed to load chapter.');
|
||||
} finally {
|
||||
setLoadingChapter(false);
|
||||
}
|
||||
};
|
||||
|
||||
const beginStudying = () => {
|
||||
if (!project || allChunks.length === 0) return;
|
||||
updateProject((current) => ({
|
||||
...current,
|
||||
selectedChunkId: current.selectedChunkId || allChunks[0]?.id || null,
|
||||
}));
|
||||
setCurrentPage('study');
|
||||
};
|
||||
|
||||
const addChunkToChapter = (chapterIndex, start, end, spilloverEndVerse = null) => {
|
||||
updateProject((current) => {
|
||||
const chapters = current.chapters.map((ch, idx) => {
|
||||
if (idx !== chapterIndex) return ch;
|
||||
const overlap = ch.chunks.find(
|
||||
(c) => c.startVerse === start
|
||||
&& c.endVerse === end
|
||||
&& (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null),
|
||||
);
|
||||
if (overlap) return ch;
|
||||
const newChunk = {
|
||||
id: makeId(),
|
||||
startVerse: start,
|
||||
endVerse: end,
|
||||
spilloverEndVerse,
|
||||
observation: '',
|
||||
interpretation: '',
|
||||
application: '',
|
||||
crossReferences: [],
|
||||
greekWords: [],
|
||||
generalNotes: '',
|
||||
episodeNumber: '',
|
||||
episodeTitle: '',
|
||||
finalScript: '',
|
||||
tags: [],
|
||||
};
|
||||
return { ...ch, chunks: [...ch.chunks, newChunk] };
|
||||
});
|
||||
// Find the newly added chunk id
|
||||
const newChunk = chapters[chapterIndex].chunks.at(-1);
|
||||
return { ...current, chapters, selectedChunkId: newChunk?.id ?? current.selectedChunkId };
|
||||
});
|
||||
};
|
||||
|
||||
const addChunk = (start, end, spilloverEndVerse = null) => {
|
||||
if (!project) return;
|
||||
const chapter = activeChapter;
|
||||
if (!chapter) return;
|
||||
const overlap = chapter.chunks.find(
|
||||
(c) => c.startVerse === start
|
||||
&& c.endVerse === end
|
||||
&& (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null),
|
||||
);
|
||||
if (overlap) {
|
||||
setStatusMessage('That chunk already exists.');
|
||||
window.setTimeout(() => setStatusMessage(''), 1800);
|
||||
return;
|
||||
}
|
||||
addChunkToChapter(activeChapterIndex, start, end, spilloverEndVerse);
|
||||
};
|
||||
|
||||
const addChunkRanges = (ranges) => {
|
||||
if (!project || !activeChapter || ranges.length === 0) {
|
||||
return { addedCount: 0, skippedCount: 0 };
|
||||
}
|
||||
|
||||
let addedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let lastAddedChunkId = null;
|
||||
|
||||
updateProject((current) => {
|
||||
const chapters = current.chapters.map((ch, idx) => {
|
||||
if (idx !== activeChapterIndex) return ch;
|
||||
const existing = new Set(
|
||||
ch.chunks.map((c) => `${c.startVerse}-${c.endVerse}-${c.spilloverEndVerse ?? ''}`),
|
||||
);
|
||||
const newChunks = [];
|
||||
|
||||
ranges.forEach(({ start, end, spilloverEndVerse = null }) => {
|
||||
const key = `${start}-${end}-${spilloverEndVerse ?? ''}`;
|
||||
if (existing.has(key)) {
|
||||
skippedCount += 1;
|
||||
return;
|
||||
}
|
||||
const newChunk = {
|
||||
id: makeId(),
|
||||
startVerse: start,
|
||||
endVerse: end,
|
||||
spilloverEndVerse,
|
||||
observation: '',
|
||||
interpretation: '',
|
||||
application: '',
|
||||
crossReferences: [],
|
||||
greekWords: [],
|
||||
tags: [],
|
||||
};
|
||||
existing.add(key);
|
||||
newChunks.push(newChunk);
|
||||
addedCount += 1;
|
||||
lastAddedChunkId = newChunk.id;
|
||||
});
|
||||
|
||||
return newChunks.length > 0
|
||||
? { ...ch, chunks: [...ch.chunks, ...newChunks] }
|
||||
: ch;
|
||||
});
|
||||
|
||||
return {
|
||||
...current,
|
||||
chapters,
|
||||
selectedChunkId: lastAddedChunkId ?? current.selectedChunkId,
|
||||
};
|
||||
});
|
||||
|
||||
return { addedCount, skippedCount };
|
||||
};
|
||||
|
||||
const addTypedChunk = () => {
|
||||
if (!activeChapter) return;
|
||||
const start = Number.parseInt(typedChunkStart, 10);
|
||||
const end = Number.parseInt(typedChunkEnd, 10);
|
||||
const nextEndRaw = typedChunkNextEnd.trim();
|
||||
const nextEnd = nextEndRaw ? Number.parseInt(nextEndRaw, 10) : null;
|
||||
const maxVerse = activeChapter.verses.at(-1)?.number ?? 0;
|
||||
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
|
||||
const nextMaxVerse = nextChapter?.verses?.at(-1)?.number ?? 0;
|
||||
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end)) {
|
||||
setStatusMessage('Type a valid start and end verse.');
|
||||
window.setTimeout(() => setStatusMessage(''), 2200);
|
||||
return;
|
||||
}
|
||||
if (start < 1 || end < 1 || start > maxVerse || end > maxVerse) {
|
||||
setStatusMessage(`Verse range must be between 1 and ${maxVerse}.`);
|
||||
window.setTimeout(() => setStatusMessage(''), 2200);
|
||||
return;
|
||||
}
|
||||
if (start > end) {
|
||||
setStatusMessage('Start verse must be less than or equal to end verse.');
|
||||
window.setTimeout(() => setStatusMessage(''), 2200);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextEndRaw) {
|
||||
if (!nextChapter) {
|
||||
setStatusMessage('Add the next chapter first to span chunks across chapters.');
|
||||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||
return;
|
||||
}
|
||||
if (nextChapter.bookAbbrev !== activeChapter.bookAbbrev) {
|
||||
setStatusMessage('Chapter spanning only works into the next chapter of the same book.');
|
||||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(nextEnd) || nextEnd < 1 || nextEnd > nextMaxVerse) {
|
||||
setStatusMessage(`Next chapter end must be between 1 and ${nextMaxVerse}.`);
|
||||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||
return;
|
||||
}
|
||||
if (end !== maxVerse) {
|
||||
setStatusMessage('To span chapters, set End to the last verse of this chapter.');
|
||||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { addedCount } = addChunkRanges([{ start, end, spilloverEndVerse: nextEnd }]);
|
||||
if (addedCount === 0) {
|
||||
setStatusMessage('That chunk already exists.');
|
||||
window.setTimeout(() => setStatusMessage(''), 2200);
|
||||
return;
|
||||
}
|
||||
|
||||
setTypedChunkStart('');
|
||||
setTypedChunkEnd('');
|
||||
setTypedChunkNextEnd('');
|
||||
};
|
||||
|
||||
const addBulkTypedChunks = () => {
|
||||
if (!activeChapter) return;
|
||||
const raw = typedChunkBulk.trim();
|
||||
if (!raw) {
|
||||
setStatusMessage('Type chunk ranges first. Example: 1-6, 7-12');
|
||||
window.setTimeout(() => setStatusMessage(''), 2200);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxVerse = activeChapter.verses.at(-1)?.number ?? 0;
|
||||
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
|
||||
const nextMaxVerse = nextChapter?.verses?.at(-1)?.number ?? 0;
|
||||
const parts = raw
|
||||
.split(/[\n,;]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const validRanges = [];
|
||||
let invalidCount = 0;
|
||||
|
||||
parts.forEach((part) => {
|
||||
const single = /^(\d+)$/.exec(part);
|
||||
if (single) {
|
||||
const v = Number.parseInt(single[1], 10);
|
||||
if (v >= 1 && v <= maxVerse) {
|
||||
validRanges.push({ start: v, end: v });
|
||||
} else {
|
||||
invalidCount += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const range = /^(\d+)\s*-\s*(\d+)(?:\s*:\s*(\d+))?$/.exec(part);
|
||||
if (!range) {
|
||||
invalidCount += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const start = Number.parseInt(range[1], 10);
|
||||
const end = Number.parseInt(range[2], 10);
|
||||
const spilloverEndVerse = range[3] ? Number.parseInt(range[3], 10) : null;
|
||||
if (start < 1 || end < 1 || start > end || start > maxVerse || end > maxVerse) {
|
||||
invalidCount += 1;
|
||||
return;
|
||||
}
|
||||
if (spilloverEndVerse !== null) {
|
||||
if (!nextChapter
|
||||
|| nextChapter.bookAbbrev !== activeChapter.bookAbbrev
|
||||
|| spilloverEndVerse < 1
|
||||
|| spilloverEndVerse > nextMaxVerse
|
||||
|| end !== maxVerse) {
|
||||
invalidCount += 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
validRanges.push({ start, end, spilloverEndVerse });
|
||||
});
|
||||
|
||||
if (validRanges.length === 0) {
|
||||
setStatusMessage(`No valid ranges found. Use 1-${maxVerse} and format like 1-6, 7-12.`);
|
||||
window.setTimeout(() => setStatusMessage(''), 2600);
|
||||
return;
|
||||
}
|
||||
|
||||
const { addedCount, skippedCount } = addChunkRanges(validRanges);
|
||||
const notes = [];
|
||||
if (addedCount > 0) notes.push(`Added ${addedCount} chunk${addedCount > 1 ? 's' : ''}`);
|
||||
if (skippedCount > 0) notes.push(`skipped ${skippedCount} duplicate${skippedCount > 1 ? 's' : ''}`);
|
||||
if (invalidCount > 0) notes.push(`ignored ${invalidCount} invalid`);
|
||||
setStatusMessage(notes.join(' • '));
|
||||
window.setTimeout(() => setStatusMessage(''), 2800);
|
||||
|
||||
if (addedCount > 0) {
|
||||
setTypedChunkBulk('');
|
||||
}
|
||||
};
|
||||
|
||||
const addClickSpanChunk = () => {
|
||||
if (!activeChapter || rangeStart === null) {
|
||||
setStatusMessage('Click a start verse first, then set next chapter end.');
|
||||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
|
||||
if (!nextChapter) {
|
||||
setStatusMessage('Add the next chapter first to span into it.');
|
||||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||
return;
|
||||
}
|
||||
if (nextChapter.bookAbbrev !== activeChapter.bookAbbrev) {
|
||||
setStatusMessage('Click-based spanning only works into the next chapter of the same book.');
|
||||
window.setTimeout(() => setStatusMessage(''), 2600);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextMaxVerse = nextChapter.verses.at(-1)?.number ?? 0;
|
||||
const spilloverEndVerse = Number.parseInt(clickedSpanNextEnd, 10);
|
||||
if (!Number.isInteger(spilloverEndVerse) || spilloverEndVerse < 1 || spilloverEndVerse > nextMaxVerse) {
|
||||
setStatusMessage(`Next chapter end must be between 1 and ${nextMaxVerse}.`);
|
||||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = rangeStart;
|
||||
const end = activeChapter.verses.at(-1)?.number ?? start;
|
||||
const { addedCount } = addChunkRanges([{ start, end, spilloverEndVerse }]);
|
||||
if (addedCount === 0) {
|
||||
setStatusMessage('That cross-chapter chunk already exists.');
|
||||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||
return;
|
||||
}
|
||||
|
||||
setClickedSpanNextEnd('');
|
||||
setRangeStart(null);
|
||||
setRangeEnd(null);
|
||||
setStatusMessage('Cross-chapter chunk added.');
|
||||
window.setTimeout(() => setStatusMessage(''), 1800);
|
||||
};
|
||||
|
||||
const handleVerseClick = (verseNumber, event) => {
|
||||
if (event.shiftKey && rangeStart !== null) {
|
||||
const start = Math.min(rangeStart, verseNumber);
|
||||
const end = Math.max(rangeStart, verseNumber);
|
||||
addChunk(start, end);
|
||||
setRangeStart(null);
|
||||
setRangeEnd(null);
|
||||
return;
|
||||
}
|
||||
setRangeStart(verseNumber);
|
||||
setRangeEnd(verseNumber);
|
||||
};
|
||||
|
||||
const moveChunk = (chunkId, direction) => {
|
||||
updateProject((current) => ({
|
||||
...current,
|
||||
chapters: current.chapters.map((ch) => {
|
||||
const index = ch.chunks.findIndex((c) => c.id === chunkId);
|
||||
if (index < 0) return ch;
|
||||
const nextIndex = index + direction;
|
||||
if (nextIndex < 0 || nextIndex >= ch.chunks.length) return ch;
|
||||
const updated = [...ch.chunks];
|
||||
const [removed] = updated.splice(index, 1);
|
||||
updated.splice(nextIndex, 0, removed);
|
||||
return { ...ch, chunks: updated };
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
const deleteChunk = (chunkId) => {
|
||||
updateProject((current) => {
|
||||
const newAllChunks = current.chapters
|
||||
.flatMap((ch) => ch.chunks)
|
||||
.filter((c) => c.id !== chunkId);
|
||||
const nextSelected =
|
||||
current.selectedChunkId === chunkId
|
||||
? newAllChunks[0]?.id ?? null
|
||||
: current.selectedChunkId;
|
||||
return {
|
||||
...current,
|
||||
selectedChunkId: nextSelected,
|
||||
chapters: current.chapters.map((ch) => ({
|
||||
...ch,
|
||||
chunks: ch.chunks.filter((c) => c.id !== chunkId),
|
||||
})),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
setup,
|
||||
titleEdited, setTitleEdited,
|
||||
activeChapterIndex, setActiveChapterIndex,
|
||||
activeChapter,
|
||||
showAddChapterForm, setShowAddChapterForm,
|
||||
rangeStart, setRangeStart,
|
||||
rangeEnd, setRangeEnd,
|
||||
verseSearch, setVerseSearch,
|
||||
typedChunkStart, setTypedChunkStart,
|
||||
typedChunkEnd, setTypedChunkEnd,
|
||||
typedChunkNextEnd, setTypedChunkNextEnd,
|
||||
typedChunkBulk, setTypedChunkBulk,
|
||||
clickedSpanNextEnd, setClickedSpanNextEnd,
|
||||
loadingChapter,
|
||||
errorMessage,
|
||||
statusMessage,
|
||||
handleSetupField,
|
||||
handleLoadChapter,
|
||||
beginStudying,
|
||||
handleVerseClick,
|
||||
addTypedChunk,
|
||||
addBulkTypedChunks,
|
||||
addClickSpanChunk,
|
||||
moveChunk,
|
||||
deleteChunk,
|
||||
};
|
||||
}
|
||||
@@ -20,7 +20,6 @@ export function useProjectLibrary({
|
||||
setProject,
|
||||
setProjectIndex,
|
||||
setCurrentPage,
|
||||
setActiveChapterIndex,
|
||||
}) {
|
||||
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
|
||||
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
|
||||
@@ -67,7 +66,6 @@ export function useProjectLibrary({
|
||||
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);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { bookOptions, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx'
|
||||
import { parseEpisodeListDocx, parseEpisodePassage } from '../utils/importParsers.js';
|
||||
|
||||
export default function ImportPage() {
|
||||
const { setCurrentPage, setProject, setActiveChapterIndex } = useApp();
|
||||
const { setCurrentPage, setProject } = useApp();
|
||||
const availableTranslations = useAvailableTranslations();
|
||||
const [importBookAbbrev, setImportBookAbbrev] = useState(bookOptions[0].abbrev);
|
||||
const [importTranslation, setImportTranslation] = useState('BSB');
|
||||
@@ -141,7 +141,6 @@ export default function ImportPage() {
|
||||
chapters,
|
||||
};
|
||||
setProject(newProject);
|
||||
setActiveChapterIndex(0);
|
||||
setCurrentPage('setup');
|
||||
} catch (error) {
|
||||
setImportError(error.message || 'Import failed.');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { useChunkBuilder } from '../hooks/useChunkBuilder.js';
|
||||
import ProjectHeader from '../components/ProjectHeader.jsx';
|
||||
import { useAvailableTranslations } from '../hooks/useAvailableTranslations.js';
|
||||
import { bookOptions, formatChunkReference } from '../utils/bibleUtils.jsx';
|
||||
@@ -81,8 +82,8 @@ function SetupForm({ setup, availableTranslations, titleEdited, loadingChapter,
|
||||
}
|
||||
|
||||
export default function SetupPage() {
|
||||
const { project, setProject, allChunks, updateProject, setCurrentPage } = useApp();
|
||||
const {
|
||||
project,
|
||||
setup,
|
||||
titleEdited, setTitleEdited,
|
||||
activeChapterIndex, setActiveChapterIndex,
|
||||
@@ -99,7 +100,6 @@ export default function SetupPage() {
|
||||
loadingChapter,
|
||||
errorMessage,
|
||||
statusMessage,
|
||||
allChunks,
|
||||
handleSetupField,
|
||||
handleLoadChapter,
|
||||
beginStudying,
|
||||
@@ -109,8 +109,7 @@ export default function SetupPage() {
|
||||
addClickSpanChunk,
|
||||
moveChunk,
|
||||
deleteChunk,
|
||||
updateProject,
|
||||
} = useApp();
|
||||
} = useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage });
|
||||
const availableTranslations = useAvailableTranslations();
|
||||
|
||||
const chapterTabs = project?.chapters ?? [];
|
||||
|
||||
Reference in new issue
Block a user