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
@@ -0,0 +1,141 @@
|
||||
// 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 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 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 });
|
||||
|
||||
const draft = () => {
|
||||
if (has(script) && !window.confirm('Replace the current script with a fresh outline from your notes?')) return;
|
||||
set({ finalScript: buildScriptDraft(chunk, reference) });
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user