From 8f8b5cde960bd2fc565a2360cc8e022b2eecdc34 Mon Sep 17 00:00:00 2001 From: Kazam Date: Mon, 19 Oct 2026 11:52:39 +0500 Subject: [PATCH] pre-render app pages & some fixes --- app_page.in | 18 +- assets/css/app.css | 19 +- assets/js/app_page.js | 422 +++++++----------------------------------- metapkg_page.in | 20 +- 4 files changed, 108 insertions(+), 371 deletions(-) diff --git a/app_page.in b/app_page.in index bbdb8f89e5..b4322f6fcd 100644 --- a/app_page.in +++ b/app_page.in @@ -3,7 +3,7 @@ - $APP_NAME - _("PORTABLE LINUX APPS") + $APP_TITLE - _("PORTABLE LINUX APPS") @@ -20,7 +20,6 @@ - @@ -56,8 +55,19 @@ - -

$APP_NAME

+
+
+icon for $APP_DISPLAY_NAME +

$APP_DISPLAY_NAME

$APP_META
+
+$APP_INSTALL_BTN +
+
$APP_DESC_FULL
+$APP_GALLERY +$APP_BUTTONS +$APP_SITES +$APP_SOURCES +
↑ diff --git a/assets/css/app.css b/assets/css/app.css index 12e5810d98..49e639d3f9 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -110,6 +110,14 @@ align-items: center; } +/* Square on the right so the button meets the "?" link inside .install-group, + which supplies the matching right-hand radius. On a metapackage page there + is no "?" link: install_button() emits the anchor on its own, as a direct + child of .app-detail-header, so it has to round all four corners itself. */ +.app-detail-header > .install-btn { + border-radius: var(--radius); +} + .install-btn:hover { background: var(--color-accent-hover); text-decoration: none; @@ -575,14 +583,3 @@ margin: 0; display: block; } - -.error-box { - text-align: center; - padding: 4rem 1.5rem; - color: var(--color-text-muted); -} - -.error-box h2 { - color: var(--color-text); - margin-bottom: 0.5rem; -} diff --git a/assets/js/app_page.js b/assets/js/app_page.js index 41268a97cd..50ed885153 100644 --- a/assets/js/app_page.js +++ b/assets/js/app_page.js @@ -1,373 +1,95 @@ (function() { - var name = document.getElementById('app-name').innerHTML; - var suite_name = document.getElementById('suite-name'); - var root = document.getElementById('app-root'); + var galImg = document.getElementById('gal-img'); + var galPrev = document.getElementById('gal-prev'); + var galNext = document.getElementById('gal-next'); + var galDots = document.getElementById('gal-dots'); - if (!name) { - root.innerHTML = '

No app specified

'; - return; - } - - // Set when the JSON itself could not be loaded, so the handler below can - // tell a missing data file apart from a failure while building the page. - var loadFailed = false; - // True only once the server has actually answered, so a 404 can be told - // apart from a request that never got that far. - var gotResponse = false; - - fetch( '/app/' + encodeURIComponent(name) + '.json') - .catch(function(err) { - // The request never produced a response: DNS, connection reset, CORS, - // offline, or the server closing the socket. There is no status code to - // report, so mark it here rather than in the !r.ok branch below. - loadFailed = true; - throw err; - }) - .then(function(r) { - gotResponse = true; - if (!r.ok) { - var httpError = new Error('HTTP ' + r.status + ' ' + r.statusText); - httpError.appName = name; - loadFailed = true; - throw httpError; - } - return r.json(); - }) - .then(function(app) { - var html = ''; - - const { archived = false, obsolete } = app; - var meta_parts = []; - if (archived) meta_parts.push('Archived'); - if (obsolete != null) meta_parts.push(`Obsolete since ${obsolete}`); - const meta_info = meta_parts.join('; '); - - // Header with icon, name, sites - var iconUrl = '../../icons/' + encodeURIComponent(name) + '.webp'; - html += '
'; - html += 'icon for ' + escapeHtml(app.name || name) + ''; - html += '
'; - html += '

' + escapeHtml(app.name || name) + '

'; - html += '
' + meta_info + '
'; - html += '
'; - var button_html = suite_name ? 'Goto ' + escapeHtml(suite_name.innerHTML) + '' : ''; - html += button_html; - html += '
'; - - // Description - html += '
'; - if (app.description) { - html += markdownToHtml(app.description); - } - html += '
'; - - // Screenshots gallery - if (app.screenshots && app.screenshots.length) { - html += ''; - } - - // Buttons - if (app.buttons && app.buttons.length) { - html += ''; - } - - // Sites - if (app.sites && app.sites.length) { - html += ''; - } - - // Sources - if (app.sources && app.sources.length) { - html += ''; - } - - root.innerHTML = html; - - // Gallery carousel - if (app.screenshots && app.screenshots.length > 1) { - var galIdx = 0; - var galBusy = false; - var galImg = document.getElementById('gal-img'); - var galPrev = document.getElementById('gal-prev'); - var galNext = document.getElementById('gal-next'); - var galDots = document.getElementById('gal-dots'); - var shots = app.screenshots; - - // Preload every screenshot up front so later swaps come from - // the browser cache instead of waiting on the network. - for (var p = 0; p < shots.length; p++) { - var pre = new Image(); - pre.src = shots[p]; - } - - for (var i = 0; i < shots.length; i++) { - var dot = document.createElement('button'); - dot.className = 'gallery-dot' + (i === 0 ? ' active' : ''); - dot.setAttribute('aria-label', 'Screenshot ' + (i + 1)); - (function(idx) { - dot.addEventListener('click', function() { - if (idx !== galIdx) showGal(idx, idx > galIdx ? 'next' : 'prev'); - }); - })(i); - galDots.appendChild(dot); - } - - function updateControls(idx) { - galPrev.disabled = idx === 0; - galNext.disabled = idx === shots.length - 1; - var dots = galDots.children; - for (var j = 0; j < dots.length; j++) { - dots[j].className = 'gallery-dot' + (j === idx ? ' active' : ''); - } - } - - function showGal(idx, dir) { - if (galBusy || idx === galIdx) return; - galBusy = true; - galIdx = idx; - updateControls(idx); - - // Fade/slide the current image out first... - galImg.classList.add('gal-transition', dir === 'prev' ? 'gal-prev' : 'gal-next'); + // The page is pre-rendered: the generator emits the gallery markup, so a + // missing gallery here just means the app has no screenshots. + if (!galImg || !galDots) return; - setTimeout(function() { - galImg.src = shots[idx]; - galImg.alt = 'screenshot ' + (idx + 1) + ' of ' + escapeHtml(app.name || name); + var dots = galDots.querySelectorAll('.gallery-dot'); - function reveal() { - // Force a reflow so removing the class re-triggers the transition. - void galImg.offsetWidth; - galImg.classList.remove('gal-transition', 'gal-prev', 'gal-next'); - galBusy = false; - } - - // Thanks to preloading this is almost always already cached, - // so it resolves on the very next frame rather than stalling. - if (galImg.complete) { - requestAnimationFrame(reveal); - } else { - galImg.onload = reveal; - } - }, 160); - } - - galPrev.addEventListener('click', function() { - if (galIdx > 0) showGal(galIdx - 1, 'prev'); - }); - galNext.addEventListener('click', function() { - if (galIdx < shots.length - 1) showGal(galIdx + 1, 'next'); - }); - document.addEventListener('keydown', function(e) { - if (e.key === 'ArrowLeft' && galIdx > 0) showGal(galIdx - 1, 'prev'); - if (e.key === 'ArrowRight' && galIdx < shots.length - 1) showGal(galIdx + 1, 'next'); - }); - } else if (app.screenshots && app.screenshots.length === 1) { - var p = document.getElementById('gal-prev'); - var n = document.getElementById('gal-next'); - if (p) p.style.display = 'none'; - if (n) n.style.display = 'none'; - } - - // Update page title - document.title = (app.name || name).toUpperCase() + ' - PORTABLE LINUX APPS'; - }) - .catch(function(err) { - console.error('app_page: failed to build app page for "' + name + '"', err); - - if (loadFailed) { - var reason = err && err.message ? err.message : 'request failed'; - // A missing file and an unreachable server look identical from here - // without the status, so only claim the file is missing when the - // server actually answered. - if (gotResponse) { - root.innerHTML = '

App data not found

' + - '

The server responded ' + escapeHtml(reason) + ' for ' + escapeHtml(name) + '.json.

'; - } else { - root.innerHTML = '

Could not reach the server

' + - '

The request for ' + escapeHtml(name) + '.json failed before a response arrived. ' + - 'The file may well exist; the connection is what failed.

'; - } - return; - } + if (dots.length < 2) { + if (galPrev) galPrev.style.display = 'none'; + if (galNext) galNext.style.display = 'none'; + return; + } - // The JSON loaded fine, so the data exists and something in rendering - // it broke. Saying "not found" here would send people hunting for a - // file that is present and correct. - root.innerHTML = '

Could not display this app

' + - '

The data for ' + escapeHtml(name) + ' loaded, but the page failed to build.

' + - '

See the browser console for the error.

'; - }); + if (!galPrev || !galNext) return; - function escapeHtml(s) { - var div = document.createElement('div'); - div.appendChild(document.createTextNode(s)); - return div.innerHTML; - } + var galIdx = 0; + var galBusy = false; - // Minimal markdown -> HTML for descriptions. - // Supports: `inline code`, fenced code blocks (```), **bold**, *italic*, - // ++underline++, - / * / 1. lists, ## / ### / #### headings, and tables. - // Everything else is treated as plain text (and HTML-escaped first, - // so no other tags can sneak in through the JSON data). - function inlineMarkdown(s) { - // Protect inline code spans first so no formatting is applied inside them. - var codeSpans = []; - s = s.replace(/`([^`]+)`/g, function(m, c) { - codeSpans.push(c); - return '\u0000c' + (codeSpans.length - 1) + '\u0000'; - }); - s = s.replace(/\*\*(.+?)\*\*/g, '$1'); - s = s.replace(/__(.+?)__/g, '$1'); - s = s.replace(/\+\+(.+?)\+\+/g, '$1'); - s = s.replace(/\*(.+?)\*/g, '$1'); - s = s.replace(/_(.+?)_/g, '$1'); - s = s.replace(/\u0000c(\d+)\u0000/g, function(m, i) { - return '' + codeSpans[Number(i)] + ''; - }); - return s; + // The first screenshot is already the src, so preload from the + // second onwards to make later swaps come from the browser cache instead + // of waiting on the network. + for (var p = 1; p < dots.length; p++) { + var pre = new Image(); + pre.src = dots[p].dataset.src; } - // Split a table row on the pipes, dropping the optional leading and - // trailing one, and run inline formatting on each cell. - function splitRow(r) { - return r.trim().replace(/^\||\|$/g, '').split('|').map(function(c) { - return inlineMarkdown(c.trim()); - }); + function updateControls(idx) { + galPrev.disabled = idx === 0; + galNext.disabled = idx === dots.length - 1; + for (var j = 0; j < dots.length; j++) { + dots[j].className = 'gallery-dot' + (j === idx ? ' active' : ''); + } } - function markdownToHtml(text) { - var lines = escapeHtml(text).split('\n'); - var blocks = []; - var i = 0; - - while (i < lines.length) { - var line = lines[i]; - var trimmed = line.trim(); - - // Skip completely empty lines – they should not produce
- if (trimmed === '') { - i++; - continue; - } - - var ulRe = /^[-*]\s+(.*)$/; - var olRe = /^\d+\.\s+(.*)$/; - var ulMatch = line.match(ulRe); - var olMatch = !ulMatch && line.match(olRe); + function showGal(idx, dir) { + if (galBusy || idx === galIdx) return; + galBusy = true; + galIdx = idx; + updateControls(idx); - // Headings: ## / ### / #### - var hMatch = trimmed.match(/^(#{2,4})\s+(.*)$/); - if (hMatch) { - var lvl = hMatch[1].length; - blocks.push('' + inlineMarkdown(hMatch[2]) + ''); - i++; - continue; - } + // Fade/slide the current image out first... + galImg.classList.add('gal-transition', dir === 'prev' ? 'gal-prev' : 'gal-next'); - // Tables: header row, |---|---| separator row, then body rows - if (trimmed.charAt(0) === '|' && i + 1 < lines.length && - /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?$/.test(lines[i + 1].trim())) { - var th = splitRow(line).map(function(c) { return '' + c + ''; }).join(''); - var trs = []; - i += 2; - while (i < lines.length && lines[i].trim().charAt(0) === '|') { - trs.push('' + splitRow(lines[i]).map(function(c) { return '' + c + ''; }).join('') + ''); - i++; - } - blocks.push('' + th + '' + trs.join('') + '
'); - continue; - } + setTimeout(function() { + galImg.src = dots[idx].dataset.src; + galImg.alt = dots[idx].dataset.alt; - // Fenced code block: ```[lang] ... ``` - if (trimmed.match(/^`{3}/)) { - var fenceMatch = /^`{3}(\w*)\s*$/.exec(trimmed); - var codeLang = fenceMatch && fenceMatch[1] ? fenceMatch[1] : ''; - var codeLines = []; - i++; - while (i < lines.length) { - if (/^`{3}\s*$/.test(lines[i].trim())) { - i++; - break; - } - codeLines.push(lines[i]); - i++; - } - blocks.push( - '
' +
-          codeLines.join('\n') + '
' - ); - continue; + function reveal() { + // Force a reflow so removing the class re-triggers the transition. + void galImg.offsetWidth; + galImg.classList.remove('gal-transition', 'gal-prev', 'gal-next'); + galBusy = false; } - if (ulMatch || olMatch) { - var tag = ulMatch ? 'ul' : 'ol'; - var re = ulMatch ? ulRe : olRe; - var items = []; - - // Collect all consecutive list items, skipping blank lines - while (i < lines.length) { - var currentLine = lines[i]; - var trimmedCurrent = currentLine.trim(); - - // Skip blank lines inside the list – they do not break it - if (trimmedCurrent === '') { - i++; - continue; - } - - var m = currentLine.match(re); - if (!m) break; // non-list, non-empty line ends the list - - items.push('
  • ' + inlineMarkdown(m[1]) + '
  • '); - i++; - } - - if (items.length) { - blocks.push('<' + tag + '>' + items.join('') + ''); - } - // i is now positioned at the line that broke the list (or at end) + // Thanks to preloading this is almost always already cached, + // so it resolves on the very next frame rather than stalling. + if (galImg.complete) { + requestAnimationFrame(reveal); } else { - // Normal text block - blocks.push(inlineMarkdown(line)); - i++; + galImg.onload = reveal; } - } + }, 160); + } - // Headings and tables are block elements, so a
    between them and - // their neighbour would add a stray blank line. Everything else keeps - // the original line-per-line break behaviour. - var blockRe = /^<(h[2-4]|table)/; - return blocks.reduce(function(out, b, k) { - if (k === 0) return b; - return out + (blockRe.test(b) || blockRe.test(blocks[k - 1]) ? '' : '
    ') + b; - }, ''); + for (var i = 0; i < dots.length; i++) { + (function(idx) { + dots[idx].addEventListener('click', function() { + if (idx !== galIdx) showGal(idx, idx > galIdx ? 'next' : 'prev'); + }); + })(i); } + + galPrev.addEventListener('click', function() { + if (galIdx > 0) showGal(galIdx - 1, 'prev'); + }); + galNext.addEventListener('click', function() { + if (galIdx < dots.length - 1) showGal(galIdx + 1, 'next'); + }); + document.addEventListener('keydown', function(e) { + if (e.key === 'ArrowLeft' && galIdx > 0) showGal(galIdx - 1, 'prev'); + if (e.key === 'ArrowRight' && galIdx < dots.length - 1) showGal(galIdx + 1, 'next'); + }); })(); var btt = document.getElementById('back-to-top'); -window.addEventListener('scroll', function() { - btt.classList.toggle('btt-visible', window.scrollY > 300); -}); +if (btt) { + window.addEventListener('scroll', function() { + btt.classList.toggle('btt-visible', window.scrollY > 300); + }); +} diff --git a/metapkg_page.in b/metapkg_page.in index b70a31c9b2..b4322f6fcd 100644 --- a/metapkg_page.in +++ b/metapkg_page.in @@ -3,7 +3,7 @@ - $APP_NAME - _("PORTABLE LINUX APPS") + $APP_TITLE - _("PORTABLE LINUX APPS") @@ -20,7 +20,6 @@ - @@ -56,9 +55,19 @@ - - -

    $APP_NAME

    +
    +
    +icon for $APP_DISPLAY_NAME +

    $APP_DISPLAY_NAME

    $APP_META
    +
    +$APP_INSTALL_BTN +
    +
    $APP_DESC_FULL
    +$APP_GALLERY +$APP_BUTTONS +$APP_SITES +$APP_SOURCES +
    ↑ @@ -81,4 +90,3 @@ -