refactor(webui): reorganize into components/ + core/ taxonomy

Final modularization layout (user-chosen): every page is a self-contained
folder under components/<id>/ (controllers + CSS + its html fragment), and all
shared/framework code folds into core/:
  core/kernel  (feature-registry, lifecycle, services, spa)
  core/boot    (auth, system-loader/orchestrator, setup, loaders)
  core/lib     (data-loader, router, helpers, the task kernel, shared modules)
  core/ui      (topbar, modal, notifications, … + topbar.html)
  core/css     (all shared stylesheets)
  core/icons
Top level is now just: components/, core/, themes/, index.html (+ runtime data/).

Every path reference rewritten (index.html, scripts arrays, fetch()/
loadFragment()/loadScript() literals, system-loader + config-manager controller
paths, kernel manifest URL, feature.json, backend FEATURES_DIR). The
/api/features/list endpoint NAME is unchanged (it now scans components/).
Deleted 3 dead files (app-content.html, apps-content.html, html-cache.js).
Verified: 0 stale prefixes, 0 double-rewrites, all JS/JSON valid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-30 07:13:52 +01:00
parent 8d193eda28
commit d39852aa3d
208 changed files with 200 additions and 387 deletions

View File

@ -4,12 +4,12 @@ const path = require('path');
const router = express.Router();
const FEATURES_DIR = path.join(__dirname, '..', '..', 'frontend', 'features');
const FEATURES_DIR = path.join(__dirname, '..', '..', 'frontend', 'components');
/* =========================
GET /api/features/list
Walks frontend/features/<id>/ and returns one entry per directory that
Walks frontend/components/<id>/ and returns one entry per directory that
contains a feature.json the WebUI's page manifest, discovered from the
folders themselves (exactly how /api/themes/list discovers themes). Drop a
features/<id>/ folder in and its page appears; delete it and the page is

View File

@ -401,14 +401,14 @@ class AdminSystem {
return `<div class="sys-stat"><span class="sys-stat-label">${this.escape(label)}</span><strong class="sys-stat-value">${this.escape(value)}</strong></div>`;
}
// OS stat with a distro icon (bundled under /icons/os/, generic Linux glyph
// OS stat with a distro icon (bundled under /core/icons/os/, generic Linux glyph
// for anything we don't have a logo for).
_osStat(os) {
const slug = String(os || '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'linux';
return `<div class="sys-stat">
<span class="sys-stat-label">OS</span>
<strong class="sys-stat-value sys-id-value">
<span class="sys-id-icon"><img src="/icons/os/${slug}.svg" alt="" onerror="this.onerror=null;this.src='/icons/os/linux.svg'"></span>
<span class="sys-id-icon"><img src="/core/icons/os/${slug}.svg" alt="" onerror="this.onerror=null;this.src='/core/icons/os/linux.svg'"></span>
${this.escape(os || '—')}
</strong>
</div>`;
@ -430,7 +430,7 @@ class AdminSystem {
_cpuStat(cpu) {
const raw = String(cpu || '');
const vendor = /amd/i.test(raw) ? 'amd' : /intel/i.test(raw) ? 'intel' : null;
const icon = vendor ? `<span class="sys-id-icon"><img src="/icons/cpu/${vendor}.svg" alt="${vendor}"></span>` : '';
const icon = vendor ? `<span class="sys-id-icon"><img src="/core/icons/cpu/${vendor}.svg" alt="${vendor}"></span>` : '';
return `<div class="sys-stat">
<span class="sys-stat-label">CPU</span>
<strong class="sys-stat-value sys-id-value">${icon}${this.escape(this._cleanCpu(raw))}</strong>

View File

@ -44,8 +44,8 @@ if (typeof window.ConfigManager === 'undefined') {
try { this.sidebar.populateSidebar(); } catch (e) {}
// charts.js is the chart-rendering helper admin-overview pulls in.
await Promise.all([
lazyLoad('/features/admin/admin-overview.js'),
lazyLoad('/features/admin/charts.js')
lazyLoad('/components/admin/admin-overview.js'),
lazyLoad('/components/admin/charts.js')
]);
if (typeof AdminOverview !== 'undefined') {
window.adminOverview = new AdminOverview('config-section');
@ -60,7 +60,7 @@ if (typeof window.ConfigManager === 'undefined') {
// a config category — render its own controller into the main pane.
if (category === 'ssh-access') {
try { this.sidebar.populateSidebar(); } catch (e) {}
await lazyLoad('/features/admin/ssh-page.js');
await lazyLoad('/components/admin/ssh-page.js');
if (typeof SshPage !== 'undefined') {
window.sshPage = new SshPage('config-section');
await window.sshPage.init();
@ -76,9 +76,9 @@ if (typeof window.ConfigManager === 'undefined') {
// we inject its content template, then init PeersPage.
if (category === 'peers') {
try { this.sidebar.populateSidebar(); } catch (e) {}
await lazyLoad('/features/admin/peers-page.js');
await lazyLoad('/components/admin/peers-page.js');
try {
const html = await fetch('/html/peers-content.html').then(r => r.text());
const html = await fetch('/components/admin/peers-content.html').then(r => r.text());
configSection.innerHTML = html;
} catch (e) {
configSection.innerHTML = '<div class="error">Peers page template failed to load.</div>';
@ -100,10 +100,10 @@ if (typeof window.ConfigManager === 'undefined') {
if (category === 'system') {
try { this.sidebar.populateSidebar(); } catch (e) {}
await Promise.all([
lazyLoad('/features/admin/charts.js'),
lazyLoad('/features/admin/admin-system.js'),
lazyLoad('/features/admin/system-metric-page.js'),
lazyLoad('/features/admin/system-storage-page.js')
lazyLoad('/components/admin/charts.js'),
lazyLoad('/components/admin/admin-system.js'),
lazyLoad('/components/admin/system-metric-page.js'),
lazyLoad('/components/admin/system-storage-page.js')
]);
if (typeof AdminSystem !== 'undefined') {
window.adminSystem = new AdminSystem('config-section');
@ -216,7 +216,7 @@ if (typeof window.ConfigManager === 'undefined') {
var catIcon = catMeta.icon || category;
var headerHTML =
'<div class="page-header config-page-header">' +
'<img class="page-header-icon" src="/icons/config/' + catIcon + '.svg" alt="" onerror="this.style.display=\'none\'">' +
'<img class="page-header-icon" src="/core/icons/config/' + catIcon + '.svg" alt="" onerror="this.style.display=\'none\'">' +
'<div class="page-header-title">' +
'<div class="admin-breadcrumb">Config</div>' +
'<h1>' + catTitle + '</h1>' +

View File

@ -88,7 +88,7 @@ class ConfigSidebar {
// Use correct icon from our new structure
const iconName = category.icon || category.id;
const iconPath = '/icons/config/' + iconName + '.svg';
const iconPath = '/core/icons/config/' + iconName + '.svg';
categoryItem.innerHTML = '<img src="' + iconPath + '" alt="' + category.title + '" style="width: 20px; height: 20px; margin-right: 8px;"/> ' + category.title;

View File

@ -1,7 +1,7 @@
{
"id": "admin",
"routes": ["/admin", "/admin*"],
"module": "/features/admin/index.js",
"module": "/components/admin/index.js",
"handler": "handleAdmin",
"navId": "nav-config",
"nav": { "label": "Admin", "order": 40 },

View File

@ -16,7 +16,7 @@ LP.features.register({
async mount(ctx) {
window.configCategory = ctx.services.router.adminCategoryFromPath(window.location.pathname);
const html = await ctx.loadFragment('/html/config-content.html');
const html = await ctx.loadFragment('/components/admin/config-content.html');
ctx.setContent(html, 'Admin');
if (window.configManager) {

View File

@ -384,9 +384,9 @@ class SystemStoragePage {
}
// App-icon <img>, falling back to the generic app icon when the slug has no
// bundled icon. Icons are served at /icons/apps/<slug>.svg.
// bundled icon. Icons are served at /core/icons/apps/<slug>.svg.
_iconImg(slug) {
return `<img class="sys-task-icon" src="/icons/apps/${encodeURIComponent(slug)}.svg" alt="" onerror="this.onerror=null;this.src='/icons/apps/default.svg'">`;
return `<img class="sys-task-icon" src="/core/icons/apps/${encodeURIComponent(slug)}.svg" alt="" onerror="this.onerror=null;this.src='/core/icons/apps/default.svg'">`;
}
_appIconHtml(app) {

View File

@ -1,7 +1,7 @@
{
"id": "app-detail",
"routes": ["/app", "/app*"],
"module": "/features/app-detail/index.js",
"module": "/components/app-detail/index.js",
"handler": "handleAppDetail",
"navId": "nav-app-center",
"order": 25

View File

@ -42,7 +42,7 @@ LP.features.register({
}
}
const html = await ctx.loadFragment('/html/apps-unified-layout.html');
const html = await ctx.loadFragment('/components/apps/apps-unified-layout.html');
ctx.setContent(html, appName);
if (!window.appTabbedManager) {

View File

@ -522,9 +522,9 @@ class AppsManager {
} else if (!icon && id === 'installed') {
iconHtml = '<svg class="category-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
} else {
let iconPath = icon || `/icons/categories/${id}.svg`;
let iconPath = icon || `/core/icons/categories/${id}.svg`;
if (!iconPath.startsWith('/')) iconPath = '/' + iconPath;
iconHtml = `<img src="${iconPath}" alt="${name}" onerror="this.src='/icons/categories/default.svg'"/>`;
iconHtml = `<img src="${iconPath}" alt="${name}" onerror="this.src='/core/icons/categories/default.svg'"/>`;
}
div.innerHTML = `${iconHtml} ${name}`;
@ -715,7 +715,7 @@ class AppsManager {
if (onHiddenTab) {
window.appTabbedManager.switchTab('config');
}
let icon = app.icon || '/icons/apps/default.svg';
let icon = app.icon || '/core/icons/apps/default.svg';
// Ensure absolute path from root
if (!icon.startsWith('/')) {
@ -735,7 +735,7 @@ class AppsManager {
const headerHTML = `
<div class="app-info">
<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='/core/icons/apps/default.svg'" style="width: 100%; height: 100%; object-fit: contain;"/>
</div>
<div class="app-details">
<h2>${shortName}</h2>
@ -936,14 +936,14 @@ class AppsManager {
if (requiredService && !this.checkServiceInstalled(requiredService) && !appData.installed) {
const slug = requiredService.toLowerCase();
const serviceLabel = slug.charAt(0).toUpperCase() + slug.slice(1);
const iconUrl = `/icons/apps/${encodeURIComponent(slug)}.svg`;
const iconUrl = `/core/icons/apps/${encodeURIComponent(slug)}.svg`;
configSection.innerHTML = `
<div class="config-title">
<h3>🛠 Configuration Settings</h3>
<p>Configure ${this.escHtml(appData.name)} to match your requirements</p>
</div>
<div class="dep-required-card" data-service="${this.escAttr(slug)}">
<img src="${iconUrl}" alt="" class="dep-required-icon" onerror="this.onerror=null; this.src='/icons/apps/default.svg';">
<img src="${iconUrl}" alt="" class="dep-required-icon" onerror="this.onerror=null; this.src='/core/icons/apps/default.svg';">
<div class="dep-required-body">
<div class="dep-required-title">${this.escHtml(serviceLabel)} required</div>
<div class="dep-required-reason">${this.escHtml(serviceLabel)} needs to be installed before you can configure ${this.escHtml(appData.name)}.</div>
@ -1764,7 +1764,7 @@ class AppsManager {
card.dataset.search = searchHaystack;
const appName = app.command.split(' ').pop();
let icon = app.icon || '/icons/apps/default.svg';
let icon = app.icon || '/core/icons/apps/default.svg';
// Ensure absolute path from root
if (!icon.startsWith('/')) {
@ -1809,7 +1809,7 @@ class AppsManager {
card.innerHTML = `
<div class="app-card-top" style="cursor: pointer;" onclick="appsManager.showAppDetail('${appName}')">
<div class="app-card-icon">
<img src="${icon}" alt="${app.name}" onerror="this.src='/icons/apps/default.svg'"/>
<img src="${icon}" alt="${app.name}" onerror="this.src='/core/icons/apps/default.svg'"/>
</div>
<div class="app-card-content">
<div class="app-card-title" style="cursor: pointer;">${app.name.split(' - ')[0].trim()}</div>
@ -1889,7 +1889,7 @@ class AppsManager {
cat.name.toLowerCase() === categoryId.toLowerCase()
);
let iconPath = category ? category.icon : `/icons/categories/${categoryId}.svg`;
let iconPath = category ? category.icon : `/core/icons/categories/${categoryId}.svg`;
// Ensure absolute path from root
if (iconPath && !iconPath.startsWith('/')) {
@ -2041,7 +2041,7 @@ class AppsManager {
const fieldId = fieldKey;
const slug = this.serviceForField(fieldKey, fieldConfig);
const serviceName = slug ? slug.charAt(0).toUpperCase() + slug.slice(1) : '';
const iconUrl = slug ? `/icons/apps/${encodeURIComponent(slug)}.svg` : '/icons/apps/default.svg';
const iconUrl = slug ? `/core/icons/apps/${encodeURIComponent(slug)}.svg` : '/core/icons/apps/default.svg';
const isCheckbox = fieldConfig.type === 'checkbox';
const hiddenInput = isCheckbox
? `<input type="hidden" id="${fieldId}" name="${cfgKey}" value="false">`
@ -2050,7 +2050,7 @@ class AppsManager {
return `
<div class="dep-required-card" data-service="${this.escAttr(slug)}">
${hiddenInput}
<img src="${iconUrl}" alt="" class="dep-required-icon" onerror="this.onerror=null; this.src='/icons/apps/default.svg';">
<img src="${iconUrl}" alt="" class="dep-required-icon" onerror="this.onerror=null; this.src='/core/icons/apps/default.svg';">
<div class="dep-required-body">
<div class="dep-required-title">${this.escHtml(fieldConfig.label)}</div>
<div class="dep-required-reason">${this.escHtml(disabledReason)}</div>
@ -2135,7 +2135,7 @@ class AppsManager {
});
}
const iconUrl = app.icon ? (app.icon.startsWith('/') ? app.icon : '/' + app.icon) : `/icons/apps/${slug}.svg`;
const iconUrl = app.icon ? (app.icon.startsWith('/') ? app.icon : '/' + app.icon) : `/core/icons/apps/${slug}.svg`;
const shortName = app.name.split(' - ')[0];
const eoBadges = badges.map(b => ({
@ -2352,12 +2352,12 @@ class AppsManager {
const cfgKey = `CFG_${a.slug.toUpperCase()}_NETWORK`;
const current = (a.config && a.config[cfgKey]) || 'default';
const checked = current === 'gluetun' ? 'checked' : '';
const icon = a.icon ? (a.icon.startsWith('/') ? a.icon : '/' + a.icon) : '/icons/apps/default.svg';
const icon = a.icon ? (a.icon.startsWith('/') ? a.icon : '/' + a.icon) : '/core/icons/apps/default.svg';
return `
<label class="gluetun-country-item">
<input type="checkbox" data-slug="${a.slug}" data-current="${current}" ${checked}>
<span class="gluetun-country-name" style="display:flex; align-items:center; gap:10px;">
<img src="${icon}" alt="" style="width:20px; height:20px; object-fit:contain;" onerror="this.onerror=null; this.src='/icons/apps/default.svg';">
<img src="${icon}" alt="" style="width:20px; height:20px; object-fit:contain;" onerror="this.onerror=null; this.src='/core/icons/apps/default.svg';">
${a.name || a.slug}
</span>
</label>`;
@ -2408,7 +2408,7 @@ class AppsManager {
const existing = document.getElementById('mullvad-generate-modal');
if (existing) existing.remove();
const mullvadIcon = (window.gluetunProviderIcons && window.gluetunProviderIcons.mullvad) || '/icons/vpn/mullvad.svg';
const mullvadIcon = (window.gluetunProviderIcons && window.gluetunProviderIcons.mullvad) || '/core/icons/vpn/mullvad.svg';
const bodyHtml = `
<p class="eo-modal-section-text">
Enter your 16-digit Mullvad account number. A new WireGuard key will be generated locally
@ -3144,7 +3144,7 @@ class AppsManager {
if (existing) existing.remove();
const displayName = appData?.name?.split(' - ')[0]?.trim() || appName;
let icon = appData?.icon || `/icons/apps/${appName}.svg`;
let icon = appData?.icon || `/core/icons/apps/${appName}.svg`;
if (icon && !icon.startsWith('/')) icon = '/' + icon;
const bodyHtml = `
@ -3350,11 +3350,11 @@ class AppsManager {
// Only load scripts if they're not already loaded
const scripts = [
{ name: 'TaskManager', src: '/shared/task/task-manager.js' },
{ name: 'TaskCommands', src: '/shared/task/task-commands.js' },
{ name: 'TaskActions', src: '/shared/task/task-actions.js' },
{ name: 'TaskRouter', src: '/shared/task/task-router.js' },
{ name: 'TasksManager', src: '/features/tasks/tasks-manager.js' }
{ name: 'TaskManager', src: '/core/lib/task-manager.js' },
{ name: 'TaskCommands', src: '/core/lib/task-commands.js' },
{ name: 'TaskActions', src: '/core/lib/task-actions.js' },
{ name: 'TaskRouter', src: '/core/lib/task-router.js' },
{ name: 'TasksManager', src: '/components/tasks/tasks-manager.js' }
];
for (const script of scripts) {
@ -3761,7 +3761,7 @@ class AppsManager {
showUpdateConfirmModal(appName) {
let displayName = appName;
let icon = `/icons/apps/${appName}.svg`;
let icon = `/core/icons/apps/${appName}.svg`;
if (window.apps) {
const app = window.apps.find(a =>
(a.command || '').endsWith(` ${appName}`) ||
@ -3820,7 +3820,7 @@ class AppsManager {
// Confirmation modal for the destructive "Uninstall App" action.
showUninstallConfirmModal(appName) {
let displayName = appName;
let icon = `/icons/apps/${appName}.svg`;
let icon = `/core/icons/apps/${appName}.svg`;
let app = null;
if (window.apps) {
app = window.apps.find(a =>

View File

@ -1,7 +1,7 @@
{
"id": "apps",
"routes": ["/apps", "/apps*"],
"module": "/features/apps/index.js",
"module": "/components/apps/index.js",
"handler": "handleApps",
"navId": "nav-app-center",
"nav": { "label": "App Center", "order": 20 },

View File

@ -28,7 +28,7 @@ LP.features.register({
// Load the unified layout only if it isn't already present — preserves grid
// state when moving between categories / back from app-detail (legacy behaviour).
if (!document.querySelector('.apps-layout')) {
const html = await ctx.loadFragment('/html/apps-unified-layout.html');
const html = await ctx.loadFragment('/components/apps/apps-unified-layout.html');
ctx.setContent(html, 'Applications');
}

View File

@ -93,7 +93,7 @@ class RoutingManager {
const renderRow = (p) => {
const app = byApp.get(p.appSlug);
const url = this._previewUrl(p, app);
const iconUrl = `/icons/apps/${encodeURIComponent(p.appSlug)}.svg`;
const iconUrl = `/core/icons/apps/${encodeURIComponent(p.appSlug)}.svg`;
const badges = [
p.webui ? '<span class="routing-badge routing-badge-webui" title="Marked as the webui (click-to-open) port">webui</span>' : '',
p.access === 'public' ? '<span class="routing-badge routing-badge-public" title="Host port is exposed">public</span>' : ''

View File

@ -399,7 +399,7 @@ class ServicesManager {
: (svc.openUrl ? [{ url: svc.openUrl, label: svc.openLabel || 'Open' }] : []);
const openBtn = linksToRender.map(l => renderOpenBtn(l.url, l.label)).join('');
const iconUrl = this.currentApp ? `/icons/apps/${encodeURIComponent(this.currentApp)}.svg` : '';
const iconUrl = this.currentApp ? `/core/icons/apps/${encodeURIComponent(this.currentApp)}.svg` : '';
const iconHtml = iconUrl
? `<img src="${iconUrl}" alt="${escapeHtml(this.currentApp || '')}" class="task-app-icon service-app-icon" onerror="this.style.display='none'">`
: '';

View File

@ -101,7 +101,7 @@ class ToolsManager {
const deleteTool = tools.find(t => t.id === 'delete_user');
const adminTool = tools.find(t => t.id === 'set_admin');
const appLabel = (window.getAppDisplayName ? window.getAppDisplayName(appName) : appName);
const iconUrl = `/icons/apps/${encodeURIComponent(appName)}.svg`;
const iconUrl = `/core/icons/apps/${encodeURIComponent(appName)}.svg`;
// Reopen this exact modal (used as returnTo when a row action's
// sub-modal is cancelled — keeps the user in flow instead of
@ -440,7 +440,7 @@ class ToolsManager {
const prefill = (opts && opts.prefill) || {};
const returnTo = opts && opts.returnTo;
let submitted = false;
const appIconUrl = this.currentApp ? `/icons/apps/${encodeURIComponent(this.currentApp)}.svg` : '';
const appIconUrl = this.currentApp ? `/core/icons/apps/${encodeURIComponent(this.currentApp)}.svg` : '';
const bodyHtml = `
${tool.confirm ? `<div class="tool-modal-confirm">${escapeHtml(tool.confirm)}</div>` : ''}
<form class="tool-form">
@ -592,7 +592,7 @@ class ToolsManager {
.sort((x, y) => (x.app.name || x.slug).localeCompare(y.app.name || y.slug))
.map(({ app, slug }) => {
const checked = selected.has(slug) ? 'checked' : '';
const iconUrl = `/icons/apps/${encodeURIComponent(slug)}.svg`;
const iconUrl = `/core/icons/apps/${encodeURIComponent(slug)}.svg`;
const displayName = escapeHtml(app.name || slug);
return `
<label class="installed-apps-item">
@ -763,7 +763,7 @@ class ToolsManager {
list.innerHTML = flat.map(u => {
const checked = selected.has(u.id) ? 'checked' : '';
const iconUrl = `/icons/apps/${encodeURIComponent(u.slug)}.svg`;
const iconUrl = `/core/icons/apps/${encodeURIComponent(u.slug)}.svg`;
const appLabel = displayName(u.slug);
const showButton = u.label && u.label !== appLabel;
const fullLabel = showButton

View File

@ -646,7 +646,7 @@ class BackupPage {
const when = has ? 'Last backed up ' + this.formatRelative(sys.latest_time) : 'No backup yet';
return `
<div class="backup-app-tile backup-app-tile--system" data-system="1" title="Back up system config">
<img class="backup-app-tile-icon" src="/icons/apps/libreportal.svg" alt="" onerror="this.style.display='none'">
<img class="backup-app-tile-icon" src="/core/icons/apps/libreportal.svg" alt="" onerror="this.style.display='none'">
<div class="backup-app-tile-text">
<div class="backup-app-tile-name">Configs</div>
<div class="backup-app-tile-meta">
@ -680,7 +680,7 @@ class BackupPage {
const command = a.command || '';
return command.endsWith(` ${slug}`) || a.name?.toLowerCase() === slug.toLowerCase();
});
let icon = match?.icon || '/icons/apps/default.svg';
let icon = match?.icon || '/core/icons/apps/default.svg';
if (!icon.startsWith('/')) icon = '/' + icon;
const displayName = (typeof window.getAppDisplayName === 'function')
? window.getAppDisplayName(slug)
@ -695,7 +695,7 @@ class BackupPage {
const { icon, displayName } = this.appMeta(app.app);
return `
<div class="backup-app-tile" data-app="${this.escape(app.app)}" title="Open ${this.escape(displayName)} backup history">
<img class="backup-app-tile-icon" src="${this.escape(icon)}" alt="" onerror="this.src='/icons/apps/default.svg'">
<img class="backup-app-tile-icon" src="${this.escape(icon)}" alt="" onerror="this.src='/core/icons/apps/default.svg'">
<div class="backup-app-tile-text">
<div class="backup-app-tile-name">${this.escape(displayName)}</div>
<div class="backup-app-tile-meta">
@ -1056,7 +1056,7 @@ class BackupPage {
const deepLink = hasApp
? `/app/${encodeURIComponent(r.app)}/backups?snapshot=${encodeURIComponent(r.id)}`
: null;
const iconUrl = hasApp ? `/icons/apps/${encodeURIComponent(r.app)}.svg` : '/icons/apps/libreportal.svg';
const iconUrl = hasApp ? `/core/icons/apps/${encodeURIComponent(r.app)}.svg` : '/core/icons/apps/libreportal.svg';
const displayName = hasApp ? this.appMeta(r.app).displayName : 'Configs';
const appChip = hasApp
? `<a class="backup-snapshot-link backup-snapshot-app-chip" href="${this.escape(deepLink)}" data-deep-link="${this.escape(deepLink)}" title="Open ${this.escape(displayName)} backups">${this.escape(displayName)}</a>`
@ -1980,7 +1980,7 @@ class BackupPage {
: 'No backup yet';
const rows = [
row('__system__', '/icons/apps/libreportal.svg', 'Configs', sysSub, preTickSystem),
row('__system__', '/core/icons/apps/libreportal.svg', 'Configs', sysSub, preTickSystem),
...sortedApps.map(app => {
const meta = this.appMeta(app.app);
const sub = app.latest_snapshot

View File

@ -1,7 +1,7 @@
{
"id": "backup",
"routes": ["/backup", "/backup*"],
"module": "/features/backup/index.js",
"module": "/components/backup/index.js",
"handler": "handleBackup",
"navId": "nav-backup",
"nav": { "label": "Backups", "order": 50 },

View File

@ -14,14 +14,14 @@ LP.features.register({
routes: ['/backup', '/backup*'],
// Controllers the feature needs; lazy-loaded on first mount (idempotent).
scripts: [
'/features/backup/backup-schema.js',
'/features/backup/backup-page.js',
'/shared/js/backup-app-card.js',
'/components/backup/backup-schema.js',
'/components/backup/backup-page.js',
'/core/lib/backup-app-card.js',
],
async mount(ctx) {
await ctx.loadScripts(this.scripts);
const html = await ctx.loadFragment('/html/backup-content.html');
const html = await ctx.loadFragment('/components/backup/backup-content.html');
ctx.setContent(html, 'Backups');
if (typeof BackupPage === 'undefined') {
throw new Error('BackupPage controller failed to load');

View File

@ -34,14 +34,14 @@ function renderInstalledApps() {
function createInstalledAppCard(app) {
const appName = app.command.split(' ').pop();
let icon = app.icon || '/icons/apps/default.svg';
let icon = app.icon || '/core/icons/apps/default.svg';
if (!icon.startsWith('/')) icon = '/' + icon;
const shortName = app.name.split(' - ')[0].trim();
return `
<div class="frontpage-app-tile" onclick="window.location.href='/app/${appName}'">
<div class="frontpage-app-icon-wrap">
<img src="${icon}" alt="${shortName}" onerror="this.src='/icons/apps/default.svg'">
<img src="${icon}" alt="${shortName}" onerror="this.src='/core/icons/apps/default.svg'">
<div class="frontpage-app-overlay" id="frontpage-overlay-${appName}" onclick="event.stopPropagation()"></div>
</div>
<span class="frontpage-app-name">${shortName}</span>

View File

@ -1,7 +1,7 @@
{
"id": "dashboard",
"routes": ["/", "/dashboard"],
"module": "/features/dashboard/index.js",
"module": "/components/dashboard/index.js",
"handler": "handleDashboard",
"navId": "nav-dashboard",
"nav": { "label": "Dashboard", "order": 10 },

View File

@ -13,7 +13,7 @@ LP.features.register({
routes: ['/', '/dashboard'],
async mount(ctx) {
const html = await ctx.loadFragment('/html/dashboard-content.html');
const html = await ctx.loadFragment('/components/dashboard/dashboard-content.html');
ctx.setContent(html, 'Dashboard');
// Render the installed-apps icon grid (handleDashboard's only post-render call).

View File

@ -5,7 +5,7 @@
{
"id": "dashboard",
"routes": ["/", "/dashboard"],
"module": "/features/dashboard/index.js",
"module": "/components/dashboard/index.js",
"handler": "handleDashboard",
"navId": "nav-dashboard",
"nav": { "label": "Dashboard", "order": 10 }
@ -13,7 +13,7 @@
{
"id": "apps",
"routes": ["/apps", "/apps*"],
"module": "/features/apps/index.js",
"module": "/components/apps/index.js",
"handler": "handleApps",
"navId": "nav-app-center",
"nav": { "label": "App Center", "order": 20 }
@ -21,14 +21,14 @@
{
"id": "app-detail",
"routes": ["/app", "/app*"],
"module": "/features/app-detail/index.js",
"module": "/components/app-detail/index.js",
"handler": "handleAppDetail",
"navId": "nav-app-center"
},
{
"id": "admin",
"routes": ["/admin", "/admin*"],
"module": "/features/admin/index.js",
"module": "/components/admin/index.js",
"handler": "handleAdmin",
"navId": "nav-config",
"nav": { "label": "Admin", "order": 40 }
@ -42,7 +42,7 @@
{
"id": "tasks",
"routes": ["/tasks", "/tasks*"],
"module": "/features/tasks/index.js",
"module": "/components/tasks/index.js",
"handler": "handleTasks",
"navId": "nav-tasks",
"nav": { "label": "Tasks", "order": 60 }
@ -50,14 +50,14 @@
{
"id": "updater",
"routes": ["/updater", "/updater*"],
"module": "/features/updater/index.js",
"module": "/components/updater/index.js",
"navId": "nav-updater",
"nav": { "label": "Updates", "order": 30 }
},
{
"id": "backup",
"routes": ["/backup", "/backup*"],
"module": "/features/backup/index.js",
"module": "/components/backup/index.js",
"handler": "handleBackup",
"navId": "nav-backup",
"nav": { "label": "Backups", "order": 50 }

View File

@ -1,7 +1,7 @@
{
"id": "tasks",
"routes": ["/tasks", "/tasks*"],
"module": "/features/tasks/index.js",
"module": "/components/tasks/index.js",
"handler": "handleTasks",
"navId": "nav-tasks",
"nav": { "label": "Tasks", "order": 60 },

View File

@ -11,7 +11,7 @@ LP.features.register({
routes: ['/tasks', '/tasks*'],
async mount(ctx) {
const html = await ctx.loadFragment('/html/tasks-content.html');
const html = await ctx.loadFragment('/components/tasks/tasks-content.html');
ctx.setContent(html, 'Tasks');
if (window.tasksManager) {

View File

@ -119,8 +119,8 @@ class TasksManager {
? 'LibrePortal'
: ((appName && window.getAppDisplayName) ? window.getAppDisplayName(appName) : (appName || '')));
const icon = isSystemTask
? '/icons/libreportal.svg'
: (appName ? `/icons/apps/${encodeURIComponent(appName)}.svg` : null);
? '/core/icons/libreportal.svg'
: (appName ? `/core/icons/apps/${encodeURIComponent(appName)}.svg` : null);
const typeIcon = (this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : '') || '';
return { appName, isSystemTask, actionTitle, displayName, icon, typeIcon };
}
@ -1112,7 +1112,7 @@ class TasksManager {
renderTaskIcons(task) {
const typeIcon = `<span class="task-type-icon">${this.getTaskTypeIcon(task).icon}</span>`;
// `app: 'system'` is a category sentinel (config_update, system_update, …),
// not a real app slug, so it has no /icons/apps/system.svg — fall through
// not a real app slug, so it has no /core/icons/apps/system.svg — fall through
// to the LibrePortal-system branch so those tasks still get a logo.
const isSystemSentinel = task.app === 'system';
if (task.app && !isSystemSentinel) {
@ -1120,7 +1120,7 @@ class TasksManager {
return `${typeIcon}<img src="${appIconPath}" alt="${task.app}" class="task-app-icon" onerror="this.style.display='none'">`;
}
if (isSystemSentinel || this.isLibrePortalSystemTask(task)) {
return `${typeIcon}<img src="/icons/libreportal.svg" alt="LibrePortal" class="task-app-icon">`;
return `${typeIcon}<img src="/core/icons/libreportal.svg" alt="LibrePortal" class="task-app-icon">`;
}
return typeIcon;
}
@ -1137,7 +1137,7 @@ class TasksManager {
}
// Default icon path
return `/icons/apps/${task.app}.svg`;
return `/core/icons/apps/${task.app}.svg`;
}
updateStats() {

View File

@ -1,7 +1,7 @@
{
"id": "updater",
"routes": ["/updater", "/updater*"],
"module": "/features/updater/index.js",
"module": "/components/updater/index.js",
"navId": "nav-updater",
"nav": { "label": "Updates", "order": 30 },
"order": 30,

View File

@ -9,11 +9,11 @@
LP.features.register({
id: 'updater',
routes: ['/updater', '/updater*'],
scripts: ['/features/updater/updater-page.js'],
scripts: ['/components/updater/updater-page.js'],
async mount(ctx) {
await ctx.loadScripts(this.scripts);
const html = await ctx.loadFragment('/html/updater-content.html');
const html = await ctx.loadFragment('/components/updater/updater-content.html');
ctx.setContent(html, 'Updates');
if (typeof UpdaterPage === 'undefined') {
throw new Error('UpdaterPage controller failed to load');

View File

@ -64,7 +64,7 @@ class AuthManager {
<div class="login-content">
<div class="aurora-header">
<div class="aurora-logo">
<img src="/icons/libreportal.svg" alt="LibrePortal" onload="this.classList.add('loaded')" onerror="this.style.display='none'">
<img src="/core/icons/libreportal.svg" alt="LibrePortal" onload="this.classList.add('loaded')" onerror="this.style.display='none'">
<h1>LibrePortal</h1>
</div>
<p class="aurora-subtitle">Step softly back into your own private universe</p>

View File

@ -27,7 +27,7 @@ class LoadingUI {
<div class="loading-container">
<div class="aurora-header">
<div class="aurora-logo">
<img src="/icons/libreportal.svg" alt="LibrePortal" onload="this.classList.add('loaded')" onerror="this.style.display='none'">
<img src="/core/icons/libreportal.svg" alt="LibrePortal" onload="this.classList.add('loaded')" onerror="this.style.display='none'">
<h1>LibrePortal</h1>
</div>
<p class="aurora-subtitle">Drifting softly into your private universe...</p>

View File

@ -68,7 +68,7 @@ class SetupWizard {
<div class="setup-content">
<div class="aurora-header">
<div class="aurora-logo">
<img src="/icons/libreportal.svg" alt="LibrePortal" onload="this.classList.add('loaded')" onerror="this.style.display='none'">
<img src="/core/icons/libreportal.svg" alt="LibrePortal" onload="this.classList.add('loaded')" onerror="this.style.display='none'">
<h1>LibrePortal</h1>
</div>
<p class="aurora-subtitle">Tuning your private universe before takeoff...</p>
@ -425,7 +425,7 @@ class SetupWizard {
const app = manifest.get(slug) || {};
const name = app.title || app.name || fallback.name;
const desc = app.description || fallback.description;
const icon = app.icon || `/icons/apps/${slug}.svg`;
const icon = app.icon || `/core/icons/apps/${slug}.svg`;
// Sub-option lives OUTSIDE the parent label (nested <label> behaviour
// is messy — clicks would toggle both checkboxes). Both labels are
// siblings inside a wrapper div; CSS connects them visually.
@ -440,7 +440,7 @@ class SetupWizard {
<input type="checkbox" data-app="${slug}" ${defaultChecked ? 'checked' : ''}>
<div class="setup-app-icon-wrap">
<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='/core/icons/apps/default.svg'">
</div>
<div class="setup-app-info">
<div class="setup-app-name">${name}</div>

View File

@ -39,18 +39,18 @@ class SystemLoader {
global: 'configManager',
dependencies: ['data'],
scripts: [
'/shared/js/config-options.js',
'/shared/js/config-shared.js',
'/features/admin/config-validator.js',
'/features/admin/toggle-manager.js',
'/features/admin/config-core.js',
'/features/admin/domain-manager.js',
'/features/admin/ip-whitelist-manager.js',
'/features/admin/config-renderer.js',
'/features/admin/config-sidebar.js',
'/features/admin/config-form.js',
'/features/admin/config-utils.js',
'/features/admin/config-manager.js'
'/core/lib/config-options.js',
'/core/lib/config-shared.js',
'/components/admin/config-validator.js',
'/components/admin/toggle-manager.js',
'/components/admin/config-core.js',
'/components/admin/domain-manager.js',
'/components/admin/ip-whitelist-manager.js',
'/components/admin/config-renderer.js',
'/components/admin/config-sidebar.js',
'/components/admin/config-form.js',
'/components/admin/config-utils.js',
'/components/admin/config-manager.js'
]
});
@ -69,9 +69,9 @@ class SystemLoader {
global: null,
dependencies: [],
scripts: [
'/js/components/topbar.js',
'/js/components/update-notifier.js',
'/js/components/mobile-menu.js'
'/core/ui/topbar.js',
'/core/ui/update-notifier.js',
'/core/ui/mobile-menu.js'
]
});
@ -89,7 +89,7 @@ class SystemLoader {
},
global: null,
dependencies: [],
script: '/js/components/confirmation-dialog.js'
script: '/core/ui/confirmation-dialog.js'
});
// Notifications
@ -106,7 +106,7 @@ class SystemLoader {
},
global: 'notificationSystem',
dependencies: [],
script: '/js/components/notifications.js'
script: '/core/ui/notifications.js'
});
// Dashboard
@ -167,14 +167,14 @@ class SystemLoader {
global: 'tasksManager',
dependencies: [],
scripts: [
'/shared/task/task-event-bus.js',
'/shared/task/task-commands.js',
'/shared/task/task-actions.js',
'/shared/task/task-router.js',
'/shared/task/task-global-functions.js',
'/shared/task/task-manager.js',
'/js/task-parameter-preserve.js',
'/features/tasks/tasks-manager.js'
'/core/lib/task-event-bus.js',
'/core/lib/task-commands.js',
'/core/lib/task-actions.js',
'/core/lib/task-router.js',
'/core/lib/task-global-functions.js',
'/core/lib/task-manager.js',
'/core/lib/task-parameter-preserve.js',
'/components/tasks/tasks-manager.js'
]
});
@ -204,13 +204,13 @@ class SystemLoader {
global: 'appsManager',
dependencies: ['data'],
scripts: [
'/features/apps/port-manager.js',
'/shared/task/task-manager.js', // Add TaskManager for backup functionality
'/shared/js/backup-app-card.js',
'/features/apps/services-manager.js',
'/features/apps/tools-manager.js',
'/features/apps/routing-manager.js',
'/features/apps/apps-manager.js'
'/components/apps/port-manager.js',
'/core/lib/task-manager.js', // Add TaskManager for backup functionality
'/core/lib/backup-app-card.js',
'/components/apps/services-manager.js',
'/components/apps/tools-manager.js',
'/components/apps/routing-manager.js',
'/components/apps/apps-manager.js'
]
});
@ -232,7 +232,7 @@ class SystemLoader {
},
global: 'appTabbedManager',
dependencies: [],
script: '/features/apps/app-tabbed-manager.js'
script: '/components/apps/app-tabbed-manager.js'
});
// console.log('TEST: Components added. Total components:', this.components.size);
@ -639,9 +639,9 @@ class SystemLoader {
iconUrls.add(app.icon.startsWith('/') ? app.icon : '/' + app.icon);
} else {
const cleanAppName = app.command?.split(' ').pop() || app.name;
iconUrls.add(`/icons/apps/${cleanAppName}.svg`);
iconUrls.add(`/core/icons/apps/${cleanAppName}.svg`);
}
iconUrls.add('/icons/apps/default.svg');
iconUrls.add('/core/icons/apps/default.svg');
});
}
@ -655,8 +655,8 @@ class SystemLoader {
}
// Add 'all' and 'installed' category icons
iconUrls.add('/icons/categories/all.svg');
iconUrls.add('/icons/categories/installed.svg');
iconUrls.add('/core/icons/categories/all.svg');
iconUrls.add('/core/icons/categories/installed.svg');
// console.log(`📦 Found ${iconUrls.size} unique icons to preload`);

View File

Before

Width:  |  Height:  |  Size: 598 B

After

Width:  |  Height:  |  Size: 598 B

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

Before

Width:  |  Height:  |  Size: 386 B

After

Width:  |  Height:  |  Size: 386 B

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 678 B

After

Width:  |  Height:  |  Size: 678 B

Some files were not shown because too many files have changed in this diff Show More