- 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>
203 lines
14 KiB
React
203 lines
14 KiB
React
// 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>
|
||
);
|
||
}
|