|
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"; |
2 | 2 | import { CARD_LIMIT, LIST_LIMIT, commercialLabel, entryCardHtml, escapeHtml, groupHeadingHtml, groupMoreHtml, PERSPECTIVE_LABELS } from "./cards.mjs"; |
3 | 3 | import { installViewToggles } from "./views.js"; |
4 | 4 | import { isListed, PERSPECTIVES } from "./shared.mjs"; |
@@ -26,6 +26,20 @@ import { isListed, PERSPECTIVES } from "./shared.mjs"; |
26 | 26 | const FORMAT_GROUPS = data.filters?.formatGroups || {}; |
27 | 27 |
|
28 | 28 | 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])) }; |
29 | 43 | const repo = data.site?.repo || "https://github.com/TMHSDigital/Free-Game-Dev-Assets"; |
30 | 44 | // No prototype: "#entry-constructor" must not look like an entry. |
31 | 45 | 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"; |
60 | 74 | function readUrl() { |
61 | 75 | Object.assign( |
62 | 76 | 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 | + ) |
71 | 82 | ); |
72 | 83 | } |
73 | 84 |
|
@@ -215,23 +226,15 @@ import { isListed, PERSPECTIVES } from "./shared.mjs"; |
215 | 226 | }); |
216 | 227 | } |
217 | 228 | 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 | + } |
235 | 238 |
|
236 | 239 | // The filter keeps aggregators whose files differ, which a reader |
237 | 240 | // filtering for "safe to ship" must see, not find in a tooltip. |
@@ -633,62 +636,35 @@ import { isListed, PERSPECTIVES } from "./shared.mjs"; |
633 | 636 | searchTimer = setTimeout(apply, SEARCH_DELAY); |
634 | 637 | }); |
635 | 638 |
|
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]; |
679 | 661 | 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); |
682 | 665 | }); |
683 | 666 | } |
684 | 667 |
|
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 | | - |
692 | 668 | // Back and Forward step through the pushed views. |
693 | 669 | window.addEventListener("popstate", () => { |
694 | 670 | // A hash-only move (#stacks, #catalog) also fires popstate; the |
@@ -857,16 +833,11 @@ import { isListed, PERSPECTIVES } from "./shared.mjs"; |
857 | 833 |
|
858 | 834 | function syncControls() { |
859 | 835 | $("#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 | + } |
870 | 841 | } |
871 | 842 |
|
872 | 843 | /* --------------------------------------------------------------- init */ |
|
0 commit comments