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

+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