Files
study-bible-project/src/pages/ScriptPage.jsx
T
nmemmertandClaude Sonnet 5.5 0c8a632cff Add own-work tracking, Claude hand-off check, and weekly recall
- Long pastes into Observe/Interpret/Apply are tagged as pasted; notes show
  Mine / N pasted and an own-words share. Pastes follow text through merges.
- The "-> Claude" buttons on the Script stage ask first when a chunk lacks
  enough of the user's own (non-pasted) words.
- Weekly recall card on Home: write a passage from memory, compare with notes,
  self-rate; recalls are saved on the chunk and synced.
- Recall history on Home with an eight-week strip and the full list.

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

203 lines
14 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 PresentMode from '../components/PresentMode.jsx';
import DictateButton, { appendSpoken } from '../components/DictateButton.jsx';
import { useIqAvailable } from '../hooks/useIq.js';
import { chunkRefs, gatherIqContext } from '../utils/iqbible.js';
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 { chunksNeedingWork } from '../utils/ownWork.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 iq = useIqAvailable();
const [gathering, setGathering] = useState(false);
const [presenting, setPresenting] = useState(false);
const [heldAction, setHeldAction] = useState(null); // an AI hand-off waiting on the own-work check
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.');
}
};
// AI hand-offs only go straight through once every chunk has your own notes; otherwise ask first.
const needingWork = chunksNeedingWork(allChunks);
const guarded = (action) => () => { if (needingWork.length === 0) action(); else setHeldAction(() => action); };
const goStudy = (id) => { select(id); setHeldAction(null); setCurrentPage('study'); };
// 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');
}).catch(() => toast("Couldn't copy to the clipboard. Click the button again.", { tone: 'error' }));
};
// The podcast prompt plus IQ Bible's background on each passage (commentary, cross-references, word glosses).
const copyPodcastWithContext = async () => {
setGathering(true);
try {
const { text, count, skipped } = await gatherIqContext(project);
const base = buildPodcastPrompt(project, authUser?.podcastName);
const prompt = text ? `${base}\n\n---\nBACKGROUND REFERENCE (from IQ Bible; use it to inform the script, don't quote it at length):\n\n${text}\n` : base;
if (!text) toast('IQ Bible had no background to add; copied the standard prompt', { tone: 'error' });
else if (skipped > 0) toast(`Added background for ${count} passages (the first ${count} of ${count + skipped})`);
copyPrompt(prompt);
} finally {
setGathering(false);
}
};
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={guarded(() => 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={guarded(() => 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>
{iq && (
<button type="button" disabled={gathering} onClick={guarded(copyPodcastWithContext)} className={btn}
title={`Adds IQ Bible commentary, cross-references and word glosses for up to ${chunkRefs(project).refs.length} passages (each uses a few API requests)`}>
{gathering ? 'Gathering…' : 'Podcast + background → Claude ↗'}
</button>
)}
<button type="button" onClick={() => setCurrentPage('pronunciation')} className={btn}>Pronunciation guide</button>
<button type="button" onClick={() => setPresenting(true)} className={btn} title="Full-screen, large type, with a timer and auto-scroll">Present</button>
<DictateButton label="Dictate script" onText={(said) => set({ finalScript: appendSpoken(script, said) })} />
{notice && <span role="status" className="text-xs text-[var(--r-accent)]">{notice}</span>}
</div>
{heldAction && (
<div role="alert" className="rounded-xl border border-[var(--r-accent)] bg-[var(--r-hl)] p-3 text-sm">
<p className="font-medium">Is this your study, or Claude's?</p>
<p className="mt-0.5 text-xs text-[var(--r-sub)]">These chunks don't have much of your own thinking yet. Write it first, then let Claude polish it.</p>
<ul className="mt-2 space-y-1 text-xs">
{needingWork.map(({ chunk: c, missing }) => (
<li key={c.id} className="flex items-center justify-between gap-3">
<span>{formatChunkReference(project, project.chapters.findIndex((ch) => ch.chunks.some((x) => x.id === c.id)), c, '–')}: {missing.map((m) => `${m.label} ${m.have}/${m.need}`).join(', ')}</span>
<button type="button" onClick={() => goStudy(c.id)} className="text-[var(--r-accent)] hover:underline">Write it →</button>
</li>
))}
</ul>
<div className="mt-2.5 flex gap-2">
<button type="button" onClick={() => goStudy(needingWork[0].chunk.id)} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)]">Go write my notes</button>
<button type="button" onClick={() => { const run = heldAction; setHeldAction(null); run(); }} className="rounded-full border border-[var(--r-text)] px-3 py-1 text-xs hover:bg-black/5">Send to Claude anyway</button>
<button type="button" onClick={() => setHeldAction(null)} className="text-xs text-[var(--r-sub)] hover:underline">Cancel</button>
</div>
</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>
{presenting && <PresentMode mode="script" title={reference} subtitle={project.title} script={script} onClose={() => setPresenting(false)} />}
</StudioShell>
);
}