From 02c4c661b78231979998097fd6cdfc26b16fc995 Mon Sep 17 00:00:00 2001 From: "Takuto NAKAMURA (Kyome)" Date: Mon, 20 Jul 2026 02:44:46 +0900 Subject: [PATCH] Add tab and tag-chip filters to the gallery Replaces the flat runner grid with a Monochrome/Color tab plus a multi-select tag chip row that filters by OR. Filter state is mirrored to the URL hash (e.g. `#tab=color&tags=animal,fitness`) so a filtered view is shareable and survives reload. The chip row hides tags that have zero matches in the current tab, unless already selected. Uses the same palette as the rest of the site. Co-Authored-By: Claude Opus 4.7 --- index.html | 4 ++ script.js | 165 ++++++++++++++++++++++++++++++++++++++++++++++------- style.css | 72 +++++++++++++++++++++++ 3 files changed, 221 insertions(+), 20 deletions(-) diff --git a/index.html b/index.html index 89c613b..f065346 100644 --- a/index.html +++ b/index.html @@ -41,6 +41,10 @@

Browse, download, and import your favorites.

diff --git a/script.js b/script.js index 71cc69c..52a3ae6 100644 --- a/script.js +++ b/script.js @@ -1,3 +1,16 @@ +const KNOWN_TAGS = ["animal", "dog", "object", "mechanism", "fitness"]; +const TABS = [ + { type: "monochrome", label: "Monochrome" }, + { type: "color", label: "Color" }, +]; +const DEFAULT_TAB = "monochrome"; + +const state = { + tab: DEFAULT_TAB, + tags: new Set(), + runners: [], +}; + async function fetchJson(url) { const response = await fetch(url); if (!response.ok) { @@ -6,29 +19,29 @@ async function fetchJson(url) { return response.json(); } -function createRunnerCard(runnerName, metadata) { +function createRunnerCard(runner) { const card = document.createElement("article"); card.className = "runner-card"; const preview = document.createElement("div"); preview.className = "runner-preview"; const previewImage = document.createElement("img"); - previewImage.src = `./runners/${runnerName}/preview.png`; - previewImage.alt = `${metadata.displayName} animation preview`; + previewImage.src = `./runners/${runner.name}/preview.png`; + previewImage.alt = `${runner.displayName} animation preview`; previewImage.loading = "lazy"; preview.appendChild(previewImage); const displayName = document.createElement("p"); displayName.className = "runner-name"; - displayName.textContent = metadata.displayName; + displayName.textContent = runner.displayName; const author = document.createElement("p"); author.className = "runner-author"; - author.textContent = `by ${metadata.author}`; + author.textContent = `by ${runner.author}`; const downloadLink = document.createElement("a"); downloadLink.className = "download-button"; - downloadLink.href = `./runners/${runnerName}/${runnerName}-frames.zip`; + downloadLink.href = `./runners/${runner.name}/${runner.name}-frames.zip`; downloadLink.setAttribute("download", ""); downloadLink.textContent = "Download"; @@ -37,36 +50,148 @@ function createRunnerCard(runnerName, metadata) { } async function loadGallery() { - const galleryStatus = document.getElementById("gallery-status"); - const runnerGrid = document.getElementById("runner-grid"); + const statusElement = document.getElementById("gallery-status"); + const filtersElement = document.getElementById("filters"); let runnerNames; try { const manifest = await fetchJson("./runners/manifest.json"); runnerNames = manifest.runners; } catch { - galleryStatus.textContent = "Failed to load the runner list. Please reload the page."; + statusElement.textContent = "Failed to load the runner list. Please reload the page."; return; } const metadataResults = await Promise.allSettled( - runnerNames.map((runnerName) => fetchJson(`./runners/${runnerName}/metadata.json`)) + runnerNames.map((runnerName) => + fetchJson(`./runners/${runnerName}/metadata.json`).then((metadata) => ({ + name: runnerName, + ...metadata, + })), + ), ); - const cards = []; - metadataResults.forEach((result, index) => { - if (result.status === "fulfilled") { - cards.push(createRunnerCard(runnerNames[index], result.value)); - } - }); + state.runners = metadataResults + .filter((result) => result.status === "fulfilled") + .map((result) => result.value); - if (cards.length === 0) { - galleryStatus.textContent = "No runners available yet."; + if (state.runners.length === 0) { + statusElement.textContent = "No runners available yet."; return; } - galleryStatus.hidden = true; - runnerGrid.append(...cards); + filtersElement.hidden = false; + readStateFromHash(); + render(); + window.addEventListener("hashchange", () => { + readStateFromHash(); + render(); + }); +} + +function readStateFromHash() { + const params = new URLSearchParams(window.location.hash.slice(1)); + const tab = params.get("tab"); + state.tab = TABS.some((entry) => entry.type === tab) ? tab : DEFAULT_TAB; + state.tags = new Set(); + const raw = params.get("tags"); + if (raw) { + for (const tag of raw.split(",")) { + if (KNOWN_TAGS.includes(tag)) state.tags.add(tag); + } + } +} + +function writeStateToHash() { + const params = new URLSearchParams(); + if (state.tab !== DEFAULT_TAB) params.set("tab", state.tab); + if (state.tags.size > 0) params.set("tags", [...state.tags].join(",")); + const encoded = params.toString(); + const target = encoded ? `#${encoded}` : ""; + const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; + const next = `${window.location.pathname}${window.location.search}${target}`; + if (current !== next) { + history.replaceState(null, "", next); + } +} + +function isVisible(runner) { + if (runner.type !== state.tab) return false; + if (state.tags.size === 0) return true; + return runner.tags.some((tag) => state.tags.has(tag)); +} + +function render() { + renderTabs(); + renderTagChips(); + renderGrid(); + writeStateToHash(); +} + +function renderTabs() { + const container = document.getElementById("tabs"); + container.replaceChildren(); + for (const { type, label } of TABS) { + const count = state.runners.filter((runner) => runner.type === type).length; + const button = document.createElement("button"); + button.type = "button"; + button.className = "tab" + (state.tab === type ? " active" : ""); + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", state.tab === type ? "true" : "false"); + button.textContent = `${label} (${count})`; + button.addEventListener("click", () => { + if (state.tab === type) return; + state.tab = type; + render(); + }); + container.appendChild(button); + } +} + +function renderTagChips() { + const container = document.getElementById("tag-chips"); + container.replaceChildren(); + const runnersInTab = state.runners.filter((runner) => runner.type === state.tab); + for (const tag of KNOWN_TAGS) { + const count = runnersInTab.filter((runner) => runner.tags.includes(tag)).length; + const isActive = state.tags.has(tag); + if (count === 0 && !isActive) continue; + const button = document.createElement("button"); + button.type = "button"; + button.className = "chip" + (isActive ? " active" : ""); + button.setAttribute("aria-pressed", isActive ? "true" : "false"); + button.textContent = `${tag} (${count})`; + button.addEventListener("click", () => { + if (isActive) state.tags.delete(tag); + else state.tags.add(tag); + render(); + }); + container.appendChild(button); + } + if (state.tags.size > 0) { + const clearButton = document.createElement("button"); + clearButton.type = "button"; + clearButton.className = "chip chip-clear"; + clearButton.textContent = "Clear"; + clearButton.addEventListener("click", () => { + state.tags.clear(); + render(); + }); + container.appendChild(clearButton); + } +} + +function renderGrid() { + const grid = document.getElementById("runner-grid"); + const statusElement = document.getElementById("gallery-status"); + const shown = state.runners.filter(isVisible); + grid.replaceChildren(...shown.map(createRunnerCard)); + if (shown.length === 0) { + statusElement.hidden = false; + statusElement.textContent = "No runners match the current filters."; + } else { + statusElement.hidden = true; + } } loadGallery(); diff --git a/style.css b/style.css index 55309f3..35fa924 100644 --- a/style.css +++ b/style.css @@ -70,6 +70,78 @@ p.gallery-status { color: #606060; } +div.filters { + margin: 0 0 24px; +} + +div.tabs { + display: inline-flex; + gap: 4px; + padding: 4px; + background-color: #ececec; + border-radius: 999px; +} + +button.tab { + font-family: inherit; + font-size: 14px; + font-weight: 600; + color: #606060; + background: transparent; + border: none; + padding: 6px 16px; + border-radius: 999px; + cursor: pointer; +} + +button.tab:hover:not(.active) { + color: cadetblue; +} + +button.tab.active { + background-color: #606060; + color: #f5f5f5; +} + +div.tag-chips { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; + margin-top: 16px; +} + +button.chip { + font-family: inherit; + font-size: 13px; + color: #606060; + background-color: #ffffff; + border: 1px solid #d0d0d0; + padding: 4px 12px; + border-radius: 999px; + cursor: pointer; +} + +button.chip:hover:not(.active) { + border-color: cadetblue; + color: cadetblue; +} + +button.chip.active { + background-color: cadetblue; + border-color: cadetblue; + color: #f5f5f5; +} + +button.chip-clear { + color: darkcyan; + border-style: dashed; +} + +button.chip-clear:hover { + color: cadetblue; +} + div.runner-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));