Dashboard: search and filter studies by tag
Build and Push Docker Image / build-and-push (push) Failing after 20s

- The search box matches tags as well as title and passage; "#tag" searches
  tags only
- Tag chips (with counts) replace the Tag dropdown; several can be on at
  once and a study must have all of them
- Tags on each study card are clickable filters
- "No studies match" message with a Clear filters link

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 10:03:06 -04:00
1 parent dc825f0112
commit 88bc59bac0
4 files changed
+138 -21

No files matched your search

+39 -21
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { filterStudies, isTagQuery, 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';
@@ -50,7 +51,9 @@ export default function HomePage() {
const [homeSort, setHomeSort] = useState(() => {
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
}); // 'recent' | 'title' | 'passage'
const [homeTagFilter, setHomeTagFilter] = useState('');
const [homeTags, setHomeTags] = useState([]); // tag chips switched on: a study must have all of them
const [showAllTags, setShowAllTags] = useState(false);
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'; }
}); // 'grid' | 'list'
@@ -70,7 +73,7 @@ export default function HomePage() {
useEffect(() => {
clearTimeout(fullTextTimerRef.current);
const q = homeSearch.trim().toLowerCase();
if (q.length < 3) { setHomeFullTextResults(null); return; }
if (q.length < 3 || isTagQuery(q)) { setHomeFullTextResults(null); return; }
fullTextTimerRef.current = window.setTimeout(() => {
const index = loadProjectIndex();
const results = [];
@@ -98,15 +101,9 @@ export default function HomePage() {
}, 300);
}, [homeSearch]);
const allTags = Array.from(new Set(projectIndex.flatMap((entry) => entry.tags ?? []))).sort((a, b) => a.localeCompare(b));
const visible = projectIndex
.slice()
.filter((entry) => {
if (homeTagFilter && !(entry.tags ?? []).includes(homeTagFilter)) return false;
const q = homeSearch.trim().toLowerCase();
if (!q) return true;
return entry.title?.toLowerCase().includes(q) || entry.chapterSummary?.toLowerCase().includes(q);
})
const tagList = tagCounts(projectIndex);
const shownTags = showAllTags ? tagList : tagList.slice(0, 12);
const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags })
.sort((a, b) => {
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
@@ -160,7 +157,7 @@ export default function HomePage() {
) : (
<>
<div className="mb-4 flex flex-wrap items-center gap-3">
<input type="text" value={homeSearch} onChange={(e) => setHomeSearch(e.target.value)} placeholder="Search projects by title or passage…"
<input type="text" value={homeSearch} onChange={(e) => setHomeSearch(e.target.value)} placeholder="Search projects by title, passage or #tag…"
className={`${fieldCls} w-full max-w-sm`} />
<label className="text-sm text-[var(--r-sub)]">Sort by{' '}
<select value={homeSort} onChange={(e) => setHomeSort(e.target.value)} className={`${fieldCls} ml-1`}>
@@ -175,16 +172,34 @@ export default function HomePage() {
className={`px-3 py-1.5 ${homeView === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{label}</button>
))}
</div>
{allTags.length > 0 && (
<label className="text-sm text-[var(--r-sub)]">Tag{' '}
<select value={homeTagFilter} onChange={(e) => setHomeTagFilter(e.target.value)} className={`${fieldCls} ml-1`}>
<option value="">All tags</option>
{allTags.map((tag) => <option key={tag} value={tag}>{tag}</option>)}
</select>
</label>
)}
</div>
{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>
{shownTags.map(({ tag, count }) => {
const on = homeTags.includes(tag);
return (
<button key={tag} type="button" aria-pressed={on} onClick={() => toggleTag(tag)}
className={`rounded-full border px-2.5 py-0.5 text-xs ${on ? '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} <span className="opacity-70">{count}</span>
</button>
);
})}
{tagList.length > 12 && (
<button type="button" onClick={() => setShowAllTags((o) => !o)} className="text-xs text-[var(--r-accent)] hover:underline">{showAllTags ? 'Fewer' : `All ${tagList.length} tags`}</button>
)}
{homeTags.length > 0 && <button type="button" onClick={() => setHomeTags([])} className="text-xs text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Clear</button>}
</div>
)}
{visible.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>
</p>
)}
{/* Full-text note search results */}
{homeFullTextResults !== null && (
<div className="mb-4 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
@@ -241,7 +256,10 @@ export default function HomePage() {
{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) => <span key={tag} className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]">{tag}</span>)}
{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>
)}