Files
study-bible-project/src/pages/SetupPage.jsx
T
nmemmertandClaude Sonnet 5.5 d29a66f984
Build and Push Docker Image / build-and-push (push) Successful in 7m32s
Make the app work better on iPhone and iPad
- Text fields are at least 16px on touch screens (stops iOS zooming on focus)
- Chapter swipe ignores sheets, fields, sliders, sideways-scrolling rows and text selection
- vh -> dvh for sheets, palette and dialogs
- Bigger tap targets for small links, chips and the logo; no double-tap delay
- No tap flash or landscape text inflation

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 13:08:07 -04:00

241 lines
19 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { STUDY_TEMPLATES, getTemplate } from '../utils/studyTemplates.js';
import { useApp } from '../context/AppContext.js';
import { useChunkBuilder } from '../hooks/useChunkBuilder.js';
import StudioShell from '../components/study/StudioShell.jsx';
import { bookOptions, formatChunkReference } from '../utils/bibleUtils.jsx';
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, userTemplates = [], onDeleteTemplate }) {
return (
<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">Chapter
<input type="number" min="1" value={setup.chapter} onChange={(e) => onField('chapter', e.target.value)} className={inputCls} />
</label>
{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>
)}
{withTitle && (
<label className="block text-sm sm:col-span-2">Start from
<select value={setup.template ?? 'blank'} onChange={(e) => onField('template', e.target.value)} className={inputCls}>
{STUDY_TEMPLATES.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
{userTemplates.length > 0 && <optgroup label="Your templates">{userTemplates.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</optgroup>}
</select>
<span className="mt-1 block text-xs text-[var(--r-sub)]">{getTemplate(setup.template, userTemplates).description}</span>
{setup.template?.startsWith('user-') && onDeleteTemplate && (
<button type="button" onClick={() => { onDeleteTemplate(setup.template); onField('template', 'blank'); }} className="mt-1 text-xs text-[var(--r-danger,#b42318)] hover:underline">Delete this template</button>
)}
</label>
)}
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-[var(--r-sub)]">{error ? <span className="text-[var(--r-danger,#b42318)]">{error}</span> : 'Text: Berean Standard Bible (BSB).'}</p>
<button type="button" onClick={onLoad} disabled={loading} className={primaryBtn}>{loading ? 'Loading…' : 'Load chapter'}</button>
</div>
</div>
);
}
export default function SetupPage() {
const { project, setProject, allChunks, updateProject, setCurrentPage, userTemplates, deleteUserTemplate } = useApp();
const b = useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage, userTemplates });
const { activeChapter, activeChapterIndex } = b;
const chapters = 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 (
<StudioShell stage="setup" loading={b.loadingChapter}>
<main className="mx-auto max-w-6xl px-4 py-6">
{!project && (
<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 userTemplates={userTemplates} onDeleteTemplate={deleteUserTemplate} />
</section>
)}
{project && (
<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>
{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>
)}
{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-[68dvh] 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 (
<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>
)}
</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-[42dvh] 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-[var(--r-danger,#b42318)]`}>× 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>
</StudioShell>
);
}