site: /find/ filter toggles — Unused (default), All, plus Jokes/Quotes toggles

Replaces the old "Not in lists" filter with "Unused" (no list and no
promotedTo). Adds independent Jokes/Quotes toggles, off by default.
Filter + each toggle persist separately in localStorage. Card scoping uses
:global() so the filter selectors reach FindCard's CSS scope.

Also picks up the editorial-tag picker refactor in FindCard: in-card buttons
now POST to /api/edit-find-tag/ via a single delegated handler instead of
copying slash commands.
This commit is contained in:
2026-05-04 20:25:37 -04:00
parent 6c42d0c380
commit abd401d783
2 changed files with 180 additions and 28 deletions
+79 -13
View File
@@ -6,13 +6,17 @@ import SourceBadge from './SourceBadge.astro';
interface Props { interface Props {
item: FindItem; item: FindItem;
inList?: boolean; unused?: boolean;
joke?: boolean;
quote?: boolean;
capturedAt?: Date; capturedAt?: Date;
availableTags?: string[]; availableTags?: string[];
} }
const { item, inList, capturedAt, availableTags = [] } = Astro.props; const { item, unused, joke, quote, capturedAt, availableTags = [] } = Astro.props;
const hero = findHero(item.id); const hero = findHero(item.id);
const showInListAttr = import.meta.env.DEV && inList !== undefined; const showUnusedAttr = import.meta.env.DEV && unused !== undefined;
const showJokeAttr = import.meta.env.DEV && joke !== undefined;
const showQuoteAttr = import.meta.env.DEV && quote !== undefined;
const selectedTags: string[] = item.data.editorialTags ?? []; const selectedTags: string[] = item.data.editorialTags ?? [];
const selectedSet = new Set(selectedTags); const selectedSet = new Set(selectedTags);
@@ -34,7 +38,9 @@ function host(url: string) {
<article <article
class:list={['find-card', { 'has-hero': !!hero }]} class:list={['find-card', { 'has-hero': !!hero }]}
data-in-list={showInListAttr ? String(inList) : undefined} data-unused={showUnusedAttr ? String(unused) : undefined}
data-joke={showJokeAttr ? String(joke) : undefined}
data-quote={showQuoteAttr ? String(quote) : undefined}
> >
{import.meta.env.DEV && ( {import.meta.env.DEV && (
<label class="edit-checkbox" aria-label={`Select ${item.data.name}`}> <label class="edit-checkbox" aria-label={`Select ${item.data.name}`}>
@@ -71,7 +77,7 @@ function host(url: string) {
)} )}
{import.meta.env.DEV && ( {import.meta.env.DEV && (
<div class="edit-tags"> <div class="edit-tags" data-edit-tags-root data-slug={item.id}>
{selectedTags.length > 0 && ( {selectedTags.length > 0 && (
<div class="edit-tags-selected" aria-hidden="true"> <div class="edit-tags-selected" aria-hidden="true">
{selectedTags.map((tag) => ( {selectedTags.map((tag) => (
@@ -87,11 +93,8 @@ function host(url: string) {
'edit-tag', 'edit-tag',
{ 'is-selected': selectedSet.has(tag), 'is-delete': tag === 'delete' }, { 'is-selected': selectedSet.has(tag), 'is-delete': tag === 'delete' },
]} ]}
data-edit-action="copy" data-edit-tag-toggle
data-command={`/edit-find-tag ${item.id} ${tag}`} data-tag={tag}
data-confirm={selectedSet.has(tag)
? `Remove "${tag}" from "${item.data.name}".`
: `Tag "${item.data.name}" with "${tag}".`}
> >
{tag} {tag}
</button> </button>
@@ -99,9 +102,7 @@ function host(url: string) {
<button <button
type="button" type="button"
class="edit-tag edit-tag-prompt" class="edit-tag edit-tag-prompt"
data-edit-action="tag-prompt" data-edit-tag-prompt
data-slug={item.id}
data-name={item.data.name}
aria-label="Add custom tag" aria-label="Add custom tag"
title="Add custom tag" title="Add custom tag"
> >
@@ -113,6 +114,71 @@ function host(url: string) {
</div> </div>
</article> </article>
{import.meta.env.DEV && (
<script>
if (!(window as any).__editFindTagsBound) {
(window as any).__editFindTagsBound = true;
const VALID_TAG = /^[a-z0-9][a-z0-9-]{0,40}$/i;
async function postEdit(slug: string, tag: string, mode?: 'add' | 'remove') {
const res = await fetch('/api/edit-find-tag/', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ slug, tag, mode }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || `HTTP ${res.status}`);
}
return res.json() as Promise<{ slug: string; tag: string; editorialTags: string[] }>;
}
function findRoot(el: HTMLElement): HTMLElement | null {
return el.closest<HTMLElement>('[data-edit-tags-root]');
}
document.addEventListener('click', async (e) => {
const target = e.target as HTMLElement;
const toggle = target.closest<HTMLButtonElement>('[data-edit-tag-toggle]');
const prompt = target.closest<HTMLButtonElement>('[data-edit-tag-prompt]');
if (!toggle && !prompt) return;
e.preventDefault();
e.stopPropagation();
const root = findRoot(toggle ?? prompt!);
const slug = root?.dataset.slug;
if (!slug) return;
let tag: string | null;
if (toggle) {
tag = toggle.dataset.tag ?? null;
} else {
tag = window.prompt('New editorial tag (a-z, 0-9, hyphen):');
if (!tag) return;
tag = tag.trim().toLowerCase();
if (!VALID_TAG.test(tag)) {
alert('Invalid tag — must be a-z / 0-9 / hyphen, max 40 chars.');
return;
}
}
if (!tag) return;
const btn = toggle ?? prompt!;
btn.disabled = true;
try {
await postEdit(slug, tag);
// HMR will reload the page on the file change; no need to update DOM here.
} catch (err) {
alert(`Edit failed: ${err instanceof Error ? err.message : String(err)}`);
} finally {
btn.disabled = false;
}
});
}
</script>
)}
<style> <style>
.find-card { .find-card {
position: relative; position: relative;
+101 -15
View File
@@ -39,7 +39,13 @@ const lastUpdated = finds[0]?.data.date;
const inListSlugs = new Set( const inListSlugs = new Set(
(await getCollection('finds')).flatMap((l) => l.data.items) (await getCollection('finds')).flatMap((l) => l.data.items)
); );
const notInListCount = finds.filter((f) => !inListSlugs.has(f.id)).length; const isJoke = (f: (typeof finds)[number]) => f.id.startsWith('joke-');
const isQuote = (f: (typeof finds)[number]) => f.id.startsWith('quote-');
const isUnused = (f: (typeof finds)[number]) =>
!inListSlugs.has(f.id) && !f.data.promotedTo;
const unusedCount = finds.filter(isUnused).length;
const jokeCount = finds.filter(isJoke).length;
const quoteCount = finds.filter(isQuote).length;
--- ---
<BaseLayout <BaseLayout
@@ -50,22 +56,42 @@ const notInListCount = finds.filter((f) => !inListSlugs.has(f.id)).length;
noindex noindex
> >
{import.meta.env.DEV && ( {import.meta.env.DEV && (
<div class="find-filter" role="group" aria-label="Filter"> <div class="find-filter-row">
<button type="button" data-find-filter-set="all" aria-current="true"> <div class="find-filter" role="group" aria-label="Filter">
All ({finds.length}) <button type="button" data-find-filter-set="unused" aria-current="true">
Unused ({unusedCount})
</button>
<button type="button" data-find-filter-set="all">
All ({finds.length})
</button>
</div>
<button
type="button"
class="find-toggle"
data-find-jokes-toggle
aria-pressed="false"
>
Jokes ({jokeCount})
</button> </button>
<button type="button" data-find-filter-set="not-in-lists"> <button
Not in lists ({notInListCount}) type="button"
class="find-toggle"
data-find-quotes-toggle
aria-pressed="false"
>
Quotes ({quoteCount})
</button> </button>
</div> </div>
)} )}
<div data-find-filter="all"> <div data-find-filter="unused" data-find-jokes="hide" data-find-quotes="hide">
<Masonry initial={INITIAL_GRID} batch={BATCH_SIZE}> <Masonry initial={INITIAL_GRID} batch={BATCH_SIZE}>
{decorated.map(({ item, capturedAt }) => ( {decorated.map(({ item, capturedAt }) => (
<FindCard <FindCard
item={item} item={item}
inList={inListSlugs.has(item.id)} unused={isUnused(item)}
joke={isJoke(item)}
quote={isQuote(item)}
capturedAt={capturedAt} capturedAt={capturedAt}
availableTags={editorialTags} availableTags={editorialTags}
/> />
@@ -101,13 +127,13 @@ const notInListCount = finds.filter((f) => !inListSlugs.has(f.id)).length;
refresh(); refresh();
} }
// Dev-only filter: toggle "all" vs "not-in-lists" on the masonry wrapper. // Dev-only filter: toggle "unused" vs "all" on the masonry wrapper.
const wrapper = document.querySelector<HTMLDivElement>('[data-find-filter]'); const wrapper = document.querySelector<HTMLDivElement>('[data-find-filter]');
const filterButtons = document.querySelectorAll<HTMLButtonElement>('[data-find-filter-set]'); const filterButtons = document.querySelectorAll<HTMLButtonElement>('[data-find-filter-set]');
const FILTER_KEY = 'find-filter'; const FILTER_KEY = 'find-filter';
if (wrapper && filterButtons.length) { if (wrapper && filterButtons.length) {
type FilterValue = 'all' | 'not-in-lists'; type FilterValue = 'unused' | 'all';
const apply = (value: FilterValue) => { const apply = (value: FilterValue) => {
wrapper.setAttribute('data-find-filter', value); wrapper.setAttribute('data-find-filter', value);
for (const b of filterButtons) { for (const b of filterButtons) {
@@ -119,15 +145,40 @@ const notInListCount = finds.filter((f) => !inListSlugs.has(f.id)).length;
let saved: string | null = null; let saved: string | null = null;
try { saved = localStorage.getItem(FILTER_KEY); } catch (e) {} try { saved = localStorage.getItem(FILTER_KEY); } catch (e) {}
if (saved === 'all' || saved === 'not-in-lists') apply(saved); if (saved === 'unused' || saved === 'all') apply(saved);
for (const b of filterButtons) { for (const b of filterButtons) {
b.addEventListener('click', () => { b.addEventListener('click', () => {
const v = b.dataset.findFilterSet; const v = b.dataset.findFilterSet;
if (v === 'all' || v === 'not-in-lists') apply(v); if (v === 'unused' || v === 'all') apply(v);
}); });
} }
} }
// Dev-only category toggles: independent of the Unused/All filter.
const categoryToggles: { key: string; attr: string; selector: string }[] = [
{ key: 'find-jokes', attr: 'data-find-jokes', selector: '[data-find-jokes-toggle]' },
{ key: 'find-quotes', attr: 'data-find-quotes', selector: '[data-find-quotes-toggle]' },
];
for (const { key, attr, selector } of categoryToggles) {
const btn = document.querySelector<HTMLButtonElement>(selector);
if (!wrapper || !btn) continue;
const applyToggle = (show: boolean) => {
wrapper.setAttribute(attr, show ? 'show' : 'hide');
btn.setAttribute('aria-pressed', String(show));
try { localStorage.setItem(key, show ? '1' : '0'); } catch (e) {}
};
let saved: string | null = null;
try { saved = localStorage.getItem(key); } catch (e) {}
if (saved === '1') applyToggle(true);
btn.addEventListener('click', () => {
applyToggle(btn.getAttribute('aria-pressed') !== 'true');
});
}
</script> </script>
<style> <style>
@@ -157,10 +208,16 @@ const notInListCount = finds.filter((f) => !inListSlugs.has(f.id)).length;
font-weight: 700; font-weight: 700;
} }
.find-filter-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
margin-bottom: 1.25rem;
}
.find-filter { .find-filter {
display: inline-flex; display: inline-flex;
gap: 0; gap: 0;
margin-bottom: 1.25rem;
border: 1px solid var(--rule); border: 1px solid var(--rule);
border-radius: 999px; border-radius: 999px;
overflow: hidden; overflow: hidden;
@@ -187,8 +244,37 @@ const notInListCount = finds.filter((f) => !inListSlugs.has(f.id)).length;
color: var(--bg); color: var(--bg);
} }
/* Hide in-list cards when the filter is set to not-in-lists. */ .find-toggle {
[data-find-filter="not-in-lists"] [data-in-list="true"] { font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.04em;
padding: 0.4rem 0.95rem;
margin: 0;
color: var(--muted);
background: none;
border: 1px solid var(--rule);
border-radius: 999px;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.find-toggle:hover { color: var(--accent); }
.find-toggle[aria-pressed="true"] {
background: var(--fg);
color: var(--bg);
border-color: var(--fg);
}
/* Hide non-unused cards when the filter is set to unused. */
[data-find-filter="unused"] :global([data-unused="false"]) {
display: none;
}
/* Hide joke cards when the Jokes toggle is off. */
[data-find-jokes="hide"] :global([data-joke="true"]) {
display: none;
}
/* Hide quote cards when the Quotes toggle is off. */
[data-find-quotes="hide"] :global([data-quote="true"]) {
display: none; display: none;
} }
</style> </style>