Compare commits

...
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 46a6e3bf7a Redesign the login page around what the app can do
- Hero ("Read it. Study it. Teach it.") with small previews of the outline
  and the translation comparison
- Four groups of features (Read, Study, Teach, Everywhere), all things the
  app really does
- Sign-in / create-account card stays in view beside the tour on wide
  screens and comes first on a phone; theme switcher before signing in
- Sign-in, account creation, 2FA and error handling unchanged

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:18:50 -04:00
nmemmertandClaude Sonnet 5.5 49a0eb9d0b Merge outline-style: restyled outline share page, print/PDF and Word
Build and Push Docker Image / build-and-push (push) Failing after 20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:12:41 -04:00
nmemmertandClaude Sonnet 5.5 57db2310dd Outline: restyle the share page and the printed / Word outline
- Read-only share page: a calm paper-toned page rendered natively (no
  iframe), with outline tabs, Print / PDF, numbered points with accent
  numerals, type tags, notes and verse quotes; works on phones
- Printable / PDF page restyled to match (serif, hanging numerals, tidy
  spacing, footer)
- Word outline restyled: eyebrow + title block, bold main points, accent
  numerals, set-off verse quotes, tidy margins

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:10:42 -04:00
nmemmertandClaude Sonnet 5.5 34d04fd077 Merge outline-share-link: read-only outline links
Build and Push Docker Image / build-and-push (push) Successful in 4m33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:02:41 -04:00
7 changed files with 418 additions and 148 deletions

No files matched your search

+57 -5
View File
@@ -350,12 +350,14 @@ describe('Opening a read-only outline link', () => {
stubServer({ user: null });
window.history.replaceState({}, '', '/?outline=abc123');
render(<App />);
const frame = await screen.findByTitle('Shared outline');
expect(frame.getAttribute('srcdoc')).toContain('Appoint elders');
expect(await screen.findByRole('heading', { name: 'Titus' })).toBeInTheDocument();
expect(screen.getByText('Appoint elders')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /sign in/i })).not.toBeInTheDocument();
await userEvent.setup().selectOptions(screen.getByRole('combobox', { name: 'Outline' }), 'c1');
expect(screen.getByTitle('Shared outline').getAttribute('srcdoc')).toContain('Greeting');
expect(screen.getByTitle('Shared outline').getAttribute('srcdoc')).toContain('Titus — Titus 1:1–4');
expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); // nothing to edit
await userEvent.setup().click(screen.getByRole('button', { name: 'Titus 1:1–4' }));
expect(await screen.findByRole('heading', { name: 'Titus — Titus 1:1–4' })).toBeInTheDocument();
expect(screen.getByText('Greeting')).toBeInTheDocument();
expect(screen.queryByText('Appoint elders')).not.toBeInTheDocument();
});
test('a turned-off or wrong link says so', async () => {
@@ -389,3 +391,53 @@ describe('Window title', () => {
await waitFor(() => expect(document.title).toMatch(/^[A-Za-z0-9 ]+ \d+ · Bible Study$/));
});
});
describe('Login page', () => {
async function openLogin() {
stubServer({ user: null });
render(<App />);
return screen.findByRole('button', { name: /^sign in$/i });
}
test('tells you what the app can do, in groups', async () => {
await openLogin();
expect(screen.getByRole('heading', { level: 1, name: /read it\. study it\./i })).toBeInTheDocument();
for (const group of ['Read', 'Study', 'Teach', 'Everywhere']) expect(screen.getByRole('region', { name: group })).toBeInTheDocument();
expect(screen.getByText('A real outline')).toBeInTheDocument();
expect(screen.getByText('Compare them')).toBeInTheDocument();
});
test('switches between signing in and creating an account', async () => {
const user = userEvent.setup();
await openLogin();
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /need an account/i }));
expect(screen.getByRole('heading', { name: 'Create an account' })).toBeInTheDocument();
expect(screen.getByText(/at least 8 characters/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^create account$/i })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /already have an account/i }));
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
});
test('a theme can be picked before signing in', async () => {
const user = userEvent.setup();
await openLogin();
await user.click(screen.getByRole('button', { name: 'Dark' }));
expect(screen.getByRole('button', { name: 'Dark' })).toHaveAttribute('aria-pressed', 'true');
expect(localStorage.getItem('reader-theme')).toBe('dark');
});
test('a wrong password shows the error', async () => {
const user = userEvent.setup();
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
if (url === '/api/auth/me') return json(401, {});
if (url === '/api/auth/login') return json(401, { error: 'Incorrect email or password.' });
return Promise.resolve({ ok: false });
}));
render(<App />);
await user.type(await screen.findByLabelText(/email/i), 'a@b.co');
await user.type(screen.getByLabelText(/^password/i), 'wrong-password');
await user.click(screen.getByRole('button', { name: /^sign in$/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/incorrect email or password/i);
});
});
+55
View File
@@ -0,0 +1,55 @@
// An outline laid out for reading (not editing): numbered points, small type tags, notes, verse text. Used
// by the read-only share page. Styled to match the printed / PDF page.
import { labelFor } from '../utils/outlineLabels.js';
import { kindLabel } from '../utils/outlineLabels.js';
function Points({ nodes, depth }) {
return (
<ul className={depth === 0 ? '' : 'ml-6 sm:ml-10'}>
{nodes.map((n, i) => {
const main = depth === 0;
const mins = Number.isFinite(n.minutes) && n.minutes > 0;
return (
<li key={n.id} className={main ? 'mt-7 first:mt-5' : 'mt-2.5'}>
<div className="flex gap-3">
<span className={`shrink-0 font-semibold tabular-nums text-[var(--r-accent)] ${main ? 'w-9 text-lg' : 'w-8'}`}>{labelFor(depth, i)}</span>
<div className="min-w-0 flex-1">
<p className={`leading-snug ${main ? 'text-xl font-semibold' : 'text-[1.05rem]'}`}>
{n.kind && <span className="mr-2 inline-block -translate-y-px rounded-full border border-[#e4b9a3] px-2 py-0.5 align-middle font-sans text-[10px] font-semibold uppercase tracking-widest text-[var(--r-accent)]">{kindLabel(n.kind)}</span>}
{n.text.trim() || <span className="text-[var(--r-sub)]">(untitled)</span>}
{n.ref && <span className="ml-2 whitespace-nowrap font-sans text-[0.8rem] font-normal text-[var(--r-sub)]">{n.ref}</span>}
{mins && <span className="ml-2 whitespace-nowrap font-sans text-[0.8rem] font-normal text-[var(--r-sub)]">{n.minutes} min</span>}
</p>
{n.note.trim() && <p className="mt-1 whitespace-pre-line text-[0.95rem] italic leading-relaxed text-[var(--r-sub)]">{n.note.trim()}</p>}
{n.scripture?.text && (
<blockquote className="mt-2 border-l-[3px] border-[#e4b9a3] pl-4 italic leading-relaxed">
“{n.scripture.text}” <span className="whitespace-nowrap font-sans text-[0.8rem] not-italic text-[var(--r-sub)]">— {n.scripture.ref}</span>
</blockquote>
)}
{n.attachments.length > 0 && (
<p className="mt-1.5 font-sans text-[0.8rem] leading-relaxed text-[var(--r-sub)]">
{n.attachments.map((a, k) => <span key={a.id || k}>{k > 0 && ' · '}{a.type === 'word' ? 'Word' : 'See'}: {a.label}{a.detail ? ` — ${a.detail}` : ''}</span>)}
</p>
)}
{n.children.length > 0 && <Points nodes={n.children} depth={depth + 1} />}
</div>
</div>
</li>
);
})}
</ul>
);
}
export default function OutlineDocument({ title, meta, tree }) {
return (
<article className="mx-auto max-w-[44rem] px-5 pb-20 pt-10 font-serif text-[var(--r-text)] sm:px-8 sm:pt-14">
<p className="font-sans text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">Outline</p>
<h1 className="mt-3 text-3xl font-semibold leading-tight tracking-tight sm:text-[2.1rem]">{title}</h1>
{meta && <p className="mt-2 font-sans text-sm text-[var(--r-sub)]">{meta}</p>}
<hr className="mb-1 mt-6 border-[var(--r-line)]" />
{tree.length > 0 ? <Points nodes={tree} depth={0} /> : <p className="mt-8 text-[var(--r-sub)]">Nothing in this outline yet.</p>}
<p className="mt-14 border-t border-[var(--r-line)] pt-4 text-center font-sans text-[11px] tracking-wide text-[var(--r-sub)]">Bible Study Project</p>
</article>
);
}
+37 -29
View File
@@ -1,9 +1,17 @@
// What someone sees when they open a read-only outline link: the outline(s), nothing editable, and a
// way to print or save it.
// 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 { useEffect, useState } from 'react';
import { getSharedOutline } from '../syncService.js';
import { buildOutlineHtml } from '../utils/bibleUtils.jsx';
import { themeVars } from './reader/readerThemes.js';
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);
@@ -23,40 +31,40 @@ export default function SharedOutlineView({ token }) {
if (error) {
return (
<div className="flex min-h-dvh items-center justify-center bg-slate-100 px-4">
<div className="w-full max-w-sm rounded-3xl bg-white p-8 text-center shadow">
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
<p className="mt-4 text-sm text-rose-600">{error}</p>
<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>
</div>
</Frame>
);
}
if (!data) return <div className="flex min-h-dvh items-center justify-center bg-slate-100"><p className="text-sm text-slate-500">Loading outline…</p></div>;
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 (
<div className="min-h-dvh bg-slate-100">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-slate-900 px-4 py-3 text-xs text-slate-300">
<span>🔗 Read-only outline — <a href="/" className="underline hover:text-white">go to Bible Study Project</a></span>
<span className="flex items-center gap-3">
{data.outlines.length > 1 && (
<label className="flex items-center gap-2">Outline
<select value={current?.scope} onChange={(e) => setScope(e.target.value)} aria-label="Outline" className="rounded-md border border-slate-600 bg-slate-800 px-2 py-1 text-slate-100">
{data.outlines.map((o) => <option key={o.scope} value={o.scope}>{o.label}</option>)}
</select>
</label>
)}
{outline && <button type="button" onClick={() => printOutline(outline)} className="rounded-md border border-slate-500 px-3 py-1 text-slate-100 hover:bg-slate-700">Print / PDF</button>}
</span>
</div>
{outline ? (
<iframe title="Shared outline" srcDoc={buildOutlineHtml(outline)} sandbox="allow-popups" className="h-[calc(100dvh-3rem)] w-full border-0 bg-white" />
) : (
<p className="p-10 text-center text-sm text-slate-500">This study doesn’t have an outline yet.</p>
)}
</div>
<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>
);
}
+184 -77
View File
@@ -1,8 +1,91 @@
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { themeVars } from '../components/reader/readerThemes.js';
import { themeVars, READER_THEMES } from '../components/reader/readerThemes.js';
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
// What the app does, in four groups. Everything listed here exists in the app.
const FEATURES = [
{
title: 'Read',
points: [
['Six translations', 'BSB, ESV, NLT, CSB, NASB and NET, with audio and whole-Bible search.'],
['Compare them', 'Read two side by side and see which words differ, verse by verse.'],
['Mark it up', 'Highlight in colours you name, bookmark, and draw notes on the page.'],
['Reading plans', 'Daily readings that open straight into the text, with streaks and reminders.'],
],
},
{
title: 'Study',
points: [
['Chunk a passage', 'Break it into sections and write Observe, Interpret and Apply notes.'],
['Original languages', 'Greek and Hebrew word studies with pronunciation, from the interlinear.'],
['Beside the text', 'Commentary, cross-references, and people and places for each chunk.'],
],
},
{
title: 'Teach',
points: [
['A real outline', 'Main points and sub-points for the whole study and for each chunk, with notes, verse text and minutes.'],
['Scripts', 'Turn your notes into a script for a lesson, episode or sermon.'],
['Share it', 'Word, PDF and Markdown files, or a read-only link anyone can open.'],
],
},
{
title: 'Everywhere',
points: [
['Your devices', 'Studies, marks and plans sync to your account, and nothing is overwritten silently.'],
['Offline and backup', 'Save books to read without a connection, and download everything as one file.'],
['Private', 'Your studies stay in your account, with optional two-factor sign-in.'],
],
},
];
// Small, decorative pictures of two real views, so you can see what you're signing in to.
function OutlinePreview() {
const row = (label, text, depth = 0, tag = '') => (
<div className="flex items-baseline gap-2" style={{ paddingLeft: `${depth * 1.1}rem` }}>
<span className="w-5 shrink-0 text-right font-semibold text-[var(--r-accent)]">{label}</span>
<span className={depth === 0 ? 'font-semibold' : ''}>
{tag && <span className="mr-1.5 rounded-full border border-[var(--r-line)] px-1.5 py-px font-sans text-[8px] font-semibold uppercase tracking-widest text-[var(--r-accent)]">{tag}</span>}
{text}
</span>
</div>
);
return (
<div aria-hidden="true" className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-4 font-serif text-[13px] leading-relaxed shadow-sm">
<p className="mb-2 font-sans text-[9px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">Outline</p>
{row('I.', 'Greeting and the gospel hope')}
{row('A.', 'Servant of God', 1, 'Illustration')}
{row('B.', 'Apostle of Jesus Christ', 1)}
{row('II.', 'Appoint elders in every town')}
{row('A.', 'Blameless and faithful', 1, 'Application')}
</div>
);
}
function ComparePreview() {
const mark = (t) => <span className="diff-hl">{t}</span>;
return (
<div aria-hidden="true" className="grid grid-cols-2 gap-3 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-4 font-serif text-[13px] leading-relaxed shadow-sm">
<div><p className="mb-1 font-sans text-[9px] font-semibold uppercase tracking-[0.18em] text-[var(--r-sub)]">BSB</p>The earth was {mark('formless and void')}, and darkness was over the surface of the deep.</div>
<div><p className="mb-1 font-sans text-[9px] font-semibold uppercase tracking-[0.18em] text-[var(--r-sub)]">NET</p>The earth was {mark('without shape and empty')}, and darkness was over the surface of the deep.</div>
</div>
);
}
function ThemeSwitch() {
const { readerTheme, setReaderTheme } = useApp();
if (!setReaderTheme) return null;
return (
<div role="group" aria-label="Theme" className="flex overflow-hidden rounded-full border border-[var(--r-line)] text-xs">
{Object.entries(READER_THEMES).map(([id, t]) => (
<button key={id} type="button" aria-pressed={readerTheme === id} onClick={() => setReaderTheme(id)}
className={`px-3 py-1.5 ${readerTheme === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{t.label}</button>
))}
</div>
);
}
export default function AuthPage() {
const { signIn, readerTheme } = useApp();
const shell = { style: themeVars(readerTheme) };
@@ -53,8 +136,8 @@ export default function AuthPage() {
return (
<div {...shell} className="flex min-h-dvh items-center justify-center bg-[var(--r-bg)] px-4 text-[var(--r-text)]">
<div className="w-full max-w-sm rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-8">
<p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)]">Bible Study Project</p>
<h1 className="mt-2 text-xl font-medium text-[var(--r-text)]">Two-factor verification</h1>
<p className="flex items-center gap-2 text-sm uppercase tracking-[0.24em] text-[var(--r-sub)]"><img src="/logo.svg" alt="" className="h-6 w-6" />Bible Study Project</p>
<h1 className="mt-3 text-xl font-medium text-[var(--r-text)]">Two-factor verification</h1>
<p className="mt-2 text-sm text-[var(--r-sub)]">
{authMfaUseBackup
? 'Enter one of your saved backup codes.'
@@ -109,83 +192,107 @@ export default function AuthPage() {
}
return (
<div {...shell} className="flex min-h-dvh items-center justify-center bg-[var(--r-bg)] px-4 py-12 text-[var(--r-text)]">
<div className="grid w-full max-w-5xl items-center gap-16 lg:grid-cols-2">
{/* Branding / feature panel — hidden on small screens to keep the form front and center there */}
<div className="hidden lg:block">
<p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)]">Bible Study Project</p>
<h1 className="mt-4 text-3xl font-semibold leading-tight text-[var(--r-text)]">
Deep Bible study,<br />one chunk at a time.
</h1>
<p className="mt-4 max-w-sm text-[var(--r-sub)]">
Read Scripture in six translations, follow a reading plan into the text, and study a passage one chunk
at a time with commentary, word studies, and cross-references beside it.
</p>
<ul className="mt-8 space-y-3 text-sm text-[var(--r-sub)]">
<li>Read in BSB, ESV, NLT, CSB, NASB, or NET, with audio and search</li>
<li>Reading plans that open straight into the Reader</li>
<li>Observe, Interpret, Apply notes with Greek &amp; Hebrew word studies</li>
<li>Turn your notes into a script, then export or share</li>
<li>Your studies stay private to your account, with optional 2FA</li>
</ul>
<blockquote className="mt-8 border-l-2 border-[var(--r-line)] pl-4 text-sm italic text-[var(--r-sub)]">
"Make every effort to present yourself approved to God, an unashamed workman who accurately
handles the word of truth."
<footer className="mt-1 not-italic text-[var(--r-sub)]">— 2 Timothy 2:15 (BSB)</footer>
</blockquote>
<div {...shell} className="min-h-dvh bg-[var(--r-bg)] text-[var(--r-text)]">
<div className="mx-auto flex max-w-6xl items-center justify-between px-5 pt-5 sm:px-8">
<p className="flex items-center gap-2.5 text-sm font-medium">
<img src="/logo.svg" alt="" className="h-7 w-7" />
<span>Bible Study Project</span>
</p>
<ThemeSwitch />
</div>
<div className="mx-auto grid max-w-6xl gap-x-14 gap-y-12 px-5 pb-16 pt-8 sm:px-8 lg:grid-cols-[minmax(0,1fr)_24rem] lg:pt-14">
{/* Sign in / create account. First on a phone; on a wide screen it stays in view beside the tour. */}
<div className="lg:order-2">
<div className="lg:sticky lg:top-10">
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-7 shadow-sm sm:p-8">
<h2 className="text-xl font-medium">{authMode === 'login' ? 'Sign in' : 'Create an account'}</h2>
<p className="mt-1 text-sm text-[var(--r-sub)]">
{authMode === 'login' ? 'Pick up where you left off.' : 'Free to start. Your studies stay in your account.'}
</p>
<form onSubmit={submitAuth} className="mt-6 space-y-4">
<label className="block text-sm">
Email
<input
type="email"
required
autoComplete="email"
value={authForm.email}
onChange={(e) => setAuthForm((f) => ({ ...f, email: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 text-sm text-[var(--r-text)]"
/>
</label>
<label className="block text-sm">
Password
<input
type="password"
required
minLength={8}
autoComplete={authMode === 'login' ? 'current-password' : 'new-password'}
value={authForm.password}
onChange={(e) => setAuthForm((f) => ({ ...f, password: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 text-sm text-[var(--r-text)]"
/>
{authMode === 'register' && <span className="mt-1 block text-xs text-[var(--r-sub)]">At least 8 characters.</span>}
</label>
{authError && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>}
<button
type="submit"
disabled={authBusy}
className="w-full rounded-full bg-[var(--r-accent)] px-4 py-2.5 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{authBusy ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'}
</button>
</form>
<button
type="button"
onClick={() => {
setAuthMode((m) => (m === 'login' ? 'register' : 'login'));
setAuthError('');
}}
className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]"
>
{authMode === 'login' ? 'Need an account? Sign up' : 'Already have an account? Sign in'}
</button>
</div>
<blockquote className="mt-6 hidden border-l-2 border-[var(--r-line)] pl-4 text-sm italic leading-relaxed text-[var(--r-sub)] lg:block">
“Make every effort to present yourself approved to God, an unashamed workman who accurately handles the word of truth.”
<footer className="mt-1 not-italic">— 2 Timothy 2:15 (BSB)</footer>
</blockquote>
</div>
</div>
{/* Sign in / register form */}
<div className="flex justify-center lg:justify-start">
<div className="w-full max-w-sm rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-8">
<p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)] lg:hidden">Bible Study Project</p>
<h1 className="mt-2 text-xl font-medium text-[var(--r-text)]">
{authMode === 'login' ? 'Sign in' : 'Create an account'}
{/* What the app does */}
<div className="lg:order-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">Bible study, start to finish</p>
<h1 className="mt-3 text-4xl font-semibold leading-[1.1] tracking-tight sm:text-5xl">
Read it. Study it.<br />Teach it.
</h1>
<form onSubmit={submitAuth} className="mt-6 space-y-4">
<label className="block text-sm">
Email
<input
type="email"
required
autoComplete="email"
value={authForm.email}
onChange={(e) => setAuthForm((f) => ({ ...f, email: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/>
</label>
<label className="block text-sm font-medium text-[var(--r-text)]">
Password
<input
type="password"
required
minLength={8}
autoComplete={authMode === 'login' ? 'current-password' : 'new-password'}
value={authForm.password}
onChange={(e) => setAuthForm((f) => ({ ...f, password: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/>
</label>
{authError && <p className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>}
<button
type="submit"
disabled={authBusy}
className="w-full rounded-full bg-[var(--r-accent)] px-4 py-2.5 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{authBusy ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'}
</button>
</form>
<button
type="button"
onClick={() => {
setAuthMode((m) => (m === 'login' ? 'register' : 'login'));
setAuthError('');
}}
className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]"
>
{authMode === 'login' ? "Need an account? Sign up" : 'Already have an account? Sign in'}
</button>
</div>
<p className="mt-5 max-w-xl text-base leading-relaxed text-[var(--r-sub)]">
A place to read Scripture in six translations, work through a passage one chunk at a time, and turn what you learn into an outline,
a script, and something you can share.
</p>
<div className="mt-8 grid gap-4 sm:grid-cols-2">
<OutlinePreview />
<ComparePreview />
</div>
<div className="mt-12 grid gap-x-10 gap-y-9 sm:grid-cols-2">
{FEATURES.map((group) => (
<section key={group.title} aria-label={group.title}>
<h2 className="border-b border-[var(--r-line)] pb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">{group.title}</h2>
<ul className="mt-3 space-y-3.5">
{group.points.map(([name, text]) => (
<li key={name}>
<p className="text-[15px] font-medium">{name}</p>
<p className="mt-0.5 text-sm leading-relaxed text-[var(--r-sub)]">{text}</p>
</li>
))}
</ul>
</section>
))}
</div>
</div>
</div>
</div>
+33 -17
View File
@@ -334,23 +334,36 @@ function outlineListHtml(nodes, depth) {
// A page of just one outline (for printing or saving as PDF): the title, the passage, the points.
export function buildOutlineHtml({ title, subtitle = '', tree }) {
const total = flattenMinutes(tree);
const meta = [subtitle, total > 0 ? `About ${total} minute${total === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ');
// Simple and calm: paper tones, a serif face, hanging numerals in the accent colour, and generous space.
const style = `
body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 40px; }
.page { max-width: 760px; margin: auto; }
h1 { margin: 0 0 0.25rem; font-size: 1.7rem; }
.sub { color: #64748b; margin: 0 0 1.5rem; }
.outline ul { list-style: none; margin: 0.25rem 0 0.25rem 1.6rem; padding: 0; }
.outline > ul { margin-left: 0; }
.outline li { margin: 0.45rem 0; line-height: 1.5; }
.outline > ul > li { margin-top: 0.9rem; font-weight: 600; }
.outline > ul > li li { font-weight: 400; }
.lbl { display: inline-block; min-width: 2.1rem; font-weight: 700; }
.ref { color: #64748b; font-size: 0.9em; font-weight: 400; }
.kind { font-size: 0.72em; text-transform: uppercase; letter-spacing: 0.05em; color: #92400e; border: 1px solid #fcd34d; border-radius: 9999px; padding: 0 0.4em; margin-right: 0.3em; font-weight: 400; }
.o-note { margin: 0.2rem 0 0 2.1rem; color: #334155; font-size: 0.95em; font-weight: 400; }
.o-quote { margin: 0.3rem 0 0.3rem 2.1rem; padding-left: 0.75rem; border-left: 3px solid #cbd5e1; font-style: italic; font-weight: 400; }
.o-attach { margin-left: 2.1rem; font-size: 0.85em; color: #64748b; font-weight: 400; }
@media print { body { padding: 0; } li { break-inside: avoid; } }
:root { --text: #2b2620; --sub: #6b6458; --accent: #a84e27; --line: #e0d8c6; --paper: #faf7f0; }
* { box-sizing: border-box; }
body { font-family: Georgia, 'Times New Roman', serif; color: var(--text); background: var(--paper); margin: 0; padding: 48px 24px 64px; -webkit-font-smoothing: antialiased; }
.page { max-width: 720px; margin: 0 auto; }
.eyebrow { font: 600 0.72rem/1 system-ui, sans-serif; letter-spacing: 0.18em; text-transform: uppercase; color: var(--accent); margin: 0 0 0.9rem; }
h1 { margin: 0; font-size: 2.1rem; line-height: 1.2; font-weight: 600; letter-spacing: -0.01em; }
.sub { font: 0.9rem/1.4 system-ui, sans-serif; color: var(--sub); margin: 0.6rem 0 0; }
.rule { height: 1px; background: var(--line); margin: 1.6rem 0 0.4rem; border: 0; }
.outline ul { list-style: none; margin: 0; padding: 0; }
.outline ul ul { margin-left: 2.4rem; }
.outline li { margin: 0.55rem 0; line-height: 1.55; font-size: 1.05rem; }
.outline > ul > li { margin-top: 1.5rem; font-size: 1.15rem; font-weight: 600; }
.outline > ul > li li { font-weight: 400; font-size: 1.05rem; }
.lbl { display: inline-block; min-width: 2.4rem; color: var(--accent); font-weight: 600; font-variant-numeric: tabular-nums; }
.ref { color: var(--sub); font: 0.82em/1 system-ui, sans-serif; font-weight: 400; white-space: nowrap; }
.kind { font: 600 0.62rem/1 system-ui, sans-serif; text-transform: uppercase; letter-spacing: 0.1em; color: var(--accent); border: 1px solid #e4b9a3; border-radius: 9999px; padding: 0.2em 0.65em; margin-right: 0.5em; vertical-align: 0.15em; }
.o-note { margin: 0.25rem 0 0 2.4rem; color: var(--sub); font-size: 0.95rem; font-weight: 400; font-style: italic; }
.o-quote { margin: 0.5rem 0 0.4rem 2.4rem; padding: 0.1rem 0 0.1rem 1rem; border-left: 3px solid #e4b9a3; font-style: italic; font-weight: 400; font-size: 1rem; }
.o-attach { margin: 0.3rem 0 0 2.4rem; font: 0.8rem/1.5 system-ui, sans-serif; color: var(--sub); font-weight: 400; }
.foot { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--line); font: 0.72rem/1 system-ui, sans-serif; color: var(--sub); text-align: center; letter-spacing: 0.04em; }
@media (max-width: 520px) { body { padding: 32px 18px 48px; } h1 { font-size: 1.7rem; } .outline ul ul { margin-left: 1.5rem; } .o-note, .o-quote, .o-attach { margin-left: 1.5rem; } }
@media print {
body { background: #fff; padding: 0; }
.page { max-width: none; }
li { break-inside: avoid; }
.outline > ul > li { break-after: avoid; }
}
`;
return `<!DOCTYPE html>
<html lang="en">
@@ -362,9 +375,12 @@ export function buildOutlineHtml({ title, subtitle = '', tree }) {
</head>
<body>
<div class="page">
<p class="eyebrow">Outline</p>
<h1>${escHtml(title)}</h1>
<p class="sub">${escHtml([subtitle, total > 0 ? `About ${total} minute${total === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · '))}</p>
${meta ? `<p class="sub">${escHtml(meta)}</p>` : ''}
<hr class="rule" />
<div class="outline">${outlineListHtml(tree, 0)}</div>
<p class="foot">Bible Study Project</p>
</div>
</body>
</html>`;
+42 -20
View File
@@ -75,33 +75,55 @@ export function sectionLabel(text) {
}
// The paragraphs of an outline: each point indented by level, with its note, verse and attachments under it.
export const outlineParagraphs = (tree) => flatten(tree).flatMap(({ node, depth, label }) => [
new Paragraph({
indent: { left: 420 * depth, hanging: 420 },
spacing: { after: 60 },
children: [
new TextRun({ text: `${label}\t`, bold: true, color: DOCX_ACCENT }),
...(node.kind ? [new TextRun({ text: `${kindLabel(node.kind)}: `, italics: true, color: '92400E' })] : []),
new TextRun({ text: (node.text ?? '').trim() || '(untitled)' }),
...(node.ref ? [new TextRun({ text: ` (${node.ref})`, italics: true, color: '7A7060' })] : []),
...(Number.isFinite(node.minutes) && node.minutes > 0 ? [new TextRun({ text: ` · ${node.minutes} min`, color: '7A7060' })] : []),
],
}),
...detailLines(node).map((line) => new Paragraph({
indent: { left: 420 * depth + 420 },
spacing: { after: 40 },
children: [new TextRun({ text: line, italics: true, color: '4B5563', size: 20 })],
})),
]);
export const outlineParagraphs = (tree) => flatten(tree).flatMap(({ node, depth, label }) => {
const main = depth === 0;
return [
new Paragraph({
indent: { left: 480 * depth + 480, hanging: 480 },
spacing: { before: main ? 260 : 80, after: 60, line: 300 },
keepNext: main,
children: [
new TextRun({ text: `${label}\t`, bold: true, color: 'A84E27', size: main ? 26 : 23 }),
...(node.kind ? [new TextRun({ text: `${kindLabel(node.kind).toUpperCase()} `, bold: true, color: 'A84E27', size: 16, characterSpacing: 20 })] : []),
new TextRun({ text: (node.text ?? '').trim() || '(untitled)', bold: main, size: main ? 26 : 23 }),
...(node.ref ? [new TextRun({ text: ` ${node.ref}`, color: '7A7060', size: 19 })] : []),
...(Number.isFinite(node.minutes) && node.minutes > 0 ? [new TextRun({ text: ` ${node.minutes} min`, color: '7A7060', size: 19 })] : []),
],
}),
// note, then the verse's words (set off with a rule), then attachments
...(node.note ?? '').split('\n').filter((l) => l.trim()).map((line) => new Paragraph({
indent: { left: 480 * depth + 480 },
spacing: { after: 40 },
children: [new TextRun({ text: line.trim(), italics: true, color: '6B6458', size: 21 })],
})),
...(node.scripture?.text ? [new Paragraph({
indent: { left: 480 * depth + 600 },
spacing: { before: 40, after: 60 },
border: { left: { style: BorderStyle.SINGLE, size: 18, color: 'E4B9A3', space: 8 } },
children: [
new TextRun({ text: `\u201C${node.scripture.text}\u201D`, italics: true, size: 21 }),
new TextRun({ text: ` \u2014 ${node.scripture.ref}`, color: '7A7060', size: 19 }),
],
})] : []),
...(node.attachments ?? []).map((a) => new Paragraph({
indent: { left: 480 * depth + 480 },
spacing: { after: 20 },
children: [new TextRun({ text: `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}${a.detail ? ` \u2014 ${a.detail}` : ''}`, color: '7A7060', size: 19 })],
})),
];
});
// Just one outline, as its own Word document.
export async function buildOutlineDocxBlob({ title, subtitle = '', tree }) {
const meta = [subtitle, timeSummary(tree)].filter(Boolean).join(' \u00B7 ');
const children = [
new Paragraph({ children: [new TextRun({ text: 'OUTLINE', bold: true, color: 'A84E27', size: 18, characterSpacing: 60 })], spacing: { after: 100 } }),
new Paragraph({ text: title, heading: HeadingLevel.TITLE }),
new Paragraph({ children: [new TextRun({ text: [subtitle, timeSummary(tree)].filter(Boolean).join(' · '), italics: true, color: '7A7060' })], spacing: { after: 300 } }),
...(meta ? [new Paragraph({ children: [new TextRun({ text: meta, color: '7A7060', size: 21 })], spacing: { after: 160 } })] : []),
new Paragraph({ border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'E0D8C6', space: 1 } }, spacing: { after: 120 } }),
...outlineParagraphs(tree),
];
const doc = new Document({ styles: DOCX_STYLES, sections: [{ children }] });
const doc = new Document({ styles: DOCX_STYLES, sections: [{ properties: { page: { margin: { top: 1200, bottom: 1200, left: 1400, right: 1400 } } }, children }] });
return Packer.toBlob(doc);
}
+10
View File
@@ -54,3 +54,13 @@ test('the printable page escapes text and shows notes', () => {
expect(html).toContain('&lt;i&gt;hi&lt;/i&gt;');
expect(html).toContain('class="o-note"');
});
test('the Word document builds, with and without the extras', async () => {
const { buildOutlineDocxBlob } = await import('./docxExport.js');
const blob = await buildOutlineDocxBlob({
...outline,
tree: [{ ...outline.tree[0], scripture: { ref: 'Titus 1:5', text: 'appoint elders', translation: 'BSB' }, attachments: [{ id: 'x', type: 'word', label: 'presbyteros', detail: 'elder' }] }, { id: 'z', text: '', ref: '', children: [] }],
});
expect(blob.size).toBeGreaterThan(1000);
expect((await buildOutlineDocxBlob({ title: 'Empty', tree: [] })).size).toBeGreaterThan(500);
});