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));