Redesign the study side: Outline, Study, Script and Export stages
Build and Push Docker Image / build-and-push (push) Successful in 4m15s
Build and Push Docker Image / build-and-push (push) Successful in 4m15s
Studies now move through a stage bar in the reader's themes. Outline splits chunks by clicking between verses (join merges notes) and suggests chunks from section headings. Study is three panes: outline, passage with an Observe / Interpret / Apply accordion, and commentary / word study / cross-refs / people & places for the chunk; original words are added by clicking them in the interlinear. Script drafts an outline from notes; Export holds files and the share link. Home, Import and the Pronunciation guide are restyled. Study projects are BSB only. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b1bc1c8ea4
commit
31abee0695
31 files changed
+1911
-2460
No files matched your search
+188
-399
@@ -1,81 +1,39 @@
|
||||
// The Outline stage: choose a chapter, then cut it into chunks by clicking between
|
||||
// verses (or let the section headings suggest them). Each chunk becomes one unit of
|
||||
// study in the next stage.
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { useChunkBuilder } from '../hooks/useChunkBuilder.js';
|
||||
import ProjectHeader from '../components/ProjectHeader.jsx';
|
||||
import { useAvailableTranslations } from '../hooks/useAvailableTranslations.js';
|
||||
import StudioShell from '../components/study/StudioShell.jsx';
|
||||
import { bookOptions, formatChunkReference } from '../utils/bibleUtils.jsx';
|
||||
|
||||
function SetupForm({ setup, availableTranslations, titleEdited, loadingChapter, errorMessage, hideTitle, onField, onTitleChange, onLoad }) {
|
||||
const inputCls = 'mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||
const smallBtn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-40';
|
||||
const primaryBtn = 'rounded-xl bg-[var(--r-accent)] px-5 py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40';
|
||||
|
||||
// Band tints that alternate from chunk to chunk so neighbouring chunks are easy to tell apart.
|
||||
const BANDS = ['color-mix(in srgb, var(--r-accent) 13%, transparent)', 'color-mix(in srgb, var(--r-sub) 17%, transparent)'];
|
||||
|
||||
function PassageForm({ setup, onField, onTitleChange, onLoad, loading, error, withTitle }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!hideTitle && (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Project Setup</h2>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
Pick a translation, chapter, and title. Load the chapter to begin structuring your study into chunks.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Translation
|
||||
<select
|
||||
value={setup.translation}
|
||||
onChange={(e) => onField('translation', e.target.value)}
|
||||
className="mt-2 block w-full rounded-xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
{availableTranslations.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block text-sm">Book
|
||||
<select value={setup.bookAbbrev} onChange={(e) => onField('book', e.target.value)} className={inputCls}>
|
||||
{bookOptions.map((b) => <option key={b.abbrev} value={b.abbrev}>{b.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Book
|
||||
<select
|
||||
value={setup.bookAbbrev}
|
||||
onChange={(e) => onField('book', e.target.value)}
|
||||
className="mt-2 block w-full rounded-xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
{bookOptions.map((book) => (
|
||||
<option key={book.abbrev} value={book.abbrev}>{book.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="block text-sm">Chapter
|
||||
<input type="number" min="1" value={setup.chapter} onChange={(e) => onField('chapter', e.target.value)} className={inputCls} />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Chapter
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={setup.chapter}
|
||||
onChange={(e) => onField('chapter', e.target.value)}
|
||||
className="mt-2 block w-full rounded-xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
{!hideTitle && (
|
||||
<label className="block text-sm font-medium text-slate-700 sm:col-span-2">
|
||||
Project title
|
||||
<input
|
||||
type="text"
|
||||
value={setup.title}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
className="mt-2 block w-full rounded-xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
{withTitle && (
|
||||
<label className="block text-sm sm:col-span-2">Study title
|
||||
<input type="text" value={setup.title} onChange={(e) => onTitleChange(e.target.value)} className={inputCls} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-slate-600">
|
||||
{errorMessage
|
||||
? <span className="text-rose-500">{errorMessage}</span>
|
||||
: 'Start by loading the chapter text from HelloAO.'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoad}
|
||||
disabled={loadingChapter}
|
||||
className="inline-flex items-center justify-center rounded-xl bg-slate-900 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:bg-slate-500"
|
||||
>
|
||||
{loadingChapter ? 'Loading...' : 'Load Chapter'}
|
||||
</button>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-[var(--r-sub)]">{error ? <span className="text-red-600">{error}</span> : 'Text: Berean Standard Bible (BSB).'}</p>
|
||||
<button type="button" onClick={onLoad} disabled={loading} className={primaryBtn}>{loading ? 'Loading…' : 'Load chapter'}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -83,356 +41,187 @@ function SetupForm({ setup, availableTranslations, titleEdited, loadingChapter,
|
||||
|
||||
export default function SetupPage() {
|
||||
const { project, setProject, allChunks, updateProject, setCurrentPage } = useApp();
|
||||
const {
|
||||
setup,
|
||||
titleEdited, setTitleEdited,
|
||||
activeChapterIndex, setActiveChapterIndex,
|
||||
activeChapter,
|
||||
showAddChapterForm, setShowAddChapterForm,
|
||||
rangeStart, setRangeStart,
|
||||
rangeEnd, setRangeEnd,
|
||||
verseSearch, setVerseSearch,
|
||||
typedChunkStart, setTypedChunkStart,
|
||||
typedChunkEnd, setTypedChunkEnd,
|
||||
typedChunkNextEnd, setTypedChunkNextEnd,
|
||||
typedChunkBulk, setTypedChunkBulk,
|
||||
clickedSpanNextEnd, setClickedSpanNextEnd,
|
||||
loadingChapter,
|
||||
errorMessage,
|
||||
statusMessage,
|
||||
handleSetupField,
|
||||
handleLoadChapter,
|
||||
beginStudying,
|
||||
handleVerseClick,
|
||||
addTypedChunk,
|
||||
addBulkTypedChunks,
|
||||
addClickSpanChunk,
|
||||
moveChunk,
|
||||
deleteChunk,
|
||||
} = useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage });
|
||||
const availableTranslations = useAvailableTranslations();
|
||||
const b = useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage });
|
||||
const { activeChapter, activeChapterIndex } = b;
|
||||
const chapters = project?.chapters ?? [];
|
||||
|
||||
const chapterTabs = project?.chapters ?? [];
|
||||
const onTitleChange = (val) => { b.setTitleEdited(true); b.handleSetupField('title', val); };
|
||||
const select = (id) => updateProject((current) => ({ ...current, selectedChunkId: id }));
|
||||
|
||||
// Which chunk (if any) each verse of the active chapter belongs to
|
||||
const chunkOf = (n) => activeChapter?.chunks.findIndex((c) => n >= c.startVerse && n <= c.endVerse) ?? -1;
|
||||
const prevChapter = chapters[activeChapterIndex - 1];
|
||||
const spilledFromPrev = (n) => !!prevChapter && prevChapter.bookAbbrev === activeChapter.bookAbbrev
|
||||
&& prevChapter.chunks.some((c) => Number.isInteger(c.spilloverEndVerse) && n <= c.spilloverEndVerse);
|
||||
const lastVerse = activeChapter?.verses.at(-1)?.number ?? 0;
|
||||
const uncovered = activeChapter ? activeChapter.verses.some((v) => chunkOf(v.number) < 0 && !spilledFromPrev(v.number)) : false;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold">
|
||||
{project ? project.title : 'New Study'}
|
||||
</h1>
|
||||
</div>
|
||||
<ProjectHeader loading={loadingChapter} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-7xl px-4 py-4 sm:py-8 sm:px-6 lg:px-8 space-y-8">
|
||||
{/* Project setup form — shown when no project loaded yet */}
|
||||
<StudioShell stage="setup" loading={b.loadingChapter}>
|
||||
<main className="mx-auto max-w-6xl px-4 py-6">
|
||||
{!project && (
|
||||
<section className="mx-auto max-w-3xl rounded-3xl border border-slate-200 bg-white p-8 shadow-panel">
|
||||
<SetupForm
|
||||
setup={setup}
|
||||
availableTranslations={availableTranslations}
|
||||
titleEdited={titleEdited}
|
||||
loadingChapter={loadingChapter}
|
||||
errorMessage={errorMessage}
|
||||
onField={handleSetupField}
|
||||
onTitleChange={(val) => { setTitleEdited(true); handleSetupField('title', val); }}
|
||||
onLoad={handleLoadChapter}
|
||||
/>
|
||||
<section className="mx-auto max-w-xl rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
|
||||
<h1 className="mb-1 text-xl font-medium">New study</h1>
|
||||
<p className="mb-5 text-sm text-[var(--r-sub)]">Pick a chapter, then cut it into chunks. You’ll study one chunk at a time.</p>
|
||||
<PassageForm setup={b.setup} onField={b.handleSetupField} onTitleChange={onTitleChange} onLoad={b.handleLoadChapter}
|
||||
loading={b.loadingChapter} error={b.errorMessage} withTitle />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Chapter tabs + verse/chunk editors */}
|
||||
{project && (
|
||||
<section className="space-y-6">
|
||||
{/* Chapter tabs */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{chapterTabs.map((ch, idx) => (
|
||||
<button
|
||||
key={`${ch.bookAbbrev}-${ch.chapter}`}
|
||||
type="button"
|
||||
onClick={() => { setActiveChapterIndex(idx); setRangeStart(null); setRangeEnd(null); }}
|
||||
className={`rounded-full px-4 py-1.5 text-sm font-medium transition ${
|
||||
activeChapterIndex === idx
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{ch.book} {ch.chapter}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddChapterForm((v) => !v)}
|
||||
className="rounded-full border border-dashed border-slate-400 px-4 py-1.5 text-sm text-slate-500 transition hover:border-slate-600 hover:text-slate-700"
|
||||
>
|
||||
+ Add Chapter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add-chapter form */}
|
||||
{showAddChapterForm && (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
|
||||
<h3 className="mb-4 text-sm font-semibold text-slate-900">Add another chapter</h3>
|
||||
<SetupForm
|
||||
setup={setup}
|
||||
availableTranslations={availableTranslations}
|
||||
titleEdited={titleEdited}
|
||||
loadingChapter={loadingChapter}
|
||||
errorMessage={errorMessage}
|
||||
hideTitle
|
||||
onField={handleSetupField}
|
||||
onTitleChange={(val) => { setTitleEdited(true); handleSetupField('title', val); }}
|
||||
onLoad={handleLoadChapter}
|
||||
/>
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
<section className="min-w-0 space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{chapters.map((ch, idx) => (
|
||||
<button key={`${ch.bookAbbrev}-${ch.chapter}`} type="button" aria-current={b.activeChapterIndex === idx ? 'true' : undefined}
|
||||
onClick={() => { b.setActiveChapterIndex(idx); b.setRangeStart(null); b.setRangeEnd(null); }}
|
||||
className={`rounded-full border px-4 py-1.5 text-sm ${b.activeChapterIndex === idx ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
|
||||
{ch.book} {ch.chapter}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={() => b.setShowAddChapterForm((v) => !v)}
|
||||
className="rounded-full border border-dashed border-[var(--r-sub)] px-4 py-1.5 text-sm text-[var(--r-sub)] hover:bg-black/5">+ Add chapter</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verse + chunk panel for active chapter */}
|
||||
{activeChapter && (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500">Scripture & Chunks</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-slate-900">
|
||||
{activeChapter.book} {activeChapter.chapter} ({project.translation})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-slate-100 px-3 py-2 text-sm text-slate-700">
|
||||
{statusMessage || 'Click/shift-click, or type a verse range, to create a chunk.'}
|
||||
</div>
|
||||
{b.showAddChapterForm && (
|
||||
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
|
||||
<h2 className="mb-3 text-sm font-medium">Add another chapter</h2>
|
||||
<PassageForm setup={b.setup} onField={b.handleSetupField} onTitleChange={onTitleChange} onLoad={b.handleLoadChapter}
|
||||
loading={b.loadingChapter} error={b.errorMessage} />
|
||||
</div>
|
||||
<div className="mt-6 grid min-w-0 gap-6 lg:grid-cols-[1.25fr_0.75fr]">
|
||||
<div className="min-w-0 rounded-3xl border border-slate-200 bg-slate-50 p-4 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium text-slate-600">Chapter verses</span>
|
||||
<span className="text-xs text-slate-500">Click a verse, then shift-click an end verse.</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={verseSearch}
|
||||
onChange={(e) => setVerseSearch(e.target.value)}
|
||||
placeholder="Search verses in this chapter…"
|
||||
className="mb-3 block w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
<div data-testid="verse-list" className="max-h-[520px] overflow-y-auto rounded-3xl border border-slate-200 bg-white p-4 scrollbar-thin">
|
||||
{activeChapter.verses
|
||||
.filter((verse) => verse.text.toLowerCase().includes(verseSearch.trim().toLowerCase()))
|
||||
.map((verse) => {
|
||||
const inRange =
|
||||
rangeStart !== null &&
|
||||
verse.number >= Math.min(rangeStart, rangeEnd) &&
|
||||
verse.number <= Math.max(rangeStart, rangeEnd);
|
||||
const inOwnChapterChunk = activeChapter.chunks.some(
|
||||
(chunk) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse,
|
||||
);
|
||||
const prevChapter = project?.chapters?.[activeChapterIndex - 1] ?? null;
|
||||
const inPrevChapterSpillover = prevChapter
|
||||
? prevChapter.chunks.some((chunk) =>
|
||||
Number.isInteger(chunk.spilloverEndVerse)
|
||||
&& prevChapter.bookAbbrev === activeChapter.bookAbbrev
|
||||
&& verse.number <= chunk.spilloverEndVerse
|
||||
)
|
||||
: false;
|
||||
const inChunk = inOwnChapterChunk || inPrevChapterSpillover;
|
||||
)}
|
||||
|
||||
{activeChapter && (
|
||||
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
|
||||
<div className="mb-3">
|
||||
<p className="text-xs text-[var(--r-sub)]">Scripture & Chunks</p>
|
||||
<h1 className="text-xl font-medium">{activeChapter.book} {activeChapter.chapter} <span className="text-sm font-normal text-[var(--r-sub)]">({project.translation})</span></h1>
|
||||
<p className="mt-1 text-xs text-[var(--r-sub)]">Click the line between two verses to split or join chunks. Or click a verse, then shift-click another, to make a chunk from that range.</p>
|
||||
</div>
|
||||
<input type="text" value={b.verseSearch} onChange={(e) => b.setVerseSearch(e.target.value)} placeholder="Search verses in this chapter…"
|
||||
className="mb-3 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
||||
<div data-testid="verse-list" className="max-h-[68vh] overflow-y-auto rounded-xl bg-[var(--r-bg)] px-3 py-3 font-serif text-[1.02rem] leading-[1.8]">
|
||||
{activeChapter.verses
|
||||
.filter((v) => v.text.toLowerCase().includes(b.verseSearch.trim().toLowerCase()))
|
||||
.map((verse) => {
|
||||
const n = verse.number;
|
||||
const ci = chunkOf(n);
|
||||
const chunk = ci >= 0 ? activeChapter.chunks[ci] : null;
|
||||
const inRange = b.rangeStart !== null && n >= Math.min(b.rangeStart, b.rangeEnd) && n <= Math.max(b.rangeStart, b.rangeEnd);
|
||||
const isFirst = chunk && chunk.startVerse === n;
|
||||
const next = activeChapter.chunks.find((c) => c.startVerse === n + 1);
|
||||
const insideChunk = chunk && n < chunk.endVerse && !chunk.spilloverEndVerse;
|
||||
const endsChunk = chunk && chunk.endVerse === n && next && chunk.id;
|
||||
const showGutter = n < lastVerse && !b.verseSearch.trim();
|
||||
return (
|
||||
<button
|
||||
key={verse.number}
|
||||
type="button"
|
||||
onClick={(event) => handleVerseClick(verse.number, event)}
|
||||
className={`group mb-2 w-full rounded-3xl px-4 py-3 text-left transition ${
|
||||
inRange ? 'bg-sky-100 ring-1 ring-sky-200' : inChunk ? 'bg-slate-100' : 'bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-200 text-sm font-semibold text-slate-700 transition group-hover:bg-slate-300">
|
||||
{verse.number}
|
||||
</span>
|
||||
<span className="ml-3 text-sm leading-relaxed text-slate-700">{verse.text}</span>
|
||||
</button>
|
||||
<div key={n}>
|
||||
{isFirst && (
|
||||
<button type="button" onClick={() => select(chunk.id)}
|
||||
className={`mt-2 block font-sans text-[11px] ${project.selectedChunkId === chunk.id ? 'text-[var(--r-accent)]' : 'text-[var(--r-sub)]'} hover:underline`}>
|
||||
Chunk {ci + 1} · {formatChunkReference(project, activeChapterIndex, chunk, '–')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={(event) => b.handleVerseClick(n, event)}
|
||||
className={`block w-full rounded-lg px-3 py-1.5 text-left transition hover:brightness-95 ${inRange ? 'ring-2 ring-[var(--r-accent)]' : ''}`}
|
||||
style={{ background: chunk ? BANDS[ci % 2] : spilledFromPrev(n) ? BANDS[1] : 'transparent' }}>
|
||||
<span className="mr-2 font-sans text-[11px] font-medium text-[var(--r-accent)]">{n}</span>{verse.text}
|
||||
</button>
|
||||
{showGutter && (insideChunk ? (
|
||||
<button type="button" onClick={() => b.splitAfterVerse(n)} aria-label={`Split after verse ${n}`}
|
||||
className="group relative block h-3 w-full" title="Split the chunk here">
|
||||
<span className="absolute inset-x-2 top-1/2 h-px bg-[var(--r-line)] group-hover:h-0.5 group-hover:bg-[var(--r-accent)]" />
|
||||
<span className="absolute left-1/2 top-1/2 hidden -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--r-bg)] px-2 font-sans text-[10px] text-[var(--r-accent)] group-hover:block group-focus:block">split here</span>
|
||||
</button>
|
||||
) : endsChunk ? (
|
||||
<button type="button" onClick={() => b.joinWithNext(chunk.id)} aria-label={`Join chunks at verse ${n}`}
|
||||
className="group relative block h-4 w-full" title="Join these two chunks (their notes are combined)">
|
||||
<span className="absolute inset-x-2 top-1/2 h-0.5 bg-[var(--r-accent)] opacity-60" />
|
||||
<span className="absolute left-1/2 top-1/2 hidden -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--r-bg)] px-2 font-sans text-[10px] text-[var(--r-accent)] group-hover:block group-focus:block">join</span>
|
||||
</button>
|
||||
) : !chunk && !spilledFromPrev(n) ? (
|
||||
<button type="button" onClick={() => b.splitAfterVerse(n)} aria-label={`End a chunk after verse ${n}`}
|
||||
className="group relative block h-3 w-full" title="End a chunk after this verse">
|
||||
<span className="absolute inset-x-2 top-1/2 h-px border-t border-dashed border-[var(--r-sub)] group-hover:border-[var(--r-accent)]" />
|
||||
<span className="absolute left-1/2 top-1/2 hidden -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--r-bg)] px-2 font-sans text-[10px] text-[var(--r-accent)] group-hover:block group-focus:block">end chunk here</span>
|
||||
</button>
|
||||
) : <div className="h-3" />)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-4 rounded-3xl border border-slate-200 bg-slate-50 p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-slate-900">Chunks</h3>
|
||||
<span className="text-xs text-slate-500">{activeChapter.chunks.length} created</span>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Type chunk range</p>
|
||||
<div className="mt-2 flex flex-wrap items-end gap-2">
|
||||
<label className="min-w-0 flex-1 text-xs text-slate-500">
|
||||
Start
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={activeChapter.verses.at(-1)?.number ?? 1}
|
||||
value={typedChunkStart}
|
||||
onChange={(e) => setTypedChunkStart(e.target.value)}
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-0 flex-1 text-xs text-slate-500">
|
||||
End
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={activeChapter.verses.at(-1)?.number ?? 1}
|
||||
value={typedChunkEnd}
|
||||
onChange={(e) => setTypedChunkEnd(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addTypedChunk();
|
||||
}
|
||||
}}
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-0 flex-1 text-xs text-slate-500">
|
||||
Next ch end (optional)
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={project?.chapters?.[activeChapterIndex + 1]?.verses?.at(-1)?.number ?? 1}
|
||||
value={typedChunkNextEnd}
|
||||
onChange={(e) => setTypedChunkNextEnd(e.target.value)}
|
||||
placeholder="e.g. 5"
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTypedChunk}
|
||||
className="rounded-xl bg-slate-900 px-3 py-2 text-xs font-semibold text-white transition hover:bg-slate-800"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="text-xs text-slate-500">
|
||||
Bulk ranges (comma/new line; use `start-end:nextEnd` to span)
|
||||
<textarea
|
||||
rows={2}
|
||||
value={typedChunkBulk}
|
||||
onChange={(e) => setTypedChunkBulk(e.target.value)}
|
||||
placeholder="1-6, 7-31:5, 6"
|
||||
className="mt-1 block w-full resize-y rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addBulkTypedChunks}
|
||||
className="mt-2 rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 transition hover:border-slate-400 hover:bg-slate-50"
|
||||
>
|
||||
Add All Ranges
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Click-based chapter span</p>
|
||||
<p className="mt-1 text-xs text-slate-500">Click a start verse in this chapter, then choose where to end in the next chapter.</p>
|
||||
<div className="mt-2 flex items-end gap-2">
|
||||
<div className="flex-1 text-xs text-slate-500">
|
||||
Start
|
||||
<div className="mt-1 rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-2 text-sm text-slate-900">
|
||||
{rangeStart ?? 'Not selected'}
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex-1 text-xs text-slate-500">
|
||||
Next ch end
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={project?.chapters?.[activeChapterIndex + 1]?.verses?.at(-1)?.number ?? 1}
|
||||
value={clickedSpanNextEnd}
|
||||
onChange={(e) => setClickedSpanNextEnd(e.target.value)}
|
||||
placeholder="e.g. 5"
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addClickSpanChunk}
|
||||
className="rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700 transition hover:border-slate-400 hover:bg-slate-50"
|
||||
>
|
||||
Span Into Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 max-h-[520px] overflow-y-auto scrollbar-thin">
|
||||
{activeChapter.chunks.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-slate-300 bg-white p-4 text-sm text-slate-500">
|
||||
No chunks yet. Select verse ranges to add sections.
|
||||
</div>
|
||||
) : (
|
||||
activeChapter.chunks.map((chunk, index) => (
|
||||
<div
|
||||
key={chunk.id}
|
||||
className={`rounded-3xl border p-4 ${project.selectedChunkId === chunk.id ? 'border-sky-300 bg-sky-50' : 'border-slate-200 bg-white'} shadow-sm`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateProject((current) => ({ ...current, selectedChunkId: chunk.id }))}
|
||||
className="mb-3 w-full text-left"
|
||||
>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{formatChunkReference(project, activeChapterIndex, chunk, '–')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-600 truncate">
|
||||
{activeChapter.verses.find((v) => v.number === chunk.startVerse)?.text || ''}
|
||||
</p>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveChunk(chunk.id, -1)}
|
||||
disabled={index === 0}
|
||||
className="rounded-full border border-slate-300 bg-white px-2 py-1 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveChunk(chunk.id, 1)}
|
||||
disabled={index === activeChapter.chunks.length - 1}
|
||||
className="rounded-full border border-slate-300 bg-white px-2 py-1 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteChunk(chunk.id)}
|
||||
className="rounded-full border border-rose-300 bg-rose-50 px-2 py-1 text-rose-600 transition hover:bg-rose-100"
|
||||
>
|
||||
× Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={beginStudying}
|
||||
disabled={allChunks.length === 0}
|
||||
className="inline-flex items-center justify-center rounded-xl bg-slate-900 px-6 py-3 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:bg-slate-500"
|
||||
>
|
||||
Begin Studying →
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
{activeChapter && (
|
||||
<aside className="min-w-0 lg:sticky lg:top-4 lg:self-start">
|
||||
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4">
|
||||
<div className="mb-3 flex items-baseline justify-between">
|
||||
<h2 className="text-sm font-medium">Chunks</h2>
|
||||
<span className="text-xs text-[var(--r-sub)]">{activeChapter.chunks.length} created</span>
|
||||
</div>
|
||||
{b.statusMessage && <p role="status" className="mb-3 rounded-lg bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-accent)]">{b.statusMessage}</p>}
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={b.suggestChunks} disabled={b.suggesting} className={smallBtn}
|
||||
title="Make a chunk for each section heading in the text (only verses not yet in a chunk)">{b.suggesting ? 'Suggesting…' : 'Suggest from headings'}</button>
|
||||
{uncovered && activeChapter.chunks.length > 0 && (
|
||||
<button type="button" onClick={() => b.splitAfterVerse(lastVerse)} className={smallBtn} title="Make one chunk from the last chunk to the end of the chapter">Chunk the rest</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-[42vh] space-y-2 overflow-y-auto">
|
||||
{activeChapter.chunks.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-[var(--r-line)] p-3 text-sm text-[var(--r-sub)]">No chunks yet. Click between verses, or suggest them from the headings.</p>
|
||||
) : activeChapter.chunks.map((chunk, index) => (
|
||||
<div key={chunk.id} className={`rounded-xl border p-3 ${project.selectedChunkId === chunk.id ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)]'}`}>
|
||||
<button type="button" onClick={() => select(chunk.id)} className="block w-full text-left">
|
||||
<span className="text-sm font-medium">{formatChunkReference(project, activeChapterIndex, chunk, '–')}</span>
|
||||
<span className="mt-0.5 block truncate font-serif text-xs text-[var(--r-sub)]">{activeChapter.verses.find((v) => v.number === chunk.startVerse)?.text || ''}</span>
|
||||
</button>
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
<button type="button" onClick={() => b.moveChunk(chunk.id, -1)} disabled={index === 0} aria-label="Move chunk up" className={smallBtn}>↑</button>
|
||||
<button type="button" onClick={() => b.moveChunk(chunk.id, 1)} disabled={index === activeChapter.chunks.length - 1} aria-label="Move chunk down" className={smallBtn}>↓</button>
|
||||
<button type="button" onClick={() => b.deleteChunk(chunk.id)} className={`${smallBtn} ml-auto text-red-700`}>× Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<details className="mt-4 rounded-xl border border-[var(--r-line)] p-3 text-xs">
|
||||
<summary className="cursor-pointer font-medium">Type ranges instead</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="min-w-0 flex-1 text-[var(--r-sub)]">Start
|
||||
<input type="number" min="1" max={lastVerse} value={b.typedChunkStart} onChange={(e) => b.setTypedChunkStart(e.target.value)} className={inputCls} /></label>
|
||||
<label className="min-w-0 flex-1 text-[var(--r-sub)]">End
|
||||
<input type="number" min="1" max={lastVerse} value={b.typedChunkEnd} onChange={(e) => b.setTypedChunkEnd(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); b.addTypedChunk(); } }} className={inputCls} /></label>
|
||||
<label className="min-w-0 flex-1 text-[var(--r-sub)]">Next ch end
|
||||
<input type="number" min="1" value={b.typedChunkNextEnd} onChange={(e) => b.setTypedChunkNextEnd(e.target.value)} placeholder="optional" className={inputCls} /></label>
|
||||
<button type="button" onClick={b.addTypedChunk} className={smallBtn}>Add</button>
|
||||
</div>
|
||||
<label className="block text-[var(--r-sub)]">Bulk ranges (comma or new line; <code>start-end:nextEnd</code> spans into the next chapter)
|
||||
<textarea rows={2} value={b.typedChunkBulk} onChange={(e) => b.setTypedChunkBulk(e.target.value)} placeholder="1-6, 7-31:5, 6" className={`${inputCls} resize-y`} />
|
||||
</label>
|
||||
<button type="button" onClick={b.addBulkTypedChunks} className={smallBtn}>Add all ranges</button>
|
||||
<div className="border-t border-[var(--r-line)] pt-3">
|
||||
<p className="mb-1 text-[var(--r-sub)]">Span into the next chapter: click a start verse, then choose where to end there.</p>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1 text-[var(--r-sub)]">Start<div className={`${inputCls} text-[var(--r-text)]`}>{b.rangeStart ?? 'Not selected'}</div></div>
|
||||
<label className="flex-1 text-[var(--r-sub)]">Next ch end
|
||||
<input type="number" min="1" value={b.clickedSpanNextEnd} onChange={(e) => b.setClickedSpanNextEnd(e.target.value)} placeholder="e.g. 5" className={inputCls} /></label>
|
||||
<button type="button" onClick={b.addClickSpanChunk} className={smallBtn}>Span into next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<button type="button" onClick={b.beginStudying} disabled={allChunks.length === 0} className={`${primaryBtn} mt-4 w-full`}>Begin studying →</button>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</StudioShell>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user