Invite-only sign-up, present mode, series, handouts, templates, dictation, progress, sharing
Build and Push Docker Image / build-and-push (push) Successful in 6m47s
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:
1 parent
88bc59bac0
commit
54cb1c217d
73 files changed
+2739
-134
No files matched your search
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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]">
|
||||
|
||||
Reference in new issue
Block a user