Redesign the study side: Outline, Study, Script and Export stages
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:
nmemmertandClaude Sonnet 5.5 committed 2026-09-29 10:13:46 -04:00
1 parent b1bc1c8ea4
commit 31abee0695
31 files changed
+1911 -2460

No files matched your search

-59
View File
@@ -1,26 +1,7 @@
import { useState, useEffect } from 'react';
// ---------------------------------------------------------------------------
// Study constants
// ---------------------------------------------------------------------------
export const COMMENTARY_OPTIONS = [
{ id: 'matthew-henry', name: 'Matthew Henry' },
{ id: 'john-gill', name: 'John Gill' },
{ id: 'jamieson-fausset-brown', name: 'Jamieson-Fausset-Brown' },
{ id: 'adam-clarke', name: 'Adam Clarke' },
{ id: 'keil-delitzsch', name: 'Keil & Delitzsch (OT)' },
{ id: 'tyndale', name: 'Tyndale Open Study Notes' },
];
export const STUDY_TABS = [
{ id: 'notes', label: 'Notes' },
{ id: 'crossRefs', label: 'Cross-Refs' },
{ id: 'wordStudy', label: 'Word Study' },
{ id: 'commentary', label: 'Commentary' },
{ id: 'script', label: 'Script' },
];
export const CHAPTER_COUNTS = {
GEN:50,EXO:40,LEV:27,NUM:36,DEU:34,JOS:24,JDG:21,RUT:4,
'1SA':31,'2SA':24,'1KI':22,'2KI':25,'1CH':29,'2CH':36,
@@ -474,46 +455,6 @@ export function CopyIcon() {
);
}
export function CrossRefChip({ label, onRemove, loadVerseText }) {
const [hovered, setHovered] = useState(false);
const [verseText, setVerseText] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
if (!hovered || verseText || error) return;
let cancelled = false;
loadVerseText(label)
.then((text) => { if (!cancelled) setVerseText(text || 'Verse text unavailable.'); })
.catch(() => { if (!cancelled) setError('Verse text unavailable.'); });
return () => { cancelled = true; };
}, [hovered]);
return (
<span
className="relative inline-flex items-center gap-1 rounded-full bg-slate-200 px-3 py-1 text-sm text-slate-800"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{label}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="ml-1 text-slate-500 hover:text-rose-600"
aria-label={`Remove ${label}`}
>
×
</button>
)}
{hovered && (
<span className="absolute bottom-full left-0 z-10 mb-2 w-64 rounded-xl border border-slate-300 bg-white p-3 text-xs font-normal normal-case text-slate-700 shadow-lg">
{error || verseText || 'Loading...'}
</span>
)}
</span>
);
}
export function buildPronunciationGuide(project) {
const chapters = Array.isArray(project?.chapters) ? project.chapters : [];
const lines = [];
+29
View File
@@ -0,0 +1,29 @@
// Turns a chunk's study notes into a starting script outline for a talk or episode.
const has = (s) => (s ?? '').trim().length > 0;
export function buildScriptDraft(chunk, reference) {
const title = [chunk.episodeNumber, chunk.episodeTitle].filter(has).join(': ');
const words = (chunk.greekWords ?? [])
.filter((w) => w.lexeme || w.transliteration)
.map((w) => `• ${[w.lexeme, w.transliteration && `(${w.transliteration})`].filter(Boolean).join(' ')}${w.englishGloss || w.shortDefinition ? ` — ${w.englishGloss || w.shortDefinition}` : ''}`);
const section = (heading, body) => `${heading}\n${has(body) ? body.trim() : '[…]'}`;
return [
title || reference,
title ? reference : null,
'',
section('OPENING', ''),
'',
section('WHAT THE TEXT SAYS', chunk.observation),
'',
section('WHAT IT MEANS', chunk.interpretation),
...(words.length ? ['', 'KEY WORDS', ...words] : []),
'',
section('WHAT IT MEANS FOR US', chunk.application),
'',
section('CLOSING', ''),
].filter((line) => line !== null).join('\n');
}
export const wordCount = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
// Speaking pace for the time estimate: about 150 words a minute.
export const spokenMinutes = (s) => Math.round((wordCount(s) / 150) * 10) / 10;
+33
View File
@@ -0,0 +1,33 @@
import { test, expect } from 'vitest';
import { buildScriptDraft, spokenMinutes, wordCount } from './scriptDraft.js';
const chunk = (over = {}) => ({
episodeNumber: '', episodeTitle: '', observation: '', interpretation: '', application: '', greekWords: [], ...over,
});
test('an outline uses your notes where you have them and placeholders where you do not', () => {
const out = buildScriptDraft(chunk({ observation: 'Paul greets Titus.', application: 'Serve well.' }), 'Titus 1:1–4');
expect(out.split('\n')[0]).toBe('Titus 1:1–4');
expect(out).toContain('WHAT THE TEXT SAYS\nPaul greets Titus.');
expect(out).toContain('WHAT IT MEANS\n[…]');
expect(out).toContain('WHAT IT MEANS FOR US\nServe well.');
expect(out).toContain('OPENING\n[…]');
expect(out).toContain('CLOSING\n[…]');
});
test('a titled session leads with its number and title, then the passage', () => {
const [first, second] = buildScriptDraft(chunk({ episodeNumber: 'Ep 4', episodeTitle: 'Grace at work' }), 'Titus 2:11–14').split('\n');
expect([first, second]).toEqual(['Ep 4: Grace at work', 'Titus 2:11–14']);
});
test('key words are listed when the chunk has word studies', () => {
const out = buildScriptDraft(chunk({ greekWords: [{ lexeme: 'χάρις', transliteration: 'charis', englishGloss: 'grace' }, { lexeme: '', transliteration: '' }] }), 'Titus 2:11');
expect(out).toContain('KEY WORDS\n• χάρις (charis) — grace');
expect(out.match(/•/g)).toHaveLength(1);
});
test('word count and spoken time (about 150 words a minute)', () => {
expect(wordCount(' one two\nthree ')).toBe(3);
expect(wordCount('')).toBe(0);
expect(spokenMinutes(Array(300).fill('word').join(' '))).toBe(2);
});