fix(webui): make all icon and data asset URLs absolute under path routing

Same class of bug as the topbar partial: icon and data-file references were
relative (icons/apps/x.svg, data/apps/...), so on deep path routes (/app/<name>,
/admin/config/x) the browser resolved them against the route dir and the SPA
catch-all served index.html with HTTP 200 instead of 404 — broken images and
silently-wrong JSON.

Make every reference absolute (anchored on the quote/backtick so already-absolute
/icons paths are untouched):
- JS: all icons/ and data/ literals + templates across components/utils/system
- html/topbar.html: logo <img>
- generators: webui_config.sh and webui_create_app_categories.sh now emit
  /icons/... into apps.json / apps-categories.json (regenerated on install)
- updated the two icon-path comments to match

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-24 23:20:42 +01:00
parent 45d2f5f5c7
commit 152d9c5d28
16 changed files with 33 additions and 33 deletions

View File

@ -9,7 +9,7 @@
</button> </button>
<div class="topbar-left"> <div class="topbar-left">
<div class="libreportal-logo"> <div class="libreportal-logo">
<img src="icons/libreportal.svg" alt="LibrePortal" width="32" height="32"> <img src="/icons/libreportal.svg" alt="LibrePortal" width="32" height="32">
</div> </div>
</div> </div>
<div class="mobile-drawer" id="mobile-drawer"> <div class="mobile-drawer" id="mobile-drawer">

View File

@ -652,7 +652,7 @@ class AppsManager {
if (onHiddenTab) { if (onHiddenTab) {
window.appTabbedManager.switchTab('config'); window.appTabbedManager.switchTab('config');
} }
let icon = app.icon || 'icons/apps/default.svg'; let icon = app.icon || '/icons/apps/default.svg';
// Ensure absolute path from root // Ensure absolute path from root
if (!icon.startsWith('/')) { if (!icon.startsWith('/')) {
@ -672,7 +672,7 @@ class AppsManager {
const headerHTML = ` const headerHTML = `
<div class="app-info"> <div class="app-info">
<div class="app-card-icon" style="width: 80px; height: 80px; min-width: 80px;"> <div class="app-card-icon" style="width: 80px; height: 80px; min-width: 80px;">
<img src="${icon}" alt="${shortName}" onerror="this.onerror=null; this.src='icons/apps/default.svg'" style="width: 100%; height: 100%; object-fit: contain;"/> <img src="${icon}" alt="${shortName}" onerror="this.onerror=null; this.src='/icons/apps/default.svg'" style="width: 100%; height: 100%; object-fit: contain;"/>
</div> </div>
<div class="app-details"> <div class="app-details">
<h2>${shortName}</h2> <h2>${shortName}</h2>
@ -1701,7 +1701,7 @@ class AppsManager {
card.dataset.search = searchHaystack; card.dataset.search = searchHaystack;
const appName = app.command.split(' ').pop(); const appName = app.command.split(' ').pop();
let icon = app.icon || 'icons/apps/default.svg'; let icon = app.icon || '/icons/apps/default.svg';
// Ensure absolute path from root // Ensure absolute path from root
if (!icon.startsWith('/')) { if (!icon.startsWith('/')) {
@ -2476,7 +2476,7 @@ class AppsManager {
async getFieldMappings() { async getFieldMappings() {
try { try {
// Load from apps folder (static file) // Load from apps folder (static file)
const response = await fetch('data/apps/apps-field-mappings.json'); const response = await fetch('/data/apps/apps-field-mappings.json');
const data = await response.json(); const data = await response.json();
//// // console.log('✅ Loaded field mappings from apps folder'); //// // console.log('✅ Loaded field mappings from apps folder');
return data.fields || data; return data.fields || data;

View File

@ -577,7 +577,7 @@ class BackupPage {
const command = a.command || ''; const command = a.command || '';
return command.endsWith(` ${slug}`) || a.name?.toLowerCase() === slug.toLowerCase(); return command.endsWith(` ${slug}`) || a.name?.toLowerCase() === slug.toLowerCase();
}); });
let icon = match?.icon || 'icons/apps/default.svg'; let icon = match?.icon || '/icons/apps/default.svg';
if (!icon.startsWith('/')) icon = '/' + icon; if (!icon.startsWith('/')) icon = '/' + icon;
const displayName = (typeof window.getAppDisplayName === 'function') const displayName = (typeof window.getAppDisplayName === 'function')
? window.getAppDisplayName(slug) ? window.getAppDisplayName(slug)

View File

@ -23,7 +23,7 @@ window.ConfigValidator = function() {
// Check if unified config file exists (file existence check only) // Check if unified config file exists (file existence check only)
const configFiles = [ const configFiles = [
{ name: 'Unified System Config', path: 'data/config/generated/configs.json' } { name: 'Unified System Config', path: '/data/config/generated/configs.json' }
]; ];
for (const config of configFiles) { for (const config of configFiles) {

View File

@ -34,7 +34,7 @@ function renderInstalledApps() {
function createInstalledAppCard(app) { function createInstalledAppCard(app) {
const appName = app.command.split(' ').pop(); const appName = app.command.split(' ').pop();
let icon = app.icon || 'icons/apps/default.svg'; let icon = app.icon || '/icons/apps/default.svg';
if (!icon.startsWith('/')) icon = '/' + icon; if (!icon.startsWith('/')) icon = '/' + icon;
const shortName = app.name.split(' - ')[0].trim(); const shortName = app.name.split(' - ')[0].trim();

View File

@ -103,7 +103,7 @@ class NotificationSystem {
if (appIcon) { if (appIcon) {
content += ` content += `
<div class="notification-app-icon"> <div class="notification-app-icon">
<img src="${appIcon}" alt="${appName}" onerror="this.onerror=null; this.src='icons/apps/default.svg'"> <img src="${appIcon}" alt="${appName}" onerror="this.onerror=null; this.src='/icons/apps/default.svg'">
</div> </div>
`; `;
} }

View File

@ -290,7 +290,7 @@ async configUpdate(changes) {
} catch (error) { } catch (error) {
console.warn('Could not get app data:', error); console.warn('Could not get app data:', error);
} }
const appIcon = appData?.icon || `icons/apps/${task.app}.svg`; const appIcon = appData?.icon || `/icons/apps/${task.app}.svg`;
let taskUrl; let taskUrl;
const currentUrl = window.location.href; const currentUrl = window.location.href;
@ -356,7 +356,7 @@ async configUpdate(changes) {
} catch (error) { } catch (error) {
console.warn('Could not get app data:', error); console.warn('Could not get app data:', error);
} }
const appIcon = appData?.icon || `icons/apps/${task.app}.svg`; const appIcon = appData?.icon || `/icons/apps/${task.app}.svg`;
// Smart URL generation - always include app name // Smart URL generation - always include app name
let taskUrl; let taskUrl;

View File

@ -331,7 +331,7 @@ class TaskCommands {
// Fallback: create minimal app data // Fallback: create minimal app data
return { return {
name: appName, name: appName,
icon: `icons/apps/${appName}.svg`, icon: `/icons/apps/${appName}.svg`,
command: `libreportal app install ${appName}` command: `libreportal app install ${appName}`
}; };
} }

View File

@ -1035,7 +1035,7 @@ class TasksManager {
} }
// Default icon path // Default icon path
return `icons/apps/${task.app}.svg`; return `/icons/apps/${task.app}.svg`;
} }
updateStats() { updateStats() {
@ -1343,7 +1343,7 @@ class TasksManager {
'info', 'info',
appName, appName,
taskUrl, taskUrl,
`icons/apps/${appName}.svg`, `/icons/apps/${appName}.svg`,
customIcon customIcon
); );
} }
@ -1458,7 +1458,7 @@ class TasksManager {
const url = (onAppPage && appName) const url = (onAppPage && appName)
? `/app/${appName}?tab=tasks&task=${taskId}` ? `/app/${appName}?tab=tasks&task=${taskId}`
: `/tasks/all?task=${taskId}`; : `/tasks/all?task=${taskId}`;
const icon = appName ? `icons/apps/${appName}.svg` : null; const icon = appName ? `/icons/apps/${appName}.svg` : null;
// Match the per-action emoji used in the task list rows (see // Match the per-action emoji used in the task list rows (see
// `getTaskTypeIcon`). Passed as the 6th `customIcon` arg so the // `getTaskTypeIcon`). Passed as the 6th `customIcon` arg so the

View File

@ -327,7 +327,7 @@ class SetupWizard {
const app = manifest.get(slug) || {}; const app = manifest.get(slug) || {};
const name = app.title || app.name || fallback.name; const name = app.title || app.name || fallback.name;
const desc = app.description || fallback.description; const desc = app.description || fallback.description;
const icon = app.icon || `icons/apps/${slug}.svg`; const icon = app.icon || `/icons/apps/${slug}.svg`;
// Sub-option lives OUTSIDE the parent label (nested <label> behaviour // Sub-option lives OUTSIDE the parent label (nested <label> behaviour
// is messy — clicks would toggle both checkboxes). Both labels are // is messy — clicks would toggle both checkboxes). Both labels are
// siblings inside a wrapper div; CSS connects them visually. // siblings inside a wrapper div; CSS connects them visually.
@ -342,7 +342,7 @@ class SetupWizard {
<input type="checkbox" data-app="${slug}" ${defaultChecked ? 'checked' : ''}> <input type="checkbox" data-app="${slug}" ${defaultChecked ? 'checked' : ''}>
<div class="setup-app-icon-wrap"> <div class="setup-app-icon-wrap">
<img src="${icon}" alt="${name}" class="setup-app-icon" <img src="${icon}" alt="${name}" class="setup-app-icon"
onerror="this.onerror=null; this.src='icons/apps/default.svg'"> onerror="this.onerror=null; this.src='/icons/apps/default.svg'">
</div> </div>
<div class="setup-app-info"> <div class="setup-app-info">
<div class="setup-app-name">${name}</div> <div class="setup-app-name">${name}</div>

View File

@ -118,7 +118,7 @@ let categories = [];
function getCategoryIcon(categoryId) { function getCategoryIcon(categoryId) {
const category = categories.find(cat => cat.id === categoryId); const category = categories.find(cat => cat.id === categoryId);
return category ? category.icon : 'icons/categories/misc.svg'; return category ? category.icon : '/icons/categories/misc.svg';
} }
function getCategoryName(categoryId) { function getCategoryName(categoryId) {

View File

@ -5,7 +5,7 @@
function getAppIcon(appName, appIconUrl) { function getAppIcon(appName, appIconUrl) {
const cleanAppName = appName.replace('install_', '').replace(' ', ''); const cleanAppName = appName.replace('install_', '').replace(' ', '');
// Use icon URL from JSON if available, otherwise fall back to default // Use icon URL from JSON if available, otherwise fall back to default
return appIconUrl || `icons/apps/${cleanAppName}.svg`; return appIconUrl || `/icons/apps/${cleanAppName}.svg`;
} }
function getAppStatus(installed) { function getAppStatus(installed) {

View File

@ -212,7 +212,7 @@ EOF
fields+=(" \"backup_live_capable\": $backup_live_capable") fields+=(" \"backup_live_capable\": $backup_live_capable")
[[ -n "$url" ]] && fields+=(" \"url\": \"$url\"") [[ -n "$url" ]] && fields+=(" \"url\": \"$url\"")
[[ -n "$description" ]] && fields+=(" \"description\": \"$description\"") [[ -n "$description" ]] && fields+=(" \"description\": \"$description\"")
[[ -n "$icon_file" ]] && fields+=(" \"icon\": \"icons/apps/$icon_file\"") [[ -n "$icon_file" ]] && fields+=(" \"icon\": \"/icons/apps/$icon_file\"")
[[ -n "$services_json" ]] && fields+=(" \"services\": [$services_json]") [[ -n "$services_json" ]] && fields+=(" \"services\": [$services_json]")
if [[ -n "$config_vars" ]]; then if [[ -n "$config_vars" ]]; then
fields+=(" \"config\": {"$'\n'"${config_vars%,}"$'\n'" }") fields+=(" \"config\": {"$'\n'"${config_vars%,}"$'\n'" }")

View File

@ -26,67 +26,67 @@ webuiCreateAppsCategories() {
{ {
"id": "recommended", "id": "recommended",
"name": "Recommended", "name": "Recommended",
"icon": "icons/categories/recommended.svg", "icon": "/icons/categories/recommended.svg",
"description": "Hand-picked apps to round out your install" "description": "Hand-picked apps to round out your install"
}, },
{ {
"id": "communication", "id": "communication",
"name": "Communication", "name": "Communication",
"icon": "icons/categories/communication.svg", "icon": "/icons/categories/communication.svg",
"description": "Communication and collaboration tools" "description": "Communication and collaboration tools"
}, },
{ {
"id": "development", "id": "development",
"name": "Development", "name": "Development",
"icon": "icons/categories/development.svg", "icon": "/icons/categories/development.svg",
"description": "Development tools and version control" "description": "Development tools and version control"
}, },
{ {
"id": "knowledge", "id": "knowledge",
"name": "Knowledge", "name": "Knowledge",
"icon": "icons/categories/knowledge.svg", "icon": "/icons/categories/knowledge.svg",
"description": "Knowledge management and wikis" "description": "Knowledge management and wikis"
}, },
{ {
"id": "media", "id": "media",
"name": "Media", "name": "Media",
"icon": "icons/categories/media.svg", "icon": "/icons/categories/media.svg",
"description": "Media streaming and entertainment" "description": "Media streaming and entertainment"
}, },
{ {
"id": "monitoring", "id": "monitoring",
"name": "Monitoring", "name": "Monitoring",
"icon": "icons/categories/monitoring.svg", "icon": "/icons/categories/monitoring.svg",
"description": "Metrics, dashboards and observability" "description": "Metrics, dashboards and observability"
}, },
{ {
"id": "networking", "id": "networking",
"name": "Networking", "name": "Networking",
"icon": "icons/categories/networking.svg", "icon": "/icons/categories/networking.svg",
"description": "Network tools and services" "description": "Network tools and services"
}, },
{ {
"id": "productivity", "id": "productivity",
"name": "Productivity", "name": "Productivity",
"icon": "icons/categories/productivity.svg", "icon": "/icons/categories/productivity.svg",
"description": "Productivity and business tools" "description": "Productivity and business tools"
}, },
{ {
"id": "security", "id": "security",
"name": "Security", "name": "Security",
"icon": "icons/categories/security.svg", "icon": "/icons/categories/security.svg",
"description": "Security and privacy tools" "description": "Security and privacy tools"
}, },
{ {
"id": "storage", "id": "storage",
"name": "Storage", "name": "Storage",
"icon": "icons/categories/storage.svg", "icon": "/icons/categories/storage.svg",
"description": "File storage and sharing solutions" "description": "File storage and sharing solutions"
}, },
{ {
"id": "system", "id": "system",
"name": "System", "name": "System",
"icon": "icons/categories/system.svg", "icon": "/icons/categories/system.svg",
"description": "LibrePortal itself and platform-level services" "description": "LibrePortal itself and platform-level services"
} }
] ]

View File

@ -2,7 +2,7 @@
# Sync app icons from container template dirs to the LibrePortal frontend. # Sync app icons from container template dirs to the LibrePortal frontend.
# #
# apps.json references icons/apps/<app>.<ext> unconditionally (see # apps.json references /icons/apps/<app>.<ext> unconditionally (see
# webui_config.sh), so the icon file must physically exist in the frontend # webui_config.sh), so the icon file must physically exist in the frontend
# icons dir or the apps page falls back to the default icon. # icons dir or the apps page falls back to the default icon.

View File

@ -22,7 +22,7 @@ webuiContainerSetup()
webuiGenerateLibrePortalConfig webuiGenerateLibrePortalConfig
fi fi
# The patch path rewrites apps.json (which references # The patch path rewrites apps.json (which references
# icons/apps/<app>.svg) but doesn't place the icon file — sync it here # /icons/apps/<app>.svg) but doesn't place the icon file — sync it here
# so the apps page never points at a missing icon. The full regen # so the apps page never points at a missing icon. The full regen
# copies icons itself; this is cheap and idempotent regardless. # copies icons itself; this is cheap and idempotent regardless.
if declare -F webuiSyncAppIcon >/dev/null 2>&1; then if declare -F webuiSyncAppIcon >/dev/null 2>&1; then