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 {
|
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;
|
||||||
|
|||||||
+99
-13
@@ -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-row">
|
||||||
<div class="find-filter" role="group" aria-label="Filter">
|
<div class="find-filter" role="group" aria-label="Filter">
|
||||||
<button type="button" data-find-filter-set="all" aria-current="true">
|
<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})
|
All ({finds.length})
|
||||||
</button>
|
</button>
|
||||||
<button type="button" data-find-filter-set="not-in-lists">
|
</div>
|
||||||
Not in lists ({notInListCount})
|
<button
|
||||||
|
type="button"
|
||||||
|
class="find-toggle"
|
||||||
|
data-find-jokes-toggle
|
||||||
|
aria-pressed="false"
|
||||||
|
>
|
||||||
|
Jokes ({jokeCount})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user