Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@
<p class="short-description">Browse, download, and import your favorites.</p>
</div>
<section class="gallery">
<div class="filters" id="filters" hidden>
<div class="tabs" id="tabs" role="tablist"></div>
<div class="tag-chips" id="tag-chips"></div>
</div>
<p class="gallery-status" id="gallery-status">Loading runners…</p>
<div class="runner-grid" id="runner-grid"></div>
</section>
Expand Down
165 changes: 145 additions & 20 deletions script.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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";

Expand All @@ -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();
72 changes: 72 additions & 0 deletions style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down