Studies: Finished & archived section with Archive/Restore
Build and Push Docker Image / build-and-push (push) Successful in 6m3s
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:
1 parent
afc3206da4
commit
843056b963
6 files changed
+140
-74
No files matched your search
+102
-67
@@ -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 & 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>
|
||||
|
||||
Reference in new issue
Block a user