Redesign the study side: Outline, Study, Script and Export stages
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:
nmemmertandClaude Sonnet 5.5 committed 2026-09-29 10:13:46 -04:00
1 parent b1bc1c8ea4
commit 31abee0695
31 files changed
+1911 -2460

No files matched your search

+6 -2
View File
@@ -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
View File
@@ -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();
});
});
+13 -13
View File
@@ -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 />
+9 -5
View File
@@ -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>
-228
View File
@@ -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>&nbsp;</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>
);
}
+1 -1
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}
+31
View File
@@ -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>
);
}
+30
View File
@@ -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>
);
}
+50
View File
@@ -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];
}
+46
View File
@@ -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>
);
}
+60
View File
@@ -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>
);
}
+93
View File
@@ -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>
);
}
-25
View File
@@ -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
View File
@@ -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,
};
}
+109
View File
@@ -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]]);
});
+38
View File
@@ -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,
+63
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+141
View File
@@ -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
View File
@@ -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 &amp; 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
View File
File diff suppressed because it is too large. Load diff
-59
View File
@@ -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 = [];
+29
View File
@@ -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;
+33
View File
@@ -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);
});