Outline: per-chunk outlines, richer points, print and share
- One outline for the whole study plus one per chunk (picker at the top); chunk outlines can start from the chunk's verses, each carrying its words - Points can have a type (illustration, application, quote, question, transition), minutes (with a running total), a longer note, verse text fetched from a passage, and cross-references / word studies attached from the chunk - Share (system share sheet, or copy), Print / PDF, Copy as text, Word and Markdown downloads for any one outline - All of it included in the Markdown, HTML/PDF and Word study exports, with each chunk's outline under that chunk Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d273c8cb3e
commit
820886e3ab
12 files changed
+741
-103
No files matched your search
+207
-77
@@ -1,21 +1,28 @@
|
||||
// The Outline stage: the shape of the message as a real outline — main points (I.), sub-points (A.),
|
||||
// and deeper (1., a.) — written after studying the chunks. Each point can carry a passage and link to
|
||||
// the chunk it comes from. Everything is kept on the project and goes out with the exports.
|
||||
// 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 { downloadOutlineDocx, downloadOutlineMarkdown, printOutline, shareOutline } from '../utils/outlineExport.js';
|
||||
import {
|
||||
addAfter, addChild, addRoot, countNodes, flatten, getNode, indent, insertAt, locate, moveNode, newNode,
|
||||
outdent, outlineToText, removeNode, seedFromChunks, updateNode,
|
||||
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 PointText({ value, onChange, ...rest }) {
|
||||
function AutoText({ value, onChange, className, ...rest }) {
|
||||
const ref = useRef(null);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
@@ -23,23 +30,25 @@ function PointText({ value, onChange, ...rest }) {
|
||||
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="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"
|
||||
{...rest} />
|
||||
);
|
||||
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, allChunks, updateProject, setCurrentPage } = useApp();
|
||||
const { project, updateProject, setCurrentPage } = useApp();
|
||||
const { toast } = useToast();
|
||||
const tree = project?.outline ?? [];
|
||||
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 passage/chunk fields are showing
|
||||
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose detail fields are showing
|
||||
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) => ({ ...current, outline: fn(current.outline ?? []) }));
|
||||
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;
|
||||
@@ -47,27 +56,27 @@ export default function OutlinePage() {
|
||||
setPendingFocus(null);
|
||||
}, [pendingFocus, tree]);
|
||||
|
||||
// The chunk choices, named by passage
|
||||
// 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, '–') });
|
||||
chunkChoices.push({ id: chunk.id, label: formatChunkReference(project, chapterIndex, chunk, '–'), count: countNodes(chunk.outline ?? []) });
|
||||
}));
|
||||
const unlinked = seedFromChunks(project, tree).length - tree.length;
|
||||
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 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 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);
|
||||
@@ -94,42 +103,87 @@ export default function OutlinePage() {
|
||||
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 addChunks = () => {
|
||||
edit((t) => seedFromChunks(project, t));
|
||||
toast(`Added ${unlinked} point${unlinked === 1 ? '' : 's'} from your chunks`);
|
||||
const share = async () => {
|
||||
const result = await shareOutline(outline);
|
||||
if (result === 'copied') toast('Outline copied. Paste it into a message or email.');
|
||||
else if (result === 'failed') toast("Couldn't share or copy the outline.", { 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-5 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-medium">Outline</h1>
|
||||
<p className="mt-1 text-sm text-[var(--r-sub)]">The shape of the message: main points, sub-points under them, and deeper if you need it.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap 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={addChunks} disabled={unlinked === 0} className={btn} title="Adds a main point for each chunk that isn't in the outline yet">
|
||||
Add my chunks{unlinked > 0 ? ` (${unlinked})` : ''}
|
||||
</button>
|
||||
<button type="button" onClick={copy} disabled={tree.length === 0} className={btn}>Copy as text</button>
|
||||
</div>
|
||||
<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={share} 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</p>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-[var(--r-sub)]">Start from your chunks to get a main point for each passage you studied, or start blank and write your own.</p>
|
||||
<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={addChunks} disabled={unlinked === 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">Start from my chunks</button>
|
||||
<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>
|
||||
@@ -137,54 +191,130 @@ export default function OutlinePage() {
|
||||
<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) || (!!node.ref && false);
|
||||
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">
|
||||
<PointText
|
||||
value={node.text} data-outline-id={node.id}
|
||||
aria-label={`Point ${label}`}
|
||||
placeholder={depth === 0 ? 'Main point' : 'Sub-point'}
|
||||
onChange={(value) => edit((t) => updateNode(t, node.id, { text: value }))}
|
||||
<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)}
|
||||
/>
|
||||
{(node.ref || linked) && !showMeta && (
|
||||
<p className="px-2 text-xs text-[var(--r-sub)]">
|
||||
{node.ref}
|
||||
{linked && <>{node.ref ? ' · ' : ''}<button type="button" onClick={() => openChunk(node.chunkId)} className="text-[var(--r-accent)] hover:underline">Open chunk in Study</button></>}
|
||||
|
||||
{/* 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-1 grid gap-2 px-2 pt-1 sm:grid-cols-2">
|
||||
<label className="text-xs text-[var(--r-sub)]">Passage
|
||||
<input value={node.ref} onChange={(e) => edit((t) => updateNode(t, node.id, { ref: e.target.value }))} placeholder="e.g. Titus 1:5–9"
|
||||
className="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)]" />
|
||||
</label>
|
||||
<label className="text-xs text-[var(--r-sub)]">Linked chunk
|
||||
<select value={node.chunkId ?? ''} onChange={(e) => edit((t) => updateNode(t, node.id, {
|
||||
chunkId: e.target.value || null,
|
||||
...(e.target.value && !node.ref ? { ref: chunkChoices.find((c) => c.id === e.target.value)?.label ?? '' } : {}),
|
||||
}))}
|
||||
className="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)]">
|
||||
<option value="">None</option>
|
||||
{chunkChoices.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
|
||||
</select>
|
||||
<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} 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={`Passage and chunk for ${label}`} aria-expanded={showMeta} title="Passage and chunk" onClick={() => toggleMeta(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>
|
||||
@@ -196,7 +326,7 @@ export default function OutlinePage() {
|
||||
|
||||
{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.
|
||||
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>
|
||||
|
||||
Reference in new issue
Block a user