Invite-only sign-up, present mode, series, handouts, templates, dictation, progress, sharing
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>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 10:26:43 -04:00
1 parent 88bc59bac0
commit 54cb1c217d
73 files changed
+2739 -134

No files matched your search

+8 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import AdminSignup from '../components/AdminSignup.jsx';
import PageShell from '../components/study/PageShell.jsx';
import { buildExportHtml } from '../utils/bibleUtils.jsx';
import {
@@ -13,7 +14,7 @@ import {
export default function AdminPage() {
const { authUser, goHome } = useApp();
const [adminTab, setAdminTab] = useState('users'); // 'users' | 'projects'
const [adminTab, setAdminTab] = useState('users'); // 'users' | 'projects' | 'signup'
const [adminUsers, setAdminUsers] = useState([]);
const [adminProjects, setAdminProjects] = useState([]);
const [adminLoading, setAdminLoading] = useState(false);
@@ -79,8 +80,14 @@ export default function AdminPage() {
className={`rounded-full px-4 py-1.5 text-sm transition ${adminTab === 'projects' ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
Projects ({adminProjects.length})
</button>
<button type="button" onClick={() => setAdminTab('signup')}
className={`rounded-full px-4 py-1.5 text-sm transition ${adminTab === 'signup' ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
Sign-ups
</button>
</div>
{adminTab === 'signup' && <AdminSignup />}
{adminLoading && <p className="text-sm text-[var(--r-sub)]">Loading…</p>}
{adminError && <p className="text-sm text-[var(--r-danger,#b42318)]">{adminError}</p>}
+23 -7
View File
@@ -1,8 +1,8 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { themeVars, READER_THEMES } from '../components/reader/readerThemes.js';
import ResetPasswordForm from '../components/ResetPasswordForm.jsx';
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
import { getSignupMode, loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
// What the app does, in four groups. Everything listed here exists in the app.
const FEATURES = [
@@ -90,7 +90,16 @@ function ThemeSwitch() {
export default function AuthPage() {
const { signIn, readerTheme } = useApp();
const shell = { style: themeVars(readerTheme) };
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register' | 'forgot'
// An invite link (?invite=CODE) opens straight on "Create an account" with the code filled in
const linkInvite = new URLSearchParams(window.location.search).get('invite') ?? '';
const [authMode, setAuthMode] = useState(linkInvite ? 'register' : 'login'); // 'login' | 'register' | 'forgot'
const [signupMode, setSignupMode] = useState('open'); // what the server allows: 'open' | 'invite' | 'closed'
const [invite, setInvite] = useState(linkInvite);
useEffect(() => {
let live = true;
getSignupMode().then((r) => { if (live && r.ok && r.data?.mode) setSignupMode(r.data.mode); });
return () => { live = false; };
}, []);
const [authForm, setAuthForm] = useState({ email: '', password: '' });
const [authError, setAuthError] = useState('');
const [authBusy, setAuthBusy] = useState(false);
@@ -102,8 +111,7 @@ export default function AuthPage() {
e.preventDefault();
setAuthError('');
setAuthBusy(true);
const action = authMode === 'login' ? loginUser : registerUser;
const result = await action(authForm.email.trim(), authForm.password);
const result = authMode === 'login' ? await loginUser(authForm.email.trim(), authForm.password) : await registerUser(authForm.email.trim(), authForm.password, invite.trim());
setAuthBusy(false);
if (!result.ok) {
setAuthError(result.error ?? 'Something went wrong.');
@@ -240,6 +248,14 @@ export default function AuthPage() {
/>
{authMode === 'register' && <span className="mt-1 block text-xs text-[var(--r-sub)]">At least 8 characters.</span>}
</label>
{authMode === 'register' && signupMode === 'invite' && (
<label className="block text-sm">
Invite code
<input type="text" required autoComplete="off" value={invite} onChange={(e) => setInvite(e.target.value)} placeholder="ABCD-EFGH-JKMN"
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 font-mono text-sm uppercase tracking-wider text-[var(--r-text)]" />
<span className="mt-1 block text-xs text-[var(--r-sub)]">Sign-up here is by invitation. Ask the administrator for a code.</span>
</label>
)}
{authError && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>}
<button
type="submit"
@@ -253,7 +269,7 @@ export default function AuthPage() {
<button type="button" onClick={() => { setAuthMode('forgot'); setAuthError(''); }}
className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Forgot your password?</button>
)}
<button
{(signupMode !== 'closed' || authMode === 'register') && <button
type="button"
onClick={() => {
setAuthMode((m) => (m === 'login' ? 'register' : 'login'));
@@ -262,7 +278,7 @@ export default function AuthPage() {
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>
</button>}
</>
)}
</div>
+14 -2
View File
@@ -1,6 +1,10 @@
// The Export stage: take the finished study out as a file, or share it read-only.
import { useApp } from '../context/AppContext.js';
import { useShareLink } from '../hooks/useShareLink.js';
import HandoutExport from '../components/HandoutExport.jsx';
import SessionSchedule from '../components/SessionSchedule.jsx';
import CoTeachers from '../components/CoTeachers.jsx';
import LinkRules from '../components/LinkRules.jsx';
import StudioShell from '../components/study/StudioShell.jsx';
import { exportProjectDocx, exportProjectHtml, exportProjectMarkdown, printProjectPdf } from '../utils/projectExport.js';
@@ -17,7 +21,7 @@ function Row({ title, note, button, onClick }) {
}
export default function ExportPage() {
const { project, allChunks, setCurrentPage } = useApp();
const { project, allChunks, setCurrentPage, sharedStudies, deleteProject } = useApp();
const share = useShareLink(project?.id);
const filled = allChunks.filter((c) => (c.finalScript ?? '').trim()).length;
@@ -47,13 +51,21 @@ export default function ExportPage() {
className="min-w-0 flex-1 truncate rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-sub)]" />
<button type="button" onClick={share.copy} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{share.shareCopied ? 'Copied!' : 'Copy'}</button>
</div>
<LinkRules expiresAt={share.expiresAt} hasPasscode={share.hasPasscode} busy={share.shareBusy} onSave={share.enable} />
{share.shareError && <p role="alert" className="text-xs text-[var(--r-danger,#b42318)]">{share.shareError}</p>}
<button type="button" onClick={share.revoke} disabled={share.shareBusy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">{share.shareBusy ? 'Revoking…' : 'Revoke link'}</button>
</div>
) : (
<button type="button" onClick={share.enable} disabled={share.shareBusy} className={btn}>{share.shareBusy ? 'Enabling…' : 'Enable sharing'}</button>
<button type="button" onClick={() => share.enable()} disabled={share.shareBusy} className={btn}>{share.shareBusy ? 'Enabling…' : 'Enable sharing'}</button>
)}
</section>
<HandoutExport project={project} />
<SessionSchedule project={project} />
<CoTeachers projectId={project?.id} sharedBy={project && sharedStudies?.[project.id]} onLeave={() => { deleteProject(project.id); setCurrentPage('home'); }} />
<section className={card} aria-label="Pronunciation">
<Row title="Pronunciation guide" note="Every Greek and Hebrew word in this study, with transliterations." button="Open guide" onClick={() => setCurrentPage('pronunciation')} />
</section>
+44 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { filterStudies, isTagQuery, tagCounts } from '../utils/studyFilter.js';
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
import BookCover from '../components/BookCover.jsx';
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
@@ -20,6 +20,7 @@ export default function HomePage() {
const {
autoRestoredCount,
staleLocalProjects,
sharedStudies,
projectIndex,
planProgress,
readingActivity,
@@ -39,6 +40,7 @@ export default function HomePage() {
pullLatestFromServer,
resumeProject,
renameProjectInStorage,
setProjectSeries,
deleteProject,
handlePlayBookAudio,
handleStopBookAudio,
@@ -53,6 +55,9 @@ export default function HomePage() {
}); // 'recent' | 'title' | 'passage'
const [homeTags, setHomeTags] = useState([]); // tag chips switched on: a study must have all of them
const [showAllTags, setShowAllTags] = useState(false);
const [seriesFilter, setSeriesFilter] = useState(''); // '' = all, '__none' = not in a series, or a series name
const [seriesEditingId, setSeriesEditingId] = useState(null);
const [seriesValue, setSeriesValue] = useState('');
const toggleTag = (tag) => setHomeTags((cur) => (cur.includes(tag) ? cur.filter((t) => t !== tag) : [...cur, tag]));
const [homeView, setHomeView] = useState(() => {
try { return localStorage.getItem('homeView') === 'list' ? 'list' : 'grid'; } catch { return 'grid'; }
@@ -103,12 +108,15 @@ export default function HomePage() {
const tagList = tagCounts(projectIndex);
const shownTags = showAllTags ? tagList : tagList.slice(0, 12);
const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags })
const seriesNames = seriesList(projectIndex);
const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags, series: seriesFilter })
.sort((a, b) => {
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
return (b.lastEdited ?? 0) - (a.lastEdited ?? 0);
});
// With series in use (and no single series chosen) the studies are grouped under their series name
const sections = seriesFilter === '' && seriesNames.length > 0 ? groupBySeries(visible) : [{ name: '', items: visible, flat: true }];
const fieldCls = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const linkBtn = 'rounded-lg px-3 py-1.5 text-sm text-[var(--r-sub)] hover:bg-black/5';
const notice = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm';
@@ -166,6 +174,15 @@ export default function HomePage() {
<option value="passage">Passage</option>
</select>
</label>
{seriesNames.length > 0 && (
<label className="text-sm text-[var(--r-sub)]">Series{' '}
<select value={seriesFilter} onChange={(e) => setSeriesFilter(e.target.value)} className={`${fieldCls} ml-1`}>
<option value="">All studies</option>
{seriesNames.map((sr) => <option key={sr.name} value={sr.name}>{sr.name} ({sr.count})</option>)}
<option value="__none">Not in a series</option>
</select>
</label>
)}
<div role="group" aria-label="Studies layout" className="ml-auto flex overflow-hidden rounded-xl border border-[var(--r-line)] text-sm">
{[['grid', 'Grid'], ['list', 'List']].map(([id, label]) => (
<button key={id} type="button" aria-pressed={homeView === id} onClick={() => setHomeView(id)}
@@ -174,6 +191,7 @@ export default function HomePage() {
</div>
</div>
<datalist id="series-names">{seriesNames.map((sr) => <option key={sr.name} value={sr.name} />)}</datalist>
{tagList.length > 0 && (
<div className="-mt-1 mb-4 flex flex-wrap items-center gap-1.5" role="group" aria-label="Filter by tag">
<span className="mr-1 text-xs text-[var(--r-sub)]">Tags</span>
@@ -226,8 +244,15 @@ export default function HomePage() {
</div>
)}
{sections.map((section) => (
<div key={section.name || '__none'} className="mb-5 last:mb-0">
{!section.flat && (
<h3 className="mb-2 flex items-baseline gap-2 text-sm font-medium">
{section.name || 'Not in a series'} <span className="text-xs font-normal text-[var(--r-sub)]">{section.items.length}</span>
</h3>
)}
<div className={homeView === 'list' ? 'flex flex-col gap-2' : 'grid gap-3 sm:grid-cols-2'}>
{visible.map((entry) => (
{section.items.map((entry) => (
<div key={entry.id} className={homeView === 'list'
? 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-3 sm:flex-row sm:items-center sm:gap-4'
: 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5'}>
@@ -249,7 +274,20 @@ export default function HomePage() {
) : (
<div className={`flex items-start gap-2 ${homeView === 'list' ? 'flex-wrap items-baseline' : 'justify-between'}`}>
<h2 className="text-base font-medium">{entry.title}</h2>
{sharedStudies?.[entry.id] !== undefined && (
<span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]" title="Another account invited you to edit this study">Shared{sharedStudies[entry.id] ? ` by ${sharedStudies[entry.id]}` : ''}</span>
)}
<button type="button" onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Rename project">✎ Rename</button>
<button type="button" onClick={() => { setSeriesEditingId(entry.id); setSeriesValue(entry.series ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Put this study in a series">{entry.series ? '⧉ Series' : '⧉ Add to series'}</button>
</div>
)}
{seriesEditingId === entry.id && (
<div className="mt-2 flex items-center gap-2">
<input type="text" autoFocus list="series-names" aria-label="Series name" placeholder="Series name (blank to remove)" value={seriesValue} onChange={(e) => setSeriesValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); } else if (e.key === 'Escape') setSeriesEditingId(null); }}
className={`${fieldCls} flex-1`} />
<button type="button" onClick={() => { setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
<button type="button" onClick={() => setSeriesEditingId(null)} aria-label="Cancel" className="text-sm text-[var(--r-sub)]">×</button>
</div>
)}
{entry.chapterSummary && <p className="mt-1 text-sm text-[var(--r-sub)]" title={entry.chapterSummary}>{compactPassages(entry.chapterSummary)}</p>}
@@ -267,11 +305,13 @@ export default function HomePage() {
</div>
<div className={`flex items-center gap-2 ${homeView === 'list' ? 'sm:w-56 sm:shrink-0' : ''}`}>
<button type="button" onClick={() => resumeProject(entry.id)} className="flex-1 rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] hover:opacity-90">Resume</button>
<button type="button" onClick={() => deleteProject(entry.id)} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm text-[var(--r-danger,#b42318)] hover:bg-black/5">Delete</button>
<button type="button" onClick={() => deleteProject(entry.id)} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm text-[var(--r-danger,#b42318)] hover:bg-black/5">{sharedStudies?.[entry.id] !== undefined ? 'Leave' : 'Delete'}</button>
</div>
</div>
))}
</div>
</div>
))}
</>
)}
</section>
+4
View File
@@ -10,6 +10,7 @@ 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 PresentMode from '../components/PresentMode.jsx';
import IqOutlineAttach from '../components/iq/IqOutlineAttach.jsx';
import ShareOutlineSheet from '../components/ShareOutlineSheet.jsx';
import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline } from '../utils/outlineExport.js';
@@ -47,6 +48,7 @@ export default function OutlinePage() {
const [pendingFocus, setPendingFocus] = useState(null);
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose detail fields are showing
const [shareOpen, setShareOpen] = useState(false);
const [presenting, setPresenting] = 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).
@@ -142,6 +144,7 @@ export default function OutlinePage() {
return (
<StudioShell stage="outline">
{presenting && <PresentMode mode="outline" title={title} subtitle={subtitle} tree={tree} onClose={() => setPresenting(false)} />}
<main className="mx-auto max-w-3xl px-4 py-8">
<div className="mb-4">
<h1 className="text-xl font-medium">Outline</h1>
@@ -171,6 +174,7 @@ export default function OutlinePage() {
Build from my notes{fromNotes > 0 ? ` (${fromNotes})` : ''}
</button>
<span className="mx-1 hidden h-6 w-px bg-[var(--r-line)] sm:block" aria-hidden="true" />
<button type="button" onClick={() => setPresenting(true)} disabled={tree.length === 0} className={btn} title="Step through the outline full-screen, with a timer and your planned minutes">Present</button>
<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">
+3
View File
@@ -1,6 +1,7 @@
// Reading plans home, styled to match the reader (paper / sepia / dark). One big
// "Continue reading" card, your plans in progress, and a browsable catalog. Details,
// the custom-plan builder and everything else open as sheets.
import ReadingProgress from '../components/ReadingProgress.jsx';
import IqPlanExport from '../components/iq/IqPlanExport.jsx';
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
@@ -39,6 +40,7 @@ export default function ReadingPlansPage() {
openPlanReading, studyReadingPlanDay, studyBuildBusy,
readingPlanSyncStatus, readingPlanSyncError, retryReadingPlanSync,
readerTranslation, planTranslationPref,
readingHistory, readReadingPlanDay,
} = useApp();
const [detail, setDetail] = useState(null); // { planId, progressId? }
@@ -219,6 +221,7 @@ export default function ReadingPlansPage() {
</div>
</>
)}
<div className="mt-8"><ReadingProgress history={readingHistory ?? { chapters: {}, days: [] }} planProgress={planProgress} activity={readingActivity} onOpenBook={(abbrev) => readReadingPlanDay(abbrev, 1)} /></div>
<div className="mt-8"><IqPlanExport mcheyne /></div>
</main>
+6
View File
@@ -2,6 +2,8 @@
// write the final script beside your study notes. Claude prompts and the
// pronunciation guide live here because this is where they're needed.
import { useState } from 'react';
import PresentMode from '../components/PresentMode.jsx';
import DictateButton, { appendSpoken } from '../components/DictateButton.jsx';
import { useIqAvailable } from '../hooks/useIq.js';
import { chunkRefs, gatherIqContext } from '../utils/iqbible.js';
import { useApp } from '../context/AppContext.js';
@@ -20,6 +22,7 @@ export default function ScriptPage() {
const [notice, setNotice] = useState('');
const iq = useIqAvailable();
const [gathering, setGathering] = useState(false);
const [presenting, setPresenting] = useState(false);
const { toast } = useToast();
const chunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
@@ -138,6 +141,8 @@ export default function ScriptPage() {
</button>
)}
<button type="button" onClick={() => setCurrentPage('pronunciation')} className={btn}>Pronunciation guide</button>
<button type="button" onClick={() => setPresenting(true)} className={btn} title="Full-screen, large type, with a timer and auto-scroll">Present</button>
<DictateButton label="Dictate script" onText={(said) => set({ finalScript: appendSpoken(script, said) })} />
{notice && <span role="status" className="text-xs text-[var(--r-accent)]">{notice}</span>}
</div>
<textarea aria-label="Final script" value={script} onChange={(e) => set({ finalScript: e.target.value })}
@@ -165,6 +170,7 @@ export default function ScriptPage() {
)}
</aside>
</div>
{presenting && <PresentMode mode="script" title={reference} subtitle={project.title} script={script} onClose={() => setPresenting(false)} />}
</StudioShell>
);
}
+16
View File
@@ -85,6 +85,8 @@ export default function SettingsPage() {
ntfyUrl: authUser?.ntfyUrl ?? '',
ntfyTopic: authUser?.ntfyTopic ?? '',
reminderTime: authUser?.reminderTime ?? '07:00',
votdEnabled: authUser?.votdEnabled ?? false,
votdTime: authUser?.votdTime ?? '06:30',
});
const [reminderBusy, setReminderBusy] = useState(false);
const [reminderError, setReminderError] = useState('');
@@ -426,6 +428,20 @@ export default function SettingsPage() {
/>
</label>
</div>
{authUser?.votdAvailable && (
<div className="rounded-xl border border-[var(--r-line)] p-3">
<label className="flex items-center gap-2 text-sm font-medium text-[var(--r-text)]">
<input type="checkbox" checked={reminderForm.votdEnabled} onChange={(e) => setReminderForm((f) => ({ ...f, votdEnabled: e.target.checked }))} className="h-4 w-4 accent-[var(--r-accent)]" />
Also send the verse of the day
</label>
<label className="mt-2 block max-w-xs text-sm font-medium text-[var(--r-text)]">
Send it at
<input type="time" value={reminderForm.votdTime} onChange={(e) => setReminderForm((f) => ({ ...f, votdTime: 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-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
</label>
<span className="mt-1 block text-xs text-[var(--r-sub)]">A short notification with today’s verse (KJV) to the same topic, even if the reading reminder is off.</span>
</div>
)}
<label className="block max-w-xs text-sm font-medium text-[var(--r-text)]">
ntfy server <span className="text-[var(--r-sub)]">(optional — defaults to ntfy.sh)</span>
<input
+9 -5
View File
@@ -14,7 +14,7 @@ const primaryBtn = 'rounded-xl bg-[var(--r-accent)] px-5 py-2.5 text-sm font-med
// Band tints that alternate from chunk to chunk so neighbouring chunks are easy to tell apart.
const BANDS = ['color-mix(in srgb, var(--r-accent) 13%, transparent)', 'color-mix(in srgb, var(--r-sub) 17%, transparent)'];
function PassageForm({ setup, onField, onTitleChange, onLoad, loading, error, withTitle }) {
function PassageForm({ setup, onField, onTitleChange, onLoad, loading, error, withTitle, userTemplates = [], onDeleteTemplate }) {
return (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
@@ -35,8 +35,12 @@ function PassageForm({ setup, onField, onTitleChange, onLoad, loading, error, wi
<label className="block text-sm sm:col-span-2">Start from
<select value={setup.template ?? 'blank'} onChange={(e) => onField('template', e.target.value)} className={inputCls}>
{STUDY_TEMPLATES.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
{userTemplates.length > 0 && <optgroup label="Your templates">{userTemplates.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</optgroup>}
</select>
<span className="mt-1 block text-xs text-[var(--r-sub)]">{getTemplate(setup.template).description}</span>
<span className="mt-1 block text-xs text-[var(--r-sub)]">{getTemplate(setup.template, userTemplates).description}</span>
{setup.template?.startsWith('user-') && onDeleteTemplate && (
<button type="button" onClick={() => { onDeleteTemplate(setup.template); onField('template', 'blank'); }} className="mt-1 text-xs text-[var(--r-danger,#b42318)] hover:underline">Delete this template</button>
)}
</label>
)}
</div>
@@ -49,8 +53,8 @@ function PassageForm({ setup, onField, onTitleChange, onLoad, loading, error, wi
}
export default function SetupPage() {
const { project, setProject, allChunks, updateProject, setCurrentPage } = useApp();
const b = useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage });
const { project, setProject, allChunks, updateProject, setCurrentPage, userTemplates, deleteUserTemplate } = useApp();
const b = useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage, userTemplates });
const { activeChapter, activeChapterIndex } = b;
const chapters = project?.chapters ?? [];
@@ -73,7 +77,7 @@ export default function SetupPage() {
<h1 className="mb-1 text-xl font-medium">New study</h1>
<p className="mb-5 text-sm text-[var(--r-sub)]">Pick a chapter, then cut it into chunks. You’ll study one chunk at a time.</p>
<PassageForm setup={b.setup} onField={b.handleSetupField} onTitleChange={onTitleChange} onLoad={b.handleLoadChapter}
loading={b.loadingChapter} error={b.errorMessage} withTitle />
loading={b.loadingChapter} error={b.errorMessage} withTitle userTemplates={userTemplates} onDeleteTemplate={deleteUserTemplate} />
</section>
)}
+2 -2
View File
@@ -16,7 +16,7 @@ import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.j
import SuggestWordsSheet from '../components/study/SuggestWordsSheet.jsx';
export default function StudyPage() {
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary } = useApp();
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate } = useApp();
// Short-lived status line (word suggestions, lookups, …)
const [statusMessage, setStatusMessage] = useState('');
@@ -132,7 +132,7 @@ export default function StudyPage() {
<section aria-label="Notes" className="flex min-h-[22rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[11]">
<h2 className="shrink-0 border-b border-[var(--r-line)] px-4 py-2.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes</h2>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateId} />
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateDef ?? project?.templateId} saveTemplate={saveUserTemplate} notify={notify} />
</div>
</section>
<section aria-label="Resources" className="flex min-h-[24rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[10]">