Skip to content

Commit cc0337c

Browse files
committed
Add new filter and optimized catalog page
#284
1 parent 559d79b commit cc0337c

File tree

5 files changed

+408
-26
lines changed

5 files changed

+408
-26
lines changed

assets/js/catalog.js

Lines changed: 154 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const sortVersionsByPublishedDate = (versions) => {
1717
});
1818
};
1919
const API_URL = "https://api.launcherhub.net/giveMeTheList";
20+
const DEVICES_API_URL = "https://api.launcherhub.net/devices";
2021
const CDN_COVER = "https://m5burner-cdn.m5stack.com/cover/";
2122
const CDN_FIRMWARE = "https://m5burner-cdn.m5stack.com/firmware/";
2223
const SAMPLE_CARDPUTER_COVER = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='320' height='200'%3E%3Crect width='320' height='200' fill='%2300dd00'/%3E%3Ctext x='160' y='110' font-family='Inter,Arial,sans-serif' font-size='32' fill='%2301110b' text-anchor='middle'%3ENo Image%3C/text%3E%3C/svg%3E";
@@ -82,6 +83,17 @@ const makeDownloadName = (entry, versionLabel) => {
8283
const base = parts.filter((part) => part.length > 0).join("-");
8384
return `${base || "launcher-firmware"}.bin`;
8485
};
86+
const formatPublishedDate = (value) => {
87+
const timestamp = getPublishedTimestamp(value);
88+
if (!Number.isFinite(timestamp)) {
89+
return null;
90+
}
91+
return new Intl.DateTimeFormat("en-US", {
92+
year: "numeric",
93+
month: "short",
94+
day: "2-digit"
95+
}).format(new Date(timestamp));
96+
};
8597
document.addEventListener("DOMContentLoaded", () => {
8698
const list = document.querySelector("[data-catalog-list]");
8799
const emptyState = document.querySelector("[data-catalog-empty]");
@@ -97,10 +109,17 @@ document.addEventListener("DOMContentLoaded", () => {
97109
if (!orderSelect) {
98110
return;
99111
}
100-
currentOrder = orderSelect.value === "name" ? "name" : "downloads";
112+
currentOrder =
113+
orderSelect.value === "name"
114+
? "name"
115+
: orderSelect.value === "published_at"
116+
? "published_at"
117+
: "downloads";
101118
const offlineMode = new URLSearchParams(window.location.search).has("offline");
102119
let firmware = [];
103120
let filtered = [];
121+
let totalFirmwareCount = 0;
122+
let initialCategory = "cardputer";
104123
const pendingImages = new Set();
105124
let imageObserver = null;
106125
const loadImage = (image) => {
@@ -150,7 +169,22 @@ document.addEventListener("DOMContentLoaded", () => {
150169
});
151170
};
152171
const renderCounter = () => {
153-
counter.textContent = `${filtered.length} firmware${filtered.length === 1 ? "" : "s"}`;
172+
counter.textContent = `${filtered.length} of ${totalFirmwareCount} firmwares`;
173+
};
174+
const getLatestVersionTimestamp = (entry) => {
175+
return (entry.versions ?? []).reduce((latest, version) => {
176+
return Math.max(latest, getPublishedTimestamp(version.published_at));
177+
}, Number.NEGATIVE_INFINITY);
178+
};
179+
const getLatestVersion = (entry) => {
180+
return (entry.versions ?? []).reduce((latest, version) => {
181+
if (!latest) {
182+
return version;
183+
}
184+
return getPublishedTimestamp(version.published_at) > getPublishedTimestamp(latest.published_at)
185+
? version
186+
: latest;
187+
}, null);
154188
};
155189
const buildCard = (entry) => {
156190
const article = document.createElement("article");
@@ -191,9 +225,59 @@ document.addEventListener("DOMContentLoaded", () => {
191225
body.style.justifyContent = "flex-start";
192226
const title = document.createElement("h3");
193227
title.className = "card__title";
194-
title.textContent = entry.author ? `${entry.name} (${entry.author})` : entry.name;
195228
title.style.margin = "0";
196229
title.style.textAlign = "center";
230+
title.style.display = "flex";
231+
title.style.alignItems = "center";
232+
title.style.justifyContent = "center";
233+
title.style.gap = "8px";
234+
const titleText = document.createElement("span");
235+
titleText.textContent = entry.author ? `${entry.name} (${entry.author})` : entry.name;
236+
title.append(titleText);
237+
if (entry.github) {
238+
const githubLink = document.createElement("a");
239+
githubLink.href = entry.github;
240+
githubLink.target = "_blank";
241+
githubLink.rel = "noopener";
242+
githubLink.title = "Open GitHub repository";
243+
githubLink.setAttribute("aria-label", "Open GitHub repository");
244+
githubLink.style.display = "inline-flex";
245+
githubLink.style.alignItems = "center";
246+
githubLink.style.color = "var(--accent, #00dd00)";
247+
githubLink.style.textDecoration = "none";
248+
githubLink.style.flex = "0 0 auto";
249+
const githubIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
250+
githubIcon.setAttribute("viewBox", "0 0 16 16");
251+
githubIcon.setAttribute("width", "18");
252+
githubIcon.setAttribute("height", "18");
253+
githubIcon.setAttribute("aria-hidden", "true");
254+
githubIcon.style.fill = "currentColor";
255+
const githubPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
256+
githubPath.setAttribute("d", "M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.5-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.7 7.7 0 0 1 4 0c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8 8 0 0 0 16 8c0-4.42-3.58-8-8-8Z");
257+
githubIcon.append(githubPath);
258+
githubLink.append(githubIcon);
259+
title.append(githubLink);
260+
}
261+
const latestVersion = getLatestVersion(entry);
262+
const publishedDateLabel = formatPublishedDate(latestVersion?.published_at);
263+
const metaRow = document.createElement("div");
264+
metaRow.style.display = "flex";
265+
metaRow.style.flexWrap = "wrap";
266+
metaRow.style.alignItems = "center";
267+
metaRow.style.justifyContent = "center";
268+
metaRow.style.gap = "10px 14px";
269+
metaRow.style.fontSize = "0.9rem";
270+
metaRow.style.color = "rgba(245, 248, 242, 0.78)";
271+
if ((entry.download ?? 0) > 0) {
272+
const downloadsMeta = document.createElement("span");
273+
downloadsMeta.textContent = `${entry.download} downloads`;
274+
metaRow.append(downloadsMeta);
275+
}
276+
if (publishedDateLabel) {
277+
const publishedMeta = document.createElement("span");
278+
publishedMeta.textContent = `Published ${publishedDateLabel}`;
279+
metaRow.append(publishedMeta);
280+
}
197281
const descriptionWrapper = document.createElement("div");
198282
descriptionWrapper.style.position = "relative";
199283
descriptionWrapper.style.maxHeight = `${DESCRIPTION_COLLAPSED_HEIGHT}px`;
@@ -289,7 +373,7 @@ document.addEventListener("DOMContentLoaded", () => {
289373
updateReadMoreState();
290374
});
291375
setTimeout(updateReadMoreState, 0);
292-
body.append(title, descriptionWrapper, readMoreButton, controls);
376+
body.append(title, metaRow, descriptionWrapper, readMoreButton, controls);
293377
article.append(figure, body);
294378
return article;
295379
};
@@ -311,6 +395,17 @@ document.addEventListener("DOMContentLoaded", () => {
311395
filtered.sort((a, b) => a.name.localeCompare(b.name));
312396
return;
313397
}
398+
if (currentOrder === "published_at") {
399+
filtered.sort((a, b) => {
400+
const aTime = getLatestVersionTimestamp(a);
401+
const bTime = getLatestVersionTimestamp(b);
402+
if (aTime !== bTime) {
403+
return bTime - aTime;
404+
}
405+
return a.name.localeCompare(b.name);
406+
});
407+
return;
408+
}
314409
filtered.sort((a, b) => (b.download ?? 0) - (a.download ?? 0));
315410
};
316411
const applyFilters = () => {
@@ -325,39 +420,82 @@ document.addEventListener("DOMContentLoaded", () => {
325420
sortFiltered();
326421
renderList();
327422
};
328-
const populateCategories = () => {
423+
const populateCategories = (inputCategories) => {
329424
const categories = new Set(["all"]);
330-
firmware.forEach((entry) => {
331-
if (entry.category) {
332-
categories.add(entry.category);
425+
(inputCategories ?? []).forEach((category) => {
426+
if (category) {
427+
categories.add(category);
333428
}
334429
});
430+
if (!inputCategories) {
431+
firmware.forEach((entry) => {
432+
if (entry.category) {
433+
categories.add(entry.category);
434+
}
435+
});
436+
}
437+
const orderedCategories = [
438+
"all",
439+
...Array.from(categories)
440+
.filter((category) => category !== "all")
441+
.sort((a, b) => a.localeCompare(b))
442+
];
335443
categorySelect.innerHTML = "";
336-
Array.from(categories).forEach((category) => {
444+
orderedCategories.forEach((category) => {
337445
const option = document.createElement("option");
338446
option.value = category;
339447
option.textContent = category === "all" ? "All categories" : category;
340448
categorySelect.append(option);
341449
});
450+
if (initialCategory && orderedCategories.includes(initialCategory)) {
451+
categorySelect.value = initialCategory;
452+
}
453+
else {
454+
categorySelect.value = "all";
455+
}
342456
};
343457
const hydrate = (entries) => {
344458
const sortedEntries = entries.map((item) => ({
345459
...item,
346460
versions: sortVersionsByPublishedDate(item.versions ?? [])
347461
}));
348462
firmware = sortedEntries.filter((item) => Array.isArray(item.versions) && item.versions.some((version) => Boolean(version.file)));
463+
totalFirmwareCount = firmware.length;
349464
filtered = [...firmware];
350-
populateCategories();
465+
if (categorySelect.options.length === 0) {
466+
populateCategories();
467+
}
468+
else {
469+
const availableCategories = firmware
470+
.map((entry) => entry.category)
471+
.filter((category) => Boolean(category));
472+
populateCategories(availableCategories);
473+
}
351474
applyFilters();
352475
};
476+
const fetchCategories = async () => {
477+
const response = await fetch(DEVICES_API_URL);
478+
if (!response.ok) {
479+
throw new Error(`Device request failed with status ${response.status}`);
480+
}
481+
const payload = (await response.json());
482+
const categories = payload
483+
.map((item) => item.category?.trim())
484+
.filter((category) => Boolean(category));
485+
populateCategories(categories);
486+
};
353487
const fetchData = async () => {
354488
try {
355-
status.textContent = "Loading firmware list...";
489+
status.textContent = "Loading catalog...";
490+
const categoryPromise = fetchCategories().catch((error) => {
491+
console.error(error);
492+
});
356493
const response = await fetch(API_URL);
357494
if (!response.ok) {
358495
throw new Error(`Request failed with status ${response.status}`);
359496
}
360497
const payload = (await response.json());
498+
await categoryPromise;
361499
hydrate(payload);
362500
status.textContent = "";
363501
}
@@ -374,7 +512,11 @@ document.addEventListener("DOMContentLoaded", () => {
374512
applyFilters();
375513
});
376514
orderSelect.addEventListener("change", () => {
377-
const value = orderSelect.value === "name" ? "name" : "downloads";
515+
const value = orderSelect.value === "name"
516+
? "name"
517+
: orderSelect.value === "published_at"
518+
? "published_at"
519+
: "downloads";
378520
currentOrder = value;
379521
sortFiltered();
380522
renderList();

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
"version": "1.0.0",
44
"description": "Modernized Launcher web presence for GitHub Pages.",
55
"scripts": {
6-
"build": "node scripts/build.mjs"
6+
"build": "node scripts/build.mjs",
7+
"serve": "node scripts/dev-server.mjs",
8+
"dev": "npm run build && npm run serve"
79
},
810
"keywords": [
911
"esp32",

scripts/dev-server.mjs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { createReadStream, existsSync, statSync } from 'node:fs';
2+
import { createServer } from 'node:http';
3+
import path from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
6+
const __filename = fileURLToPath(import.meta.url);
7+
const __dirname = path.dirname(__filename);
8+
const projectRoot = path.resolve(__dirname, '..');
9+
const rootDir = path.join(projectRoot, 'build');
10+
const port = Number(process.env.PORT || 3000);
11+
12+
const mimeTypes = {
13+
'.css': 'text/css; charset=utf-8',
14+
'.gif': 'image/gif',
15+
'.html': 'text/html; charset=utf-8',
16+
'.jpg': 'image/jpeg',
17+
'.jpeg': 'image/jpeg',
18+
'.js': 'application/javascript; charset=utf-8',
19+
'.json': 'application/json; charset=utf-8',
20+
'.png': 'image/png',
21+
'.svg': 'image/svg+xml; charset=utf-8',
22+
'.txt': 'text/plain; charset=utf-8',
23+
'.webp': 'image/webp'
24+
};
25+
26+
const resolveRequestPath = (requestUrl) => {
27+
const url = new URL(requestUrl, `http://localhost:${port}`);
28+
const relativePath = decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname);
29+
const normalizedPath = path.normalize(relativePath).replace(/^(\.\.[/\\])+/, '');
30+
let filePath = path.join(rootDir, normalizedPath);
31+
32+
if (existsSync(filePath) && statSync(filePath).isDirectory()) {
33+
filePath = path.join(filePath, 'index.html');
34+
}
35+
36+
if (!existsSync(filePath) && path.extname(filePath).length === 0) {
37+
const htmlFilePath = `${filePath}.html`;
38+
if (existsSync(htmlFilePath) && statSync(htmlFilePath).isFile()) {
39+
filePath = htmlFilePath;
40+
}
41+
}
42+
43+
return filePath;
44+
};
45+
46+
const server = createServer((request, response) => {
47+
if (!request.url) {
48+
response.writeHead(400);
49+
response.end('Bad Request');
50+
return;
51+
}
52+
53+
const filePath = resolveRequestPath(request.url);
54+
const relativeFilePath = path.relative(rootDir, filePath);
55+
if (relativeFilePath.startsWith('..') || !existsSync(filePath) || !statSync(filePath).isFile()) {
56+
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
57+
response.end('Not Found');
58+
return;
59+
}
60+
61+
const extension = path.extname(filePath).toLowerCase();
62+
const contentType = mimeTypes[extension] ?? 'application/octet-stream';
63+
64+
response.writeHead(200, { 'Content-Type': contentType });
65+
createReadStream(filePath).pipe(response);
66+
});
67+
68+
server.listen(port, () => {
69+
console.log(`Local preview available at http://localhost:${port}`);
70+
});

0 commit comments

Comments
 (0)