Files
study-bible-project/src/pages/ScriptPage.jsx
T
nmemmertandClaude Sonnet 5.5 d13f55faf7 Add Outline stage, parallel reading, offline saves, backup, sync-conflict handling and more
Study
- New Outline stage (hierarchical I./A./1. points, linked to chunks, in all exports);
  the chunk stage is now "Chunks"
- Home cards: passages shown as ranges, tags capped with "+N more"

Reader
- Side-by-side translations and a Compare tab per verse
- Typeface, line spacing, verse-number settings; text and column scale with the screen
- Highlights/bookmarks sheet with named colours (synced); BSB footnote spacing fix
- Cmd-K palette (passages, studies, notes, marks), keyboard shortcuts and "?" help

Flow and safety
- Toasts with Undo replace most confirm()/alert()
- Back/Forward move between pages
- Server refuses saves over a newer copy (409); keep-mine / use-other banner;
  opening a study no longer re-stamps it
- Latest chapter request wins when flipping quickly

Offline and data
- Save books, plan days or a whole BSB/NET for offline
- Service worker fixes: helloao /api/ URLs were skipped and responses cloned too late
- Settings: download/restore a full backup

Accessibility
- Sheets trap focus and close on Escape, skip link, focus ring, reduced motion,
  theme contrast raised to 4.5:1 (with a test)

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

146 lines
9.4 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 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 { useToast } from '../components/Toasts.jsx';
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 { toast } = useToast();
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 });
// Replaces the script with an outline from your notes — and says how to get the old one back.
const draft = () => {
const before = script;
set({ finalScript: buildScriptDraft(chunk, reference) });
if (has(before)) toast('Script replaced with a fresh outline', { action: { label: 'Undo', onClick: () => set({ finalScript: before }) } });
};
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>
);
}