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:
@@ -6,13 +6,17 @@ import SourceBadge from './SourceBadge.astro';
|
||||
|
||||
interface Props {
|
||||
item: FindItem;
|
||||
inList?: boolean;
|
||||
unused?: boolean;
|
||||
joke?: boolean;
|
||||
quote?: boolean;
|
||||
capturedAt?: Date;
|
||||
availableTags?: string[];
|
||||
}
|
||||
const { item, inList, capturedAt, availableTags = [] } = Astro.props;
|
||||
const { item, unused, joke, quote, capturedAt, availableTags = [] } = Astro.props;
|
||||
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 selectedSet = new Set(selectedTags);
|
||||
|
||||
@@ -34,7 +38,9 @@ function host(url: string) {
|
||||
|
||||
<article
|
||||
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 && (
|
||||
<label class="edit-checkbox" aria-label={`Select ${item.data.name}`}>
|
||||
@@ -71,7 +77,7 @@ function host(url: string) {
|
||||
)}
|
||||
|
||||
{import.meta.env.DEV && (
|
||||
<div class="edit-tags">
|
||||
<div class="edit-tags" data-edit-tags-root data-slug={item.id}>
|
||||
{selectedTags.length > 0 && (
|
||||
<div class="edit-tags-selected" aria-hidden="true">
|
||||
{selectedTags.map((tag) => (
|
||||
@@ -87,11 +93,8 @@ function host(url: string) {
|
||||
'edit-tag',
|
||||
{ 'is-selected': selectedSet.has(tag), 'is-delete': tag === 'delete' },
|
||||
]}
|
||||
data-edit-action="copy"
|
||||
data-command={`/edit-find-tag ${item.id} ${tag}`}
|
||||
data-confirm={selectedSet.has(tag)
|
||||
? `Remove "${tag}" from "${item.data.name}".`
|
||||
: `Tag "${item.data.name}" with "${tag}".`}
|
||||
data-edit-tag-toggle
|
||||
data-tag={tag}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
@@ -99,9 +102,7 @@ function host(url: string) {
|
||||
<button
|
||||
type="button"
|
||||
class="edit-tag edit-tag-prompt"
|
||||
data-edit-action="tag-prompt"
|
||||
data-slug={item.id}
|
||||
data-name={item.data.name}
|
||||
data-edit-tag-prompt
|
||||
aria-label="Add custom tag"
|
||||
title="Add custom tag"
|
||||
>
|
||||
@@ -113,6 +114,71 @@ function host(url: string) {
|
||||
</div>
|
||||
</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>
|
||||
.find-card {
|
||||
position: relative;
|
||||
|
||||
+101
-15
@@ -39,7 +39,13 @@ const lastUpdated = finds[0]?.data.date;
|
||||
const inListSlugs = new Set(
|
||||
(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
|
||||
@@ -50,22 +56,42 @@ const notInListCount = finds.filter((f) => !inListSlugs.has(f.id)).length;
|
||||
noindex
|
||||
>
|
||||
{import.meta.env.DEV && (
|
||||
<div class="find-filter" role="group" aria-label="Filter">
|
||||
<button type="button" data-find-filter-set="all" aria-current="true">
|
||||
All ({finds.length})
|
||||
<div class="find-filter-row">
|
||||
<div class="find-filter" role="group" aria-label="Filter">
|
||||
<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 type="button" data-find-filter-set="not-in-lists">
|
||||
Not in lists ({notInListCount})
|
||||
<button
|
||||
type="button"
|
||||
class="find-toggle"
|
||||
data-find-quotes-toggle
|
||||
aria-pressed="false"
|
||||
>
|
||||
Quotes ({quoteCount})
|
||||
</button>
|
||||
</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}>
|
||||
{decorated.map(({ item, capturedAt }) => (
|
||||
<FindCard
|
||||
item={item}
|
||||
inList={inListSlugs.has(item.id)}
|
||||
unused={isUnused(item)}
|
||||
joke={isJoke(item)}
|
||||
quote={isQuote(item)}
|
||||
capturedAt={capturedAt}
|
||||
availableTags={editorialTags}
|
||||
/>
|
||||
@@ -101,13 +127,13 @@ const notInListCount = finds.filter((f) => !inListSlugs.has(f.id)).length;
|
||||
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 filterButtons = document.querySelectorAll<HTMLButtonElement>('[data-find-filter-set]');
|
||||
const FILTER_KEY = 'find-filter';
|
||||
|
||||
if (wrapper && filterButtons.length) {
|
||||
type FilterValue = 'all' | 'not-in-lists';
|
||||
type FilterValue = 'unused' | 'all';
|
||||
const apply = (value: FilterValue) => {
|
||||
wrapper.setAttribute('data-find-filter', value);
|
||||
for (const b of filterButtons) {
|
||||
@@ -119,15 +145,40 @@ const notInListCount = finds.filter((f) => !inListSlugs.has(f.id)).length;
|
||||
|
||||
let saved: string | null = null;
|
||||
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) {
|
||||
b.addEventListener('click', () => {
|
||||
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>
|
||||
|
||||
<style>
|
||||
@@ -157,10 +208,16 @@ const notInListCount = finds.filter((f) => !inListSlugs.has(f.id)).length;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.find-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.find-filter {
|
||||
display: inline-flex;
|
||||
gap: 0;
|
||||
margin-bottom: 1.25rem;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
@@ -187,8 +244,37 @@ const notInListCount = finds.filter((f) => !inListSlugs.has(f.id)).length;
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
/* Hide in-list cards when the filter is set to not-in-lists. */
|
||||
[data-find-filter="not-in-lists"] [data-in-list="true"] {
|
||||
.find-toggle {
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user