Build and Push Docker Image / build-and-push (push) Successful in 6m47s
- Admin: sign-up Open / Invite only / Closed with single-use invite links - Present mode for scripts (auto-scroll) and outlines (step through, pace vs planned minutes) - Series: group studies on the dashboard, filter by series, calendar file for a study's sessions - Lesson handout: printable page per session (passage, outline, questions, writing lines) - Save a chunk's notes as your own study template (synced) - Voice dictation for notes and scripts (browser speech recognition) - Reading progress: map of the 66 books and a calendar of days read, synced and merged across devices - Export highlights and bookmarks as Markdown or Word, grouped by named colour - Verse of the day push to the ntfy topic (needs IQBIBLE_API_KEY) - Sharing: co-teacher editing via single-use invite links; read-only links can expire and take a passcode Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
81 lines
4.5 KiB
React
81 lines
4.5 KiB
React
// What someone sees when they open a read-only outline link: a calm page with the outline, a switcher
|
||
// when there is more than one, and Print / PDF. Nothing is editable.
|
||
import { useCallback, useEffect, useState } from 'react';
|
||
import { getSharedOutline } from '../syncService.js';
|
||
import { themeVars } from './reader/readerThemes.js';
|
||
import SharedPasscodeForm from './SharedPasscodeForm.jsx';
|
||
import OutlineDocument from './OutlineDocument.jsx';
|
||
import { printOutline } from '../utils/outlineExport.js';
|
||
import { flatten } from '../utils/outline.js';
|
||
|
||
const pill = (active) => `shrink-0 rounded-full px-3.5 py-1.5 text-sm transition ${active ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`;
|
||
|
||
function Frame({ children }) {
|
||
return <div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars('paper')}>{children}</div>;
|
||
}
|
||
|
||
export default function SharedOutlineView({ token }) {
|
||
const [data, setData] = useState(null);
|
||
const [error, setError] = useState('');
|
||
const [scope, setScope] = useState('study');
|
||
const [needsPasscode, setNeedsPasscode] = useState(false);
|
||
const [passcodeError, setPasscodeError] = useState('');
|
||
const [checking, setChecking] = useState(false);
|
||
|
||
const load = useCallback((passcode) => {
|
||
setChecking(true);
|
||
return getSharedOutline(token, passcode).then((result) => {
|
||
setChecking(false);
|
||
if (result.ok) { setData(result.data); setNeedsPasscode(false); setScope(result.data.outlines[0]?.scope ?? 'study'); return; }
|
||
if (result.data?.needsPasscode) { setNeedsPasscode(true); setPasscodeError(passcode ? result.error : ''); return; }
|
||
setNeedsPasscode(false);
|
||
setError(result.error ?? 'This outline link is invalid or has been turned off.');
|
||
});
|
||
}, [token]);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
useEffect(() => { if (data?.title) document.title = `${data.title} — Outline`; }, [data]);
|
||
|
||
if (needsPasscode) return <Frame><SharedPasscodeForm error={passcodeError} busy={checking} onSubmit={load} /></Frame>;
|
||
|
||
if (error) {
|
||
return (
|
||
<Frame>
|
||
<div className="flex min-h-dvh items-center justify-center px-5">
|
||
<div className="max-w-sm text-center">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">Bible Study Project</p>
|
||
<p className="mt-4 text-base">{error}</p>
|
||
</div>
|
||
</div>
|
||
</Frame>
|
||
);
|
||
}
|
||
if (!data) return <Frame><p className="flex min-h-dvh items-center justify-center text-sm text-[var(--r-sub)]">Loading outline…</p></Frame>;
|
||
|
||
const current = data.outlines.find((o) => o.scope === scope) ?? data.outlines[0];
|
||
const title = current && current.scope !== 'study' ? `${data.title} — ${current.label}` : data.title;
|
||
const minutes = current ? flatten(current.tree).reduce((sum, { node }) => sum + (Number.isFinite(node.minutes) ? node.minutes : 0), 0) : 0;
|
||
const meta = current ? [current.scope === 'study' ? data.translation : `${current.label} · ${data.translation}`, minutes > 0 ? `About ${minutes} minute${minutes === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ') : '';
|
||
const outline = current ? { title, subtitle: current.scope === 'study' ? data.translation : `${current.label} · ${data.translation}`, tree: current.tree } : null;
|
||
|
||
return (
|
||
<Frame>
|
||
<header className="sticky top-0 z-10 border-b border-[var(--r-line)] bg-[var(--r-bar)]/95 backdrop-blur">
|
||
<div className="mx-auto flex max-w-[44rem] flex-wrap items-center gap-x-3 gap-y-2 px-5 py-2.5 sm:px-8">
|
||
<span className="shrink-0 text-sm font-medium">Bible Study</span>
|
||
{data.outlines.length > 1 ? (
|
||
<nav aria-label="Outline" className="order-last flex w-full min-w-0 gap-1 overflow-x-auto sm:order-none sm:w-auto sm:flex-1">
|
||
{data.outlines.map((o) => (
|
||
<button key={o.scope} type="button" onClick={() => setScope(o.scope)} aria-current={current?.scope === o.scope ? 'true' : undefined} className={pill(current?.scope === o.scope)}>{o.label}</button>
|
||
))}
|
||
</nav>
|
||
) : <span className="flex-1" />}
|
||
{outline && <button type="button" onClick={() => printOutline(outline)} className="ml-auto shrink-0 sm:ml-0 rounded-full border border-[var(--r-line)] px-3.5 py-1.5 text-sm hover:bg-black/5">Print / PDF</button>}
|
||
</div>
|
||
</header>
|
||
{current ? <OutlineDocument title={title} meta={meta} tree={current.tree} /> : <p className="p-10 text-center text-sm text-[var(--r-sub)]">This study doesn’t have an outline yet.</p>}
|
||
</Frame>
|
||
);
|
||
}
|