Files
study-bible-project/src/pages/OutlinePage.jsx
T
nmemmertandClaude Sonnet 5.5 d7a1601f80 Outline: share as a read-only link
- Share on the Outline page opens a sheet: create / copy / turn off a
  read-only link, share the link with the system share sheet, or send the
  outline as text
- The link has its own token (separate from the whole-study share link) and
  the public endpoint returns only the outlines, rebuilt field by field:
  no observations, interpretations, applications, scripts or word notes
- Opening the link shows the outline(s) with no sign-in, a switcher between
  the whole-study and per-chunk outlines, and Print / PDF
- New outline_share_token column (added automatically on start)

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

334 lines
24 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 Outline stage: the shape of the message as a real outline — main points (I.), sub-points (A.),
// and deeper (1., a.). There is one outline for the whole study and one for each chunk (pick which at
// the top). A point can be an illustration, application, quote…, carry minutes, a longer note, a verse's
// words, and cross-references or word studies from its chunk. Print it, share it, or take it as a file.
import { useEffect, useLayoutEffect, useRef, 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 { buildChapterSummary } from '../utils/projectStorage.js';
import { compactPassages } from '../utils/passageSummary.js';
import { attachableFromChunk, fetchPassageText } from '../utils/passageText.js';
import ShareOutlineSheet from '../components/ShareOutlineSheet.jsx';
import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline } from '../utils/outlineExport.js';
import {
KINDS, STUDY, addAfter, addChild, addRoot, chunkOf, countNodes, flatten, getNode, indent, insertAt, kindLabel, locate,
moveNode, newNode, outdent, outlineFor, outlineToText, removeNode, seedFromChunks, seedFromVerses, timeSummary, updateNode, withOutline,
} from '../utils/outline.js';
const iconBtn = 'flex h-7 w-7 items-center justify-center rounded-md text-sm text-[var(--r-sub)] hover:bg-black/5 hover:text-[var(--r-text)] disabled:opacity-30 disabled:hover:bg-transparent';
const btn = 'rounded-xl border border-[var(--r-line)] px-3.5 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
const field = 'mt-0.5 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]';
const chip = 'rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]';
// A text box that grows with what is typed, so a long point never hides behind a scroll bar.
function AutoText({ value, onChange, className, ...rest }) {
const ref = useRef(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}, [value]);
return <textarea ref={ref} rows={1} value={value} onChange={(e) => onChange(e.target.value)} className={className} {...rest} />;
}
const pointBox = 'block w-full resize-none overflow-hidden rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-[15px] leading-snug text-[var(--r-text)] placeholder:text-[var(--r-sub)] hover:border-[var(--r-line)] focus:border-[var(--r-line)] focus:bg-[var(--r-bg)] focus:outline-none';
export default function OutlinePage() {
const { project, updateProject, setCurrentPage } = useApp();
const { toast } = useToast();
const [chosen, setChosen] = useState(STUDY);
const scope = chosen === STUDY || chunkOf(project, chosen) ? chosen : STUDY; // a deleted chunk falls back to the whole study
const scopeChunk = scope === STUDY ? null : chunkOf(project, scope);
const tree = outlineFor(project, scope);
const rows = flatten(tree);
const [pendingFocus, setPendingFocus] = useState(null);
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose detail fields are showing
const [shareOpen, setShareOpen] = useState(false);
const [lookup, setLookup] = useState({ id: null, busy: false, error: '' });
// The one way every change is made, always from the latest outline (typing is fast).
const edit = (fn) => updateProject((current) => withOutline(current, scope, fn(outlineFor(current, scope))));
const patch = (id, change) => edit((t) => updateNode(t, id, change));
useEffect(() => {
if (!pendingFocus) return;
document.querySelector(`[data-outline-id="${pendingFocus}"]`)?.focus();
setPendingFocus(null);
}, [pendingFocus, tree]);
// Every chunk, named by passage, for the scope picker and the "linked chunk" choice
const chunkChoices = [];
(project?.chapters ?? []).forEach((chapter, chapterIndex) => chapter.chunks.forEach((chunk) => {
chunkChoices.push({ id: chunk.id, label: formatChunkReference(project, chapterIndex, chunk, '–'), count: countNodes(chunk.outline ?? []) });
}));
const scopeLabel = scope === STUDY ? 'Whole study' : chunkChoices.find((c) => c.id === scope)?.label ?? '';
const title = scope === STUDY ? project.title : `${project.title} — ${scopeLabel}`;
const subtitle = scope === STUDY ? compactPassages(buildChapterSummary(project)) : `${scopeLabel} · ${project.translation ?? 'BSB'}`;
const outline = { title, subtitle, tree };
const addable = scope === STUDY
? seedFromChunks(project, tree).length - tree.length
: seedFromVerses(project, scope, tree).length - tree.length;
const seedLabel = scope === STUDY ? 'Add my chunks' : 'Add the verses';
const addMain = () => { const node = newNode(); edit((t) => addRoot(t, node)); setPendingFocus(node.id); };
const addSub = (id) => { const node = newNode(); edit((t) => addChild(t, id, node)); setPendingFocus(node.id); };
const addSibling = (id) => { const node = newNode(); edit((t) => addAfter(t, id, node)); setPendingFocus(node.id); };
const seed = () => {
edit((t) => (scope === STUDY ? seedFromChunks(project, t) : seedFromVerses(project, scope, t)));
toast(`Added ${addable} point${addable === 1 ? '' : 's'}`);
};
const remove = (id) => {
const where = locate(tree, id);
const gone = getNode(tree, id);
if (!where || !gone) return;
const index = rows.findIndex((r) => r.node.id === id);
edit((t) => removeNode(t, id));
setPendingFocus(rows[index - 1]?.node.id ?? null);
const count = countNodes([gone]);
toast(count > 1 ? `Deleted a point and its ${count - 1} sub-point${count > 2 ? 's' : ''}` : 'Deleted a point', {
action: { label: 'Undo', onClick: () => edit((t) => insertAt(t, where.parentId, where.index, gone)) },
});
};
const onKeyDown = (e, id, text, hasChildren) => {
if (e.nativeEvent.isComposing) return;
const at = rows.findIndex((r) => r.node.id === id);
const el = e.currentTarget;
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); addSibling(id); }
else if (e.key === 'Tab') { e.preventDefault(); edit((t) => (e.shiftKey ? outdent(t, id) : indent(t, id))); setPendingFocus(id); }
else if (e.key === 'Backspace' && text === '' && !hasChildren) { e.preventDefault(); remove(id); }
else if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { e.preventDefault(); edit((t) => moveNode(t, id, e.key === 'ArrowUp' ? -1 : 1)); setPendingFocus(id); }
else if (e.key === 'ArrowUp' && el.selectionStart === 0 && rows[at - 1]) { e.preventDefault(); setPendingFocus(rows[at - 1].node.id); }
else if (e.key === 'ArrowDown' && el.selectionStart === text.length && rows[at + 1]) { e.preventDefault(); setPendingFocus(rows[at + 1].node.id); }
};
// ---- taking it out: copy, share, print, files
const copy = async () => {
try { await navigator.clipboard.writeText(outlineToText(tree)); toast('Outline copied'); } catch { toast("Couldn't copy. Select the text and copy it instead.", { tone: 'error' }); }
};
const print = () => { if (!printOutline(outline)) toast('Allow pop-ups for this site to print or save as PDF.', { tone: 'error' }); };
const word = async () => { try { await downloadOutlineDocx(outline); } catch { toast("Couldn't make the Word file.", { tone: 'error' }); } };
const openChunk = (chunkId) => {
updateProject((current) => ({ ...current, selectedChunkId: chunkId }));
setCurrentPage('study');
};
const toggleMeta = (id) => setMetaOpen((s) => { const next = new Set(s); if (next.has(id)) next.delete(id); else next.add(id); return next; });
// Puts a passage's words into a point (the point's passage field says which).
const addVerseText = async (node) => {
setLookup({ id: node.id, busy: true, error: '' });
try {
const got = await fetchPassageText(node.ref, project.translation ?? 'BSB');
patch(node.id, { scripture: got });
setLookup({ id: null, busy: false, error: '' });
} catch (err) {
setLookup({ id: node.id, busy: false, error: err.message || 'Could not load those verses.' });
}
};
return (
<StudioShell stage="outline">
<main className="mx-auto max-w-3xl px-4 py-8">
<div className="mb-4">
<h1 className="text-xl font-medium">Outline</h1>
<p className="mt-1 text-sm text-[var(--r-sub)]">The shape of the message. Keep one for the whole study, and one for each chunk if you like.</p>
</div>
<div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2">
<label className="flex items-center gap-2 text-sm text-[var(--r-sub)]">Outline for
<select value={scope} onChange={(e) => setChosen(e.target.value)} aria-label="Outline for"
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2.5 py-1.5 text-sm text-[var(--r-text)]">
<option value={STUDY}>Whole study{countNodes(project.outline ?? []) ? ` · ${countNodes(project.outline)}` : ''}</option>
{chunkChoices.map((c) => <option key={c.id} value={c.id}>{c.label}{c.count ? ` · ${c.count}` : ''}</option>)}
</select>
</label>
{timeSummary(tree) && <span className="text-sm text-[var(--r-sub)]">{timeSummary(tree)}</span>}
{scopeChunk && <button type="button" onClick={() => openChunk(scope)} className="text-sm text-[var(--r-accent)] hover:underline">Open this chunk in Study</button>}
</div>
<div className="mb-5 flex flex-wrap items-center gap-2">
<button type="button" onClick={addMain} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ Main point</button>
<button type="button" onClick={seed} disabled={addable <= 0} className={btn}
title={scope === STUDY ? "Adds a main point for each chunk that isn't in the outline yet" : "Adds a point for each verse of this chunk, with its words"}>
{seedLabel}{addable > 0 ? ` (${addable})` : ''}
</button>
<span className="mx-1 hidden h-6 w-px bg-[var(--r-line)] sm:block" aria-hidden="true" />
<button type="button" onClick={() => setShareOpen(true)} disabled={tree.length === 0} className={btn}>Share</button>
<button type="button" onClick={print} disabled={tree.length === 0} className={btn}>Print / PDF</button>
<details className="relative">
<summary className={`${btn} cursor-pointer list-none ${tree.length === 0 ? 'pointer-events-none opacity-50' : ''}`}>More ▾</summary>
<div className="absolute left-0 z-20 mt-1 w-48 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] p-1 shadow-lg">
<button type="button" onClick={copy} className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-black/5">Copy as text</button>
<button type="button" onClick={word} className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-black/5">Download Word (.docx)</button>
<button type="button" onClick={() => downloadOutlineMarkdown(outline)} className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-black/5">Download Markdown</button>
</div>
</details>
</div>
{tree.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--r-line)] p-10 text-center">
<p className="text-base font-medium">No outline yet{scope === STUDY ? '' : ` for ${scopeLabel}`}</p>
<p className="mx-auto mt-2 max-w-md text-sm text-[var(--r-sub)]">
{scope === STUDY
? 'Start from your chunks to get a main point for each passage you studied, or start blank and write your own.'
: 'Start from the verses to get a point for each one, with its words, or start blank and write your own.'}
</p>
<div className="mt-5 flex flex-wrap justify-center gap-2">
<button type="button" onClick={seed} disabled={addable <= 0} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">
{scope === STUDY ? 'Start from my chunks' : 'Start from the verses'}
</button>
<button type="button" onClick={addMain} className={btn}>Start blank</button>
</div>
</div>
) : (
<ul aria-label="Outline" className="space-y-0.5">
{rows.map(({ node, depth, label }, at) => {
const hasChildren = node.children.length > 0;
const showMeta = metaOpen.has(node.id);
const linked = chunkChoices.find((c) => c.id === node.chunkId);
const siblings = rows.filter((r) => r.parentId === rows[at].parentId);
const place = siblings.findIndex((r) => r.node.id === node.id);
const attachSource = chunkOf(project, node.chunkId ?? scopeChunk?.id);
const attached = new Set((node.attachments ?? []).map((a) => a.id));
const available = attachableFromChunk(attachSource).filter((a) => !attached.has(a.key));
const mins = Number.isFinite(node.minutes) && node.minutes > 0;
const noteLine = (node.note ?? '').trim().split('\n')[0];
return (
<li key={node.id} className="group" style={{ paddingLeft: `${depth * 1.75}rem` }}>
<div className="flex items-start gap-1.5">
<span className="w-9 shrink-0 pt-1.5 text-right font-serif text-sm text-[var(--r-sub)]" aria-hidden="true">{label}</span>
<div className="min-w-0 flex-1">
<AutoText
value={node.text} data-outline-id={node.id} className={pointBox}
aria-label={`Point ${label}`} placeholder={depth === 0 ? 'Main point' : 'Sub-point'}
onChange={(value) => patch(node.id, { text: value })}
onKeyDown={(e) => onKeyDown(e, node.id, node.text, hasChildren)}
/>
{/* What the point carries, at a glance */}
{(node.kind || mins || node.ref || linked) && (
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 text-xs text-[var(--r-sub)]">
{node.kind && <span className={`${chip} border-[var(--r-accent)] text-[var(--r-accent)]`}>{kindLabel(node.kind)}</span>}
{mins && <span>{node.minutes} min</span>}
{node.ref && <span>{node.ref}</span>}
{linked && node.chunkId !== scope && <button type="button" onClick={() => openChunk(node.chunkId)} className="text-[var(--r-accent)] hover:underline">Open chunk in Study</button>}
</p>
)}
{noteLine && !showMeta && <p className="truncate px-2 text-xs italic text-[var(--r-sub)]">{noteLine}</p>}
{node.scripture?.text && !showMeta && (
<blockquote className="mx-2 mt-1 border-l-2 border-[var(--r-line)] pl-2.5 font-serif text-sm italic text-[var(--r-sub)]">“{node.scripture.text.length > 220 ? `${node.scripture.text.slice(0, 220)}…` : node.scripture.text}” <span className="not-italic">— {node.scripture.ref}</span></blockquote>
)}
{(node.attachments ?? []).length > 0 && !showMeta && (
<div className="mt-1 flex flex-wrap gap-1 px-2">
{node.attachments.map((a) => <span key={a.id} className={chip} title={a.detail}>{a.type === 'word' ? 'Word' : 'See'}: {a.label}</span>)}
</div>
)}
{showMeta && (
<div className="mb-2 mt-1 space-y-3 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] p-3">
<div className="grid gap-3 sm:grid-cols-[1fr_6rem]">
<label className="text-xs text-[var(--r-sub)]">Type
<select value={node.kind ?? ''} onChange={(e) => patch(node.id, { kind: e.target.value })} className={field}>
{KINDS.map(([id, text]) => <option key={id} value={id}>{text}</option>)}
</select>
</label>
<label className="text-xs text-[var(--r-sub)]">Minutes
<input type="number" min="0" max="240" inputMode="numeric" value={node.minutes ?? ''}
onChange={(e) => patch(node.id, { minutes: e.target.value === '' ? null : Math.max(0, Math.min(240, Math.round(Number(e.target.value)))) })} className={field} />
</label>
</div>
<label className="block text-xs text-[var(--r-sub)]">Notes
<AutoText value={node.note ?? ''} onChange={(value) => patch(node.id, { note: value })} placeholder="Anything to remember when you speak or write this point"
className={`${field} min-h-[4.5rem] resize-none overflow-hidden`} aria-label={`Notes for ${label}`} />
</label>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="text-xs text-[var(--r-sub)]">Passage
<input value={node.ref ?? ''} onChange={(e) => patch(node.id, { ref: e.target.value })} placeholder="e.g. Titus 1:5–9" className={field} />
</label>
<div className="mt-1.5 flex items-center gap-2">
<button type="button" disabled={!node.ref?.trim() || (lookup.busy && lookup.id === node.id)} onClick={() => addVerseText(node)} className={`${btn} !px-3 !py-1 text-xs`}>
{lookup.busy && lookup.id === node.id ? 'Loading…' : node.scripture ? 'Replace verse text' : 'Add verse text'}
</button>
{node.scripture && <button type="button" onClick={() => patch(node.id, { scripture: null })} className="text-xs text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">Remove</button>}
</div>
{lookup.id === node.id && lookup.error && <p role="alert" className="mt-1 text-xs text-[var(--r-danger,#b42318)]">{lookup.error}</p>}
</div>
<label className="text-xs text-[var(--r-sub)]">Linked chunk
<select value={node.chunkId ?? ''} onChange={(e) => patch(node.id, {
chunkId: e.target.value || null,
...(e.target.value && !node.ref ? { ref: chunkChoices.find((c) => c.id === e.target.value)?.label ?? '' } : {}),
})} className={field}>
<option value="">None</option>
{chunkChoices.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
</select>
</label>
</div>
{node.scripture?.text && (
<blockquote className="border-l-2 border-[var(--r-line)] pl-2.5 font-serif text-sm italic text-[var(--r-sub)]">“{node.scripture.text}” <span className="not-italic">— {node.scripture.ref} ({node.scripture.translation})</span></blockquote>
)}
<div>
<p className="text-xs text-[var(--r-sub)]">Attach from {attachSource ? 'its chunk' : 'a chunk'}</p>
{(node.attachments ?? []).length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{node.attachments.map((a) => (
<span key={a.id} className={`${chip} inline-flex items-center gap-1`} title={a.detail}>
{a.type === 'word' ? 'Word' : 'See'}: {a.label}
<button type="button" aria-label={`Remove ${a.label}`} onClick={() => patch(node.id, { attachments: node.attachments.filter((x) => x.id !== a.id) })} className="hover:text-[var(--r-danger,#b42318)]">×</button>
</span>
))}
</div>
)}
{!attachSource ? (
<p className="mt-1 text-xs text-[var(--r-sub)]">Link a chunk above to attach its cross-references and word studies.</p>
) : available.length === 0 ? (
<p className="mt-1 text-xs text-[var(--r-sub)]">{(node.attachments ?? []).length ? 'Everything from this chunk is attached.' : 'This chunk has no cross-references or word studies yet. Add some in the Study stage.'}</p>
) : (
<select value="" aria-label={`Attach to ${label}`} className={field}
onChange={(e) => {
const pick = available.find((a) => a.key === e.target.value);
if (pick) patch(node.id, { attachments: [...(node.attachments ?? []), { id: pick.key, type: pick.type, label: pick.label, detail: pick.detail }] });
}}>
<option value="">Choose a cross-reference or word…</option>
{available.map((a) => <option key={a.key} value={a.key}>{a.type === 'word' ? 'Word' : 'See'}: {a.label}</option>)}
</select>
)}
</div>
</div>
)}
</div>
<div className="flex shrink-0 items-center opacity-0 transition focus-within:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100">
<button type="button" className={iconBtn} aria-label={`Move ${label} out a level`} title="Out a level (Shift+Tab)" disabled={depth === 0} onClick={() => edit((t) => outdent(t, node.id))}>←</button>
<button type="button" className={iconBtn} aria-label={`Move ${label} in a level`} title="In a level (Tab)" disabled={place === 0} onClick={() => edit((t) => indent(t, node.id))}>→</button>
<button type="button" className={iconBtn} aria-label={`Move ${label} up`} title="Up (Alt+↑)" disabled={place === 0} onClick={() => edit((t) => moveNode(t, node.id, -1))}>↑</button>
<button type="button" className={iconBtn} aria-label={`Move ${label} down`} title="Down (Alt+↓)" disabled={place === siblings.length - 1} onClick={() => edit((t) => moveNode(t, node.id, 1))}>↓</button>
<button type="button" className={iconBtn} aria-label={`Add a sub-point under ${label}`} title="Add a sub-point" onClick={() => addSub(node.id)}>+</button>
<button type="button" className={iconBtn} aria-label={`Details for ${label}`} aria-expanded={showMeta} title="Type, minutes, notes, verse text, attachments" onClick={() => toggleMeta(node.id)}>⋯</button>
<button type="button" className={`${iconBtn} hover:!text-[var(--r-danger,#b42318)]`} aria-label={`Delete ${label}`} title="Delete" onClick={() => remove(node.id)}>×</button>
</div>
</div>
</li>
);
})}
</ul>
)}
{tree.length > 0 && (
<p className="mt-6 text-xs text-[var(--r-sub)]">
Enter adds a point · Tab and Shift+Tab change its level · Alt+↑ / ↓ moves it · Backspace on an empty point deletes it · ⋯ opens its details.
</p>
)}
</main>
<ShareOutlineSheet open={shareOpen} onClose={() => setShareOpen(false)} projectId={project.id} outline={outline} />
</StudioShell>
);
}