Redesign the study side: Outline, Study, Script and Export stages
Build and Push Docker Image / build-and-push (push) Successful in 4m15s
Build and Push Docker Image / build-and-push (push) Successful in 4m15s
Studies now move through a stage bar in the reader's themes. Outline splits chunks by clicking between verses (join merges notes) and suggests chunks from section headings. Study is three panes: outline, passage with an Observe / Interpret / Apply accordion, and commentary / word study / cross-refs / people & places for the chunk; original words are added by clicking them in the interlinear. Script drafts an outline from notes; Export holds files and the share link. Home, Import and the Pronunciation guide are restyled. Study projects are BSB only. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b1bc1c8ea4
commit
31abee0695
31 files changed
+1911
-2460
No files matched your search
+6
-2
@@ -23,6 +23,8 @@ const ReaderPage = lazy(() => import('./pages/ReaderPage.jsx'));
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx'));
|
||||
const SetupPage = lazy(() => import('./pages/SetupPage.jsx'));
|
||||
const StudyPage = lazy(() => import('./pages/StudyPage.jsx'));
|
||||
const ScriptPage = lazy(() => import('./pages/ScriptPage.jsx'));
|
||||
const ExportPage = lazy(() => import('./pages/ExportPage.jsx'));
|
||||
const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx'));
|
||||
const ReadingPlansPage = lazy(() => import('./pages/ReadingPlansPage.jsx'));
|
||||
|
||||
@@ -32,7 +34,7 @@ const ReadingPlansPage = lazy(() => import('./pages/ReadingPlansPage.jsx'));
|
||||
|
||||
const App = () => {
|
||||
const [project, setProject] = useState(null);
|
||||
// 'home' | 'setup' | 'study' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' | 'reading-plans'
|
||||
// 'home' | 'setup' | 'study' | 'script' | 'export' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' | 'reading-plans'
|
||||
const [currentPage, setCurrentPage] = useState('home');
|
||||
const [projectIndex, setProjectIndex] = useState([]);
|
||||
const [saveStatus, setSaveStatus] = useState('');
|
||||
@@ -245,7 +247,7 @@ const App = () => {
|
||||
authUser, setAuthUser,
|
||||
signIn,
|
||||
logout,
|
||||
// Save / sync status (shown in ProjectHeader)
|
||||
// Save / sync status (shown in the stage bar)
|
||||
saveStatus, setSaveStatus,
|
||||
syncStatus,
|
||||
retrySync,
|
||||
@@ -348,6 +350,8 @@ function AppRouter() {
|
||||
if (currentPage === 'reader') return <ReaderPage />;
|
||||
if (currentPage === 'import') return <ImportPage />;
|
||||
if (currentPage === 'study') return <StudyPage />;
|
||||
if (currentPage === 'script') return <ScriptPage />;
|
||||
if (currentPage === 'export') return <ExportPage />;
|
||||
if (currentPage === 'setup') return <SetupPage />;
|
||||
if (currentPage === 'pronunciation') return <PronunciationPage />;
|
||||
return <HomePage />;
|
||||
|
||||
+153
-40
@@ -135,17 +135,18 @@ describe('Initial render', () => {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||
expect(await screen.findByText('Project Setup')).toBeInTheDocument();
|
||||
expect(await screen.findByText('New study')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('setup form shows translation, book, and chapter inputs', async () => {
|
||||
test('setup form shows book and chapter inputs, and uses the BSB', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||
expect(screen.getByText('Translation')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Translation')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Book')).toBeInTheDocument();
|
||||
expect(screen.getByText('Chapter')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Berean Standard Bible/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -200,7 +201,7 @@ describe('Chunk management', () => {
|
||||
test('creates a chunk by clicking a verse range', async () => {
|
||||
await loadChapter();
|
||||
addChunk('Paul, a servant', 'Grace and peace');
|
||||
await screen.findByText(/1[-–]3/);
|
||||
expect((await screen.findAllByText(/1[-–]3/)).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('chunk count increments after each addition', async () => {
|
||||
@@ -250,7 +251,7 @@ async function goToStudyWith3Chunks() {
|
||||
addChunk('Grace and peace', 'Grace and peace');
|
||||
await screen.findByText('3 created');
|
||||
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
|
||||
await screen.findByText(/chunk editor/i);
|
||||
await findChunkCounter(3, 3);
|
||||
// addChunk always selects the newest chunk; navigate back to chunk 1
|
||||
// so navigation tests start from a known position.
|
||||
fireEvent.click(screen.getByRole('button', { name: /previous chunk/i }));
|
||||
@@ -264,7 +265,7 @@ describe('Study view navigation', () => {
|
||||
addChunk('Paul, a servant', 'In hope of eternal');
|
||||
await screen.findByText('1 created');
|
||||
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
|
||||
await screen.findByText(/chunk editor/i);
|
||||
await findChunkCounter(1, 1);
|
||||
});
|
||||
|
||||
test('"Previous Chunk" is disabled on the first chunk', async () => {
|
||||
@@ -305,8 +306,9 @@ async function goToStudyAndAddGreekWord(fetchMock) {
|
||||
addChunk('Paul, a servant', 'In hope of eternal');
|
||||
await screen.findByText('1 created');
|
||||
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
|
||||
await screen.findByText(/chunk editor/i);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add greek word/i }));
|
||||
await findChunkCounter(1, 1);
|
||||
fireEvent.click(screen.getByRole('tab', { name: /word study/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /add word/i }));
|
||||
await screen.findByPlaceholderText(/G4102, H7225, 4102/i);
|
||||
}
|
||||
|
||||
@@ -507,12 +509,13 @@ describe('Admin page', () => {
|
||||
// Import page (state is local to ImportPage)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Import page', () => {
|
||||
test('opens from Home, lists fetched translations, and goes back', async () => {
|
||||
test('opens from Home, uses the BSB (no translation choice), and goes back', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.click(screen.getByRole('button', { name: /import session list/i }));
|
||||
expect(await screen.findByRole('heading', { name: /import session list/i })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('option', { name: 'KJV' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'KJV' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Berean Standard Bible/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /back/i }));
|
||||
expect(await screen.findByRole('button', { name: /import session list/i })).toBeInTheDocument();
|
||||
});
|
||||
@@ -625,15 +628,17 @@ describe('Home page', () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project header (ProjectHeader + useShareLink) and the shared read-only view
|
||||
// Export stage (useShareLink) and the shared read-only view
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Project header and sharing', () => {
|
||||
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||
|
||||
test('Export → Markdown downloads a file', async () => {
|
||||
test('Export stage → Markdown downloads a file', async () => {
|
||||
const user = await loadChapter();
|
||||
await user.click(screen.getByRole('button', { name: /export/i }));
|
||||
await user.click(screen.getByRole('button', { name: /export markdown/i }));
|
||||
addChunk('Paul, a servant', 'Paul, a servant');
|
||||
await screen.findByText('1 created');
|
||||
await user.click(screen.getByRole('button', { name: /^export$/i }));
|
||||
await user.click(await screen.findByRole('button', { name: /export markdown/i }));
|
||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -647,8 +652,10 @@ describe('Project header and sharing', () => {
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const user = await loadChapter();
|
||||
await user.click(screen.getByRole('button', { name: /share/i }));
|
||||
await user.click(screen.getByRole('button', { name: /enable sharing/i }));
|
||||
addChunk('Paul, a servant', 'Paul, a servant');
|
||||
await screen.findByText('1 created');
|
||||
await user.click(screen.getByRole('button', { name: /^export$/i }));
|
||||
await user.click(await screen.findByRole('button', { name: /enable sharing/i }));
|
||||
expect(await screen.findByDisplayValue(/\?share=tok123$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -675,6 +682,7 @@ describe('Study editor', () => {
|
||||
test('study-page messages now show as a status toast', async () => {
|
||||
await goToStudyWith3Chunks();
|
||||
// Titus is a New Testament book, so the Hebrew picker explains why it can't help.
|
||||
fireEvent.click(screen.getByRole('tab', { name: /word study/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /suggest hebrew/i }));
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(/old testament/i);
|
||||
});
|
||||
@@ -689,6 +697,7 @@ describe('Study editor', () => {
|
||||
|
||||
test('a typed cross-reference is added, and the input clears on chunk change', async () => {
|
||||
await goToStudyWith3Chunks();
|
||||
fireEvent.click(screen.getByRole('tab', { name: /cross-refs/i }));
|
||||
const input = screen.getByPlaceholderText(/john 1:1 or rom 3:23/i);
|
||||
fireEvent.change(input, { target: { value: 'Rom 3:23' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
@@ -700,6 +709,7 @@ describe('Study editor', () => {
|
||||
|
||||
test('a tag can be added with Enter', async () => {
|
||||
await goToStudyWith3Chunks();
|
||||
fireEvent.click(screen.getByRole('button', { name: /^more/i }));
|
||||
const input = screen.getByPlaceholderText(/add a tag/i);
|
||||
fireEvent.change(input, { target: { value: 'grace' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
@@ -722,49 +732,152 @@ describe('Chunk builder', () => {
|
||||
|
||||
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');
|
||||
expect(await addSecondChapter(user)).toHaveAttribute('aria-current', 'true');
|
||||
});
|
||||
|
||||
test('"Back to Chunks" reopens the chapter of the chunk being studied', async () => {
|
||||
test('the Outline stage 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');
|
||||
await findChunkCounter(1, 1);
|
||||
await user.click(screen.getByRole('button', { name: /^outline$/i }));
|
||||
expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveAttribute('aria-current', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header redesign: Tools menu + one-dropdown-at-a-time
|
||||
// Stage bar (Outline / Study / Script / Export)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Header menus', () => {
|
||||
test('Tools is disabled until a chunk exists, then opens the Pronunciation Guide', async () => {
|
||||
const user = await loadChapter();
|
||||
expect(screen.getByRole('button', { name: /tools/i })).toBeDisabled();
|
||||
describe('Stage bar', () => {
|
||||
test('later stages are disabled until a chunk exists', async () => {
|
||||
await loadChapter();
|
||||
for (const name of [/^study$/i, /^script$/i, /^export$/i]) {
|
||||
expect(screen.getByRole('button', { name })).toBeDisabled();
|
||||
}
|
||||
expect(screen.getByRole('button', { name: /^outline$/i })).not.toBeDisabled();
|
||||
addChunk('Paul, a servant', 'Paul, a servant');
|
||||
await screen.findByText('1 created');
|
||||
await user.click(screen.getByRole('button', { name: /tools/i }));
|
||||
await user.click(screen.getByRole('button', { name: /pronunciation guide/i }));
|
||||
expect(await screen.findByRole('heading', { name: /pronunciation guide/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^study$/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test('opening one dropdown closes the other; Escape and outside clicks close it', async () => {
|
||||
test('Script stage opens the Pronunciation Guide, and back returns to Script', async () => {
|
||||
const user = await loadChapter();
|
||||
await user.click(screen.getByRole('button', { name: /export/i }));
|
||||
expect(screen.getByRole('button', { name: /export markdown/i })).toBeInTheDocument();
|
||||
addChunk('Paul, a servant', 'Paul, a servant');
|
||||
await screen.findByText('1 created');
|
||||
await user.click(screen.getByRole('button', { name: /^script$/i }));
|
||||
await user.click(await screen.findByRole('button', { name: /pronunciation guide/i }));
|
||||
expect(await screen.findByRole('heading', { name: /pronunciation guide/i })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /back to script/i }));
|
||||
expect(await screen.findByLabelText(/final script/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /share/i }));
|
||||
expect(screen.queryByRole('button', { name: /export markdown/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/read-only share link/i)).toBeInTheDocument();
|
||||
test('a script written in the Script stage is kept on the chunk', async () => {
|
||||
const user = await loadChapter();
|
||||
addChunk('Paul, a servant', 'Paul, a servant');
|
||||
await screen.findByText('1 created');
|
||||
await user.click(screen.getByRole('button', { name: /^script$/i }));
|
||||
const script = await screen.findByLabelText(/final script/i);
|
||||
fireEvent.change(script, { target: { value: 'Welcome to the episode.' } });
|
||||
await user.click(screen.getByRole('button', { name: /^study$/i }));
|
||||
await user.click(screen.getByRole('button', { name: /^script$/i }));
|
||||
expect(await screen.findByDisplayValue('Welcome to the episode.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByText(/read-only share link/i)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /export/i }));
|
||||
await user.click(screen.getByText(/scripture & chunks/i));
|
||||
expect(screen.queryByRole('button', { name: /export markdown/i })).not.toBeInTheDocument();
|
||||
// ---------------------------------------------------------------------------
|
||||
// Study workspace: outline editing, notes accordion, and the resource tabs
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Study workspace', () => {
|
||||
const okJson = (data) => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(data), headers: { get: () => 'application/json' } });
|
||||
// Wraps the default mock so individual tests can answer specific URLs.
|
||||
const withRoutes = (routes) => {
|
||||
const base = buildFetchMock();
|
||||
return vi.fn((url, opts) => {
|
||||
for (const [needle, data] of routes) if (String(url).includes(needle)) return okJson(data);
|
||||
return base(url, opts);
|
||||
});
|
||||
};
|
||||
async function studyOneChunk(routes = []) {
|
||||
if (routes.length) vi.stubGlobal('fetch', withRoutes(routes));
|
||||
const user = await loadChapter();
|
||||
addChunk('Paul, a servant', 'In hope of eternal');
|
||||
await screen.findByText('1 created');
|
||||
await user.click(screen.getByRole('button', { name: /begin studying/i }));
|
||||
await findChunkCounter(1, 1);
|
||||
return user;
|
||||
}
|
||||
|
||||
test('outline: clicking between verses ends a chunk; clicking again joins them', async () => {
|
||||
const user = await loadChapter();
|
||||
await user.click(screen.getByRole('button', { name: /end a chunk after verse 1/i }));
|
||||
await screen.findByText('1 created');
|
||||
await user.click(screen.getByRole('button', { name: /end a chunk after verse 2/i }));
|
||||
await screen.findByText('2 created');
|
||||
await user.click(screen.getByRole('button', { name: /join chunks at verse 1/i }));
|
||||
await screen.findByText('1 created');
|
||||
expect((await screen.findAllByText(/1[-–]2/)).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('outline: "Suggest from headings" fills the chapter, and never touches existing chunks', async () => {
|
||||
const user = await loadChapter();
|
||||
addChunk('Paul, a servant', 'Paul, a servant');
|
||||
await screen.findByText('1 created');
|
||||
await user.click(screen.getByRole('button', { name: /suggest from headings/i }));
|
||||
await screen.findByText('2 created');
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/added 1 chunk/i);
|
||||
});
|
||||
|
||||
test('notes: one section is open at a time, and text is kept when switching', async () => {
|
||||
const user = await studyOneChunk();
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'Paul calls himself a servant.' } });
|
||||
await user.click(screen.getByRole('button', { name: /interpret/i }));
|
||||
expect(screen.queryByRole('textbox', { name: 'Observe' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: 'Interpret' })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /observe/i }));
|
||||
expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('Paul calls himself a servant.');
|
||||
});
|
||||
|
||||
test('notes: the outline dots and word count reflect what you wrote', async () => {
|
||||
await studyOneChunk();
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'one two three' } });
|
||||
expect(screen.getByText('3 words')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('interlinear: clicking an original word adds it to the word study', async () => {
|
||||
const user = await studyOneChunk([['/interlinear/TIT.json', { 1: { 1: [{ o: 'Παῦλος', t: 'Paulos', g: 'Paul', s: 'G3972' }] } }]]);
|
||||
await user.click(await screen.findByRole('button', { name: '1' })); // verse number opens its original words
|
||||
await user.click(await screen.findByRole('button', { name: /Παῦλος/ }));
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(/added/i);
|
||||
expect(screen.getByRole('tab', { name: /word study · 1/i })).toBeInTheDocument();
|
||||
expect(within(screen.getByRole('tabpanel')).getByRole('button', { name: /Παῦλος/ })).toBeInTheDocument(); // the new card
|
||||
});
|
||||
|
||||
test('commentary: "Add to notes" quotes a paragraph with its citation', async () => {
|
||||
const user = await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]);
|
||||
await user.click(await screen.findByRole('button', { name: /add to notes/i }));
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(/background notes/i);
|
||||
await user.click(screen.getByRole('button', { name: /^more/i }));
|
||||
expect(screen.getByLabelText(/background/i)).toHaveValue('Paul was an apostle. (Matthew Henry, Titus 1:1)');
|
||||
});
|
||||
|
||||
test('people & places: lists what is tagged in the chunk', async () => {
|
||||
const user = await studyOneChunk([['/d/theographic/TIT/1.json', { chapter: { people: [{ id: 'paul_1', name: 'Paul', verses: [1] }, { id: 'x_2', name: 'Elsewhere', verses: [9] }], places: [], events: [] } }]]);
|
||||
await user.click(screen.getByRole('tab', { name: /people & places/i }));
|
||||
const panel = screen.getByRole('tabpanel');
|
||||
expect(await within(panel).findByRole('button', { name: /paul/i })).toBeInTheDocument();
|
||||
expect(within(panel).queryByText('Elsewhere')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('script: "Build outline from notes" starts the script from your notes', async () => {
|
||||
const user = await studyOneChunk();
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'Paul greets Titus.' } });
|
||||
await user.click(screen.getByRole('button', { name: /^script$/i }));
|
||||
await user.click(await screen.findByRole('button', { name: /build outline from notes/i }));
|
||||
const script = screen.getByLabelText(/final script/i);
|
||||
expect(script.value).toContain('WHAT THE TEXT SAYS\nPaul greets Titus.');
|
||||
expect(screen.getByText(/\d+ words · about/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -77,11 +77,11 @@ export default function AudioPlayer({
|
||||
const progressPct = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-panel sm:p-5">
|
||||
<div className="rounded-3xl border border-[var(--r-line,#e2e8f0)] bg-[var(--r-bar,#ffffff)] p-4 sm:p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-sm font-semibold text-slate-900 sm:text-base">{title}</h3>
|
||||
<p className="mt-0.5 truncate text-xs text-slate-500 sm:text-sm">{subtitle}</p>
|
||||
<div className="min-w-[10rem] flex-1">
|
||||
<h3 className="truncate text-sm font-semibold text-[var(--r-text,#0f172a)] sm:text-base">{title}</h3>
|
||||
<p className="mt-0.5 truncate text-xs text-[var(--r-sub,#64748b)] sm:text-sm">{subtitle}</p>
|
||||
{status === 'error' && errorMessage && (
|
||||
<p className="mt-1 text-xs text-rose-600">{errorMessage}</p>
|
||||
)}
|
||||
@@ -99,11 +99,11 @@ export default function AudioPlayer({
|
||||
value={Math.min(currentTime, duration || 0)}
|
||||
onChange={(e) => onSeekTo?.(Number(e.target.value))}
|
||||
disabled={!duration}
|
||||
className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-sky-600 disabled:cursor-default"
|
||||
style={{ background: `linear-gradient(to right, rgb(2 132 199) ${progressPct}%, rgb(226 232 240) ${progressPct}%)` }}
|
||||
className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-[var(--r-line,#e2e8f0)] accent-[var(--r-accent,#0284c7)] disabled:cursor-default"
|
||||
style={{ background: `linear-gradient(to right, var(--r-accent, rgb(2 132 199)) ${progressPct}%, var(--r-line, rgb(226 232 240)) ${progressPct}%)` }}
|
||||
aria-label="Seek"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs tabular-nums text-slate-400">
|
||||
<div className="mt-1 flex justify-between text-xs tabular-nums text-[var(--r-sub,#64748b)]">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
@@ -118,7 +118,7 @@ export default function AudioPlayer({
|
||||
onClick={onSeekBack}
|
||||
title="Rewind 10 seconds"
|
||||
aria-label="Rewind 10 seconds"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 hover:bg-slate-100"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
|
||||
>
|
||||
<Seek10Icon direction="back" />
|
||||
</button>
|
||||
@@ -126,7 +126,7 @@ export default function AudioPlayer({
|
||||
type="button"
|
||||
onClick={onTogglePause}
|
||||
aria-label={status === 'paused' ? 'Resume' : 'Pause'}
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full bg-sky-600 text-white shadow-sm hover:bg-sky-500"
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--r-accent,#0284c7)] text-[var(--r-on,#ffffff)] hover:opacity-90"
|
||||
>
|
||||
{status === 'paused' ? <PlayIcon /> : <PauseIcon />}
|
||||
</button>
|
||||
@@ -135,7 +135,7 @@ export default function AudioPlayer({
|
||||
onClick={onSeekForward}
|
||||
title="Fast-forward 10 seconds"
|
||||
aria-label="Fast-forward 10 seconds"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 hover:bg-slate-100"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
|
||||
>
|
||||
<Seek10Icon direction="forward" />
|
||||
</button>
|
||||
@@ -146,7 +146,7 @@ export default function AudioPlayer({
|
||||
disabled={nextDisabled}
|
||||
title="Next chapter"
|
||||
aria-label="Next chapter"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 hover:bg-slate-100 disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<NextTrackIcon />
|
||||
</button>
|
||||
@@ -156,7 +156,7 @@ export default function AudioPlayer({
|
||||
onClick={onStop}
|
||||
title="Stop"
|
||||
aria-label="Stop"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 hover:bg-slate-100"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
|
||||
>
|
||||
<StopIcon />
|
||||
</button>
|
||||
@@ -165,7 +165,7 @@ export default function AudioPlayer({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPlay}
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full bg-sky-600 text-white shadow-sm hover:bg-sky-500"
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--r-accent,#0284c7)] text-[var(--r-on,#ffffff)] hover:opacity-90"
|
||||
aria-label="Play"
|
||||
>
|
||||
<PlayIcon />
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
// Signed-in email plus Admin / Settings / Log out buttons for page headers.
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
|
||||
export default function AuthStatus() {
|
||||
// `themed` swaps the dark-header look for the reader themes' colours.
|
||||
export default function AuthStatus({ themed = false }) {
|
||||
const { authUser, setCurrentPage, logout } = useApp();
|
||||
if (!authUser) return null;
|
||||
const btn = themed
|
||||
? 'rounded-lg px-2.5 py-1.5 text-xs text-[var(--r-sub)] transition hover:bg-black/5'
|
||||
: 'rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15';
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<div className={`flex items-center gap-2 text-sm ${themed ? 'text-[var(--r-sub)]' : 'text-slate-300'}`}>
|
||||
<span className="hidden sm:inline">{authUser.email}</span>
|
||||
{authUser.isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage('admin')}
|
||||
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
|
||||
className={btn}
|
||||
>
|
||||
🛡 Admin
|
||||
</button>
|
||||
@@ -19,14 +23,14 @@ export default function AuthStatus() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage('settings')}
|
||||
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
|
||||
className={btn}
|
||||
>
|
||||
⚙ Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
|
||||
className={btn}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
// Header for an open project (Setup and Study pages): navigation, Claude
|
||||
// prompts, pronunciation guide, export menu, share link, and save/sync status.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { useShareLink } from '../hooks/useShareLink.js';
|
||||
import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js';
|
||||
import {
|
||||
exportProjectDocx,
|
||||
exportProjectHtml,
|
||||
exportProjectMarkdown,
|
||||
printProjectPdf,
|
||||
} from '../utils/projectExport.js';
|
||||
import AuthStatus from './AuthStatus.jsx';
|
||||
|
||||
// loading: true while the Setup page is fetching a chapter.
|
||||
export default function ProjectHeader({ loading = false }) {
|
||||
const {
|
||||
authUser,
|
||||
currentPage, setCurrentPage,
|
||||
goHome,
|
||||
project,
|
||||
allChunks,
|
||||
saveStatus, setSaveStatus,
|
||||
syncStatus,
|
||||
retrySync,
|
||||
} = useApp();
|
||||
// Only one dropdown open at a time: 'tools' | 'export' | 'share' | null
|
||||
const [openMenu, setOpenMenu] = useState(null);
|
||||
const toggleMenu = (name) => setOpenMenu((current) => (current === name ? null : name));
|
||||
const closeMenu = () => setOpenMenu(null);
|
||||
const menusRef = useRef(null);
|
||||
|
||||
// Close the open dropdown on an outside click or Escape.
|
||||
useEffect(() => {
|
||||
if (!openMenu) return;
|
||||
const onPointerDown = (e) => {
|
||||
if (!menusRef.current?.contains(e.target)) setOpenMenu(null);
|
||||
};
|
||||
const onKeyDown = (e) => { if (e.key === 'Escape') setOpenMenu(null); };
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [openMenu]);
|
||||
const share = useShareLink(project?.id);
|
||||
|
||||
// Copies a prompt, then opens claude.ai so it can be pasted straight in.
|
||||
const copyPrompt = (prompt) => {
|
||||
navigator.clipboard.writeText(prompt).then(() => {
|
||||
setSaveStatus('Copied! Opening Claude…');
|
||||
window.setTimeout(() => setSaveStatus(''), 3000);
|
||||
window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{currentPage !== 'home' && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{currentPage !== 'home' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goHome}
|
||||
className="rounded-xl border border-white/15 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/15"
|
||||
>
|
||||
← Home
|
||||
</button>
|
||||
)}
|
||||
{project && currentPage === 'study' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage('setup')}
|
||||
className="rounded-xl border border-white/15 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/15"
|
||||
>
|
||||
← Back to Chunks
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{project && (
|
||||
<div ref={menusRef} className="flex flex-wrap items-center gap-3 border-white/10 sm:border-l sm:pl-3">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleMenu('tools')}
|
||||
aria-expanded={openMenu === 'tools'}
|
||||
disabled={allChunks.length === 0}
|
||||
title={allChunks.length === 0 ? 'Add a chunk first to use these tools' : undefined}
|
||||
className="flex items-center gap-1.5 rounded-md bg-violet-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-violet-400 disabled:cursor-not-allowed disabled:bg-slate-500 disabled:opacity-60 disabled:shadow-none"
|
||||
>
|
||||
🧰 Tools
|
||||
</button>
|
||||
{openMenu === 'tools' && allChunks.length > 0 && (
|
||||
<div className="absolute left-0 top-full z-20 mt-2 w-64 overflow-hidden rounded-2xl border border-slate-200 bg-white py-1.5 text-left shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { copyPrompt(buildClaudePrompt(project)); closeMenu(); }}
|
||||
title="Copies your study notes as a Claude prompt, then opens claude.ai so you can paste and go"
|
||||
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Study Guide → Claude ↗
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { copyPrompt(buildPodcastPrompt(project, authUser?.podcastName)); closeMenu(); }}
|
||||
title="Copies a podcast script prompt for Claude, then opens claude.ai — set your show name in Account Settings first"
|
||||
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
🎙 Podcast → Claude ↗
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCurrentPage('pronunciation'); closeMenu(); }}
|
||||
title="Open the pronunciation guide — all Greek & Hebrew words with transliterations"
|
||||
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
🗣 Pronunciation Guide
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleMenu('export')}
|
||||
aria-expanded={openMenu === 'export'}
|
||||
className="flex items-center gap-1.5 rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400"
|
||||
>
|
||||
⬇ Export
|
||||
</button>
|
||||
{openMenu === 'export' && (
|
||||
<div className="absolute left-0 top-full z-20 mt-2 w-56 overflow-hidden rounded-2xl border border-slate-200 bg-white py-1.5 text-left shadow-lg">
|
||||
<button type="button" onClick={() => { exportProjectHtml(project); closeMenu(); }}
|
||||
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
|
||||
Export HTML
|
||||
</button>
|
||||
<button type="button" onClick={() => { exportProjectDocx(project); closeMenu(); }}
|
||||
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
|
||||
Export DOCX
|
||||
</button>
|
||||
<button type="button" onClick={() => { exportProjectMarkdown(project); closeMenu(); }}
|
||||
title="Handy for Obsidian, Notion, or any Markdown-based notes app"
|
||||
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
|
||||
Export Markdown
|
||||
</button>
|
||||
<button type="button" onClick={() => { printProjectPdf(project); closeMenu(); }}
|
||||
title="Opens a print-friendly version in a new tab — choose 'Save as PDF' in the print dialog"
|
||||
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
|
||||
🖨 Print / Save PDF
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleMenu('share')}
|
||||
aria-expanded={openMenu === 'share'}
|
||||
className={`rounded-md px-4 py-2 text-sm font-medium shadow-sm transition ${share.shareToken ? 'bg-teal-600 text-white hover:bg-teal-500' : 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50'}`}
|
||||
>
|
||||
🔗 {share.shareToken ? 'Shared' : 'Share'}
|
||||
</button>
|
||||
{openMenu === 'share' && (
|
||||
<div className="absolute right-0 top-full z-20 mt-2 w-80 rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-lg">
|
||||
<h3 className="text-sm font-semibold text-slate-900">Read-only share link</h3>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Anyone with this link can view (not edit) this study — no account needed. Good for a co-teacher
|
||||
or a listener who wants to follow along.
|
||||
</p>
|
||||
{share.shareToken ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
readOnly
|
||||
value={share.shareUrl}
|
||||
onFocus={(e) => e.target.select()}
|
||||
className="w-full flex-1 truncate rounded-lg border border-slate-300 bg-slate-50 px-2 py-1.5 text-xs text-slate-600"
|
||||
/>
|
||||
<button type="button" onClick={share.copy}
|
||||
className="shrink-0 rounded-lg bg-sky-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-sky-400">
|
||||
{share.shareCopied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={share.revoke} disabled={share.shareBusy}
|
||||
className="w-full rounded-lg border border-rose-200 px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
{share.shareBusy ? 'Revoking…' : 'Revoke link'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={share.enable} disabled={share.shareBusy}
|
||||
className="mt-3 w-full rounded-lg bg-teal-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-teal-500 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
{share.shareBusy ? 'Enabling…' : 'Enable sharing'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-right text-sm text-slate-300 space-y-0.5">
|
||||
{loading ? (
|
||||
<span>Loading…</span>
|
||||
) : saveStatus ? (
|
||||
<span className="text-emerald-300">{saveStatus}</span>
|
||||
) : (
|
||||
<span> </span>
|
||||
)}
|
||||
{syncStatus === 'syncing' && <div className="text-xs text-slate-400">Syncing…</div>}
|
||||
{syncStatus === 'synced' && <div className="text-xs text-emerald-400">Synced ✓</div>}
|
||||
{syncStatus === 'error' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-rose-400">⚠ Sync failed</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={retrySync}
|
||||
className="text-xs text-slate-300 underline hover:text-white"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AuthStatus />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,7 @@ function PersonDetails({ id }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PersonCard({ person }) {
|
||||
export function PersonCard({ person }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<li className="rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2">
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Commentary for the chunk's verse range, from seven open sources. Some sources
|
||||
// comment on whole passages, so when nothing starts inside the range we show the
|
||||
// closest earlier entry. "Add to notes" quotes a paragraph, with its citation, into
|
||||
// the chunk's background notes.
|
||||
import { COMMENTARIES } from '../../hooks/useVerseStudy.js';
|
||||
import { formatChunkReference } from '../../utils/bibleUtils.jsx';
|
||||
|
||||
export default function CommentaryChunkTab({ resources, chunk, project, chapterIndex, updateChunk, notify }) {
|
||||
const { commentarySource, setCommentarySource, commentaryData, commentaryLoading, commentaryError } = resources;
|
||||
const source = COMMENTARIES.find((c) => c.id === commentarySource) ?? COMMENTARIES[0];
|
||||
const items = (commentaryData?.chapter?.content ?? []).filter((i) => i.type === 'verse').sort((a, b) => a.number - b.number);
|
||||
let entries = items.filter((i) => i.number >= chunk.startVerse && i.number <= chunk.endVerse);
|
||||
let earlier = false;
|
||||
if (entries.length === 0) {
|
||||
const before = [...items].reverse().find((i) => i.number <= chunk.startVerse);
|
||||
if (before) { entries = [before]; earlier = true; }
|
||||
}
|
||||
const ref = formatChunkReference(project, chapterIndex, chunk, '–');
|
||||
|
||||
const quote = (text, verse) => {
|
||||
const citation = `${text} (${source.name}, ${ref.split(':')[0]}:${verse})`;
|
||||
const existing = chunk.generalNotes ?? '';
|
||||
updateChunk(chunk.id, { generalNotes: existing ? `${existing}\n\n${citation}` : citation });
|
||||
notify('Added to background notes.', 1600);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<select aria-label="Commentary source" value={source.id} onChange={(e) => setCommentarySource(e.target.value)}
|
||||
className="mb-3 w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]">
|
||||
{COMMENTARIES.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
{commentaryLoading && <p className="text-sm text-[var(--r-sub)]">Loading commentary…</p>}
|
||||
{commentaryError && <p className="text-sm text-[var(--r-sub)]">{commentaryError}</p>}
|
||||
{!commentaryLoading && !commentaryError && commentaryData && entries.length === 0 && (
|
||||
<p className="text-sm text-[var(--r-sub)]">No commentary found for {ref}.</p>
|
||||
)}
|
||||
{!commentaryLoading && !commentaryError && entries.map((v) => (
|
||||
<div key={v.number} className="mb-4">
|
||||
<p className="mb-1 text-xs font-medium text-[var(--r-sub)]">{earlier ? `Notes on the passage starting at verse ${v.number}` : `Verse ${v.number}`}</p>
|
||||
{(v.content ?? []).map((p, i) => {
|
||||
const text = typeof p === 'string' ? p : '';
|
||||
if (!text) return null;
|
||||
return (
|
||||
<div key={i} className="group mb-2">
|
||||
<p className="whitespace-pre-line text-[13px] leading-relaxed">{text}</p>
|
||||
<button type="button" onClick={() => quote(text, v.number)} className="mt-0.5 text-[11px] text-[var(--r-accent)] opacity-70 hover:underline hover:opacity-100">Add to notes</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
<p className="mt-2 text-[10px] text-[var(--r-sub)]">{source.credit}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Cross-references for the chunk: typed, suggested from the Open Cross Ref
|
||||
// dataset, and a preview of each reference's verse text.
|
||||
import { useState } from 'react';
|
||||
|
||||
function RefChip({ label, onRemove, loadVerseText }) {
|
||||
const [text, setText] = useState(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const toggle = () => {
|
||||
setOpen((o) => !o);
|
||||
if (text == null) loadVerseText(label).then((t) => setText(t || 'Verse text unavailable.')).catch(() => setText('Verse text unavailable.'));
|
||||
};
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--r-line)]">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-1.5">
|
||||
<button type="button" onClick={toggle} aria-expanded={open} className="flex-1 text-left text-sm">{label}</button>
|
||||
<button type="button" onClick={onRemove} aria-label={`Remove ${label}`} className="text-[var(--r-sub)] hover:text-red-600">×</button>
|
||||
</div>
|
||||
{open && <p className="border-t border-[var(--r-line)] px-3 py-2 font-serif text-[13px] leading-relaxed text-[var(--r-sub)]">{text ?? 'Loading…'}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CrossRefsTab({ crossRefs, chunk }) {
|
||||
const { crossRefInput, setCrossRefInput, suggestingCrossRefs, crossRefSuggestions, addCrossRef, removeCrossRef,
|
||||
suggestCrossRefsForChunk, addSuggestedCrossRef, loadVerseText } = crossRefs;
|
||||
const refs = chunk.crossReferences ?? [];
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<input value={crossRefInput} onChange={(e) => setCrossRefInput(e.target.value)} placeholder="e.g. John 1:1 or Rom 3:23"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCrossRef(chunk.id); } }}
|
||||
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
||||
<button type="button" onClick={() => addCrossRef(chunk.id)} className="rounded-lg bg-[var(--r-text)] px-3 py-1.5 text-sm text-[var(--r-bg)]">Add</button>
|
||||
</div>
|
||||
<button type="button" onClick={() => suggestCrossRefsForChunk(chunk.id)} disabled={suggestingCrossRefs}
|
||||
className="mt-2 rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5 disabled:opacity-50">
|
||||
{suggestingCrossRefs ? 'Suggesting…' : 'Suggest from passage'}
|
||||
</button>
|
||||
{crossRefSuggestions.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{crossRefSuggestions.map((ref) => (
|
||||
<button key={ref} type="button" onClick={() => addSuggestedCrossRef(chunk.id, ref)}
|
||||
className="rounded-full border border-[var(--r-accent)] px-2.5 py-0.5 text-xs text-[var(--r-accent)] hover:bg-black/5">+ {ref}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 space-y-1.5">
|
||||
{refs.map((ref) => <RefChip key={ref} label={ref} onRemove={() => removeCrossRef(chunk.id, ref)} loadVerseText={loadVerseText} />)}
|
||||
{refs.length === 0 && <p className="text-sm text-[var(--r-sub)]">No cross-references yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Observe / Interpret / Apply as an accordion (one open at a time), plus a "More"
|
||||
// area for background notes and tags so the page stays quiet.
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const SECTIONS = [
|
||||
{ field: 'observation', label: 'Observe', hint: 'What does the text say?',
|
||||
placeholder: "What does the text actually say?\n• Who is speaking, and to whom?\n• What key words or phrases repeat?\n• What's the tone, structure, or literary style?" },
|
||||
{ field: 'interpretation', label: 'Interpret', hint: 'What does it mean?',
|
||||
placeholder: "What did this mean to its original audience?\n• What's the historical/cultural context?\n• How does it fit the surrounding argument?\n• What does it reveal about God's character?" },
|
||||
{ field: 'application', label: 'Apply', hint: 'What do I do with it?',
|
||||
placeholder: 'How should this shape your life today?\n• What attitude or action does this call for?\n• Is there a promise to trust or a warning to heed?\n• Who could you share this with?' },
|
||||
];
|
||||
|
||||
const areaCls = 'w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3.5 py-3 font-serif text-[15px] leading-7 text-[var(--r-text)] placeholder:font-sans placeholder:text-[13px] placeholder:leading-5 placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||
const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
|
||||
|
||||
export default function NotesPane({ chunk, updateChunk, allTags }) {
|
||||
const [open, setOpen] = useState(() => {
|
||||
try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; }
|
||||
});
|
||||
const [more, setMore] = useState(false);
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
useEffect(() => { setTagInput(''); }, [chunk.id]);
|
||||
useEffect(() => { try { localStorage.setItem('studyNotesOpen', open || ''); } catch { /* storage unavailable */ } }, [open]);
|
||||
|
||||
const tags = chunk.tags ?? [];
|
||||
const addTag = (tag) => {
|
||||
const t = tag.trim();
|
||||
if (!t || tags.some((x) => x.toLowerCase() === t.toLowerCase())) { setTagInput(''); return; }
|
||||
updateChunk(chunk.id, { tags: [...tags, t] });
|
||||
setTagInput('');
|
||||
};
|
||||
const suggestions = allTags.filter((t) => !tags.some((x) => x.toLowerCase() === t.toLowerCase()));
|
||||
|
||||
return (
|
||||
<section aria-label="Study notes" className="space-y-2">
|
||||
{SECTIONS.map(({ field, label, hint, placeholder }) => {
|
||||
const isOpen = open === field;
|
||||
const n = words(chunk[field]);
|
||||
return (
|
||||
<div key={field} className={`overflow-hidden rounded-2xl border ${isOpen ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)]'}`}>
|
||||
<button type="button" onClick={() => setOpen(isOpen ? null : field)} aria-expanded={isOpen}
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left">
|
||||
<span className="flex items-center gap-2.5">
|
||||
<span aria-hidden="true" className={`h-2.5 w-2.5 rounded-full border ${n ? 'border-[var(--r-accent)] bg-[var(--r-accent)]' : 'border-[var(--r-sub)]'}`} />
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<span className="text-xs text-[var(--r-sub)]">{hint}</span>
|
||||
</span>
|
||||
<span className="text-xs text-[var(--r-sub)]">{n ? `${n} word${n === 1 ? '' : 's'}` : ''}</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="px-4 pb-4">
|
||||
<textarea aria-label={label} value={chunk[field] ?? ''} rows={8} placeholder={placeholder}
|
||||
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="rounded-2xl border border-[var(--r-line)]">
|
||||
<button type="button" onClick={() => setMore((m) => !m)} aria-expanded={more}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm">
|
||||
<span><span className="font-medium">More</span> <span className="text-xs text-[var(--r-sub)]">background{tags.length ? ` · ${tags.length} tag${tags.length === 1 ? '' : 's'}` : ' and tags'}</span></span>
|
||||
<span className="text-xs text-[var(--r-sub)]">{more ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
{more && (
|
||||
<div className="space-y-4 px-4 pb-4">
|
||||
<label className="block text-xs text-[var(--r-sub)]">
|
||||
Background / general notes
|
||||
<textarea value={chunk.generalNotes ?? ''} rows={5} onChange={(e) => updateChunk(chunk.id, { generalNotes: e.target.value })}
|
||||
placeholder="e.g. author, date, audience, historical context, key themes…" className={`mt-1 ${areaCls}`} />
|
||||
</label>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs text-[var(--r-sub)]">Tags</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="inline-flex items-center gap-1 rounded-full bg-[var(--r-bar)] px-2.5 py-1 text-xs">
|
||||
{tag}
|
||||
<button type="button" aria-label={`Remove tag ${tag}`} onClick={() => updateChunk(chunk.id, { tags: tags.filter((t) => t !== tag) })} className="text-[var(--r-sub)] hover:text-red-600">×</button>
|
||||
</span>
|
||||
))}
|
||||
<input value={tagInput} onChange={(e) => setTagInput(e.target.value)} list="study-tag-suggestions"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addTag(tagInput); } }}
|
||||
placeholder="Add a tag and press Enter"
|
||||
className="min-w-[10rem] flex-1 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1 text-xs text-[var(--r-text)] focus:outline-none" />
|
||||
</div>
|
||||
{suggestions.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span className="text-[var(--r-sub)]">Suggestions:</span>
|
||||
{suggestions.slice(0, 12).map((t) => (
|
||||
<button key={t} type="button" onClick={() => addTag(t)} className="rounded-full border border-[var(--r-line)] px-2.5 py-0.5 text-[var(--r-sub)] hover:bg-black/5">+ {t}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<datalist id="study-tag-suggestions">{allTags.map((t) => <option key={t} value={t} />)}</datalist>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Left rail of the Study stage: every chunk of the project as an outline, with
|
||||
// three dots (Observe / Interpret / Apply) showing which notes are written.
|
||||
import { formatChunkReference } from '../../utils/bibleUtils.jsx';
|
||||
|
||||
const filled = (s) => (s ?? '').trim().length > 0;
|
||||
|
||||
export default function OutlineRail({ project, selectedId, onSelect, onEditOutline }) {
|
||||
return (
|
||||
<nav aria-label="Chunks" className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex items-center justify-between px-3 pb-2 pt-3">
|
||||
<span className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Outline</span>
|
||||
<button type="button" onClick={onEditOutline} className="text-[11px] text-[var(--r-accent)] hover:underline">Edit</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-4">
|
||||
{project.chapters.map((ch, chapterIndex) => (
|
||||
<div key={`${ch.bookAbbrev}-${ch.chapter}`} className="mb-3">
|
||||
{project.chapters.length > 1 && <p className="px-2 pb-1 text-[11px] text-[var(--r-sub)]">{ch.book} {ch.chapter}</p>}
|
||||
{ch.chunks.length === 0 && <p className="px-2 text-xs text-[var(--r-sub)]">No chunks in this chapter.</p>}
|
||||
{ch.chunks.map((chunk) => {
|
||||
const selected = chunk.id === selectedId;
|
||||
const first = ch.verses.find((v) => v.number === chunk.startVerse)?.text ?? '';
|
||||
return (
|
||||
<button key={chunk.id} type="button" onClick={() => onSelect(chunk.id)} aria-current={selected ? 'true' : undefined}
|
||||
className={`mb-1 block w-full rounded-xl border px-2.5 py-2 text-left transition ${selected ? 'border-[var(--r-line)] bg-[var(--r-bg)]' : 'border-transparent hover:bg-black/5'}`}>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="text-[13px] font-medium">{formatChunkReference(project, chapterIndex, chunk, '–')}</span>
|
||||
<span className="flex gap-1" aria-hidden="true">
|
||||
{['observation', 'interpretation', 'application'].map((f) => (
|
||||
<span key={f} className={`h-2 w-2 rounded-full border ${filled(chunk[f]) ? 'border-[var(--r-accent)] bg-[var(--r-accent)]' : 'border-[var(--r-sub)]'}`} />
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
{chunk.episodeTitle && <span className="mt-0.5 block truncate text-[11px] text-[var(--r-accent)]">{chunk.episodeNumber ? `${chunk.episodeNumber} · ` : ''}{chunk.episodeTitle}</span>}
|
||||
<span className="mt-0.5 block truncate font-serif text-xs text-[var(--r-sub)]">{first}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Themed page frame for pages that aren't part of a study project (Home, Import):
|
||||
// slim top bar with a title, optional back button, and actions on the right.
|
||||
import { useApp } from '../../context/AppContext.js';
|
||||
import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
|
||||
import { ChevronLeft } from '../reader/icons.jsx';
|
||||
import AuthStatus from '../AuthStatus.jsx';
|
||||
|
||||
const THEME_ORDER = ['paper', 'sepia', 'dark'];
|
||||
|
||||
export default function PageShell({ title, onBack, actions, maxWidth = 'max-w-6xl', children }) {
|
||||
const { readerTheme, setReaderTheme } = useApp();
|
||||
const cycle = () => setReaderTheme(THEME_ORDER[(THEME_ORDER.indexOf(readerTheme) + 1) % THEME_ORDER.length]);
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
|
||||
<div className="sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
|
||||
<div className={`mx-auto flex min-h-[3rem] ${maxWidth} flex-wrap items-center gap-2 px-3 py-1.5`}>
|
||||
{onBack && (
|
||||
<button type="button" onClick={onBack} aria-label="Back" className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /></button>
|
||||
)}
|
||||
<h1 className="text-base font-medium">{title}</h1>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-1.5">
|
||||
{actions}
|
||||
<button type="button" onClick={cycle} className="rounded-lg px-2 py-1.5 text-xs text-[var(--r-sub)] hover:bg-black/5" title="Change theme">{READER_THEMES[readerTheme]?.label}</button>
|
||||
<AuthStatus themed />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// People, places and events (Theographic data) tied to the chunk's verses.
|
||||
import { loadTheographic, useAsync } from '../../hooks/useVerseStudy.js';
|
||||
import { PersonCard } from '../StudyPanel.jsx';
|
||||
|
||||
export default function PeopleChunkTab({ book, chapter, chunk }) {
|
||||
const { status, data } = useAsync(() => loadTheographic(book, chapter), [book, chapter]);
|
||||
if (status === 'loading') return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
|
||||
if (status === 'empty') return <p className="text-sm text-[var(--r-sub)]">No people or places data for this chapter.</p>;
|
||||
const inChunk = (x) => x.verses?.some((n) => n >= chunk.startVerse && n <= chunk.endVerse);
|
||||
const people = data.people.filter(inChunk);
|
||||
const places = [...new Map(data.places.filter(inChunk).map((p) => [p.name, p])).values()];
|
||||
const events = data.events.filter(inChunk);
|
||||
if (!people.length && !places.length && !events.length) return <p className="text-sm text-[var(--r-sub)]">Nothing tagged in these verses.</p>;
|
||||
const head = 'mb-1 text-[11px] uppercase tracking-wide text-[var(--r-sub)]';
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{people.length > 0 && <div><p className={head}>People</p><ul className="space-y-1">{people.map((p) => <PersonCard key={p.id} person={p} />)}</ul></div>}
|
||||
{places.length > 0 && (
|
||||
<div><p className={head}>Places</p>
|
||||
<ul className="space-y-1">{places.map((p) => (
|
||||
<li key={p.id} className="flex items-center justify-between rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm">
|
||||
<span>{p.name}{p.featureType && <span className="ml-1 text-xs text-[var(--r-sub)]">{p.featureType}</span>}</span>
|
||||
{p.latitude != null && <a href={`https://www.openstreetmap.org/?mlat=${p.latitude}&mlon=${p.longitude}#map=8/${p.latitude}/${p.longitude}`} target="_blank" rel="noopener noreferrer" className="text-xs text-[var(--r-accent)] hover:underline">Map ↗</a>}
|
||||
</li>))}</ul></div>
|
||||
)}
|
||||
{events.length > 0 && <div><p className={head}>Events</p><ul className="space-y-1">{events.map((e) => <li key={e.id} className="rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm">{e.name}{e.startDate && <span className="ml-1 text-xs text-[var(--r-sub)]">approx. AD {e.startDate}</span>}</li>)}</ul></div>}
|
||||
<p className="text-[10px] text-[var(--r-sub)]">People, places and events: Theographic Bible Metadata, CC BY-SA 4.0.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Right-hand pane of the Study stage: commentary, word study, cross-references and
|
||||
// people & places, all driven by the selected chunk.
|
||||
import { useState } from 'react';
|
||||
import CommentaryChunkTab from './CommentaryChunkTab.jsx';
|
||||
import WordStudyTab from './WordStudyTab.jsx';
|
||||
import CrossRefsTab from './CrossRefsTab.jsx';
|
||||
import PeopleChunkTab from './PeopleChunkTab.jsx';
|
||||
|
||||
export const RESOURCE_TABS = [
|
||||
{ id: 'commentary', label: 'Commentary' },
|
||||
{ id: 'words', label: 'Word study' },
|
||||
{ id: 'refs', label: 'Cross-refs' },
|
||||
{ id: 'people', label: 'People & places' },
|
||||
];
|
||||
|
||||
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) {
|
||||
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length };
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div role="tablist" aria-label="Study resources" className="flex shrink-0 flex-wrap gap-1.5 border-b border-[var(--r-line)] px-3 py-2.5">
|
||||
{RESOURCE_TABS.map((t) => (
|
||||
<button key={t.id} type="button" role="tab" aria-selected={tab === t.id} onClick={() => setTab(t.id)}
|
||||
className={`rounded-full border px-3 py-1 text-xs ${tab === t.id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||
{t.label}{counts[t.id] ? ` · ${counts[t.id]}` : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
{tab === 'commentary' && <CommentaryChunkTab resources={resources} chunk={chunk} project={project} chapterIndex={chapterIndex} updateChunk={updateChunk} notify={notify} />}
|
||||
{tab === 'words' && <WordStudyTab wordStudy={wordStudy} chunk={chunk} openWordId={openWordId} setOpenWordId={setOpenWordId} />}
|
||||
{tab === 'refs' && <CrossRefsTab crossRefs={crossRefs} chunk={chunk} />}
|
||||
{tab === 'people' && <PeopleChunkTab book={chapter.bookAbbrev} chapter={chapter.chapter} chunk={chunk} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useResourceTab() {
|
||||
const [tab, setTabState] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('studyResourceTab');
|
||||
return RESOURCE_TABS.some((t) => t.id === saved) ? saved : 'commentary';
|
||||
} catch { return 'commentary'; }
|
||||
});
|
||||
const setTab = (id) => {
|
||||
setTabState(id);
|
||||
try { localStorage.setItem('studyResourceTab', id); } catch { /* storage unavailable */ }
|
||||
};
|
||||
return [tab, setTab];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// The chunk's passage, set like the Reader. Click a verse number to open its
|
||||
// interlinear words; click an original word to add it to the chunk's word study.
|
||||
import { useState } from 'react';
|
||||
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
|
||||
|
||||
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord }) {
|
||||
const [openVerse, setOpenVerse] = useState(null);
|
||||
const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev];
|
||||
return (
|
||||
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4">
|
||||
<div className="font-serif text-[1.05rem] leading-[1.85]">
|
||||
{verses.map((v) => {
|
||||
const words = v.chapter === chapter?.chapter ? interlinear?.[String(v.number)] : null;
|
||||
const open = openVerse === `${v.chapter}:${v.number}`;
|
||||
return (
|
||||
<div key={`${v.chapter}-${v.number}`} className="mb-1.5">
|
||||
<button type="button" disabled={!words} onClick={() => setOpenVerse(open ? null : `${v.chapter}:${v.number}`)}
|
||||
title={words ? 'Show original words' : undefined}
|
||||
className={`mr-1.5 align-super font-sans text-[0.6em] ${words ? 'text-[var(--r-accent)] hover:underline' : 'cursor-default text-[var(--r-sub)]'} ${open ? 'font-bold' : ''}`}>
|
||||
{v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number}
|
||||
</button>
|
||||
<span>{v.text}</span>
|
||||
{open && words && (
|
||||
<div dir={isHebrew ? 'rtl' : 'ltr'} className="mt-2 flex flex-wrap gap-2 rounded-xl bg-[var(--r-bg)] p-2.5 font-sans">
|
||||
{words.map((w, i) => {
|
||||
const added = w.s && addedStrongs.has(w.s.toUpperCase());
|
||||
return (
|
||||
<button key={i} type="button" disabled={!w.s} onClick={() => onPickWord(w)}
|
||||
title={[w.t, w.s, w.p].filter(Boolean).join(' · ') + (w.s ? (added ? ' — already in word study' : ' — click to add') : '')}
|
||||
className={`flex flex-col items-center rounded-lg border px-2 py-1 text-center ${added ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)] hover:bg-black/5'} disabled:cursor-default`}>
|
||||
<span className={`text-base ${isHebrew ? 'font-serif' : ''}`}>{w.o}</span>
|
||||
<span className="text-[11px] leading-tight text-[var(--r-sub)]">{w.g || '—'}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{interlinearLoading && <p className="mt-2 font-sans text-xs text-[var(--r-sub)]">Loading original words…</p>}
|
||||
{!interlinearLoading && interlinear && <p className="mt-2 font-sans text-[11px] text-[var(--r-sub)]">Click a verse number to see its original words; click a word to study it.</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Shared frame for every stage of a study project (Outline, Study, Script,
|
||||
// Export): themed like the Reader, with a slim bar that carries the project title,
|
||||
// the stage switcher, and save/sync status.
|
||||
import { useApp } from '../../context/AppContext.js';
|
||||
import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
|
||||
import { ChevronLeft } from '../reader/icons.jsx';
|
||||
|
||||
export const STAGES = [
|
||||
{ id: 'setup', label: 'Outline' },
|
||||
{ id: 'study', label: 'Study' },
|
||||
{ id: 'script', label: 'Script' },
|
||||
{ id: 'export', label: 'Export' },
|
||||
];
|
||||
const THEME_ORDER = ['paper', 'sepia', 'dark'];
|
||||
|
||||
export default function StudioShell({ stage, loading = false, fixedHeight = false, children }) {
|
||||
const {
|
||||
goHome, setCurrentPage, project, allChunks,
|
||||
saveStatus, syncStatus, retrySync,
|
||||
readerTheme, setReaderTheme,
|
||||
} = useApp();
|
||||
const noChunks = allChunks.length === 0;
|
||||
const cycleTheme = () => setReaderTheme(THEME_ORDER[(THEME_ORDER.indexOf(readerTheme) + 1) % THEME_ORDER.length]);
|
||||
|
||||
return (
|
||||
<div className={`bg-[var(--r-bg)] font-sans text-[var(--r-text)] ${fixedHeight ? 'flex h-screen flex-col overflow-hidden' : 'min-h-screen'}`} style={themeVars(readerTheme)}>
|
||||
<div className="z-30 shrink-0 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
|
||||
<div className="mx-auto flex h-12 max-w-[1600px] items-center gap-3 px-3">
|
||||
<button type="button" onClick={goHome} aria-label="Back to studies"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /></button>
|
||||
<div className="min-w-0 max-w-[16rem] truncate text-sm font-medium">{project?.title ?? ''}</div>
|
||||
<nav aria-label="Study stages" className="mx-auto flex gap-0.5">
|
||||
{STAGES.map((s) => {
|
||||
const disabled = s.id !== 'setup' && (!project || noChunks);
|
||||
return (
|
||||
<button key={s.id} type="button" disabled={disabled} aria-current={stage === s.id ? 'step' : undefined}
|
||||
title={disabled ? 'Add a chunk in Outline first' : undefined}
|
||||
onClick={() => setCurrentPage(s.id)}
|
||||
className={`rounded-full px-3.5 py-1.5 text-xs ${stage === s.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'} disabled:opacity-40 disabled:hover:bg-transparent`}>
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="flex min-w-[7rem] items-center justify-end gap-2 text-xs text-[var(--r-sub)]" aria-live="polite">
|
||||
{loading ? <span>Loading…</span>
|
||||
: saveStatus ? <span className="text-[var(--r-accent)]">{saveStatus}</span>
|
||||
: syncStatus === 'syncing' ? <span>Syncing…</span>
|
||||
: syncStatus === 'synced' ? <span>Saved</span>
|
||||
: syncStatus === 'error' ? (
|
||||
<span className="text-red-600">Sync failed <button type="button" onClick={retrySync} className="underline">Retry</button></span>
|
||||
) : null}
|
||||
<button type="button" onClick={cycleTheme} className="rounded-lg px-2 py-1 hover:bg-black/5" title="Change theme">{READER_THEMES[readerTheme]?.label}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Picker for "suggest words from this passage": checkbox list of candidate words.
|
||||
import DOMPurify from 'dompurify';
|
||||
import Sheet from '../reader/Sheet.jsx';
|
||||
import { buildGreekDefinitionHtml } from '../../utils/wordStudy.js';
|
||||
|
||||
export default function SuggestWordsSheet({ wordStudy }) {
|
||||
const { suggestModal, setSuggestModal, suggestSelection, setSuggestSelection, confirmSuggestWords } = wordStudy;
|
||||
if (!suggestModal) return null;
|
||||
const close = () => { setSuggestModal(null); setSuggestSelection(new Set()); };
|
||||
const n = suggestSelection.size;
|
||||
return (
|
||||
<Sheet open onClose={close} title={`Select ${suggestModal.language === 'hebrew' ? 'Hebrew' : 'Greek'} words to add`}>
|
||||
<p className="mb-2 text-xs text-[var(--r-sub)]">{suggestModal.helperText || "Content words are pre-checked. Uncheck any you don't need."}</p>
|
||||
<div className="mb-2 flex items-center gap-3 text-xs">
|
||||
<button type="button" onClick={() => setSuggestSelection(new Set(suggestModal.words.map((w) => w.strongKey)))} className="text-[var(--r-accent)] hover:underline">Select all</button>
|
||||
<button type="button" onClick={() => setSuggestSelection(new Set())} className="text-[var(--r-accent)] hover:underline">Select none</button>
|
||||
<span className="ml-auto text-[var(--r-sub)]">{n} selected</span>
|
||||
</div>
|
||||
<ul className="max-h-[40vh] divide-y divide-[var(--r-line)] overflow-y-auto rounded-xl border border-[var(--r-line)] px-3">
|
||||
{suggestModal.words.map((w) => (
|
||||
<li key={w.strongKey}>
|
||||
<label className="flex cursor-pointer items-center gap-3 py-2.5">
|
||||
<input type="checkbox" checked={suggestSelection.has(w.strongKey)} className="h-4 w-4 shrink-0 accent-[var(--r-accent)]"
|
||||
onChange={(e) => setSuggestSelection((prev) => { const next = new Set(prev); if (e.target.checked) next.add(w.strongKey); else next.delete(w.strongKey); return next; })} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<span className="text-sm font-medium">{w.def || w.strongKey}</span>
|
||||
<span className="text-sm text-[var(--r-sub)]">{w.lexeme}</span>
|
||||
{w.translit && <span className="text-xs italic text-[var(--r-sub)]">{w.translit}</span>}
|
||||
<span className="font-mono text-[10px] text-[var(--r-sub)]">{w.strongKey}</span>
|
||||
</div>
|
||||
{w.entry && (w.entry.strongs_def || w.entry.kjv_def || w.entry.derivation) && (
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer text-xs text-[var(--r-accent)]" onClick={(e) => e.stopPropagation()}>Definition</summary>
|
||||
<div className="mt-1 text-xs leading-5" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(buildGreekDefinitionHtml(w.strongKey, w.entry)) }} />
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button type="button" onClick={close} className="flex-1 rounded-xl border border-[var(--r-line)] py-2 text-sm hover:bg-black/5">Cancel</button>
|
||||
<button type="button" onClick={confirmSuggestWords} disabled={n === 0}
|
||||
className="flex-1 rounded-xl bg-[var(--r-accent)] py-2 text-sm font-medium text-[var(--r-on)] disabled:opacity-40">Add {n > 0 ? `${n} word${n > 1 ? 's' : ''}` : 'words'}</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Greek/Hebrew word study for the chunk: compact cards (one open for editing at a
|
||||
// time), Strong's lookup, and suggestions from the passage.
|
||||
import { useState } from 'react';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { externalLookupLinks, speakOriginalWord } from '../../utils/wordStudy.js';
|
||||
|
||||
const inputCls = 'mt-1 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||
const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
|
||||
|
||||
function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
|
||||
const { updateChunkWord, lookupWord, removeGreekWord } = wordStudy;
|
||||
const set = (patch) => updateChunkWord(chunkId, word.id, patch);
|
||||
const field = (label, key, placeholder) => (
|
||||
<label className="block text-xs text-[var(--r-sub)]">{label}
|
||||
<input type="text" value={word[key] ?? ''} placeholder={placeholder} onChange={(e) => set({ [key]: e.target.value })} className={inputCls} />
|
||||
</label>
|
||||
);
|
||||
const notFound = (word.shortDefinition === 'No definition found.' || word.shortDefinition === 'Lookup failed.') && !word.lexeme && !word.definitionHtml && word.query.trim();
|
||||
return (
|
||||
<div className={`rounded-xl border ${open ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)]'}`}>
|
||||
<button type="button" onClick={onToggle} aria-expanded={open} className="flex w-full items-baseline gap-2 px-3 py-2 text-left">
|
||||
<span className="font-serif text-lg leading-tight">{word.lexeme || <span className="font-sans text-sm text-[var(--r-sub)]">New word</span>}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-[var(--r-sub)]">{[word.transliteration, word.englishGloss || word.shortDefinition].filter(Boolean).join(' · ')}</span>
|
||||
{word.strongNumber && <span className="shrink-0 rounded-full bg-[var(--r-bar)] px-2 py-0.5 text-[10px]">{word.strongNumber}</span>}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="space-y-3 border-t border-[var(--r-line)] px-3 py-3">
|
||||
<label className="block text-xs text-[var(--r-sub)]">Strong’s number or English gloss
|
||||
<input type="text" value={word.query} onChange={(e) => set({ query: e.target.value })}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); lookupWord(chunkId, word.id, 'greek'); } }}
|
||||
placeholder="G4102, H7225, 4102, or a word" className={inputCls} />
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'greek')} className={`${btn} bg-[var(--r-text)] text-[var(--r-bg)] hover:opacity-90`}>{word.loading ? 'Looking up...' : 'Look up Greek'}</button>
|
||||
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'hebrew')} className={btn}>{word.loading ? 'Looking up...' : 'Look up Hebrew'}</button>
|
||||
{word.lexeme && <button type="button" onClick={() => speakOriginalWord(word.lexeme, word.strongNumber)} className={btn} title="Hear pronunciation">Speak</button>}
|
||||
<button type="button" onClick={() => removeGreekWord(chunkId, word.id)} className={`${btn} ml-auto text-red-700`}>Delete</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{field(word.strongNumber?.startsWith('H') ? 'Hebrew word' : 'Greek word', 'lexeme')}
|
||||
{field('Transliteration', 'transliteration')}
|
||||
{field('Part of speech', 'partOfSpeech', 'e.g. Noun')}
|
||||
{field('English word', 'englishGloss', 'e.g. grace')}
|
||||
</div>
|
||||
{field('Short definition', 'shortDefinition')}
|
||||
{word.definitionHtml && (
|
||||
<details className="rounded-lg border border-[var(--r-line)] p-2.5">
|
||||
<summary className="cursor-pointer text-xs font-medium">Extended definition</summary>
|
||||
<div className="mt-2 text-[13px] leading-relaxed" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(word.definitionHtml) }} />
|
||||
</details>
|
||||
)}
|
||||
{notFound ? (
|
||||
<div className="rounded-lg border border-[var(--r-line)] p-2.5">
|
||||
<p className="mb-1.5 text-xs text-[var(--r-sub)]">Not found in BDBT — look it up manually:</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{externalLookupLinks(word.query).map(({ label, url }) => (
|
||||
<a key={label} href={url} target="_blank" rel="noreferrer" className={btn}>{label} ↗</a>
|
||||
))}
|
||||
<button type="button" onClick={() => navigator.clipboard.writeText(word.query.trim())} className={btn}>Copy “{word.query.trim()}”</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WordStudyTab({ wordStudy, chunk, openWordId, setOpenWordId }) {
|
||||
const { suggestingGreekForChunkId, suggestingHebrewForChunkId, suggestGreekWordsForChunk, suggestHebrewWordsForChunk, addGreekWord } = wordStudy;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => suggestGreekWordsForChunk(chunk.id)} disabled={suggestingGreekForChunkId === chunk.id} className={btn}
|
||||
title="Auto-populate Greek words that actually appear in this passage">{suggestingGreekForChunkId === chunk.id ? 'Suggesting…' : 'Suggest Greek'}</button>
|
||||
<button type="button" onClick={() => suggestHebrewWordsForChunk(chunk.id)} disabled={suggestingHebrewForChunkId === chunk.id} className={btn}
|
||||
title="Suggest Hebrew words for Old Testament passages">{suggestingHebrewForChunkId === chunk.id ? 'Suggesting…' : 'Suggest Hebrew'}</button>
|
||||
<button type="button" onClick={() => setOpenWordId(addGreekWord(chunk.id))} className={`${btn} ml-auto bg-[var(--r-text)] text-[var(--r-bg)] hover:opacity-90`}>Add word</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{chunk.greekWords.length === 0 && (
|
||||
<p className="rounded-xl border border-dashed border-[var(--r-line)] p-4 text-sm text-[var(--r-sub)]">
|
||||
No words yet. Open a verse’s original words in the text and click one, or add a word to look it up.
|
||||
</p>
|
||||
)}
|
||||
{chunk.greekWords.map((w) => (
|
||||
<WordCard key={w.id} word={w} open={openWordId === w.id} chunkId={chunk.id} wordStudy={wordStudy}
|
||||
onToggle={() => setOpenWordId(openWordId === w.id ? null : w.id)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// The list of Bible translations offered by the HelloAO API, fetched once per
|
||||
// visit and shared by every page that needs it (Setup, Import).
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
let cached = null;
|
||||
let pending = null;
|
||||
|
||||
export function useAvailableTranslations() {
|
||||
const [translations, setTranslations] = useState(cached ?? ['BSB']);
|
||||
useEffect(() => {
|
||||
if (cached) return;
|
||||
pending ??= fetch('https://bible.helloao.org/api/available_translations.json')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (Array.isArray(data) && data.length > 0) cached = data;
|
||||
return cached;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => { pending = null; });
|
||||
let active = true;
|
||||
pending.then((list) => { if (active && list) setTranslations(list); });
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
return translations;
|
||||
}
|
||||
+154
-12
@@ -4,6 +4,77 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
||||
|
||||
// Every chunk starts blank; all note fields exist from the start.
|
||||
export function blankChunk(start, end, spilloverEndVerse = null) {
|
||||
return {
|
||||
id: makeId(),
|
||||
startVerse: start,
|
||||
endVerse: end,
|
||||
spilloverEndVerse,
|
||||
observation: '',
|
||||
interpretation: '',
|
||||
application: '',
|
||||
crossReferences: [],
|
||||
greekWords: [],
|
||||
generalNotes: '',
|
||||
episodeNumber: '',
|
||||
episodeTitle: '',
|
||||
finalScript: '',
|
||||
tags: [],
|
||||
};
|
||||
}
|
||||
|
||||
const joinText = (a, b) => [a, b].filter((s) => (s ?? '').trim()).join('\n\n');
|
||||
|
||||
// Merges chunk `b` (the one after `a`) into `a`, keeping a's id and combining notes,
|
||||
// words, cross-references and tags so joining never loses anything.
|
||||
export function mergeChunks(a, b) {
|
||||
const uniq = (list) => [...new Set(list)];
|
||||
return {
|
||||
...a,
|
||||
endVerse: b.endVerse,
|
||||
spilloverEndVerse: b.spilloverEndVerse ?? null,
|
||||
observation: joinText(a.observation, b.observation),
|
||||
interpretation: joinText(a.interpretation, b.interpretation),
|
||||
application: joinText(a.application, b.application),
|
||||
generalNotes: joinText(a.generalNotes, b.generalNotes),
|
||||
finalScript: joinText(a.finalScript, b.finalScript),
|
||||
crossReferences: uniq([...(a.crossReferences ?? []), ...(b.crossReferences ?? [])]),
|
||||
greekWords: [...(a.greekWords ?? []), ...(b.greekWords ?? [])],
|
||||
tags: uniq([...(a.tags ?? []), ...(b.tags ?? [])]),
|
||||
episodeNumber: a.episodeNumber || b.episodeNumber || '',
|
||||
episodeTitle: a.episodeTitle || b.episodeTitle || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Verse numbers where a section heading starts, from a HelloAO chapter payload.
|
||||
export function headingStarts(chapterData) {
|
||||
const starts = [];
|
||||
let pending = false;
|
||||
for (const item of chapterData?.chapter?.content ?? []) {
|
||||
if (item.type === 'heading') pending = true;
|
||||
else if (item.type === 'verse') {
|
||||
if (pending) starts.push(item.number);
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
// Uncovered runs of verses inside [from, to], given the chunks already there.
|
||||
export function uncoveredRuns(chunks, from, to) {
|
||||
const covered = new Set();
|
||||
chunks.forEach((c) => { for (let v = c.startVerse; v <= c.endVerse; v += 1) covered.add(v); });
|
||||
const runs = [];
|
||||
let start = null;
|
||||
for (let v = from; v <= to + 1; v += 1) {
|
||||
const free = v <= to && !covered.has(v);
|
||||
if (free && start === null) start = v;
|
||||
if (!free && start !== null) { runs.push([start, v - 1]); start = null; }
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -228,18 +299,7 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
|
||||
skippedCount += 1;
|
||||
return;
|
||||
}
|
||||
const newChunk = {
|
||||
id: makeId(),
|
||||
startVerse: start,
|
||||
endVerse: end,
|
||||
spilloverEndVerse,
|
||||
observation: '',
|
||||
interpretation: '',
|
||||
application: '',
|
||||
crossReferences: [],
|
||||
greekWords: [],
|
||||
tags: [],
|
||||
};
|
||||
const newChunk = blankChunk(start, end, spilloverEndVerse);
|
||||
existing.add(key);
|
||||
newChunks.push(newChunk);
|
||||
addedCount += 1;
|
||||
@@ -491,6 +551,84 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
|
||||
});
|
||||
};
|
||||
|
||||
// Splits the chunk containing verse `n` into [start..n] and [n+1..end] (the first keeps
|
||||
// its notes). If `n` isn't in any chunk yet, makes one from the last covered verse to `n`.
|
||||
const splitAfterVerse = (n) => {
|
||||
if (!activeChapter) return;
|
||||
updateProject((current) => {
|
||||
let selected = current.selectedChunkId;
|
||||
const chapters = current.chapters.map((ch, idx) => {
|
||||
if (idx !== activeChapterIndex) return ch;
|
||||
const chunks = [...ch.chunks];
|
||||
const i = chunks.findIndex((c) => c.startVerse <= n && n < c.endVerse && !c.spilloverEndVerse);
|
||||
if (i >= 0) {
|
||||
const c = chunks[i];
|
||||
chunks[i] = { ...c, endVerse: n, spilloverEndVerse: null };
|
||||
chunks.splice(i + 1, 0, blankChunk(n + 1, c.endVerse, c.spilloverEndVerse ?? null));
|
||||
return { ...ch, chunks };
|
||||
}
|
||||
if (chunks.some((c) => c.startVerse <= n && n <= c.endVerse)) return ch; // already a boundary
|
||||
const start = Math.max(0, ...chunks.filter((c) => c.endVerse < n).map((c) => c.endVerse)) + 1;
|
||||
const created = blankChunk(start, n);
|
||||
selected = created.id;
|
||||
const at = chunks.findIndex((c) => c.startVerse > n);
|
||||
chunks.splice(at < 0 ? chunks.length : at, 0, created);
|
||||
return { ...ch, chunks };
|
||||
});
|
||||
return { ...current, chapters, selectedChunkId: selected };
|
||||
});
|
||||
};
|
||||
|
||||
// Joins a chunk with the one right after it (adjacent verses), combining their notes.
|
||||
const joinWithNext = (chunkId) => {
|
||||
updateProject((current) => {
|
||||
let selected = current.selectedChunkId;
|
||||
const chapters = current.chapters.map((ch) => {
|
||||
const i = ch.chunks.findIndex((c) => c.id === chunkId);
|
||||
if (i < 0 || i >= ch.chunks.length - 1) return ch;
|
||||
const a = ch.chunks[i];
|
||||
const b = ch.chunks[i + 1];
|
||||
if (a.endVerse + 1 !== b.startVerse) return ch;
|
||||
if (selected === b.id) selected = a.id;
|
||||
const chunks = [...ch.chunks];
|
||||
chunks.splice(i, 2, mergeChunks(a, b));
|
||||
return { ...ch, chunks };
|
||||
});
|
||||
return { ...current, chapters, selectedChunkId: selected };
|
||||
});
|
||||
};
|
||||
|
||||
// Fills verses that aren't in a chunk yet: one chunk per section heading (or every 8
|
||||
// verses when the text has no headings). Chunks you already made are left alone.
|
||||
const [suggesting, setSuggesting] = useState(false);
|
||||
const suggestChunks = async () => {
|
||||
if (!activeChapter) return;
|
||||
const notify = (message, ms = 2400) => { setStatusMessage(message); window.setTimeout(() => setStatusMessage(''), ms); };
|
||||
const last = activeChapter.verses.at(-1)?.number ?? 0;
|
||||
setSuggesting(true);
|
||||
try {
|
||||
let starts = [];
|
||||
try {
|
||||
const res = await fetch(`https://bible.helloao.org/api/${project?.translation ?? 'BSB'}/${activeChapter.bookAbbrev}/${activeChapter.chapter}.json`);
|
||||
if (res.ok) starts = headingStarts(await res.json());
|
||||
} catch { /* headings are a nicety — fall back to even chunks */ }
|
||||
const usedHeadings = starts.length > 0;
|
||||
const first = activeChapter.verses[0]?.number ?? 1;
|
||||
if (!usedHeadings) for (let v = first; v <= last; v += 8) starts.push(v);
|
||||
const bounds = [...new Set([first, ...starts])].sort((a, b) => a - b);
|
||||
const ranges = [];
|
||||
bounds.forEach((from, k) => {
|
||||
const to = (bounds[k + 1] ?? last + 1) - 1;
|
||||
uncoveredRuns(activeChapter.chunks, from, to).forEach(([s, e]) => ranges.push({ start: s, end: e }));
|
||||
});
|
||||
if (ranges.length === 0) { notify('Every verse is already in a chunk.'); return; }
|
||||
const { addedCount } = addChunkRanges(ranges);
|
||||
notify(`Added ${addedCount} chunk${addedCount === 1 ? '' : 's'} ${usedHeadings ? 'from section headings' : 'of about 8 verses'}.`);
|
||||
} finally {
|
||||
setSuggesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
setup,
|
||||
titleEdited, setTitleEdited,
|
||||
@@ -517,5 +655,9 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
|
||||
addClickSpanChunk,
|
||||
moveChunk,
|
||||
deleteChunk,
|
||||
splitAfterVerse,
|
||||
joinWithNext,
|
||||
suggestChunks,
|
||||
suggesting,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
import { blankChunk, headingStarts, mergeChunks, uncoveredRuns, useChunkBuilder } from './useChunkBuilder.js';
|
||||
|
||||
const c = (s, e) => ({ startVerse: s, endVerse: e });
|
||||
|
||||
test('uncoveredRuns lists the verse runs not yet in any chunk', () => {
|
||||
expect(uncoveredRuns([c(1, 3), c(6, 7)], 1, 10)).toEqual([[4, 5], [8, 10]]);
|
||||
expect(uncoveredRuns([], 5, 8)).toEqual([[5, 8]]);
|
||||
expect(uncoveredRuns([c(1, 10)], 1, 10)).toEqual([]);
|
||||
});
|
||||
|
||||
test('headingStarts finds the verse each section heading precedes', () => {
|
||||
const data = { chapter: { content: [
|
||||
{ type: 'heading' }, { type: 'verse', number: 1 }, { type: 'verse', number: 2 },
|
||||
{ type: 'heading' }, { type: 'line_break' }, { type: 'verse', number: 22 },
|
||||
] } };
|
||||
expect(headingStarts(data)).toEqual([1, 22]);
|
||||
});
|
||||
|
||||
test('mergeChunks keeps the first id and combines notes, words, refs and tags', () => {
|
||||
const a = { ...blankChunk(1, 3), observation: 'A', tags: ['x'], crossReferences: ['John 1:1'], greekWords: [{ id: 'w1' }] };
|
||||
const b = { ...blankChunk(4, 6), observation: 'B', tags: ['x', 'y'], crossReferences: ['John 1:1', 'Rom 3:23'], greekWords: [{ id: 'w2' }], episodeTitle: 'Ep' };
|
||||
const m = mergeChunks(a, b);
|
||||
expect([m.id, m.startVerse, m.endVerse]).toEqual([a.id, 1, 6]);
|
||||
expect(m.observation).toBe('A\n\nB');
|
||||
expect(m.tags).toEqual(['x', 'y']);
|
||||
expect(m.crossReferences).toEqual(['John 1:1', 'Rom 3:23']);
|
||||
expect(m.greekWords.map((w) => w.id)).toEqual(['w1', 'w2']);
|
||||
expect(m.episodeTitle).toBe('Ep');
|
||||
});
|
||||
|
||||
// --- hook operations against a small in-memory project -----------------------
|
||||
const verses = Array.from({ length: 12 }, (_, i) => ({ number: i + 1, text: `verse ${i + 1}` }));
|
||||
const makeProject = (chunks = []) => ({
|
||||
id: 'p', title: 'T', translation: 'BSB', selectedChunkId: null,
|
||||
chapters: [{ book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses, chunks }],
|
||||
});
|
||||
|
||||
function setup(initialChunks) {
|
||||
return renderHook(() => {
|
||||
const [project, setProject] = useState(makeProject(initialChunks));
|
||||
const updateProject = (fn) => setProject((cur) => fn(cur));
|
||||
const allChunks = project.chapters.flatMap((ch) => ch.chunks);
|
||||
const builder = useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage: () => {} });
|
||||
return { project, builder };
|
||||
});
|
||||
}
|
||||
const ranges = (h) => h.result.current.project.chapters[0].chunks.map((k) => [k.startVerse, k.endVerse]);
|
||||
|
||||
beforeEach(() => { vi.restoreAllMocks(); });
|
||||
|
||||
test('splitting between uncovered verses builds chunks one after another', () => {
|
||||
const h = setup();
|
||||
act(() => h.result.current.builder.splitAfterVerse(4));
|
||||
act(() => h.result.current.builder.splitAfterVerse(9));
|
||||
expect(ranges(h)).toEqual([[1, 4], [5, 9]]);
|
||||
});
|
||||
|
||||
test('splitting inside a chunk keeps the notes on the first part', () => {
|
||||
const original = { ...blankChunk(1, 8), observation: 'keep me' };
|
||||
const h = setup([original]);
|
||||
act(() => h.result.current.builder.splitAfterVerse(3));
|
||||
const [first, second] = h.result.current.project.chapters[0].chunks;
|
||||
expect([first.id, first.startVerse, first.endVerse, first.observation]).toEqual([original.id, 1, 3, 'keep me']);
|
||||
expect([second.startVerse, second.endVerse, second.observation]).toEqual([4, 8, '']);
|
||||
});
|
||||
|
||||
test('splitting where a boundary already exists changes nothing', () => {
|
||||
const h = setup([blankChunk(1, 4), blankChunk(5, 8)]);
|
||||
act(() => h.result.current.builder.splitAfterVerse(4));
|
||||
expect(ranges(h)).toEqual([[1, 4], [5, 8]]);
|
||||
});
|
||||
|
||||
test('joining merges a chunk with the next one and re-selects the survivor', () => {
|
||||
const a = { ...blankChunk(1, 4), observation: 'one' };
|
||||
const b = { ...blankChunk(5, 8), observation: 'two' };
|
||||
const h = setup([a, b]);
|
||||
act(() => h.result.current.builder.joinWithNext(a.id));
|
||||
const [only] = h.result.current.project.chapters[0].chunks;
|
||||
expect([only.id, only.startVerse, only.endVerse, only.observation]).toEqual([a.id, 1, 8, 'one\n\ntwo']);
|
||||
});
|
||||
|
||||
test('joining is refused when the chunks are not adjacent', () => {
|
||||
const a = blankChunk(1, 3);
|
||||
const h = setup([a, blankChunk(6, 8)]);
|
||||
act(() => h.result.current.builder.joinWithNext(a.id));
|
||||
expect(ranges(h)).toEqual([[1, 3], [6, 8]]);
|
||||
});
|
||||
|
||||
test('suggest from headings fills only the uncovered verses, one chunk per section', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ chapter: { content: [
|
||||
{ type: 'heading' }, { type: 'verse', number: 1 }, { type: 'heading' }, { type: 'verse', number: 5 }, { type: 'heading' }, { type: 'verse', number: 9 },
|
||||
] } }),
|
||||
})));
|
||||
const h = setup([blankChunk(5, 6)]); // verses 5-6 already done
|
||||
await act(async () => { await h.result.current.builder.suggestChunks(); });
|
||||
expect(ranges(h).sort((x, y) => x[0] - y[0])).toEqual([[1, 4], [5, 6], [7, 8], [9, 12]]);
|
||||
});
|
||||
|
||||
test('suggest falls back to even chunks when the chapter has no headings', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ chapter: { content: [{ type: 'verse', number: 1 }] } }) })));
|
||||
const h = setup();
|
||||
await act(async () => { await h.result.current.builder.suggestChunks(); });
|
||||
expect(ranges(h)).toEqual([[1, 8], [9, 12]]);
|
||||
});
|
||||
@@ -46,6 +46,7 @@ export function useWordStudy({ project, allChunks, updateProject, notify }) {
|
||||
}));
|
||||
};
|
||||
|
||||
// Adds a blank word entry and returns its id (so the UI can open it for editing).
|
||||
const addGreekWord = (chunkId) => {
|
||||
const newWord = {
|
||||
id: makeId(),
|
||||
@@ -70,6 +71,42 @@ export function useWordStudy({ project, allChunks, updateProject, notify }) {
|
||||
),
|
||||
})),
|
||||
}));
|
||||
return newWord.id;
|
||||
};
|
||||
|
||||
// Adds a fully filled-in entry for an original word picked from the interlinear
|
||||
// (Strong's number, lexeme, transliteration and gloss come with the click).
|
||||
const addWordFromInterlinear = async (chunkId, w) => {
|
||||
const chunk = allChunks.find((c) => c.id === chunkId);
|
||||
const key = String(w?.s ?? '').toUpperCase();
|
||||
if (!chunk || !key) return null;
|
||||
if (chunk.greekWords.some((x) => (x.strongNumber || '').toUpperCase() === key)) {
|
||||
notify('That word is already in this chunk.', 1800);
|
||||
return chunk.greekWords.find((x) => (x.strongNumber || '').toUpperCase() === key).id;
|
||||
}
|
||||
let entry = null;
|
||||
try { entry = (key.startsWith('H') ? await loadHebrewDict() : await loadGreekDict())[key] ?? null; } catch { /* dictionary unavailable — keep what the click gave us */ }
|
||||
const newWord = {
|
||||
id: makeId(),
|
||||
query: key,
|
||||
strongNumber: key,
|
||||
englishGloss: w.g || '',
|
||||
lexeme: w.o || entry?.lemma || '',
|
||||
transliteration: w.t || entry?.translit || entry?.xlit || '',
|
||||
partOfSpeech: '',
|
||||
shortDefinition: entry?.kjv_def || w.g || '',
|
||||
definitionHtml: entry ? buildGreekDefinitionHtml(key, entry) : '',
|
||||
loading: false,
|
||||
};
|
||||
updateProject((current) => ({
|
||||
...current,
|
||||
chapters: current.chapters.map((ch) => ({
|
||||
...ch,
|
||||
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, greekWords: [...c.greekWords, newWord] } : c)),
|
||||
})),
|
||||
}));
|
||||
notify(`Added ${newWord.lexeme || key} to word study.`, 1800);
|
||||
return newWord.id;
|
||||
};
|
||||
|
||||
const removeGreekWord = (chunkId, wordId) => {
|
||||
@@ -332,6 +369,7 @@ export function useWordStudy({ project, allChunks, updateProject, notify }) {
|
||||
suggestSelection, setSuggestSelection,
|
||||
updateChunkWord,
|
||||
addGreekWord,
|
||||
addWordFromInterlinear,
|
||||
removeGreekWord,
|
||||
lookupWord,
|
||||
suggestGreekWordsForChunk,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// The Export stage: take the finished study out as a file, or share it read-only.
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { useShareLink } from '../hooks/useShareLink.js';
|
||||
import StudioShell from '../components/study/StudioShell.jsx';
|
||||
import { exportProjectDocx, exportProjectHtml, exportProjectMarkdown, printProjectPdf } from '../utils/projectExport.js';
|
||||
|
||||
const card = 'rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5';
|
||||
const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
|
||||
|
||||
function Row({ title, note, button, onClick }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 border-b border-[var(--r-line)] py-3 last:border-0">
|
||||
<div className="min-w-0"><p className="text-sm font-medium">{title}</p><p className="text-xs text-[var(--r-sub)]">{note}</p></div>
|
||||
<button type="button" onClick={onClick} className={`${btn} shrink-0`}>{button}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExportPage() {
|
||||
const { project, allChunks, setCurrentPage } = useApp();
|
||||
const share = useShareLink(project?.id);
|
||||
const filled = allChunks.filter((c) => (c.finalScript ?? '').trim()).length;
|
||||
|
||||
return (
|
||||
<StudioShell stage="export">
|
||||
<main className="mx-auto max-w-2xl space-y-5 px-4 py-8">
|
||||
<div>
|
||||
<h1 className="text-xl font-medium">Export</h1>
|
||||
<p className="mt-1 text-sm text-[var(--r-sub)]">{allChunks.length} chunk{allChunks.length === 1 ? '' : 's'} · {filled} script{filled === 1 ? '' : 's'} written</p>
|
||||
</div>
|
||||
|
||||
<section className={card} aria-label="Files">
|
||||
<h2 className="mb-1 text-sm font-medium">Files</h2>
|
||||
<Row title="Word document" note="Notes, words and scripts, formatted for editing." button="Export DOCX" onClick={() => exportProjectDocx(project)} />
|
||||
<Row title="PDF" note="Opens a print-friendly page. Choose “Save as PDF” in the print dialog." button="Print / Save PDF" onClick={() => printProjectPdf(project)} />
|
||||
<Row title="Markdown" note="For Obsidian, Notion, or any Markdown-based notes app." button="Export Markdown" onClick={() => exportProjectMarkdown(project)} />
|
||||
<Row title="Web page" note="A single HTML file you can open anywhere." button="Export HTML" onClick={() => exportProjectHtml(project)} />
|
||||
</section>
|
||||
|
||||
<section className={card} aria-label="Sharing">
|
||||
<h2 className="text-sm font-medium">Read-only share link</h2>
|
||||
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">Anyone with the link can view (not edit) this study, with no account needed. Good for a co-teacher or a listener following along.</p>
|
||||
{share.shareToken ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input readOnly value={share.shareUrl} onFocus={(e) => e.target.select()}
|
||||
className="min-w-0 flex-1 truncate rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-sub)]" />
|
||||
<button type="button" onClick={share.copy} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{share.shareCopied ? 'Copied!' : 'Copy'}</button>
|
||||
</div>
|
||||
<button type="button" onClick={share.revoke} disabled={share.shareBusy} className="text-xs text-red-700 hover:underline">{share.shareBusy ? 'Revoking…' : 'Revoke link'}</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={share.enable} disabled={share.shareBusy} className={btn}>{share.shareBusy ? 'Enabling…' : 'Enable sharing'}</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={card} aria-label="Pronunciation">
|
||||
<Row title="Pronunciation guide" note="Every Greek and Hebrew word in this study, with transliterations." button="Open guide" onClick={() => setCurrentPage('pronunciation')} />
|
||||
</section>
|
||||
</main>
|
||||
</StudioShell>
|
||||
);
|
||||
}
|
||||
+191
-416
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import AuthStatus from '../components/AuthStatus.jsx';
|
||||
import PageShell from '../components/study/PageShell.jsx';
|
||||
import AudioPlayer from '../components/AudioPlayer.jsx';
|
||||
import {
|
||||
bookOptions,
|
||||
@@ -18,10 +18,8 @@ export default function HomePage() {
|
||||
projectIndex,
|
||||
planProgress,
|
||||
readingActivity,
|
||||
toggleDay,
|
||||
openReadingPlans,
|
||||
readReadingPlanDay,
|
||||
listenReadingPlanDay,
|
||||
openPlanReading,
|
||||
studyReadingPlanDay,
|
||||
studyBuildBusy,
|
||||
readingPlanSyncStatus,
|
||||
@@ -91,112 +89,163 @@ export default function HomePage() {
|
||||
}, 300);
|
||||
}, [homeSearch]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold">My Studies</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openBibleReader}
|
||||
className="rounded-xl border border-slate-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700"
|
||||
>
|
||||
📖 Read Bible
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openReadingPlans}
|
||||
className="rounded-xl border border-slate-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700"
|
||||
>
|
||||
<span className="hidden sm:inline">📅 Reading Plans</span>
|
||||
<span className="sm:hidden">📅 Plans</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openImportProject}
|
||||
className="rounded-xl border border-slate-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700"
|
||||
>
|
||||
<span className="hidden sm:inline">📥 Import Session List</span>
|
||||
<span className="sm:hidden">📥 Import</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openNewProject}
|
||||
className="rounded-xl bg-slate-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-600"
|
||||
>
|
||||
+ New Project
|
||||
</button>
|
||||
<AuthStatus />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-7xl px-4 py-4 sm:py-8 sm:px-6 lg:px-8">
|
||||
{autoRestoredCount !== null && (
|
||||
<div className="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
|
||||
<p className="text-sm font-semibold text-emerald-800">
|
||||
📥 Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{staleLocalProjects.length > 0 && (
|
||||
<div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-4">
|
||||
<p className="mb-3 text-sm font-semibold text-amber-800">
|
||||
☁️ {staleLocalProjects.length} project{staleLocalProjects.length > 1 ? 's have' : ' has'} a newer version on the server:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{staleLocalProjects.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() => pullLatestFromServer(entry.id)}
|
||||
className="rounded-xl bg-amber-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-amber-600"
|
||||
>
|
||||
Pull latest "{entry.title}"
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{readingPlanSyncStatus === 'error' && (
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-rose-200 bg-rose-50 p-4">
|
||||
<p className="text-sm text-rose-700">
|
||||
⚠️ Couldn't sync your reading plans to the server — changes here are only saved on this device until this is fixed.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={retryReadingPlanSync}
|
||||
className="shrink-0 rounded-xl border border-rose-300 px-3 py-1.5 text-sm font-semibold text-rose-700 hover:bg-rose-100"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Reading plans summary — shown regardless of whether there are any study projects yet */}
|
||||
<ReadingPlansSummaryCard
|
||||
planProgress={planProgress}
|
||||
readingActivity={readingActivity}
|
||||
openReadingPlans={openReadingPlans}
|
||||
readReadingPlanDay={readReadingPlanDay}
|
||||
listenReadingPlanDay={listenReadingPlanDay}
|
||||
studyReadingPlanDay={studyReadingPlanDay}
|
||||
studyBuildBusy={studyBuildBusy}
|
||||
toggleDay={toggleDay}
|
||||
/>
|
||||
const allTags = Array.from(new Set(projectIndex.flatMap((entry) => entry.tags ?? []))).sort((a, b) => a.localeCompare(b));
|
||||
const visible = projectIndex
|
||||
.slice()
|
||||
.filter((entry) => {
|
||||
if (homeTagFilter && !(entry.tags ?? []).includes(homeTagFilter)) return false;
|
||||
const q = homeSearch.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return entry.title?.toLowerCase().includes(q) || entry.chapterSummary?.toLowerCase().includes(q);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
|
||||
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
|
||||
return (b.lastEdited ?? 0) - (a.lastEdited ?? 0);
|
||||
});
|
||||
const fieldCls = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||
const linkBtn = 'rounded-lg px-3 py-1.5 text-sm text-[var(--r-sub)] hover:bg-black/5';
|
||||
const notice = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm';
|
||||
|
||||
{/* BSB audio player — shown regardless of whether there are any study projects yet,
|
||||
so "Listen" from a reading plan always has somewhere visible to land. */}
|
||||
<div className="mb-4">
|
||||
return (
|
||||
<PageShell
|
||||
title="My Studies"
|
||||
actions={(
|
||||
<>
|
||||
<button type="button" onClick={openBibleReader} className={linkBtn}>Read Bible</button>
|
||||
<button type="button" onClick={openReadingPlans} className={linkBtn}>Reading Plans</button>
|
||||
<button type="button" onClick={openImportProject} className={linkBtn} title="Set up a whole series from a session list (.docx)">Import Session List</button>
|
||||
<button type="button" onClick={openNewProject} className="rounded-full bg-[var(--r-accent)] px-4 py-1.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ New Project</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<main className="mx-auto grid max-w-6xl gap-6 px-4 py-6 lg:grid-cols-[minmax(0,1fr)_21rem]">
|
||||
<section className="min-w-0">
|
||||
{autoRestoredCount !== null && (
|
||||
<div className={notice}>Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.</div>
|
||||
)}
|
||||
{staleLocalProjects.length > 0 && (
|
||||
<div className={notice}>
|
||||
<p className="mb-2">{staleLocalProjects.length} project{staleLocalProjects.length > 1 ? 's have' : ' has'} a newer version on the server:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{staleLocalProjects.map((entry) => (
|
||||
<button key={entry.id} type="button" onClick={() => pullLatestFromServer(entry.id)}
|
||||
className="rounded-full border border-[var(--r-accent)] px-3 py-1 text-xs text-[var(--r-accent)] hover:bg-black/5">Pull latest “{entry.title}”</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{readingPlanSyncStatus === 'error' && (
|
||||
<div className={`${notice} flex flex-wrap items-center justify-between gap-3`}>
|
||||
<span className="text-red-700">Couldn’t sync your reading plans to the server. Changes are only saved on this device until this is fixed.</span>
|
||||
<button type="button" onClick={retryReadingPlanSync} className="shrink-0 rounded-lg border border-red-300 px-3 py-1 text-xs text-red-700 hover:bg-red-50">Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projectIndex.length === 0 ? (
|
||||
<div className="mx-auto max-w-xl rounded-2xl border border-dashed border-[var(--r-line)] p-10 text-center">
|
||||
<p className="text-lg font-medium">No projects yet</p>
|
||||
<p className="mt-2 text-sm text-[var(--r-sub)]">Start a new Bible study to get going.</p>
|
||||
<button type="button" onClick={openNewProject} className="mt-6 rounded-full bg-[var(--r-accent)] px-6 py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ New Project</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<input type="text" value={homeSearch} onChange={(e) => setHomeSearch(e.target.value)} placeholder="Search projects by title or passage…"
|
||||
className={`${fieldCls} w-full max-w-sm`} />
|
||||
<label className="text-sm text-[var(--r-sub)]">Sort by{' '}
|
||||
<select value={homeSort} onChange={(e) => setHomeSort(e.target.value)} className={`${fieldCls} ml-1`}>
|
||||
<option value="recent">Last edited</option>
|
||||
<option value="title">Title</option>
|
||||
<option value="passage">Passage</option>
|
||||
</select>
|
||||
</label>
|
||||
{allTags.length > 0 && (
|
||||
<label className="text-sm text-[var(--r-sub)]">Tag{' '}
|
||||
<select value={homeTagFilter} onChange={(e) => setHomeTagFilter(e.target.value)} className={`${fieldCls} ml-1`}>
|
||||
<option value="">All tags</option>
|
||||
{allTags.map((tag) => <option key={tag} value={tag}>{tag}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Full-text note search results */}
|
||||
{homeFullTextResults !== null && (
|
||||
<div className="mb-4 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
|
||||
<p className="mb-3 text-sm font-medium">
|
||||
{homeFullTextResults.length === 0
|
||||
? 'No notes match your search.'
|
||||
: `Notes matching "${homeSearch.trim()}" — ${homeFullTextResults.reduce((n, r) => n + r.matches.length, 0)} result${homeFullTextResults.reduce((n, r) => n + r.matches.length, 0) !== 1 ? 's' : ''}`}
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{homeFullTextResults.map(({ projectId, projectTitle, matches }) => (
|
||||
<div key={projectId}>
|
||||
<button type="button" onClick={() => resumeProject(projectId)} className="mb-1.5 text-sm font-medium text-[var(--r-accent)] hover:underline">{projectTitle} →</button>
|
||||
<div className="space-y-1.5">
|
||||
{matches.map(({ chunkId, ref, field, snippet }) => (
|
||||
<div key={chunkId} className="rounded-xl bg-[var(--r-bg)] px-3 py-2">
|
||||
<span className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{ref} · {field}</span>
|
||||
<p className="mt-0.5 text-sm leading-snug">{snippet}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{visible.map((entry) => (
|
||||
<div key={entry.id} className="flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
|
||||
<div className="flex-1">
|
||||
{renamingId === entry.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="text" autoFocus value={renameValue} onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }
|
||||
else if (e.key === 'Escape') setRenamingId(null);
|
||||
}}
|
||||
className={`${fieldCls} flex-1`} />
|
||||
<button type="button" onClick={() => { const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
|
||||
<button type="button" onClick={() => setRenamingId(null)} className="text-sm text-[var(--r-sub)]">×</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 className="text-base font-medium">{entry.title}</h2>
|
||||
<button type="button" onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Rename project">✎ Rename</button>
|
||||
</div>
|
||||
)}
|
||||
{entry.chapterSummary && <p className="mt-1 text-sm text-[var(--r-sub)]">{entry.chapterSummary}</p>}
|
||||
{entry.tags?.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{entry.tags.map((tag) => <span key={tag} className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]">{tag}</span>)}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-[var(--r-sub)]">{formatRelativeDate(entry.lastEdited)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={() => resumeProject(entry.id)} className="flex-1 rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] hover:opacity-90">Resume</button>
|
||||
<button type="button" onClick={() => deleteProject(entry.id)} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm text-red-700 hover:bg-black/5">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<TodayCard planProgress={planProgress} readingActivity={readingActivity} openReadingPlans={openReadingPlans}
|
||||
openPlanReading={openPlanReading} studyReadingPlanDay={studyReadingPlanDay} studyBuildBusy={studyBuildBusy} />
|
||||
|
||||
{/* BSB audio player — always shown, so it's easy to find */}
|
||||
<AudioPlayer
|
||||
title="Listen to BSB Audio"
|
||||
subtitle={
|
||||
audioState.status === 'idle' || audioState.status === 'error'
|
||||
? 'Play a full book of the Berean Standard Bible.'
|
||||
: `Playing ${bookOptions.find((b) => b.abbrev === audioBook)?.name} — chapter ${audioState.chapter} of ${audioState.total}`
|
||||
}
|
||||
subtitle={audioState.status === 'idle' || audioState.status === 'error'
|
||||
? 'Play a full book of the Berean Standard Bible.'
|
||||
: `Playing ${bookOptions.find((b) => b.abbrev === audioBook)?.name} — chapter ${audioState.chapter} of ${audioState.total}`}
|
||||
errorMessage="Couldn't load audio for this book/narrator."
|
||||
status={audioState.status}
|
||||
currentTime={audioProgress.currentTime}
|
||||
@@ -211,22 +260,10 @@ export default function HomePage() {
|
||||
onStop={handleStopBookAudio}
|
||||
controls={(
|
||||
<>
|
||||
<select
|
||||
value={audioBook}
|
||||
onChange={(e) => setAudioBook(e.target.value)}
|
||||
disabled={audioState.status === 'playing' || audioState.status === 'paused'}
|
||||
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-60"
|
||||
>
|
||||
{bookOptions.map((book) => (
|
||||
<option key={book.abbrev} value={book.abbrev}>{book.name}</option>
|
||||
))}
|
||||
<select value={audioBook} onChange={(e) => setAudioBook(e.target.value)} disabled={audioState.status === 'playing' || audioState.status === 'paused'} className={fieldCls}>
|
||||
{bookOptions.map((book) => <option key={book.abbrev} value={book.abbrev}>{book.name}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={audioNarrator}
|
||||
onChange={(e) => setAudioNarrator(e.target.value)}
|
||||
disabled={audioState.status === 'playing' || audioState.status === 'paused'}
|
||||
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-60"
|
||||
>
|
||||
<select value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} disabled={audioState.status === 'playing' || audioState.status === 'paused'} className={fieldCls}>
|
||||
<option value="david">David</option>
|
||||
<option value="hays">Hays</option>
|
||||
<option value="souer">Souer</option>
|
||||
@@ -234,317 +271,55 @@ export default function HomePage() {
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{projectIndex.length === 0 ? (
|
||||
<div className="mx-auto max-w-xl rounded-3xl border border-dashed border-slate-300 bg-white p-10 text-center shadow-panel">
|
||||
<p className="text-lg font-semibold text-slate-700">No projects yet</p>
|
||||
<p className="mt-2 text-sm text-slate-500">Start a new Bible study to get going.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openNewProject}
|
||||
className="mt-6 rounded-xl bg-slate-900 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800"
|
||||
>
|
||||
+ New Project
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<input
|
||||
type="text"
|
||||
value={homeSearch}
|
||||
onChange={(e) => setHomeSearch(e.target.value)}
|
||||
placeholder="Search projects by title or passage…"
|
||||
className="w-full max-w-sm rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
<label className="text-sm text-slate-600">
|
||||
Sort by{' '}
|
||||
<select
|
||||
value={homeSort}
|
||||
onChange={(e) => setHomeSort(e.target.value)}
|
||||
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
<option value="recent">Last edited</option>
|
||||
<option value="title">Title</option>
|
||||
<option value="passage">Passage</option>
|
||||
</select>
|
||||
</label>
|
||||
{(() => {
|
||||
const allTags = Array.from(
|
||||
new Set(projectIndex.flatMap((entry) => entry.tags ?? [])),
|
||||
).sort((a, b) => a.localeCompare(b));
|
||||
if (allTags.length === 0) return null;
|
||||
return (
|
||||
<label className="text-sm text-slate-600">
|
||||
Tag{' '}
|
||||
<select
|
||||
value={homeTagFilter}
|
||||
onChange={(e) => setHomeTagFilter(e.target.value)}
|
||||
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
<option value="">All tags</option>
|
||||
{allTags.map((tag) => (
|
||||
<option key={tag} value={tag}>{tag}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{/* Full-text note search results */}
|
||||
{homeFullTextResults !== null && (
|
||||
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-5 shadow-panel">
|
||||
<p className="mb-3 text-sm font-semibold text-slate-700">
|
||||
{homeFullTextResults.length === 0
|
||||
? 'No notes match your search.'
|
||||
: `Notes matching "${homeSearch.trim()}" — ${homeFullTextResults.reduce((n, r) => n + r.matches.length, 0)} result${homeFullTextResults.reduce((n, r) => n + r.matches.length, 0) !== 1 ? 's' : ''} across ${homeFullTextResults.length} project${homeFullTextResults.length !== 1 ? 's' : ''}`}
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{homeFullTextResults.map(({ projectId, projectTitle, matches }) => (
|
||||
<div key={projectId}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resumeProject(projectId)}
|
||||
className="mb-1.5 text-sm font-semibold text-violet-700 hover:underline"
|
||||
>
|
||||
{projectTitle} →
|
||||
</button>
|
||||
<div className="space-y-1.5">
|
||||
{matches.map(({ chunkId, ref, field, snippet }) => (
|
||||
<div key={chunkId} className="rounded-2xl bg-slate-50 px-3 py-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-slate-400">{ref} · {field}</span>
|
||||
<p className="mt-0.5 text-sm text-slate-700 leading-snug">{snippet}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projectIndex
|
||||
.slice()
|
||||
.filter((entry) => {
|
||||
if (homeTagFilter && !(entry.tags ?? []).includes(homeTagFilter)) return false;
|
||||
const q = homeSearch.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return entry.title?.toLowerCase().includes(q)
|
||||
|| entry.chapterSummary?.toLowerCase().includes(q);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
|
||||
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
|
||||
return (b.lastEdited ?? 0) - (a.lastEdited ?? 0);
|
||||
})
|
||||
.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex flex-col gap-4 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel"
|
||||
>
|
||||
<div className="flex-1">
|
||||
{renamingId === entry.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const trimmed = renameValue.trim();
|
||||
if (trimmed) renameProjectInStorage(entry.id, trimmed);
|
||||
setRenamingId(null);
|
||||
} else if (e.key === 'Escape') {
|
||||
setRenamingId(null);
|
||||
}
|
||||
}}
|
||||
className="flex-1 rounded-lg border border-slate-300 px-2 py-1 text-base font-semibold text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const trimmed = renameValue.trim();
|
||||
if (trimmed) renameProjectInStorage(entry.id, trimmed);
|
||||
setRenamingId(null);
|
||||
}}
|
||||
className="text-sm font-semibold text-emerald-600 hover:text-emerald-700"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenamingId(null)}
|
||||
className="text-sm text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 className="text-base font-semibold text-slate-900">{entry.title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }}
|
||||
className="shrink-0 text-xs text-slate-400 hover:text-slate-600"
|
||||
title="Rename project"
|
||||
>
|
||||
✎ Rename
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{entry.chapterSummary && (
|
||||
<p className="mt-1 text-sm text-slate-500">{entry.chapterSummary}</p>
|
||||
)}
|
||||
{entry.tags?.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{entry.tags.map((tag) => (
|
||||
<span key={tag} className="rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-semibold text-indigo-700">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-slate-400">{formatRelativeDate(entry.lastEdited)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resumeProject(entry.id)}
|
||||
className="flex-1 rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-800"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteProject(entry.id)}
|
||||
className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-semibold text-rose-700 transition hover:bg-rose-100"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadingPlansSummaryCard({
|
||||
planProgress, readingActivity, openReadingPlans, readReadingPlanDay, listenReadingPlanDay, studyReadingPlanDay, studyBuildBusy, toggleDay,
|
||||
}) {
|
||||
// Today's reading across your active plans, each opening straight into the Reader.
|
||||
function TodayCard({ planProgress, readingActivity, openReadingPlans, openPlanReading, studyReadingPlanDay, studyBuildBusy }) {
|
||||
const active = planProgress.filter((p) => !p.completedAt);
|
||||
const streak = computeStreak(readingActivity);
|
||||
|
||||
const streakBadge = streak.current > 0 && (
|
||||
<span className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700">
|
||||
🔥 {streak.current} day{streak.current !== 1 ? 's' : ''}
|
||||
</span>
|
||||
);
|
||||
const streakText = streak.current > 0 && <span className="text-xs text-[var(--r-accent)]">{streak.current} day{streak.current !== 1 ? 's' : ''} in a row</span>;
|
||||
|
||||
if (active.length === 0) {
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between gap-3 rounded-3xl border border-slate-200 bg-white p-5 shadow-panel">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold text-slate-700">Reading Plans</p>
|
||||
{streakBadge}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">Read through the Bible, the Gospels, Psalms, and more — one day at a time.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openReadingPlans}
|
||||
className="shrink-0 rounded-xl border border-slate-300 px-3 py-1.5 text-sm font-semibold text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Browse plans
|
||||
</button>
|
||||
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
|
||||
<div className="flex items-center justify-between gap-2"><p className="text-sm font-medium">Reading Plans</p>{streakText}</div>
|
||||
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">Read through the Bible, the Gospels, Psalms, and more — one day at a time.</p>
|
||||
<button type="button" onClick={openReadingPlans} className="rounded-full border border-[var(--r-line)] px-4 py-1.5 text-sm hover:bg-black/5">Browse plans</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = active
|
||||
.map((progress) => {
|
||||
const plan = getPlan(progress.planId);
|
||||
if (!plan) return null;
|
||||
const schedule = getPlanSchedule(progress.planId);
|
||||
const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0;
|
||||
const dayIndex = nextUnreadDayIndex(plan, progress.completedDays);
|
||||
const refs = dayIndex != null ? schedule[dayIndex] : null;
|
||||
return { progress, plan, pct, dayIndex, refs };
|
||||
})
|
||||
.filter(Boolean);
|
||||
const rows = active.map((progress) => {
|
||||
const plan = getPlan(progress.planId);
|
||||
if (!plan) return null;
|
||||
const dayIndex = nextUnreadDayIndex(plan, progress.completedDays);
|
||||
const refs = dayIndex != null ? getPlanSchedule(progress.planId)[dayIndex] : null;
|
||||
return { progress, plan, dayIndex, refs, pct: plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0 };
|
||||
}).filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-3xl border border-indigo-200 bg-indigo-50 p-5 shadow-panel">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-900">📅 Today's Reading</h3>
|
||||
{streakBadge}
|
||||
</div>
|
||||
<button type="button" onClick={openReadingPlans} className="text-xs font-semibold text-indigo-700 hover:underline">
|
||||
View all plans →
|
||||
</button>
|
||||
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-medium">Today’s Reading</h2>
|
||||
<span className="flex items-center gap-3">{streakText}<button type="button" onClick={openReadingPlans} className="text-xs text-[var(--r-accent)] hover:underline">All plans →</button></span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{rows.map(({ progress, plan, pct, dayIndex, refs }) => (
|
||||
<div key={progress.id} className="rounded-2xl bg-white px-4 py-2.5 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{refs ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={false}
|
||||
onChange={() => toggleDay(progress.id, dayIndex, plan.days)}
|
||||
className="h-4 w-4 shrink-0 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<span className="shrink-0 text-emerald-500">✓</span>
|
||||
)}
|
||||
<span className="shrink-0 text-lg leading-none">{plan.icon ?? '📖'}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-slate-900">{plan.title}</p>
|
||||
<p className="truncate text-xs text-slate-500">
|
||||
{refs ? formatDayReadings(refs) : `${progress.completedDays.length} / ${plan.days} days`}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{rows.map(({ progress, plan, dayIndex, refs, pct }) => (
|
||||
<div key={progress.id}>
|
||||
<p className="truncate text-sm font-medium">{plan.icon ? `${plan.icon} ` : ''}{plan.title}</p>
|
||||
<p className="mb-1.5 truncate font-serif text-sm text-[var(--r-sub)]">{refs ? formatDayReadings(refs) : `${progress.completedDays.length} / ${plan.days} days`}</p>
|
||||
<div className="mb-2 h-1 overflow-hidden rounded-full bg-[var(--r-line)]"><div className="h-full bg-[var(--r-accent)]" style={{ width: `${pct}%` }} /></div>
|
||||
{refs && (
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => openPlanReading(progress.id, dayIndex)} className="rounded-full bg-[var(--r-accent)] px-4 py-1.5 text-xs font-medium text-[var(--r-on)] hover:opacity-90">Continue reading</button>
|
||||
<button type="button" disabled={studyBuildBusy} onClick={() => studyReadingPlanDay(refs, `${plan.title} — ${formatDayReadings(refs)}`)}
|
||||
className="rounded-full border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50" title="Start a study project from this passage">Study</button>
|
||||
</div>
|
||||
{refs && (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => listenReadingPlanDay(refs[0].book, refs[0].chapter)}
|
||||
title="Listen to today's reading"
|
||||
className="rounded-xl border border-indigo-200 px-2.5 py-1.5 text-sm text-indigo-700 hover:bg-indigo-100"
|
||||
>
|
||||
🎧
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={studyBuildBusy}
|
||||
onClick={() => studyReadingPlanDay(refs, `${plan.title} — ${formatDayReadings(refs)}`)}
|
||||
title="Start a study project from this passage"
|
||||
className="rounded-xl border border-indigo-200 px-2.5 py-1.5 text-sm text-indigo-700 hover:bg-indigo-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => readReadingPlanDay(refs[0].book, refs[0].chapter)}
|
||||
className="rounded-xl bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-indigo-500"
|
||||
>
|
||||
Read
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div className="h-full rounded-full bg-indigo-400 transition-all" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+33
-90
@@ -1,15 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import AuthStatus from '../components/AuthStatus.jsx';
|
||||
import { useAvailableTranslations } from '../hooks/useAvailableTranslations.js';
|
||||
import PageShell from '../components/study/PageShell.jsx';
|
||||
import { bookOptions, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
||||
import { parseEpisodeListDocx, parseEpisodePassage } from '../utils/importParsers.js';
|
||||
|
||||
export default function ImportPage() {
|
||||
const { setCurrentPage, setProject } = useApp();
|
||||
const availableTranslations = useAvailableTranslations();
|
||||
const [importBookAbbrev, setImportBookAbbrev] = useState(bookOptions[0].abbrev);
|
||||
const [importTranslation, setImportTranslation] = useState('BSB');
|
||||
const importTranslation = 'BSB'; // studies use the Berean Standard Bible
|
||||
const [importTitle, setImportTitle] = useState('');
|
||||
const [importPreview, setImportPreview] = useState(null); // parsed episode specs, before fetching chapters
|
||||
const [importBusy, setImportBusy] = useState(false);
|
||||
@@ -149,31 +147,14 @@ export default function ImportPage() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold">Import Session List</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage('home')}
|
||||
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<AuthStatus />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
const field = 'mt-1 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||
|
||||
<main className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8 space-y-6">
|
||||
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-5">
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600 space-y-2">
|
||||
<p className="font-semibold text-slate-700">How this works</p>
|
||||
return (
|
||||
<PageShell title="Import Session List" onBack={() => setCurrentPage('home')} maxWidth="max-w-4xl">
|
||||
<main className="mx-auto max-w-3xl space-y-6 px-4 py-8">
|
||||
<section className="space-y-5 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
|
||||
<div className="space-y-2 rounded-xl bg-[var(--r-bg)] p-4 text-sm text-[var(--r-sub)]">
|
||||
<p className="font-medium text-[var(--r-text)]">How this works</p>
|
||||
<p>
|
||||
This is a shortcut for setting up a multi-part study — a teaching series, sermon series, class
|
||||
curriculum, or podcast — all at once, instead of building each chapter and chunk by hand.
|
||||
@@ -182,100 +163,62 @@ export default function ImportPage() {
|
||||
Upload a .docx containing a table with three columns: session number, title, and passage
|
||||
(e.g. <span className="font-mono text-xs">1:1–2</span>). Each row becomes one chunk, grouped
|
||||
automatically by chapter. If you don't have a document like this, just use{' '}
|
||||
<span className="font-semibold">+ New Project</span> on the home page instead — this import
|
||||
<span className="font-medium text-[var(--r-text)]">+ New Project</span> on the home page instead — this import
|
||||
step is entirely optional.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Book
|
||||
<select
|
||||
value={importBookAbbrev}
|
||||
onChange={(e) => setImportBookAbbrev(e.target.value)}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
{bookOptions.map((b) => (
|
||||
<option key={b.abbrev} value={b.abbrev}>{b.name}</option>
|
||||
))}
|
||||
<label className="block text-sm">Book
|
||||
<select value={importBookAbbrev} onChange={(e) => setImportBookAbbrev(e.target.value)} className={field}>
|
||||
{bookOptions.map((b) => <option key={b.abbrev} value={b.abbrev}>{b.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Translation
|
||||
<select
|
||||
value={importTranslation}
|
||||
onChange={(e) => setImportTranslation(e.target.value)}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
{availableTranslations.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="block text-sm">Project title
|
||||
<input type="text" value={importTitle} onChange={(e) => setImportTitle(e.target.value)}
|
||||
placeholder={`${bookOptions.find((b) => b.abbrev === importBookAbbrev)?.name ?? ''} Sessions`} className={field} />
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--r-sub)]">Text: Berean Standard Bible (BSB).</p>
|
||||
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Project title
|
||||
<input
|
||||
type="text"
|
||||
value={importTitle}
|
||||
onChange={(e) => setImportTitle(e.target.value)}
|
||||
placeholder={`${bookOptions.find((b) => b.abbrev === importBookAbbrev)?.name ?? ''} Sessions`}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
<label className="block text-sm">Session list (.docx)
|
||||
<input type="file" accept=".docx" onChange={(e) => handleImportFileChange(e.target.files?.[0] ?? null)}
|
||||
className="mt-1 block w-full text-sm text-[var(--r-sub)] file:mr-4 file:rounded-lg file:border-0 file:bg-[var(--r-text)] file:px-4 file:py-2 file:text-sm file:text-[var(--r-bg)]" />
|
||||
</label>
|
||||
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Session list (.docx)
|
||||
<input
|
||||
type="file"
|
||||
accept=".docx"
|
||||
onChange={(e) => handleImportFileChange(e.target.files?.[0] ?? null)}
|
||||
className="mt-1 block w-full text-sm text-slate-600 file:mr-4 file:rounded-xl file:border-0 file:bg-slate-700 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-white hover:file:bg-slate-600"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{importBusy && <p className="text-sm text-slate-500">Working…</p>}
|
||||
{importError && <p className="text-sm text-rose-600">{importError}</p>}
|
||||
{importBusy && <p className="text-sm text-[var(--r-sub)]">Working…</p>}
|
||||
{importError && <p className="text-sm text-red-600">{importError}</p>}
|
||||
|
||||
{importPreview && !importBusy && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold text-slate-700">
|
||||
<p className="text-sm font-medium">
|
||||
{importPreview.length} session{importPreview.length === 1 ? '' : 's'} found
|
||||
{' · '}
|
||||
{importPreview.filter((s) => s.parsed && s.parsed !== 'invalid').length} with passages
|
||||
</p>
|
||||
<div className="max-h-80 overflow-y-auto rounded-xl border border-slate-200">
|
||||
<div className="max-h-80 overflow-y-auto rounded-xl border border-[var(--r-line)]">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{importPreview.map((spec) => (
|
||||
<tr key={spec.episodeNumber} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-3 py-1.5 text-slate-500">#{spec.episodeNumber}</td>
|
||||
<td className="px-3 py-1.5 text-slate-900">{spec.title}</td>
|
||||
<td className="px-3 py-1.5 text-right text-slate-500">
|
||||
<tr key={spec.episodeNumber} className="border-b border-[var(--r-line)] last:border-0">
|
||||
<td className="px-3 py-1.5 text-[var(--r-sub)]">#{spec.episodeNumber}</td>
|
||||
<td className="px-3 py-1.5">{spec.title}</td>
|
||||
<td className="px-3 py-1.5 text-right text-[var(--r-sub)]">
|
||||
{spec.parsed === 'invalid'
|
||||
? <span className="text-rose-600">unrecognized — skipped</span>
|
||||
: spec.parsed
|
||||
? spec.passage
|
||||
: <span className="text-slate-400">marker (no passage)</span>}
|
||||
? <span className="text-red-600">unrecognized — skipped</span>
|
||||
: spec.parsed ? spec.passage : <span>marker (no passage)</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runEpisodeImport}
|
||||
disabled={importBusy}
|
||||
className="rounded-xl bg-slate-900 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:opacity-50"
|
||||
>
|
||||
Create Project from Import
|
||||
</button>
|
||||
<button type="button" onClick={runEpisodeImport} disabled={importBusy}
|
||||
className="rounded-full bg-[var(--r-accent)] px-6 py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">Create Project from Import</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
+41
-113
@@ -1,5 +1,8 @@
|
||||
// Every Greek and Hebrew word in the study with its transliteration, so a
|
||||
// speaker can rehearse them. Reached from the Script and Export stages.
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import StudioShell from '../components/study/StudioShell.jsx';
|
||||
import { buildPronunciationGuide } from '../utils/bibleUtils.jsx';
|
||||
import { speakOriginalWord } from '../utils/wordStudy.js';
|
||||
|
||||
@@ -10,7 +13,7 @@ export default function PronunciationPage() {
|
||||
|
||||
const chapters = Array.isArray(project?.chapters) ? project.chapters : [];
|
||||
|
||||
// Collect all words with chapter/chunk context, deduplicated by Strong's number
|
||||
// Collect all words with chapter context, deduplicated by Strong's number
|
||||
const seen = new Set();
|
||||
const entries = [];
|
||||
chapters.forEach((ch) => {
|
||||
@@ -27,161 +30,86 @@ export default function PronunciationPage() {
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
const filtered = q
|
||||
? entries.filter(({ word }) =>
|
||||
(word.englishGloss ?? '').toLowerCase().includes(q) ||
|
||||
(word.shortDefinition ?? '').toLowerCase().includes(q) ||
|
||||
(word.lexeme ?? '').toLowerCase().includes(q) ||
|
||||
(word.transliteration ?? '').toLowerCase().includes(q) ||
|
||||
(word.strongNumber ?? '').toLowerCase().includes(q),
|
||||
)
|
||||
? entries.filter(({ word }) => [word.englishGloss, word.shortDefinition, word.lexeme, word.transliteration, word.strongNumber]
|
||||
.some((f) => (f ?? '').toLowerCase().includes(q)))
|
||||
: entries;
|
||||
|
||||
const greek = filtered.filter(({ word }) => !word.strongNumber?.startsWith('H'));
|
||||
const hebrew = filtered.filter(({ word }) => word.strongNumber?.startsWith('H'));
|
||||
|
||||
const copyAll = () => {
|
||||
const guide = buildPronunciationGuide(project);
|
||||
navigator.clipboard.writeText(guide).then(() => {
|
||||
navigator.clipboard.writeText(buildPronunciationGuide(project)).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<div className="mx-auto max-w-4xl px-4 py-8">
|
||||
{/* Page header */}
|
||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<StudioShell stage="script">
|
||||
<main className="mx-auto max-w-4xl px-4 py-8">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Pronunciation Guide</h1>
|
||||
{project?.title && (
|
||||
<p className="mt-1 text-sm text-slate-500">{project.title}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
{entries.length} unique word{entries.length !== 1 ? 's' : ''} across all chapters
|
||||
</p>
|
||||
<h1 className="text-xl font-medium">Pronunciation Guide</h1>
|
||||
{project?.title && <p className="mt-1 text-sm text-[var(--r-sub)]">{project.title}</p>}
|
||||
<p className="mt-1 text-xs text-[var(--r-sub)]">{entries.length} unique word{entries.length !== 1 ? 's' : ''} across all chapters</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyAll}
|
||||
className="rounded-xl border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
|
||||
>
|
||||
{copied ? '✓ Copied!' : '📋 Copy all'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage('study')}
|
||||
className="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-slate-400"
|
||||
>
|
||||
← Back to Study
|
||||
</button>
|
||||
<button type="button" onClick={copyAll} className={btn}>{copied ? '✓ Copied!' : 'Copy all'}</button>
|
||||
<button type="button" onClick={() => setCurrentPage('script')} className={btn}>← Back to Script</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by word, gloss, Strong's number…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</div>
|
||||
<input type="text" placeholder="Filter by word, gloss, Strong's number…" value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
className="mb-6 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-2.5 text-sm text-[var(--r-text)] placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-slate-300 bg-white p-12 text-center">
|
||||
<p className="text-2xl">🔤</p>
|
||||
<p className="mt-3 font-semibold text-slate-700">No words yet</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Add Greek or Hebrew words in the Word Studies panel on the Study page.
|
||||
</p>
|
||||
<div className="rounded-2xl border border-dashed border-[var(--r-line)] p-12 text-center">
|
||||
<p className="font-medium">No words yet</p>
|
||||
<p className="mt-1 text-sm text-[var(--r-sub)]">Add Greek or Hebrew words in the Word study tab of the Study stage.</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-slate-500">No words match "{search}"</p>
|
||||
<p className="py-8 text-center text-sm text-[var(--r-sub)]">No words match “{search}”</p>
|
||||
) : (
|
||||
<div className="space-y-10">
|
||||
{greek.length > 0 && <WordGroup title="Greek (New Testament)" color="sky" words={greek} onSpeak={speakOriginalWord} />}
|
||||
{hebrew.length > 0 && <WordGroup title="Hebrew (Old Testament)" color="emerald" words={hebrew} onSpeak={speakOriginalWord} />}
|
||||
<div className="space-y-8">
|
||||
{greek.length > 0 && <WordGroup title="Greek (New Testament)" words={greek} onSpeak={speakOriginalWord} />}
|
||||
{hebrew.length > 0 && <WordGroup title="Hebrew (Old Testament)" words={hebrew} onSpeak={speakOriginalWord} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</StudioShell>
|
||||
);
|
||||
}
|
||||
|
||||
function WordGroup({ title, color, words, onSpeak }) {
|
||||
const accent = {
|
||||
sky: { badge: 'bg-sky-100 text-sky-700 border-sky-200', speak: 'bg-sky-100 text-sky-700 hover:bg-sky-200', heading: 'text-sky-800 border-sky-200 bg-sky-50' },
|
||||
emerald: { badge: 'bg-emerald-100 text-emerald-700 border-emerald-200', speak: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200', heading: 'text-emerald-800 border-emerald-200 bg-emerald-50' },
|
||||
}[color];
|
||||
|
||||
function WordGroup({ title, words, onSpeak }) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className={`mb-4 inline-block rounded-full border px-4 py-1.5 text-sm font-semibold ${accent.heading}`}>
|
||||
{title} · {words.length} word{words.length !== 1 ? 's' : ''}
|
||||
</h2>
|
||||
<h2 className="mb-3 text-sm font-medium">{title} <span className="font-normal text-[var(--r-sub)]">· {words.length} word{words.length !== 1 ? 's' : ''}</span></h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{words.map(({ word, chapterRef }) => (
|
||||
<WordCard key={word.id ?? word.lexeme} word={word} chapterRef={chapterRef} accent={accent} onSpeak={onSpeak} />
|
||||
))}
|
||||
{words.map(({ word, chapterRef }) => <WordCard key={word.id ?? word.lexeme} word={word} chapterRef={chapterRef} onSpeak={onSpeak} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WordCard({ word, chapterRef, accent, onSpeak }) {
|
||||
function WordCard({ word, chapterRef, onSpeak }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-3xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
{/* Top row: lexeme + speak */}
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
{word.lexeme && (
|
||||
<p className="text-3xl font-light leading-tight tracking-wide text-slate-900 break-words">
|
||||
{word.lexeme}
|
||||
</p>
|
||||
)}
|
||||
{word.transliteration && (
|
||||
<p className="mt-1 text-base font-medium italic text-slate-500">
|
||||
{word.transliteration}
|
||||
</p>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{word.lexeme && <p className="break-words font-serif text-3xl font-light leading-tight">{word.lexeme}</p>}
|
||||
{word.transliteration && <p className="mt-1 text-base italic text-[var(--r-sub)]">{word.transliteration}</p>}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||
{word.lexeme && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSpeak(word.lexeme, word.strongNumber)}
|
||||
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${accent.speak} border-current/20`}
|
||||
title="Hear pronunciation"
|
||||
>
|
||||
🔊 Speak
|
||||
</button>
|
||||
)}
|
||||
{word.strongNumber && (
|
||||
<span className={`rounded-full border px-2.5 py-0.5 text-xs font-semibold ${accent.badge}`}>
|
||||
{word.strongNumber}
|
||||
</span>
|
||||
)}
|
||||
{word.lexeme && <button type="button" onClick={() => onSpeak(word.lexeme, word.strongNumber)} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5" title="Hear pronunciation">Speak</button>}
|
||||
{word.strongNumber && <span className="rounded-full border border-[var(--r-line)] px-2.5 py-0.5 text-xs text-[var(--r-sub)]">{word.strongNumber}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gloss + definition */}
|
||||
<div className="space-y-0.5">
|
||||
{word.englishGloss && (
|
||||
<p className="text-sm font-semibold text-slate-800">{word.englishGloss}</p>
|
||||
)}
|
||||
{word.shortDefinition && word.shortDefinition !== word.englishGloss && (
|
||||
<p className="text-sm text-slate-500 leading-snug">{word.shortDefinition}</p>
|
||||
)}
|
||||
{word.englishGloss && <p className="text-sm font-medium">{word.englishGloss}</p>}
|
||||
{word.shortDefinition && word.shortDefinition !== word.englishGloss && <p className="text-sm leading-snug text-[var(--r-sub)]">{word.shortDefinition}</p>}
|
||||
</div>
|
||||
|
||||
{/* Footer: part of speech + source passage */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-slate-100 pt-3 text-xs text-slate-400">
|
||||
{word.partOfSpeech && (
|
||||
<span className="rounded-full bg-slate-100 px-2.5 py-0.5 text-slate-600">{word.partOfSpeech}</span>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-[var(--r-line)] pt-3 text-xs text-[var(--r-sub)]">
|
||||
{word.partOfSpeech && <span className="rounded-full border border-[var(--r-line)] px-2.5 py-0.5">{word.partOfSpeech}</span>}
|
||||
<span className="ml-auto">{chapterRef}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// The Script stage: for each chunk (a session, episode or lesson), name it and
|
||||
// write the final script beside your study notes. Claude prompts and the
|
||||
// pronunciation guide live here because this is where they're needed.
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import StudioShell from '../components/study/StudioShell.jsx';
|
||||
import { formatChunkReference } from '../utils/bibleUtils.jsx';
|
||||
import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js';
|
||||
import { buildScriptDraft, spokenMinutes, wordCount } from '../utils/scriptDraft.js';
|
||||
|
||||
const has = (s) => (s ?? '').trim().length > 0;
|
||||
const inputCls = 'w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||
const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-40';
|
||||
|
||||
export default function ScriptPage() {
|
||||
const { project, allChunks, updateProject, updateChunk, authUser, setSaveStatus, setCurrentPage } = useApp();
|
||||
const [notice, setNotice] = useState('');
|
||||
|
||||
const chunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
|
||||
const chapterIndex = chunk ? project.chapters.findIndex((ch) => ch.chunks.some((c) => c.id === chunk.id)) : -1;
|
||||
const reference = chunk ? formatChunkReference(project, chapterIndex, chunk, '–') : '';
|
||||
const script = chunk?.finalScript ?? '';
|
||||
|
||||
const flash = (message) => { setNotice(message); window.setTimeout(() => setNotice(''), 2200); };
|
||||
const select = (id) => updateProject((current) => ({ ...current, selectedChunkId: id }));
|
||||
const set = (patch) => updateChunk(chunk.id, patch);
|
||||
const append = (text) => set({ finalScript: script.trim() ? `${script.replace(/\s+$/, '')}\n\n${text}` : text });
|
||||
|
||||
const draft = () => {
|
||||
if (has(script) && !window.confirm('Replace the current script with a fresh outline from your notes?')) return;
|
||||
set({ finalScript: buildScriptDraft(chunk, reference) });
|
||||
};
|
||||
|
||||
const importDocx = async (file) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
const { default: mammoth } = await import('mammoth');
|
||||
const { value } = await mammoth.extractRawText({ arrayBuffer: await file.arrayBuffer() });
|
||||
set({ finalScript: value.trim() });
|
||||
flash('Imported script from DOCX.');
|
||||
} catch {
|
||||
flash('Could not read that DOCX file.');
|
||||
}
|
||||
};
|
||||
|
||||
// Copies a prompt, then opens claude.ai so it can be pasted straight in.
|
||||
const copyPrompt = (prompt) => {
|
||||
navigator.clipboard.writeText(prompt).then(() => {
|
||||
setSaveStatus('Copied! Opening Claude…');
|
||||
window.setTimeout(() => setSaveStatus(''), 3000);
|
||||
window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
};
|
||||
|
||||
const noteBlock = (label, text) => has(text) && (
|
||||
<div className="rounded-xl border border-[var(--r-line)] p-3">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
<button type="button" onClick={() => append(text.trim())} className="text-[11px] text-[var(--r-accent)] hover:underline">Insert</button>
|
||||
</div>
|
||||
<p className="max-h-40 overflow-y-auto whitespace-pre-line font-serif text-[13px] leading-relaxed text-[var(--r-sub)]">{text}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<StudioShell stage="script" fixedHeight>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto lg:grid lg:grid-cols-[17rem_minmax(0,1fr)_23rem] lg:overflow-hidden">
|
||||
<aside className="min-h-0 border-b border-[var(--r-line)] bg-[var(--r-bar)] lg:overflow-y-auto lg:border-b-0 lg:border-r">
|
||||
<p className="px-3 pb-2 pt-3 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Sessions</p>
|
||||
<div className="space-y-1 px-2 pb-4">
|
||||
{project.chapters.map((ch, ci) => ch.chunks.map((c) => {
|
||||
const selected = c.id === chunk?.id;
|
||||
return (
|
||||
<div key={c.id} className={`rounded-xl border px-2.5 py-2 ${selected ? 'border-[var(--r-line)] bg-[var(--r-bg)]' : 'border-transparent'}`}>
|
||||
<button type="button" onClick={() => select(c.id)} aria-current={selected ? 'true' : undefined} className="flex w-full items-center justify-between gap-2 text-left">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[13px] font-medium">{formatChunkReference(project, ci, c, '–')}</span>
|
||||
<span className="block truncate text-[11px] text-[var(--r-accent)]">{c.episodeNumber ? `${c.episodeNumber}${c.episodeTitle ? ' · ' : ''}` : ''}{c.episodeTitle}</span>
|
||||
</span>
|
||||
<span title={has(c.finalScript) ? 'Script written' : 'No script yet'} className={`h-2.5 w-2.5 shrink-0 rounded-full border ${has(c.finalScript) ? 'border-[var(--r-accent)] bg-[var(--r-accent)]' : 'border-[var(--r-sub)]'}`} />
|
||||
</button>
|
||||
{selected && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<input aria-label="Session number" value={c.episodeNumber ?? ''} onChange={(e) => updateChunk(c.id, { episodeNumber: e.target.value })} placeholder="Session #" className={inputCls} />
|
||||
<input aria-label="Session title" value={c.episodeTitle ?? ''} onChange={(e) => updateChunk(c.id, { episodeTitle: e.target.value })} placeholder="Session title" className={inputCls} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="min-h-0 lg:overflow-y-auto">
|
||||
{chunk && (
|
||||
<div className="mx-auto flex h-full max-w-3xl flex-col gap-3 px-5 py-5">
|
||||
<div>
|
||||
<h1 className="text-xl font-medium">{reference}</h1>
|
||||
<p className="text-xs text-[var(--r-sub)]">{wordCount(script)} words · about {spokenMinutes(script)} min spoken</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button type="button" onClick={draft} className={btn} title="Start from your Observe / Interpret / Apply notes">Build outline from notes</button>
|
||||
<label className={`${btn} cursor-pointer`}>Upload .docx
|
||||
<input type="file" accept=".docx" className="hidden" onChange={(e) => { importDocx(e.target.files?.[0]); e.target.value = ''; }} />
|
||||
</label>
|
||||
<span className="mx-1 h-4 w-px bg-[var(--r-line)]" />
|
||||
<button type="button" onClick={() => copyPrompt(buildClaudePrompt(project))} className={btn}
|
||||
title="Copies your study notes as a Claude prompt, then opens claude.ai">Study guide → Claude ↗</button>
|
||||
<button type="button" onClick={() => copyPrompt(buildPodcastPrompt(project, authUser?.podcastName))} className={btn}
|
||||
title="Copies a podcast script prompt for Claude — set your show name in Account Settings first">Podcast → Claude ↗</button>
|
||||
<button type="button" onClick={() => setCurrentPage('pronunciation')} className={btn}>Pronunciation guide</button>
|
||||
{notice && <span role="status" className="text-xs text-[var(--r-accent)]">{notice}</span>}
|
||||
</div>
|
||||
<textarea aria-label="Final script" value={script} onChange={(e) => set({ finalScript: e.target.value })}
|
||||
placeholder="Write or paste the final script here. “Build outline from notes” starts you from your study notes."
|
||||
className="min-h-[24rem] flex-1 resize-y rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] px-5 py-4 font-serif text-[16px] leading-8 text-[var(--r-text)] placeholder:font-sans placeholder:text-sm placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside className="min-h-0 border-t border-[var(--r-line)] bg-[var(--r-bar)] lg:overflow-y-auto lg:border-l lg:border-t-0">
|
||||
{chunk && (
|
||||
<div className="space-y-2.5 p-4">
|
||||
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes</p>
|
||||
{noteBlock('Background', chunk.generalNotes)}
|
||||
{noteBlock('Observe', chunk.observation)}
|
||||
{noteBlock('Interpret', chunk.interpretation)}
|
||||
{noteBlock('Apply', chunk.application)}
|
||||
{chunk.greekWords.some((w) => w.lexeme) && noteBlock('Key words', chunk.greekWords.filter((w) => w.lexeme).map((w) => `${w.lexeme}${w.transliteration ? ` (${w.transliteration})` : ''}${w.englishGloss || w.shortDefinition ? ` — ${w.englishGloss || w.shortDefinition}` : ''}`).join('\n'))}
|
||||
{(chunk.crossReferences ?? []).length > 0 && noteBlock('Cross-references', chunk.crossReferences.join(', '))}
|
||||
{![chunk.generalNotes, chunk.observation, chunk.interpretation, chunk.application].some(has)
|
||||
&& !chunk.greekWords.some((w) => w.lexeme) && (chunk.crossReferences ?? []).length === 0 && (
|
||||
<p className="text-sm text-[var(--r-sub)]">No notes for this chunk yet. Write them in the Study stage and they’ll show up here to insert.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</StudioShell>
|
||||
);
|
||||
}
|
||||
+188
-399
@@ -1,81 +1,39 @@
|
||||
// The Outline stage: choose a chapter, then cut it into chunks by clicking between
|
||||
// verses (or let the section headings suggest them). Each chunk becomes one unit of
|
||||
// study in the next stage.
|
||||
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 StudioShell from '../components/study/StudioShell.jsx';
|
||||
import { bookOptions, formatChunkReference } from '../utils/bibleUtils.jsx';
|
||||
|
||||
function SetupForm({ setup, availableTranslations, titleEdited, loadingChapter, errorMessage, hideTitle, onField, onTitleChange, onLoad }) {
|
||||
const inputCls = 'mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||
const smallBtn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-40';
|
||||
const primaryBtn = 'rounded-xl bg-[var(--r-accent)] px-5 py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40';
|
||||
|
||||
// Band tints that alternate from chunk to chunk so neighbouring chunks are easy to tell apart.
|
||||
const BANDS = ['color-mix(in srgb, var(--r-accent) 13%, transparent)', 'color-mix(in srgb, var(--r-sub) 17%, transparent)'];
|
||||
|
||||
function PassageForm({ setup, onField, onTitleChange, onLoad, loading, error, withTitle }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!hideTitle && (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Project Setup</h2>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
Pick a translation, chapter, and title. Load the chapter to begin structuring your study into chunks.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Translation
|
||||
<select
|
||||
value={setup.translation}
|
||||
onChange={(e) => onField('translation', e.target.value)}
|
||||
className="mt-2 block w-full rounded-xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
{availableTranslations.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block text-sm">Book
|
||||
<select value={setup.bookAbbrev} onChange={(e) => onField('book', e.target.value)} className={inputCls}>
|
||||
{bookOptions.map((b) => <option key={b.abbrev} value={b.abbrev}>{b.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Book
|
||||
<select
|
||||
value={setup.bookAbbrev}
|
||||
onChange={(e) => onField('book', e.target.value)}
|
||||
className="mt-2 block w-full rounded-xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
{bookOptions.map((book) => (
|
||||
<option key={book.abbrev} value={book.abbrev}>{book.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="block text-sm">Chapter
|
||||
<input type="number" min="1" value={setup.chapter} onChange={(e) => onField('chapter', e.target.value)} className={inputCls} />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Chapter
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={setup.chapter}
|
||||
onChange={(e) => onField('chapter', e.target.value)}
|
||||
className="mt-2 block w-full rounded-xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
{!hideTitle && (
|
||||
<label className="block text-sm font-medium text-slate-700 sm:col-span-2">
|
||||
Project title
|
||||
<input
|
||||
type="text"
|
||||
value={setup.title}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
className="mt-2 block w-full rounded-xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
{withTitle && (
|
||||
<label className="block text-sm sm:col-span-2">Study title
|
||||
<input type="text" value={setup.title} onChange={(e) => onTitleChange(e.target.value)} className={inputCls} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-slate-600">
|
||||
{errorMessage
|
||||
? <span className="text-rose-500">{errorMessage}</span>
|
||||
: 'Start by loading the chapter text from HelloAO.'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoad}
|
||||
disabled={loadingChapter}
|
||||
className="inline-flex items-center justify-center rounded-xl bg-slate-900 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:bg-slate-500"
|
||||
>
|
||||
{loadingChapter ? 'Loading...' : 'Load Chapter'}
|
||||
</button>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-[var(--r-sub)]">{error ? <span className="text-red-600">{error}</span> : 'Text: Berean Standard Bible (BSB).'}</p>
|
||||
<button type="button" onClick={onLoad} disabled={loading} className={primaryBtn}>{loading ? 'Loading…' : 'Load chapter'}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -83,356 +41,187 @@ function SetupForm({ setup, availableTranslations, titleEdited, loadingChapter,
|
||||
|
||||
export default function SetupPage() {
|
||||
const { project, setProject, allChunks, updateProject, setCurrentPage } = useApp();
|
||||
const {
|
||||
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,
|
||||
} = useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage });
|
||||
const availableTranslations = useAvailableTranslations();
|
||||
const b = useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage });
|
||||
const { activeChapter, activeChapterIndex } = b;
|
||||
const chapters = project?.chapters ?? [];
|
||||
|
||||
const chapterTabs = project?.chapters ?? [];
|
||||
const onTitleChange = (val) => { b.setTitleEdited(true); b.handleSetupField('title', val); };
|
||||
const select = (id) => updateProject((current) => ({ ...current, selectedChunkId: id }));
|
||||
|
||||
// Which chunk (if any) each verse of the active chapter belongs to
|
||||
const chunkOf = (n) => activeChapter?.chunks.findIndex((c) => n >= c.startVerse && n <= c.endVerse) ?? -1;
|
||||
const prevChapter = chapters[activeChapterIndex - 1];
|
||||
const spilledFromPrev = (n) => !!prevChapter && prevChapter.bookAbbrev === activeChapter.bookAbbrev
|
||||
&& prevChapter.chunks.some((c) => Number.isInteger(c.spilloverEndVerse) && n <= c.spilloverEndVerse);
|
||||
const lastVerse = activeChapter?.verses.at(-1)?.number ?? 0;
|
||||
const uncovered = activeChapter ? activeChapter.verses.some((v) => chunkOf(v.number) < 0 && !spilledFromPrev(v.number)) : false;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold">
|
||||
{project ? project.title : 'New Study'}
|
||||
</h1>
|
||||
</div>
|
||||
<ProjectHeader loading={loadingChapter} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-7xl px-4 py-4 sm:py-8 sm:px-6 lg:px-8 space-y-8">
|
||||
{/* Project setup form — shown when no project loaded yet */}
|
||||
<StudioShell stage="setup" loading={b.loadingChapter}>
|
||||
<main className="mx-auto max-w-6xl px-4 py-6">
|
||||
{!project && (
|
||||
<section className="mx-auto max-w-3xl rounded-3xl border border-slate-200 bg-white p-8 shadow-panel">
|
||||
<SetupForm
|
||||
setup={setup}
|
||||
availableTranslations={availableTranslations}
|
||||
titleEdited={titleEdited}
|
||||
loadingChapter={loadingChapter}
|
||||
errorMessage={errorMessage}
|
||||
onField={handleSetupField}
|
||||
onTitleChange={(val) => { setTitleEdited(true); handleSetupField('title', val); }}
|
||||
onLoad={handleLoadChapter}
|
||||
/>
|
||||
<section className="mx-auto max-w-xl rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
|
||||
<h1 className="mb-1 text-xl font-medium">New study</h1>
|
||||
<p className="mb-5 text-sm text-[var(--r-sub)]">Pick a chapter, then cut it into chunks. You’ll study one chunk at a time.</p>
|
||||
<PassageForm setup={b.setup} onField={b.handleSetupField} onTitleChange={onTitleChange} onLoad={b.handleLoadChapter}
|
||||
loading={b.loadingChapter} error={b.errorMessage} withTitle />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Chapter tabs + verse/chunk editors */}
|
||||
{project && (
|
||||
<section className="space-y-6">
|
||||
{/* Chapter tabs */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{chapterTabs.map((ch, idx) => (
|
||||
<button
|
||||
key={`${ch.bookAbbrev}-${ch.chapter}`}
|
||||
type="button"
|
||||
onClick={() => { setActiveChapterIndex(idx); setRangeStart(null); setRangeEnd(null); }}
|
||||
className={`rounded-full px-4 py-1.5 text-sm font-medium transition ${
|
||||
activeChapterIndex === idx
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{ch.book} {ch.chapter}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddChapterForm((v) => !v)}
|
||||
className="rounded-full border border-dashed border-slate-400 px-4 py-1.5 text-sm text-slate-500 transition hover:border-slate-600 hover:text-slate-700"
|
||||
>
|
||||
+ Add Chapter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add-chapter form */}
|
||||
{showAddChapterForm && (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
|
||||
<h3 className="mb-4 text-sm font-semibold text-slate-900">Add another chapter</h3>
|
||||
<SetupForm
|
||||
setup={setup}
|
||||
availableTranslations={availableTranslations}
|
||||
titleEdited={titleEdited}
|
||||
loadingChapter={loadingChapter}
|
||||
errorMessage={errorMessage}
|
||||
hideTitle
|
||||
onField={handleSetupField}
|
||||
onTitleChange={(val) => { setTitleEdited(true); handleSetupField('title', val); }}
|
||||
onLoad={handleLoadChapter}
|
||||
/>
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
<section className="min-w-0 space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{chapters.map((ch, idx) => (
|
||||
<button key={`${ch.bookAbbrev}-${ch.chapter}`} type="button" aria-current={b.activeChapterIndex === idx ? 'true' : undefined}
|
||||
onClick={() => { b.setActiveChapterIndex(idx); b.setRangeStart(null); b.setRangeEnd(null); }}
|
||||
className={`rounded-full border px-4 py-1.5 text-sm ${b.activeChapterIndex === idx ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
|
||||
{ch.book} {ch.chapter}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={() => b.setShowAddChapterForm((v) => !v)}
|
||||
className="rounded-full border border-dashed border-[var(--r-sub)] px-4 py-1.5 text-sm text-[var(--r-sub)] hover:bg-black/5">+ Add chapter</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verse + chunk panel for active chapter */}
|
||||
{activeChapter && (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500">Scripture & Chunks</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-slate-900">
|
||||
{activeChapter.book} {activeChapter.chapter} ({project.translation})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-slate-100 px-3 py-2 text-sm text-slate-700">
|
||||
{statusMessage || 'Click/shift-click, or type a verse range, to create a chunk.'}
|
||||
</div>
|
||||
{b.showAddChapterForm && (
|
||||
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
|
||||
<h2 className="mb-3 text-sm font-medium">Add another chapter</h2>
|
||||
<PassageForm setup={b.setup} onField={b.handleSetupField} onTitleChange={onTitleChange} onLoad={b.handleLoadChapter}
|
||||
loading={b.loadingChapter} error={b.errorMessage} />
|
||||
</div>
|
||||
<div className="mt-6 grid min-w-0 gap-6 lg:grid-cols-[1.25fr_0.75fr]">
|
||||
<div className="min-w-0 rounded-3xl border border-slate-200 bg-slate-50 p-4 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium text-slate-600">Chapter verses</span>
|
||||
<span className="text-xs text-slate-500">Click a verse, then shift-click an end verse.</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={verseSearch}
|
||||
onChange={(e) => setVerseSearch(e.target.value)}
|
||||
placeholder="Search verses in this chapter…"
|
||||
className="mb-3 block w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
<div data-testid="verse-list" className="max-h-[520px] overflow-y-auto rounded-3xl border border-slate-200 bg-white p-4 scrollbar-thin">
|
||||
{activeChapter.verses
|
||||
.filter((verse) => verse.text.toLowerCase().includes(verseSearch.trim().toLowerCase()))
|
||||
.map((verse) => {
|
||||
const inRange =
|
||||
rangeStart !== null &&
|
||||
verse.number >= Math.min(rangeStart, rangeEnd) &&
|
||||
verse.number <= Math.max(rangeStart, rangeEnd);
|
||||
const inOwnChapterChunk = activeChapter.chunks.some(
|
||||
(chunk) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse,
|
||||
);
|
||||
const prevChapter = project?.chapters?.[activeChapterIndex - 1] ?? null;
|
||||
const inPrevChapterSpillover = prevChapter
|
||||
? prevChapter.chunks.some((chunk) =>
|
||||
Number.isInteger(chunk.spilloverEndVerse)
|
||||
&& prevChapter.bookAbbrev === activeChapter.bookAbbrev
|
||||
&& verse.number <= chunk.spilloverEndVerse
|
||||
)
|
||||
: false;
|
||||
const inChunk = inOwnChapterChunk || inPrevChapterSpillover;
|
||||
)}
|
||||
|
||||
{activeChapter && (
|
||||
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
|
||||
<div className="mb-3">
|
||||
<p className="text-xs text-[var(--r-sub)]">Scripture & Chunks</p>
|
||||
<h1 className="text-xl font-medium">{activeChapter.book} {activeChapter.chapter} <span className="text-sm font-normal text-[var(--r-sub)]">({project.translation})</span></h1>
|
||||
<p className="mt-1 text-xs text-[var(--r-sub)]">Click the line between two verses to split or join chunks. Or click a verse, then shift-click another, to make a chunk from that range.</p>
|
||||
</div>
|
||||
<input type="text" value={b.verseSearch} onChange={(e) => b.setVerseSearch(e.target.value)} placeholder="Search verses in this chapter…"
|
||||
className="mb-3 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
||||
<div data-testid="verse-list" className="max-h-[68vh] overflow-y-auto rounded-xl bg-[var(--r-bg)] px-3 py-3 font-serif text-[1.02rem] leading-[1.8]">
|
||||
{activeChapter.verses
|
||||
.filter((v) => v.text.toLowerCase().includes(b.verseSearch.trim().toLowerCase()))
|
||||
.map((verse) => {
|
||||
const n = verse.number;
|
||||
const ci = chunkOf(n);
|
||||
const chunk = ci >= 0 ? activeChapter.chunks[ci] : null;
|
||||
const inRange = b.rangeStart !== null && n >= Math.min(b.rangeStart, b.rangeEnd) && n <= Math.max(b.rangeStart, b.rangeEnd);
|
||||
const isFirst = chunk && chunk.startVerse === n;
|
||||
const next = activeChapter.chunks.find((c) => c.startVerse === n + 1);
|
||||
const insideChunk = chunk && n < chunk.endVerse && !chunk.spilloverEndVerse;
|
||||
const endsChunk = chunk && chunk.endVerse === n && next && chunk.id;
|
||||
const showGutter = n < lastVerse && !b.verseSearch.trim();
|
||||
return (
|
||||
<button
|
||||
key={verse.number}
|
||||
type="button"
|
||||
onClick={(event) => handleVerseClick(verse.number, event)}
|
||||
className={`group mb-2 w-full rounded-3xl px-4 py-3 text-left transition ${
|
||||
inRange ? 'bg-sky-100 ring-1 ring-sky-200' : inChunk ? 'bg-slate-100' : 'bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-200 text-sm font-semibold text-slate-700 transition group-hover:bg-slate-300">
|
||||
{verse.number}
|
||||
</span>
|
||||
<span className="ml-3 text-sm leading-relaxed text-slate-700">{verse.text}</span>
|
||||
</button>
|
||||
<div key={n}>
|
||||
{isFirst && (
|
||||
<button type="button" onClick={() => select(chunk.id)}
|
||||
className={`mt-2 block font-sans text-[11px] ${project.selectedChunkId === chunk.id ? 'text-[var(--r-accent)]' : 'text-[var(--r-sub)]'} hover:underline`}>
|
||||
Chunk {ci + 1} · {formatChunkReference(project, activeChapterIndex, chunk, '–')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={(event) => b.handleVerseClick(n, event)}
|
||||
className={`block w-full rounded-lg px-3 py-1.5 text-left transition hover:brightness-95 ${inRange ? 'ring-2 ring-[var(--r-accent)]' : ''}`}
|
||||
style={{ background: chunk ? BANDS[ci % 2] : spilledFromPrev(n) ? BANDS[1] : 'transparent' }}>
|
||||
<span className="mr-2 font-sans text-[11px] font-medium text-[var(--r-accent)]">{n}</span>{verse.text}
|
||||
</button>
|
||||
{showGutter && (insideChunk ? (
|
||||
<button type="button" onClick={() => b.splitAfterVerse(n)} aria-label={`Split after verse ${n}`}
|
||||
className="group relative block h-3 w-full" title="Split the chunk here">
|
||||
<span className="absolute inset-x-2 top-1/2 h-px bg-[var(--r-line)] group-hover:h-0.5 group-hover:bg-[var(--r-accent)]" />
|
||||
<span className="absolute left-1/2 top-1/2 hidden -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--r-bg)] px-2 font-sans text-[10px] text-[var(--r-accent)] group-hover:block group-focus:block">split here</span>
|
||||
</button>
|
||||
) : endsChunk ? (
|
||||
<button type="button" onClick={() => b.joinWithNext(chunk.id)} aria-label={`Join chunks at verse ${n}`}
|
||||
className="group relative block h-4 w-full" title="Join these two chunks (their notes are combined)">
|
||||
<span className="absolute inset-x-2 top-1/2 h-0.5 bg-[var(--r-accent)] opacity-60" />
|
||||
<span className="absolute left-1/2 top-1/2 hidden -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--r-bg)] px-2 font-sans text-[10px] text-[var(--r-accent)] group-hover:block group-focus:block">join</span>
|
||||
</button>
|
||||
) : !chunk && !spilledFromPrev(n) ? (
|
||||
<button type="button" onClick={() => b.splitAfterVerse(n)} aria-label={`End a chunk after verse ${n}`}
|
||||
className="group relative block h-3 w-full" title="End a chunk after this verse">
|
||||
<span className="absolute inset-x-2 top-1/2 h-px border-t border-dashed border-[var(--r-sub)] group-hover:border-[var(--r-accent)]" />
|
||||
<span className="absolute left-1/2 top-1/2 hidden -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--r-bg)] px-2 font-sans text-[10px] text-[var(--r-accent)] group-hover:block group-focus:block">end chunk here</span>
|
||||
</button>
|
||||
) : <div className="h-3" />)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-4 rounded-3xl border border-slate-200 bg-slate-50 p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-slate-900">Chunks</h3>
|
||||
<span className="text-xs text-slate-500">{activeChapter.chunks.length} created</span>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Type chunk range</p>
|
||||
<div className="mt-2 flex flex-wrap items-end gap-2">
|
||||
<label className="min-w-0 flex-1 text-xs text-slate-500">
|
||||
Start
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={activeChapter.verses.at(-1)?.number ?? 1}
|
||||
value={typedChunkStart}
|
||||
onChange={(e) => setTypedChunkStart(e.target.value)}
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-0 flex-1 text-xs text-slate-500">
|
||||
End
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={activeChapter.verses.at(-1)?.number ?? 1}
|
||||
value={typedChunkEnd}
|
||||
onChange={(e) => setTypedChunkEnd(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addTypedChunk();
|
||||
}
|
||||
}}
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-0 flex-1 text-xs text-slate-500">
|
||||
Next ch end (optional)
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={project?.chapters?.[activeChapterIndex + 1]?.verses?.at(-1)?.number ?? 1}
|
||||
value={typedChunkNextEnd}
|
||||
onChange={(e) => setTypedChunkNextEnd(e.target.value)}
|
||||
placeholder="e.g. 5"
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTypedChunk}
|
||||
className="rounded-xl bg-slate-900 px-3 py-2 text-xs font-semibold text-white transition hover:bg-slate-800"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="text-xs text-slate-500">
|
||||
Bulk ranges (comma/new line; use `start-end:nextEnd` to span)
|
||||
<textarea
|
||||
rows={2}
|
||||
value={typedChunkBulk}
|
||||
onChange={(e) => setTypedChunkBulk(e.target.value)}
|
||||
placeholder="1-6, 7-31:5, 6"
|
||||
className="mt-1 block w-full resize-y rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addBulkTypedChunks}
|
||||
className="mt-2 rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 transition hover:border-slate-400 hover:bg-slate-50"
|
||||
>
|
||||
Add All Ranges
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Click-based chapter span</p>
|
||||
<p className="mt-1 text-xs text-slate-500">Click a start verse in this chapter, then choose where to end in the next chapter.</p>
|
||||
<div className="mt-2 flex items-end gap-2">
|
||||
<div className="flex-1 text-xs text-slate-500">
|
||||
Start
|
||||
<div className="mt-1 rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-2 text-sm text-slate-900">
|
||||
{rangeStart ?? 'Not selected'}
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex-1 text-xs text-slate-500">
|
||||
Next ch end
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={project?.chapters?.[activeChapterIndex + 1]?.verses?.at(-1)?.number ?? 1}
|
||||
value={clickedSpanNextEnd}
|
||||
onChange={(e) => setClickedSpanNextEnd(e.target.value)}
|
||||
placeholder="e.g. 5"
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addClickSpanChunk}
|
||||
className="rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700 transition hover:border-slate-400 hover:bg-slate-50"
|
||||
>
|
||||
Span Into Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 max-h-[520px] overflow-y-auto scrollbar-thin">
|
||||
{activeChapter.chunks.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-slate-300 bg-white p-4 text-sm text-slate-500">
|
||||
No chunks yet. Select verse ranges to add sections.
|
||||
</div>
|
||||
) : (
|
||||
activeChapter.chunks.map((chunk, index) => (
|
||||
<div
|
||||
key={chunk.id}
|
||||
className={`rounded-3xl border p-4 ${project.selectedChunkId === chunk.id ? 'border-sky-300 bg-sky-50' : 'border-slate-200 bg-white'} shadow-sm`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateProject((current) => ({ ...current, selectedChunkId: chunk.id }))}
|
||||
className="mb-3 w-full text-left"
|
||||
>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{formatChunkReference(project, activeChapterIndex, chunk, '–')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-600 truncate">
|
||||
{activeChapter.verses.find((v) => v.number === chunk.startVerse)?.text || ''}
|
||||
</p>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveChunk(chunk.id, -1)}
|
||||
disabled={index === 0}
|
||||
className="rounded-full border border-slate-300 bg-white px-2 py-1 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveChunk(chunk.id, 1)}
|
||||
disabled={index === activeChapter.chunks.length - 1}
|
||||
className="rounded-full border border-slate-300 bg-white px-2 py-1 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteChunk(chunk.id)}
|
||||
className="rounded-full border border-rose-300 bg-rose-50 px-2 py-1 text-rose-600 transition hover:bg-rose-100"
|
||||
>
|
||||
× Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={beginStudying}
|
||||
disabled={allChunks.length === 0}
|
||||
className="inline-flex items-center justify-center rounded-xl bg-slate-900 px-6 py-3 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:bg-slate-500"
|
||||
>
|
||||
Begin Studying →
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
{activeChapter && (
|
||||
<aside className="min-w-0 lg:sticky lg:top-4 lg:self-start">
|
||||
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4">
|
||||
<div className="mb-3 flex items-baseline justify-between">
|
||||
<h2 className="text-sm font-medium">Chunks</h2>
|
||||
<span className="text-xs text-[var(--r-sub)]">{activeChapter.chunks.length} created</span>
|
||||
</div>
|
||||
{b.statusMessage && <p role="status" className="mb-3 rounded-lg bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-accent)]">{b.statusMessage}</p>}
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={b.suggestChunks} disabled={b.suggesting} className={smallBtn}
|
||||
title="Make a chunk for each section heading in the text (only verses not yet in a chunk)">{b.suggesting ? 'Suggesting…' : 'Suggest from headings'}</button>
|
||||
{uncovered && activeChapter.chunks.length > 0 && (
|
||||
<button type="button" onClick={() => b.splitAfterVerse(lastVerse)} className={smallBtn} title="Make one chunk from the last chunk to the end of the chapter">Chunk the rest</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-[42vh] space-y-2 overflow-y-auto">
|
||||
{activeChapter.chunks.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-[var(--r-line)] p-3 text-sm text-[var(--r-sub)]">No chunks yet. Click between verses, or suggest them from the headings.</p>
|
||||
) : activeChapter.chunks.map((chunk, index) => (
|
||||
<div key={chunk.id} className={`rounded-xl border p-3 ${project.selectedChunkId === chunk.id ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)]'}`}>
|
||||
<button type="button" onClick={() => select(chunk.id)} className="block w-full text-left">
|
||||
<span className="text-sm font-medium">{formatChunkReference(project, activeChapterIndex, chunk, '–')}</span>
|
||||
<span className="mt-0.5 block truncate font-serif text-xs text-[var(--r-sub)]">{activeChapter.verses.find((v) => v.number === chunk.startVerse)?.text || ''}</span>
|
||||
</button>
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
<button type="button" onClick={() => b.moveChunk(chunk.id, -1)} disabled={index === 0} aria-label="Move chunk up" className={smallBtn}>↑</button>
|
||||
<button type="button" onClick={() => b.moveChunk(chunk.id, 1)} disabled={index === activeChapter.chunks.length - 1} aria-label="Move chunk down" className={smallBtn}>↓</button>
|
||||
<button type="button" onClick={() => b.deleteChunk(chunk.id)} className={`${smallBtn} ml-auto text-red-700`}>× Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<details className="mt-4 rounded-xl border border-[var(--r-line)] p-3 text-xs">
|
||||
<summary className="cursor-pointer font-medium">Type ranges instead</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="min-w-0 flex-1 text-[var(--r-sub)]">Start
|
||||
<input type="number" min="1" max={lastVerse} value={b.typedChunkStart} onChange={(e) => b.setTypedChunkStart(e.target.value)} className={inputCls} /></label>
|
||||
<label className="min-w-0 flex-1 text-[var(--r-sub)]">End
|
||||
<input type="number" min="1" max={lastVerse} value={b.typedChunkEnd} onChange={(e) => b.setTypedChunkEnd(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); b.addTypedChunk(); } }} className={inputCls} /></label>
|
||||
<label className="min-w-0 flex-1 text-[var(--r-sub)]">Next ch end
|
||||
<input type="number" min="1" value={b.typedChunkNextEnd} onChange={(e) => b.setTypedChunkNextEnd(e.target.value)} placeholder="optional" className={inputCls} /></label>
|
||||
<button type="button" onClick={b.addTypedChunk} className={smallBtn}>Add</button>
|
||||
</div>
|
||||
<label className="block text-[var(--r-sub)]">Bulk ranges (comma or new line; <code>start-end:nextEnd</code> spans into the next chapter)
|
||||
<textarea rows={2} value={b.typedChunkBulk} onChange={(e) => b.setTypedChunkBulk(e.target.value)} placeholder="1-6, 7-31:5, 6" className={`${inputCls} resize-y`} />
|
||||
</label>
|
||||
<button type="button" onClick={b.addBulkTypedChunks} className={smallBtn}>Add all ranges</button>
|
||||
<div className="border-t border-[var(--r-line)] pt-3">
|
||||
<p className="mb-1 text-[var(--r-sub)]">Span into the next chapter: click a start verse, then choose where to end there.</p>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1 text-[var(--r-sub)]">Start<div className={`${inputCls} text-[var(--r-text)]`}>{b.rangeStart ?? 'Not selected'}</div></div>
|
||||
<label className="flex-1 text-[var(--r-sub)]">Next ch end
|
||||
<input type="number" min="1" value={b.clickedSpanNextEnd} onChange={(e) => b.setClickedSpanNextEnd(e.target.value)} placeholder="e.g. 5" className={inputCls} /></label>
|
||||
<button type="button" onClick={b.addClickSpanChunk} className={smallBtn}>Span into next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<button type="button" onClick={b.beginStudying} disabled={allChunks.length === 0} className={`${primaryBtn} mt-4 w-full`}>Begin studying →</button>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</StudioShell>
|
||||
);
|
||||
}
|
||||
+94
-1057
File diff suppressed because it is too large.
Load diff
@@ -1,26 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Study constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const COMMENTARY_OPTIONS = [
|
||||
{ id: 'matthew-henry', name: 'Matthew Henry' },
|
||||
{ id: 'john-gill', name: 'John Gill' },
|
||||
{ id: 'jamieson-fausset-brown', name: 'Jamieson-Fausset-Brown' },
|
||||
{ id: 'adam-clarke', name: 'Adam Clarke' },
|
||||
{ id: 'keil-delitzsch', name: 'Keil & Delitzsch (OT)' },
|
||||
{ id: 'tyndale', name: 'Tyndale Open Study Notes' },
|
||||
];
|
||||
|
||||
export const STUDY_TABS = [
|
||||
{ id: 'notes', label: 'Notes' },
|
||||
{ id: 'crossRefs', label: 'Cross-Refs' },
|
||||
{ id: 'wordStudy', label: 'Word Study' },
|
||||
{ id: 'commentary', label: 'Commentary' },
|
||||
{ id: 'script', label: 'Script' },
|
||||
];
|
||||
|
||||
export const CHAPTER_COUNTS = {
|
||||
GEN:50,EXO:40,LEV:27,NUM:36,DEU:34,JOS:24,JDG:21,RUT:4,
|
||||
'1SA':31,'2SA':24,'1KI':22,'2KI':25,'1CH':29,'2CH':36,
|
||||
@@ -474,46 +455,6 @@ export function CopyIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
export function CrossRefChip({ label, onRemove, loadVerseText }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [verseText, setVerseText] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!hovered || verseText || error) return;
|
||||
let cancelled = false;
|
||||
loadVerseText(label)
|
||||
.then((text) => { if (!cancelled) setVerseText(text || 'Verse text unavailable.'); })
|
||||
.catch(() => { if (!cancelled) setError('Verse text unavailable.'); });
|
||||
return () => { cancelled = true; };
|
||||
}, [hovered]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="relative inline-flex items-center gap-1 rounded-full bg-slate-200 px-3 py-1 text-sm text-slate-800"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{label}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="ml-1 text-slate-500 hover:text-rose-600"
|
||||
aria-label={`Remove ${label}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
{hovered && (
|
||||
<span className="absolute bottom-full left-0 z-10 mb-2 w-64 rounded-xl border border-slate-300 bg-white p-3 text-xs font-normal normal-case text-slate-700 shadow-lg">
|
||||
{error || verseText || 'Loading...'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPronunciationGuide(project) {
|
||||
const chapters = Array.isArray(project?.chapters) ? project.chapters : [];
|
||||
const lines = [];
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Turns a chunk's study notes into a starting script outline for a talk or episode.
|
||||
const has = (s) => (s ?? '').trim().length > 0;
|
||||
|
||||
export function buildScriptDraft(chunk, reference) {
|
||||
const title = [chunk.episodeNumber, chunk.episodeTitle].filter(has).join(': ');
|
||||
const words = (chunk.greekWords ?? [])
|
||||
.filter((w) => w.lexeme || w.transliteration)
|
||||
.map((w) => `• ${[w.lexeme, w.transliteration && `(${w.transliteration})`].filter(Boolean).join(' ')}${w.englishGloss || w.shortDefinition ? ` — ${w.englishGloss || w.shortDefinition}` : ''}`);
|
||||
const section = (heading, body) => `${heading}\n${has(body) ? body.trim() : '[…]'}`;
|
||||
return [
|
||||
title || reference,
|
||||
title ? reference : null,
|
||||
'',
|
||||
section('OPENING', ''),
|
||||
'',
|
||||
section('WHAT THE TEXT SAYS', chunk.observation),
|
||||
'',
|
||||
section('WHAT IT MEANS', chunk.interpretation),
|
||||
...(words.length ? ['', 'KEY WORDS', ...words] : []),
|
||||
'',
|
||||
section('WHAT IT MEANS FOR US', chunk.application),
|
||||
'',
|
||||
section('CLOSING', ''),
|
||||
].filter((line) => line !== null).join('\n');
|
||||
}
|
||||
|
||||
export const wordCount = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
|
||||
// Speaking pace for the time estimate: about 150 words a minute.
|
||||
export const spokenMinutes = (s) => Math.round((wordCount(s) / 150) * 10) / 10;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { buildScriptDraft, spokenMinutes, wordCount } from './scriptDraft.js';
|
||||
|
||||
const chunk = (over = {}) => ({
|
||||
episodeNumber: '', episodeTitle: '', observation: '', interpretation: '', application: '', greekWords: [], ...over,
|
||||
});
|
||||
|
||||
test('an outline uses your notes where you have them and placeholders where you do not', () => {
|
||||
const out = buildScriptDraft(chunk({ observation: 'Paul greets Titus.', application: 'Serve well.' }), 'Titus 1:1–4');
|
||||
expect(out.split('\n')[0]).toBe('Titus 1:1–4');
|
||||
expect(out).toContain('WHAT THE TEXT SAYS\nPaul greets Titus.');
|
||||
expect(out).toContain('WHAT IT MEANS\n[…]');
|
||||
expect(out).toContain('WHAT IT MEANS FOR US\nServe well.');
|
||||
expect(out).toContain('OPENING\n[…]');
|
||||
expect(out).toContain('CLOSING\n[…]');
|
||||
});
|
||||
|
||||
test('a titled session leads with its number and title, then the passage', () => {
|
||||
const [first, second] = buildScriptDraft(chunk({ episodeNumber: 'Ep 4', episodeTitle: 'Grace at work' }), 'Titus 2:11–14').split('\n');
|
||||
expect([first, second]).toEqual(['Ep 4: Grace at work', 'Titus 2:11–14']);
|
||||
});
|
||||
|
||||
test('key words are listed when the chunk has word studies', () => {
|
||||
const out = buildScriptDraft(chunk({ greekWords: [{ lexeme: 'χάρις', transliteration: 'charis', englishGloss: 'grace' }, { lexeme: '', transliteration: '' }] }), 'Titus 2:11');
|
||||
expect(out).toContain('KEY WORDS\n• χάρις (charis) — grace');
|
||||
expect(out.match(/•/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('word count and spoken time (about 150 words a minute)', () => {
|
||||
expect(wordCount(' one two\nthree ')).toBe(3);
|
||||
expect(wordCount('')).toBe(0);
|
||||
expect(spokenMinutes(Array(300).fill('word').join(' '))).toBe(2);
|
||||
});
|
||||
Reference in new issue
Block a user