Skip to content

Commit dd149be

Browse files
TMHSDigitalclaude
andcommitted
One FILTERS table drives the catalog filters (#252); filter by when the licence was read (#271)
search.mjs FILTERS lists each filter's field, URL key, default, control and chip; FILTER_DEFAULTS, CHOICES and SWITCHES derive from it, and app.js builds its URL allow-lists, controls, syncControls and chips from it. A new filter is one row plus its control in index.html, as the first one shows: fresh (?fresh=6m / 12m) keeps entries whose licence was read within the freshness page's 180- and 365-day bands. Tests: table consistency against index.html, a URL round trip of every field, and the freshness boundaries. Checked in a browser: URL, control and chip stay in sync, and clearing a chip resets its control. Closes #252 Closes #271 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpkeXQPaUhxjgqdqnk6BrJ
1 parent dc615f3 commit dd149be

5 files changed

Lines changed: 151 additions & 121 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ is a page on the site.
152152

153153
The [website](https://tmhsdigital.github.io/Free-Game-Dev-Assets/) searches all 418 entries
154154
and filters them by category, licence, format, engine, commercial use, attribution, review
155-
status and 2D camera view, sorted as you choose. Press `/` to search, and keep the sources you
155+
status, 2D camera view and how recently the licence was read, sorted as you choose. Press `/` to search, and keep the sources you
156156
are weighing on a shortlist. Filtered views and individual entries have their own URLs, so you can send
157157
someone exactly what you are looking at.
158158

‎site/lib/lib.test.mjs‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -939,7 +939,7 @@ eq("url: switch off written as 0", stateQuery({ ...URL_DEF, active: false }, URL
939939
eq("url: blank q not written", stateQuery({ ...URL_DEF, q: " " }, URL_DEF), "");
940940
{
941941
// #223: every choice and switch survives a round trip through the URL.
942-
const other = { category: "3d", sort: "verified", perspective: "top_down", license: "cc0", format: "image", engine: "godot" };
942+
const other = { category: "3d", sort: "verified", perspective: "top_down", license: "cc0", format: "image", engine: "godot", fresh: "6m" };
943943
const allowed = Object.fromEntries(URL_CHOICES.map(([, f]) => [f, [URL_DEF[f], other[f]]]));
944944
const changed = { ...URL_DEF, q: "pixel art" };
945945
for (const [, f] of URL_CHOICES) {
@@ -2207,6 +2207,42 @@ eq("search: q from the URL is capped", readState(`?q=${"a".repeat(5000)}`, { q:
22072207
has("thumbs report: one failure gives a Failed section", reportMarkdown({ ...empty, failed: [{ id: "a", reason: "HTTP 500" }] }), "## Failed\n\n- a: HTTP 500");
22082208
}
22092209

2210+
/* one filter table (#252) and the freshness filter (#271) ----------------- */
2211+
{
2212+
const { FILTERS, FILTER_DEFAULTS, CHOICES, SWITCHES, readState, stateQuery, passesFilters, FRESH_DAYS } = await import("./search.mjs");
2213+
const fsm = (await import("node:fs")).default;
2214+
const indexHtml = fsm.readFileSync(new URL("../public/index.html", import.meta.url), "utf8");
2215+
for (const f of FILTERS) {
2216+
eq(`filters table: ${f.field} has a default`, f.field in FILTER_DEFAULTS && FILTER_DEFAULTS[f.field] === f.default, true);
2217+
eq(`filters table: ${f.field} has a URL key`, typeof f.urlKey === "string" && f.urlKey.length > 0, true);
2218+
if (f.control) has(`filters table: ${f.field} control exists in index.html`, indexHtml, `id="${f.control.slice(1)}"`);
2219+
if (f.kind === "choice") eq(`filters table: ${f.field} has a chip label`, typeof f.chip === "string", true);
2220+
else eq(`filters table: ${f.field} has a chip for its non-default state`, Boolean(f.default ? f.chipOff : f.chipOn), true);
2221+
}
2222+
eq("filters table: URL keys are unique", new Set(FILTERS.map((f) => f.urlKey)).size, FILTERS.length);
2223+
eq("filters table: URL order is unchanged", CHOICES.map(([k]) => k).join(), "cat,sort,view,licence,format,engine,fresh");
2224+
eq("filters table: switches", SWITCHES.map(([k]) => k).join(), "active,review,deprecated,commercial,noattr");
2225+
2226+
// Every field flipped from its default survives a trip through the URL.
2227+
const changed = { ...FILTER_DEFAULTS, q: "tiles", category: "2d", sort: "added", perspective: "top-down", license: "cc0", format: "gltf", engine: "godot", fresh: "6m" };
2228+
for (const [, field] of SWITCHES) changed[field] = !FILTER_DEFAULTS[field];
2229+
const allowed = Object.fromEntries(CHOICES.map(([, field]) => [field, [changed[field]]]));
2230+
const back = readState(stateQuery(changed, FILTER_DEFAULTS), FILTER_DEFAULTS, allowed);
2231+
eq("filters: readState(stateQuery(s)) round-trips every field", JSON.stringify(back), JSON.stringify(changed));
2232+
has("fresh: written to the URL", stateQuery(changed, FILTER_DEFAULTS), "fresh=6m");
2233+
2234+
const now = Date.parse("2026-11-06T12:00:00Z");
2235+
const at = (days) => new Date(now - days * 86400000).toISOString().slice(0, 10);
2236+
const open = { ...FILTER_DEFAULTS, deprecated: true };
2237+
const pass = (verified, fresh) => passesFilters({ status: "active", verified }, { ...open, fresh }, {}, now);
2238+
eq("fresh: 6m keeps a licence read on the last allowed day", pass(at(FRESH_DAYS["6m"]), "6m"), true);
2239+
eq("fresh: 6m drops one read a day earlier", pass(at(FRESH_DAYS["6m"] + 1), "6m"), false);
2240+
eq("fresh: 12m keeps that one", pass(at(FRESH_DAYS["6m"] + 1), "12m"), true);
2241+
eq("fresh: 12m drops past the aging band", pass(at(FRESH_DAYS["12m"] + 1), "12m"), false);
2242+
eq("fresh: an entry with no date is dropped", pass(undefined, "6m"), false);
2243+
eq("fresh: any keeps everything", pass(undefined, "any"), true);
2244+
}
2245+
22102246
/* report (keep last) ------------------------------------------------------ */
22112247
if (failures.length) {
22122248
console.error(`lib.test failed (${failures.length}):`);

‎site/lib/search.mjs‎

Lines changed: 48 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* lib.test.mjs tests it here. Its one import, shared.mjs, sits beside it in
66
* both places.
77
*/
8-
import { PERSPECTIVES } from "./shared.mjs";
8+
import { PERSPECTIVES, VERIFIED_AGING_DAYS, VERIFIED_FRESH_DAYS, verifiedAge } from "./shared.mjs";
99

1010
// Searchable words per perspective (shared.mjs PERSPECTIVES).
1111
export const PERSPECTIVE_SEARCH = Object.fromEntries(Object.entries(PERSPECTIVES).map(([k, v]) => [k, v.search]));
@@ -90,8 +90,16 @@ export function scoreFields(fields, tests) {
9090
return score;
9191
}
9292

93-
/** Every filter except the category and the search words. */
94-
export function passesFilters(entry, state, formatGroups = {}) {
93+
/**
94+
* Every filter except the category and the search words. `now` (ms) dates
95+
* the freshness filter: an entry passes `fresh` when its licence was read at
96+
* most FRESH_DAYS[fresh] days before it.
97+
*/
98+
export function passesFilters(entry, state, formatGroups = {}, now = Date.now()) {
99+
if (state.fresh && state.fresh !== "any") {
100+
const { days } = verifiedAge(entry.verified, now);
101+
if (days === null || days > (FRESH_DAYS[state.fresh] ?? Infinity)) return false;
102+
}
95103
if (entry.status === "active" && !state.active) return false;
96104
if (entry.status === "needs-review" && !state.review) return false;
97105
if (entry.status === "deprecated" && !state.deprecated) return false;
@@ -248,41 +256,50 @@ export function gapIssueUrl(repo, query, categoryLabel = "") {
248256
}
249257

250258
/**
251-
* The filter state of the catalog page and its defaults: the one list. The
252-
* page starts from it, and the URL holds only what differs from it.
259+
* Every catalog filter, in the order the URL writes them (#252). The page's
260+
* defaults, the URL keys, the controls it binds and syncs, and the chips
261+
* that show an active filter all come from here, so a new filter is one row
262+
* plus its control in index.html.
263+
* - `kind: "choice"`: a <select> (`control`); the chip reads `${chip}: label`.
264+
* `options` lists fixed choices; without it app.js supplies them from data.
265+
* - `kind: "switch"`: a checkbox; written to the URL as 1 or 0. The chip
266+
* shows `chipOn` when it is on, `chipOff` when it is off, where set.
267+
* `category` has chips of its own instead of a control.
268+
*/
269+
export const FILTERS = Object.freeze([
270+
{ field: "category", urlKey: "cat", kind: "choice", default: "all", chip: "category" },
271+
{ field: "sort", urlKey: "sort", kind: "choice", default: "name", control: "#sort", chip: "sort" },
272+
{ field: "perspective", urlKey: "view", kind: "choice", default: "any", control: "#perspective", chip: "view" },
273+
{ field: "license", urlKey: "licence", kind: "choice", default: "any", control: "#license-family", chip: "licence" },
274+
{ field: "format", urlKey: "format", kind: "choice", default: "any", control: "#format-group", chip: "format" },
275+
{ field: "engine", urlKey: "engine", kind: "choice", default: "any", control: "#engine", chip: "engine" },
276+
{
277+
field: "fresh", urlKey: "fresh", kind: "choice", default: "any", control: "#fresh", chip: "licence read",
278+
options: { "6m": `in the last ${VERIFIED_FRESH_DAYS} days`, "12m": `in the last ${VERIFIED_AGING_DAYS} days` },
279+
},
280+
{ field: "active", urlKey: "active", kind: "switch", default: true, control: "#filter-active", chipOff: "hiding active" },
281+
{ field: "review", urlKey: "review", kind: "switch", default: true, control: "#filter-review", chipOff: "hiding needs-review" },
282+
{ field: "deprecated", urlKey: "deprecated", kind: "switch", default: false, control: "#filter-deprecated", chipOn: "showing deprecated" },
283+
{ field: "commercialOnly", urlKey: "commercial", kind: "switch", default: false, control: "#filter-commercial", chipOn: "commercial OK or per-file review" },
284+
{ field: "noAttr", urlKey: "noattr", kind: "switch", default: false, control: "#filter-no-attr", chipOn: "no attribution only" },
285+
]);
286+
287+
/** Days since `verified` each `fresh` choice allows: the freshness page's bands. */
288+
export const FRESH_DAYS = Object.freeze({ "6m": VERIFIED_FRESH_DAYS, "12m": VERIFIED_AGING_DAYS });
289+
290+
/**
291+
* The filter state of the catalog page and its defaults. The page starts
292+
* from it, and the URL holds only what differs from it.
253293
*/
254294
export const FILTER_DEFAULTS = Object.freeze({
255-
category: "all",
256295
q: "",
257-
active: true,
258-
review: true,
259-
deprecated: false,
260-
commercialOnly: false,
261-
noAttr: false,
262-
perspective: "any",
263-
license: "any",
264-
format: "any",
265-
engine: "any",
266-
sort: "name",
296+
...Object.fromEntries(FILTERS.map((f) => [f.field, f.default])),
267297
});
268298

269299
/** Choice params: URL key -> state field, in the order the URL writes them (after q). */
270-
export const CHOICES = [
271-
["cat", "category"],
272-
["sort", "sort"],
273-
["view", "perspective"],
274-
["licence", "license"],
275-
["format", "format"],
276-
["engine", "engine"],
277-
];
300+
export const CHOICES = FILTERS.filter((f) => f.kind === "choice").map((f) => [f.urlKey, f.field]);
278301
/** On/off params: URL key -> state field, written as 1 or 0. */
279-
export const SWITCHES = [
280-
["active", "active"],
281-
["review", "review"],
282-
["deprecated", "deprecated"],
283-
["commercial", "commercialOnly"],
284-
["noattr", "noAttr"],
285-
];
302+
export const SWITCHES = FILTERS.filter((f) => f.kind === "switch").map((f) => [f.urlKey, f.field]);
286303

287304
/** Longest search query kept from a URL or the search box. */
288305
export const MAX_QUERY = 200;

‎site/public/app.js‎

Lines changed: 59 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { FILTER_DEFAULTS, gapIssueUrl, closestEntries, computeResults as computeResultsFor, passesFilters as passes, placementKey, queryTests as compileQuery, readState, scoreFields, searchFields, shouldGroup as shouldGroupFor, slugOf, sorterFor, SORTS, stateQuery, ENGINES } from "./search.js";
1+
import { FILTER_DEFAULTS, FILTERS, gapIssueUrl, closestEntries, computeResults as computeResultsFor, passesFilters as passes, placementKey, queryTests as compileQuery, readState, scoreFields, searchFields, shouldGroup as shouldGroupFor, slugOf, sorterFor, SORTS, stateQuery, ENGINES } from "./search.js";
22
import { CARD_LIMIT, LIST_LIMIT, commercialLabel, entryCardHtml, escapeHtml, groupHeadingHtml, groupMoreHtml, PERSPECTIVE_LABELS } from "./cards.mjs";
33
import { installViewToggles } from "./views.js";
44
import { isListed, PERSPECTIVES } from "./shared.mjs";
@@ -26,6 +26,20 @@ import { isListed, PERSPECTIVES } from "./shared.mjs";
2626
const FORMAT_GROUPS = data.filters?.formatGroups || {};
2727

2828
const categoryLabels = data.categories || {};
29+
30+
// The choices of each filter whose options come from data, value -> chip
31+
// label; a FILTERS row with fixed `options` needs nothing here (#252).
32+
const CHOICE_LABELS = {
33+
category: Object.fromEntries(Object.entries(categoryLabels).map(([k, c]) => [k, c.label || k])),
34+
sort: Object.fromEntries(Object.keys(SORTS).map((k) => [k, k])),
35+
perspective: PERSPECTIVE_LABELS,
36+
license: LICENSE_FAMILIES,
37+
engine: Object.fromEntries(Object.entries(ENGINES).map(([k, e]) => [k, e.label])),
38+
format: Object.fromEntries(Object.entries(FORMAT_GROUPS).map(([k, g]) => [k, g.label])),
39+
};
40+
const choicesOf = (f) => f.options || CHOICE_LABELS[f.field] || {};
41+
// Where a <select> option reads differently from the chip.
42+
const OPTION_TEXT = { perspective: Object.fromEntries(Object.entries(PERSPECTIVES).map(([k, p]) => [k, p.option])) };
2943
const repo = data.site?.repo || "https://github.com/TMHSDigital/Free-Game-Dev-Assets";
3044
// No prototype: "#entry-constructor" must not look like an entry.
3145
const byId = Object.assign(Object.create(null), Object.fromEntries(data.entries.map((e) => [e.id, e])));
@@ -60,14 +74,11 @@ import { isListed, PERSPECTIVES } from "./shared.mjs";
6074
function readUrl() {
6175
Object.assign(
6276
state,
63-
readState(location.search, DEFAULTS, {
64-
category: ["all", ...Object.keys(categoryLabels)],
65-
sort: Object.keys(SORTS),
66-
perspective: ["any", ...Object.keys(PERSPECTIVE_LABELS)],
67-
license: ["any", ...Object.keys(LICENSE_FAMILIES)],
68-
engine: ["any", ...Object.keys(ENGINES)],
69-
format: ["any", ...Object.keys(FORMAT_GROUPS)],
70-
})
77+
readState(
78+
location.search,
79+
DEFAULTS,
80+
Object.fromEntries(FILTERS.filter((f) => f.kind === "choice").map((f) => [f.field, [f.default, ...Object.keys(choicesOf(f))]]))
81+
)
7182
);
7283
}
7384

@@ -215,23 +226,15 @@ import { isListed, PERSPECTIVES } from "./shared.mjs";
215226
});
216227
}
217228
if (state.q.trim()) chips.push({ key: "q", label: `search: ${state.q.trim()}` });
218-
if (state.sort !== DEFAULTS.sort) chips.push({ key: "sort", label: `sort: ${state.sort}` });
219-
if (state.perspective !== DEFAULTS.perspective)
220-
chips.push({
221-
key: "perspective",
222-
label: `view: ${PERSPECTIVE_LABELS[state.perspective] || state.perspective}`,
223-
});
224-
if (state.license !== DEFAULTS.license)
225-
chips.push({ key: "license", label: `licence: ${LICENSE_FAMILIES[state.license] || state.license}` });
226-
if (state.engine !== DEFAULTS.engine) chips.push({ key: "engine", label: `engine: ${ENGINES[state.engine]?.label || state.engine}` });
227-
if (state.format !== DEFAULTS.format)
228-
chips.push({ key: "format", label: `format: ${FORMAT_GROUPS[state.format]?.label || state.format}` });
229-
if (!state.active) chips.push({ key: "active", label: "hiding active" });
230-
if (!state.review) chips.push({ key: "review", label: "hiding needs-review" });
231-
if (state.deprecated) chips.push({ key: "deprecated", label: "showing deprecated" });
232-
if (state.commercialOnly)
233-
chips.push({ key: "commercialOnly", label: "commercial OK or per-file review" });
234-
if (state.noAttr) chips.push({ key: "noAttr", label: "no attribution only" });
229+
// One chip per filter that differs from its default, from the FILTERS table (#252).
230+
for (const f of FILTERS) {
231+
if (f.field === "category") continue;
232+
const v = state[f.field];
233+
if (f.kind === "choice") {
234+
if (v !== f.default) chips.push({ key: f.field, label: `${f.chip}: ${choicesOf(f)[v] || v}` });
235+
} else if (v && f.chipOn && v !== f.default) chips.push({ key: f.field, label: f.chipOn });
236+
else if (!v && f.chipOff && v !== f.default) chips.push({ key: f.field, label: f.chipOff });
237+
}
235238

236239
// The filter keeps aggregators whose files differ, which a reader
237240
// filtering for "safe to ship" must see, not find in a tooltip.
@@ -633,62 +636,35 @@ import { isListed, PERSPECTIVES } from "./shared.mjs";
633636
searchTimer = setTimeout(apply, SEARCH_DELAY);
634637
});
635638

636-
const toggles = [
637-
["#filter-active", "active"],
638-
["#filter-review", "review"],
639-
["#filter-deprecated", "deprecated"],
640-
["#filter-commercial", "commercialOnly"],
641-
["#filter-no-attr", "noAttr"],
642-
];
643-
for (const [sel, field] of toggles) {
644-
const el = $(sel);
645-
el.checked = state[field];
646-
el.addEventListener("change", (e) => {
647-
state[field] = e.target.checked;
648-
apply();
649-
});
650-
}
651-
652-
const view = $("#perspective");
653-
view.insertAdjacentHTML(
654-
"beforeend",
655-
Object.entries(PERSPECTIVES)
656-
.map(([value, p]) => `<option value="${escapeHtml(value)}">${escapeHtml(p.option)}</option>`)
657-
.join("")
658-
);
659-
view.value = state.perspective;
660-
view.addEventListener("change", (e) => {
661-
state.perspective = e.target.value;
662-
apply();
663-
});
664-
665-
const selects = [
666-
["#license-family", "license", LICENSE_FAMILIES],
667-
["#engine", "engine", Object.fromEntries(Object.entries(ENGINES).map(([k, e]) => [k, e.label]))],
668-
["#format-group", "format", Object.fromEntries(Object.entries(FORMAT_GROUPS).map(([k, g]) => [k, g.label]))],
669-
];
670-
for (const [sel, field, choices] of selects) {
671-
const el = $(sel);
672-
el.insertAdjacentHTML(
673-
"beforeend",
674-
Object.entries(choices)
675-
.map(([value, label]) => `<option value="${escapeHtml(value)}">${escapeHtml(label)}</option>`)
676-
.join("")
677-
);
678-
el.value = state[field];
639+
// Every filter control, from the FILTERS table (#252). A select whose
640+
// markup holds only its default option is filled from its choices.
641+
for (const f of FILTERS) {
642+
if (!f.control) continue;
643+
const el = $(f.control);
644+
if (f.kind === "switch") {
645+
el.checked = state[f.field];
646+
el.addEventListener("change", (e) => {
647+
state[f.field] = e.target.checked;
648+
apply();
649+
});
650+
continue;
651+
}
652+
if (el.options.length <= 1) {
653+
el.insertAdjacentHTML(
654+
"beforeend",
655+
Object.entries(OPTION_TEXT[f.field] || choicesOf(f))
656+
.map(([value, label]) => `<option value="${escapeHtml(value)}">${escapeHtml(label)}</option>`)
657+
.join("")
658+
);
659+
}
660+
el.value = state[f.field];
679661
el.addEventListener("change", (e) => {
680-
state[field] = e.target.value;
681-
apply();
662+
state[f.field] = e.target.value;
663+
// A new sort is a new view worth a Back step; a narrowed list is not.
664+
apply(f.field === "sort" ? { push: true } : undefined);
682665
});
683666
}
684667

685-
const sort = $("#sort");
686-
sort.value = state.sort;
687-
sort.addEventListener("change", (e) => {
688-
state.sort = e.target.value;
689-
apply({ push: true });
690-
});
691-
692668
// Back and Forward step through the pushed views.
693669
window.addEventListener("popstate", () => {
694670
// A hash-only move (#stacks, #catalog) also fires popstate; the
@@ -857,16 +833,11 @@ import { isListed, PERSPECTIVES } from "./shared.mjs";
857833

858834
function syncControls() {
859835
$("#search").value = state.q;
860-
$("#filter-active").checked = state.active;
861-
$("#filter-review").checked = state.review;
862-
$("#filter-deprecated").checked = state.deprecated;
863-
$("#filter-commercial").checked = state.commercialOnly;
864-
$("#filter-no-attr").checked = state.noAttr;
865-
$("#sort").value = state.sort;
866-
$("#perspective").value = state.perspective;
867-
$("#license-family").value = state.license;
868-
$("#engine").value = state.engine;
869-
$("#format-group").value = state.format;
836+
for (const f of FILTERS) {
837+
if (!f.control) continue;
838+
if (f.kind === "switch") $(f.control).checked = state[f.field];
839+
else $(f.control).value = state[f.field];
840+
}
870841
}
871842

872843
/* --------------------------------------------------------------- init */

‎site/public/index.html‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,12 @@ <h2 id="catalog-title">Catalog</h2>
144144
<option value="any">Any</option>
145145
</select>
146146
</label>
147+
<label class="check sort-field" title="How recently someone re-read the licence at the source (each entry's verified date)">
148+
<span>Licence read</span>
149+
<select id="fresh">
150+
<option value="any">Any time</option>
151+
</select>
152+
</label>
147153
<label class="check sort-field" title="Hides projects, addons and plugins for another engine and assets licensed for one engine only, and lists ones made for this engine first">
148154
<span>Engine</span>
149155
<select id="engine">

0 commit comments

Comments
 (0)