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

+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>