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
+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>
|
||||
|
||||
Reference in new issue
Block a user