↑
diff --git a/assets/css/app.css b/assets/css/app.css
index 12e5810d9..49e639d3f 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 41268a97c..50ed88515 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 += '
';
- if (app.description) {
- html += markdownToHtml(app.description);
- }
- html += '
';
-
- // Screenshots gallery
- if (app.screenshots && app.screenshots.length) {
- html += '
';
- html += '
Screenshots
';
- html += '
';
- html += '';
- html += '';
- html += '';
- html += '
';
- html += '';
- html += '
';
- }
-
- // Buttons
- if (app.buttons && app.buttons.length) {
- html += '
Links
';
- for (var i = 0; i < app.buttons.length; i++) {
- var parts = app.buttons[i].split('::');
- var label = parts[0].replaceAll('_', ' ') || 'Link';
- var url = parts[1] || '#';
- 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('
'
- );
- 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('') + '' + tag + '>');
- }
- // 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 b70a31c9b..b4322f6fc 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 @@
-