Studies: Finished & archived section with Archive/Restore
Build and Push Docker Image / build-and-push (push) Successful in 6m3s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-14 16:55:33 -04:00
1 parent afc3206da4
commit 843056b963
6 files changed
+140 -74

No files matched your search

+102 -67
View File
@@ -50,6 +50,7 @@ export default function HomePage() {
resumeProject,
renameProjectInStorage,
setProjectSeries,
setProjectArchived,
deleteProject,
handlePlayBookAudio,
handleStopBookAudio,
@@ -68,6 +69,7 @@ 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 [showArchived, setShowArchived] = useState(false); // the "Finished & archived" section is folded away until opened
const [seriesFilter, setSeriesFilter] = useState(''); // '' = all, '__none' = not in a series, or a series name
const [seriesEditingId, setSeriesEditingId] = useState(null);
const [seriesValue, setSeriesValue] = useState('');
@@ -119,21 +121,90 @@ export default function HomePage() {
}, 300);
}, [homeSearch]);
const tagList = tagCounts(projectIndex);
// Finished studies are set aside in their own section; the tag and series filters follow the active ones
const activeIndex = projectIndex.filter((e) => !e.archived);
const tagList = tagCounts(activeIndex);
const shownTags = showAllTags ? tagList : tagList.slice(0, 12);
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);
});
const seriesNames = seriesList(activeIndex);
const byHomeSort = (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);
};
const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags, series: seriesFilter }).sort(byHomeSort);
const archivedStudies = filterStudies(projectIndex, { query: homeSearch, tags: homeTags, archived: true }).sort(byHomeSort);
const archivedCount = projectIndex.length - activeIndex.length;
const filtering = !!homeSearch.trim() || homeTags.length > 0 || seriesFilter !== '';
const archivedOpen = showArchived || ((!!homeSearch.trim() || homeTags.length > 0) && archivedStudies.length > 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';
const renderEntry = (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'}>
{homeView === 'list'
? <BookCover summary={entry.chapterSummary} compact className="h-14 w-full sm:w-28 sm:shrink-0" />
: <BookCover summary={entry.chapterSummary} className="h-24" />}
<div className="min-w-0 flex-1">
{renamingId === entry.id ? (
<div className="flex items-center gap-2">
<input type="text" autoFocus value={renameValue} onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }
else if (e.key === 'Escape') setRenamingId(null);
}}
className={`${fieldCls} flex-1`} />
<button type="button" onClick={() => { const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
<button type="button" onClick={() => setRenamingId(null)} className="text-sm text-[var(--r-sub)]">×</button>
</div>
) : (
<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>}
{entry.tags?.length > 0 && (
// A few tags and a "+N" for the rest (hover for all; the Tag filter above finds any of them)
<div className="mt-2 flex flex-wrap gap-1" title={entry.tags.length > 3 ? entry.tags.join(', ') : undefined}>
{entry.tags.slice(0, 3).map((tag) => (
<button key={tag} type="button" onClick={() => toggleTag(tag)} aria-pressed={homeTags.includes(tag)} title={`Show studies tagged “${tag}”`}
className={`rounded-full border px-2 py-0.5 text-[11px] ${homeTags.includes(tag) ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>{tag}</button>
))}
{entry.tags.length > 3 && <span className="rounded-full px-1.5 py-0.5 text-[11px] text-[var(--r-sub)]">+{entry.tags.length - 3} more</span>}
</div>
)}
<p className="mt-1 text-xs text-[var(--r-sub)]">{formatRelativeDate(entry.lastEdited)}</p>
<OfflineBadge project={studyBooks[entry.id]} offline={offline} />
</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>
{sharedStudies?.[entry.id] === undefined && (
<button type="button" onClick={() => setProjectArchived(entry.id, !entry.archived)} title={entry.archived ? 'Move back to your studies' : 'Set this study aside as finished'}
className="rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm text-[var(--r-sub)] hover:bg-black/5">{entry.archived ? 'Restore' : 'Archive'}</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>
);
return (
<PageShell
title="My Studies"
@@ -232,7 +303,7 @@ export default function HomePage() {
</div>
)}
{visible.length === 0 && (
{visible.length === 0 && archivedStudies.length === 0 && (
<p role="status" className="mb-4 rounded-xl border border-dashed border-[var(--r-line)] px-4 py-6 text-center text-sm text-[var(--r-sub)]">
No studies match{homeTags.length ? ` ${homeTags.map((t) => `“${t}”`).join(' + ')}` : ''}{homeSearch.trim() ? ` “${homeSearch.trim()}”` : ''}.{' '}
<button type="button" onClick={() => { setHomeTags([]); setHomeSearch(''); }} className="text-[var(--r-accent)] hover:underline">Clear filters</button>
@@ -273,67 +344,31 @@ export default function HomePage() {
</h3>
)}
<div className={homeView === 'list' ? 'flex flex-col gap-2' : 'grid gap-3 sm:grid-cols-2'}>
{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'}>
{homeView === 'list'
? <BookCover summary={entry.chapterSummary} compact className="h-14 w-full sm:w-28 sm:shrink-0" />
: <BookCover summary={entry.chapterSummary} className="h-24" />}
<div className="min-w-0 flex-1">
{renamingId === entry.id ? (
<div className="flex items-center gap-2">
<input type="text" autoFocus value={renameValue} onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }
else if (e.key === 'Escape') setRenamingId(null);
}}
className={`${fieldCls} flex-1`} />
<button type="button" onClick={() => { const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
<button type="button" onClick={() => setRenamingId(null)} className="text-sm text-[var(--r-sub)]">×</button>
</div>
) : (
<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>}
{entry.tags?.length > 0 && (
// A few tags and a "+N" for the rest (hover for all; the Tag filter above finds any of them)
<div className="mt-2 flex flex-wrap gap-1" title={entry.tags.length > 3 ? entry.tags.join(', ') : undefined}>
{entry.tags.slice(0, 3).map((tag) => (
<button key={tag} type="button" onClick={() => toggleTag(tag)} aria-pressed={homeTags.includes(tag)} title={`Show studies tagged “${tag}”`}
className={`rounded-full border px-2 py-0.5 text-[11px] ${homeTags.includes(tag) ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>{tag}</button>
))}
{entry.tags.length > 3 && <span className="rounded-full px-1.5 py-0.5 text-[11px] text-[var(--r-sub)]">+{entry.tags.length - 3} more</span>}
</div>
)}
<p className="mt-1 text-xs text-[var(--r-sub)]">{formatRelativeDate(entry.lastEdited)}</p>
<OfflineBadge project={studyBooks[entry.id]} offline={offline} />
</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">{sharedStudies?.[entry.id] !== undefined ? 'Leave' : 'Delete'}</button>
</div>
</div>
))}
{section.items.map(renderEntry)}
</div>
</div>
))}
{visible.length === 0 && archivedStudies.length > 0 && !filtering && (
<p role="status" className="mb-4 rounded-xl border border-dashed border-[var(--r-line)] px-4 py-6 text-center text-sm text-[var(--r-sub)]">Everything is finished or archived. Open the section below to bring a study back.</p>
)}
{archivedCount > 0 && (
<div className="mt-6 border-t border-[var(--r-line)] pt-4">
<button type="button" onClick={() => setShowArchived((o) => !o)} aria-expanded={archivedOpen} aria-controls="archived-studies"
className="flex w-full items-center gap-2 text-left text-sm font-medium">
<span aria-hidden="true" className="text-xs text-[var(--r-sub)]">{archivedOpen ? '▾' : '▸'}</span>
Finished &amp; archived <span className="text-xs font-normal text-[var(--r-sub)]">{filtering && archivedStudies.length !== archivedCount ? `${archivedStudies.length} of ${archivedCount}` : archivedCount}</span>
</button>
{archivedOpen && (
<div id="archived-studies" className={`mt-3 ${homeView === 'list' ? 'flex flex-col gap-2' : 'grid gap-3 sm:grid-cols-2'}`}>
{archivedStudies.length === 0
? <p className="text-sm text-[var(--r-sub)]">No archived studies match.</p>
: archivedStudies.map(renderEntry)}
</div>
)}
</div>
)}
</>
)}
</section>