@@ -17,6 +17,7 @@ const sortVersionsByPublishedDate = (versions) => {
1717 } ) ;
1818} ;
1919const API_URL = "https://api.launcherhub.net/giveMeTheList" ;
20+ const DEVICES_API_URL = "https://api.launcherhub.net/devices" ;
2021const CDN_COVER = "https://m5burner-cdn.m5stack.com/cover/" ;
2122const CDN_FIRMWARE = "https://m5burner-cdn.m5stack.com/firmware/" ;
2223const 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+ } ;
8597document . 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 ( ) ;
0 commit comments