2292 lines
82 KiB
JavaScript
2292 lines
82 KiB
JavaScript
const payloadNode = document.getElementById('desktop-payload');
|
|
|
|
if (payloadNode) {
|
|
const syncDesktopTimezone = () => {
|
|
try {
|
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Berlin';
|
|
if (!timezone) {
|
|
return;
|
|
}
|
|
|
|
const encoded = encodeURIComponent(timezone);
|
|
const currentCookie = document.cookie.split('; ').find((entry) => entry.startsWith('desktop_timezone='));
|
|
if (currentCookie === `desktop_timezone=${encoded}`) {
|
|
return;
|
|
}
|
|
|
|
document.cookie = `desktop_timezone=${encoded}; path=/; max-age=31536000; samesite=lax`;
|
|
} catch {
|
|
// Ignore timezone detection failures.
|
|
}
|
|
};
|
|
|
|
syncDesktopTimezone();
|
|
|
|
const payload = JSON.parse(payloadNode.textContent);
|
|
const desktopStage = document.querySelector('.desktop-stage');
|
|
const iconsRoot = document.getElementById('desktop-icons');
|
|
const widgetZoneRoot = document.getElementById('widget-zone');
|
|
const windowsRoot = document.getElementById('window-layer');
|
|
const taskbarRoot = document.getElementById('taskbar-apps');
|
|
const startButton = document.getElementById('start-button');
|
|
const startMenu = document.getElementById('start-menu');
|
|
const startMenuApps = document.getElementById('start-menu-apps');
|
|
const startMenuSelectionHeader = document.getElementById('start-menu-selection-header');
|
|
const startMenuFunctions = document.getElementById('start-menu-functions');
|
|
const startMenuUserCard = document.getElementById('start-menu-user-card');
|
|
const startMenuUserName = document.getElementById('start-menu-user-name');
|
|
const startMenuUserAvatar = document.getElementById('start-menu-user-avatar');
|
|
const startMenuSessionAction = document.getElementById('start-menu-session-action');
|
|
const accountMenu = document.getElementById('tray-account-menu');
|
|
const fxTrayMenu = document.getElementById('tray-fx-menu');
|
|
const fxTrayStatusNode = document.getElementById('tray-fx-status');
|
|
const giteaStatusNode = document.querySelector('.tray-pill[data-tray-id="gitea-deploy-status"]');
|
|
const clockNode = document.getElementById('clock');
|
|
const clockTopNode = document.getElementById('clock-top');
|
|
const debugToggleNode = document.getElementById('debug-toggle');
|
|
const debugToggleTopNode = document.getElementById('debug-toggle-top');
|
|
const windows = new Map();
|
|
const activeWidgets = new Set(payload.widgets.active_ids);
|
|
const activeSkin = payload.meta.active_skin;
|
|
const skinProfile = payload.meta.skin_profile || {};
|
|
const settingsApi = payload.desktop.settings_api || '';
|
|
const shellApps = Array.isArray(payload.shell?.apps) ? payload.shell.apps : [];
|
|
const debugShellApp = (payload.shell && typeof payload.shell.debug_app === 'object' && payload.shell.debug_app)
|
|
|| shellApps.find((app) => app && app.app_id === 'desktop-debug')
|
|
|| null;
|
|
const debugShellAppId = typeof debugShellApp?.app_id === 'string' && debugShellApp.app_id !== ''
|
|
? debugShellApp.app_id
|
|
: 'desktop-debug';
|
|
let currentUserPreferences = payload.desktop.user_preferences || {};
|
|
const maximizeOverSystemBar = Boolean(skinProfile.maximize_over_system_bar);
|
|
const storageScope = payload.desktop.storage_scope || 'guest';
|
|
const storageKey = `desktop.kusche.berlin.window-state.${storageScope}`;
|
|
const iconStorageKey = `desktop.kusche.berlin.icon-state.${storageScope}.${activeSkin}`;
|
|
const debugStorageKey = `desktop.kusche.berlin.debug-state.${storageScope}`;
|
|
const isAdmin = payload.session?.is_admin === true;
|
|
let zIndex = 20;
|
|
let topDesktopInset = 0;
|
|
let sideDesktopInset = 0;
|
|
let bottomDesktopInset = 120;
|
|
let iconContextMenu = null;
|
|
let activeFunctionArea = 'programs';
|
|
const windowControlsMode = document.body.dataset.windowControls || 'windows';
|
|
let wallpaperImagePromise = null;
|
|
let giteaStatusTimer = null;
|
|
|
|
const loadDebugState = () => {
|
|
try {
|
|
const raw = window.localStorage.getItem(debugStorageKey);
|
|
const parsed = raw ? JSON.parse(raw) : null;
|
|
|
|
return {
|
|
open: parsed?.open === true,
|
|
};
|
|
} catch {
|
|
return { open: false };
|
|
}
|
|
};
|
|
|
|
const debugState = loadDebugState();
|
|
|
|
const saveDebugState = () => {
|
|
try {
|
|
window.localStorage.setItem(debugStorageKey, JSON.stringify({
|
|
open: debugState.open === true,
|
|
}));
|
|
} catch {
|
|
// Ignore storage failures for now.
|
|
}
|
|
};
|
|
|
|
const debugBus = window.__desktopDebugBus || {
|
|
enabled: isAdmin && debugState.open === true,
|
|
sequence: 0,
|
|
entries: [],
|
|
listener: null,
|
|
maxEntries: 400,
|
|
emit(entry) {
|
|
if (!this.enabled) {
|
|
return null;
|
|
}
|
|
|
|
this.sequence += 1;
|
|
const payloadEntry = {
|
|
id: this.sequence,
|
|
time: new Date().toISOString(),
|
|
...entry,
|
|
};
|
|
|
|
this.entries.push(payloadEntry);
|
|
if (this.entries.length > this.maxEntries) {
|
|
this.entries.shift();
|
|
}
|
|
|
|
if (typeof this.listener === 'function') {
|
|
this.listener(payloadEntry, this.entries.slice());
|
|
}
|
|
|
|
return payloadEntry;
|
|
},
|
|
subscribe(listener) {
|
|
this.listener = typeof listener === 'function' ? listener : null;
|
|
if (typeof this.listener === 'function') {
|
|
this.listener(null, this.entries.slice());
|
|
}
|
|
},
|
|
setEnabled(nextEnabled) {
|
|
this.enabled = nextEnabled === true;
|
|
if (!this.enabled) {
|
|
this.entries = [];
|
|
} else {
|
|
this.emit({
|
|
type: 'debug:enabled',
|
|
source: 'desktop',
|
|
message: 'Desktop-Debug aktiviert.',
|
|
});
|
|
}
|
|
|
|
if (typeof this.listener === 'function') {
|
|
this.listener(null, this.entries.slice());
|
|
}
|
|
},
|
|
clear() {
|
|
this.entries = [];
|
|
if (typeof this.listener === 'function') {
|
|
this.listener(null, this.entries.slice());
|
|
}
|
|
},
|
|
};
|
|
window.__desktopDebugBus = debugBus;
|
|
window.__nexusDebugBus = debugBus;
|
|
|
|
const escapeHtml = (value) => String(value)
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
|
|
const emitDebug = (entry) => debugBus.emit(entry);
|
|
|
|
const updateDebugToggleUi = () => {
|
|
[debugToggleNode, debugToggleTopNode].forEach((node) => {
|
|
if (!(node instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
node.setAttribute('aria-expanded', String(debugState.open === true));
|
|
node.classList.toggle('is-active', debugState.open === true);
|
|
node.textContent = debugState.open === true
|
|
? `${String(debugShellApp?.label || 'Debug')} an`
|
|
: String(debugShellApp?.label || 'Debug');
|
|
});
|
|
};
|
|
|
|
const ensureFetchDebugHook = () => {
|
|
if (window.__desktopDebugFetchHookInstalled === true || typeof window.fetch !== 'function') {
|
|
return;
|
|
}
|
|
|
|
const originalFetch = window.fetch.bind(window);
|
|
window.fetch = async (input, init) => {
|
|
const method = String(init?.method || 'GET').toUpperCase();
|
|
const url = typeof input === 'string' ? input : (input instanceof Request ? input.url : String(input));
|
|
const startedAt = performance.now();
|
|
const requestId = emitDebug({
|
|
type: 'fetch:start',
|
|
source: 'desktop',
|
|
method,
|
|
url,
|
|
})?.id ?? null;
|
|
|
|
try {
|
|
const response = await originalFetch(input, init);
|
|
emitDebug({
|
|
type: 'fetch:end',
|
|
source: 'desktop',
|
|
request_id: requestId,
|
|
method,
|
|
url,
|
|
status: response.status,
|
|
ok: response.ok,
|
|
duration_ms: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
});
|
|
return response;
|
|
} catch (error) {
|
|
emitDebug({
|
|
type: 'fetch:error',
|
|
source: 'desktop',
|
|
request_id: requestId,
|
|
method,
|
|
url,
|
|
duration_ms: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
message: error instanceof Error ? error.message : 'Fetch fehlgeschlagen',
|
|
});
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
window.__desktopDebugFetchHookInstalled = true;
|
|
};
|
|
|
|
ensureFetchDebugHook();
|
|
|
|
window.addEventListener('error', (event) => {
|
|
emitDebug({
|
|
type: 'window:error',
|
|
source: 'desktop',
|
|
message: event.message || 'Unbekannter Fehler',
|
|
file: event.filename || null,
|
|
line: event.lineno || null,
|
|
column: event.colno || null,
|
|
});
|
|
});
|
|
|
|
window.addEventListener('unhandledrejection', (event) => {
|
|
const reason = event.reason;
|
|
emitDebug({
|
|
type: 'window:unhandledrejection',
|
|
source: 'desktop',
|
|
message: reason instanceof Error ? reason.message : String(reason || 'Promise wurde verworfen'),
|
|
});
|
|
});
|
|
|
|
const buildAppIconMarkup = (app, className = '') => {
|
|
const safeClassName = className ? ` ${className}` : '';
|
|
|
|
if (app.icon_path) {
|
|
return `<img class="app-icon-image${safeClassName}" src="${escapeHtml(app.icon_path)}" alt="">`;
|
|
}
|
|
|
|
return `<span class="app-icon-fallback${safeClassName}">${escapeHtml(app.icon || 'AP')}</span>`;
|
|
};
|
|
|
|
const extractWallpaperImageUrl = () => {
|
|
const wallpaper = getComputedStyle(document.body).getPropertyValue('--wallpaper') || '';
|
|
const match = wallpaper.match(/url\((['"]?)(.*?)\1\)/i);
|
|
return match && match[2] ? match[2] : null;
|
|
};
|
|
|
|
const loadWallpaperImage = () => {
|
|
const wallpaperUrl = extractWallpaperImageUrl();
|
|
|
|
if (!wallpaperUrl) {
|
|
return Promise.resolve(null);
|
|
}
|
|
|
|
if (wallpaperImagePromise) {
|
|
return wallpaperImagePromise;
|
|
}
|
|
|
|
wallpaperImagePromise = new Promise((resolve) => {
|
|
const image = new Image();
|
|
image.decoding = 'async';
|
|
image.crossOrigin = 'anonymous';
|
|
image.onload = () => resolve(image);
|
|
image.onerror = () => resolve(null);
|
|
image.src = wallpaperUrl;
|
|
});
|
|
|
|
return wallpaperImagePromise;
|
|
};
|
|
|
|
const computeCoverSamplePoint = (image, viewportX, viewportY) => {
|
|
const viewportWidth = window.innerWidth;
|
|
const viewportHeight = window.innerHeight;
|
|
const scale = Math.max(viewportWidth / image.naturalWidth, viewportHeight / image.naturalHeight);
|
|
const renderWidth = image.naturalWidth * scale;
|
|
const renderHeight = image.naturalHeight * scale;
|
|
const offsetX = (viewportWidth - renderWidth) / 2;
|
|
const offsetY = (viewportHeight - renderHeight) / 2;
|
|
const imageX = Math.max(0, Math.min(image.naturalWidth - 1, Math.round((viewportX - offsetX) / scale)));
|
|
const imageY = Math.max(0, Math.min(image.naturalHeight - 1, Math.round((viewportY - offsetY) / scale)));
|
|
|
|
return { imageX, imageY };
|
|
};
|
|
|
|
const createWallpaperSampler = (image) => {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = image.naturalWidth;
|
|
canvas.height = image.naturalHeight;
|
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
|
|
if (!context) {
|
|
return null;
|
|
}
|
|
|
|
context.drawImage(image, 0, 0);
|
|
|
|
const sampleLuminance = (rect) => {
|
|
const samplePoints = [
|
|
[rect.left + rect.width * 0.2, rect.top + rect.height * 0.2],
|
|
[rect.left + rect.width * 0.8, rect.top + rect.height * 0.2],
|
|
[rect.left + rect.width * 0.5, rect.top + rect.height * 0.5],
|
|
[rect.left + rect.width * 0.2, rect.top + rect.height * 0.8],
|
|
[rect.left + rect.width * 0.8, rect.top + rect.height * 0.8],
|
|
];
|
|
|
|
let luminanceTotal = 0;
|
|
|
|
samplePoints.forEach(([x, y]) => {
|
|
const { imageX, imageY } = computeCoverSamplePoint(image, x, y);
|
|
const pixel = context.getImageData(imageX, imageY, 1, 1).data;
|
|
luminanceTotal += (0.2126 * pixel[0]) + (0.7152 * pixel[1]) + (0.0722 * pixel[2]);
|
|
});
|
|
|
|
return luminanceTotal / samplePoints.length;
|
|
};
|
|
|
|
return { sampleLuminance };
|
|
};
|
|
|
|
const applyDesktopIconContrast = async () => {
|
|
const icons = Array.from(document.querySelectorAll('.desktop-icon'));
|
|
|
|
if (icons.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const image = await loadWallpaperImage();
|
|
|
|
if (!image) {
|
|
icons.forEach((icon) => {
|
|
icon.classList.remove('is-contrast-light');
|
|
icon.classList.add('is-contrast-dark');
|
|
});
|
|
return;
|
|
}
|
|
|
|
const sampler = createWallpaperSampler(image);
|
|
|
|
if (!sampler) {
|
|
return;
|
|
}
|
|
|
|
icons.forEach((icon) => {
|
|
const label = icon.querySelector('.desktop-icon-label');
|
|
|
|
if (!(label instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const rect = label.getBoundingClientRect();
|
|
const luminance = sampler.sampleLuminance(rect);
|
|
const useDarkText = luminance >= 150;
|
|
icon.classList.toggle('is-contrast-dark', useDarkText);
|
|
icon.classList.toggle('is-contrast-light', !useDarkText);
|
|
});
|
|
};
|
|
|
|
const updateSessionDisplay = () => {
|
|
const accountNode = document.querySelector('.tray-pill[data-tray-id="account"]');
|
|
|
|
const displayName = currentUserPreferences?.profile?.name || payload.session?.display_name;
|
|
|
|
if (accountNode && typeof displayName === 'string' && displayName.trim() !== '') {
|
|
accountNode.textContent = displayName;
|
|
}
|
|
|
|
if (startMenuUserName && typeof displayName === 'string' && displayName.trim() !== '') {
|
|
startMenuUserName.textContent = displayName;
|
|
}
|
|
|
|
if (startMenuUserAvatar) {
|
|
startMenuUserAvatar.textContent = buildInitials(displayName);
|
|
}
|
|
};
|
|
|
|
const buildInitials = (value) => {
|
|
const normalized = typeof value === 'string' ? value.trim() : '';
|
|
|
|
if (normalized === '') {
|
|
return 'GU';
|
|
}
|
|
|
|
const parts = normalized.split(/\s+/).filter(Boolean);
|
|
|
|
if (parts.length === 1) {
|
|
return parts[0].slice(0, 2).toUpperCase();
|
|
}
|
|
|
|
return `${parts[0][0] || ''}${parts[1][0] || ''}`.toUpperCase();
|
|
};
|
|
|
|
const setAccountMenuOpen = (isOpen) => {
|
|
const accountButton = document.querySelector('.tray-pill[data-tray-id="account"]');
|
|
|
|
if (!(accountMenu instanceof HTMLElement) || !(accountButton instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
accountMenu.hidden = !isOpen;
|
|
accountButton.setAttribute('aria-expanded', String(isOpen));
|
|
};
|
|
|
|
const setFxTrayMenuOpen = (isOpen) => {
|
|
const fxButton = document.querySelector('.tray-pill[data-tray-app-id="fx-rates"]');
|
|
|
|
if (!(fxTrayMenu instanceof HTMLElement) || !(fxButton instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
fxTrayMenu.hidden = !isOpen;
|
|
fxButton.setAttribute('aria-expanded', String(isOpen));
|
|
};
|
|
|
|
const loadFxTrayStatus = async () => {
|
|
const fxButton = document.querySelector('.tray-pill[data-tray-app-id="fx-rates"]');
|
|
|
|
if (!(fxTrayStatusNode instanceof HTMLElement) || !(fxButton instanceof HTMLElement)) {
|
|
return null;
|
|
}
|
|
|
|
fxTrayStatusNode.textContent = 'Kursstatus wird geladen ...';
|
|
fxButton.setAttribute('title', 'Kursstatus wird geladen ...');
|
|
|
|
try {
|
|
const response = await fetch('/api/fx-rates/index.php?path=v1/status', {
|
|
credentials: 'same-origin',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
const rows = Array.isArray(payload?.data) ? payload.data : [];
|
|
const latest = rows.slice().sort((left, right) => {
|
|
const leftTs = new Date(left?.fetched_at || 0).getTime();
|
|
const rightTs = new Date(right?.fetched_at || 0).getTime();
|
|
return rightTs - leftTs;
|
|
})[0] || null;
|
|
const statusText = latest
|
|
? `Letzter Abruf: ${latest.fetched_at_display || latest.fetched_at} · ${latest.base_currency} · ${latest.provider}`
|
|
: 'Noch keine gespeicherten Kurse vorhanden.';
|
|
|
|
fxTrayStatusNode.textContent = statusText;
|
|
fxButton.setAttribute('title', statusText);
|
|
return latest;
|
|
} catch (_error) {
|
|
const errorText = 'Kursstatus konnte nicht geladen werden.';
|
|
fxTrayStatusNode.textContent = errorText;
|
|
fxButton.setAttribute('title', errorText);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const updateGiteaTrayUi = (state, message) => {
|
|
if (!(giteaStatusNode instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
giteaStatusNode.classList.remove(
|
|
'tray-pill-status--idle',
|
|
'tray-pill-status--running',
|
|
'tray-pill-status--error',
|
|
'tray-pill-status--unconfigured',
|
|
);
|
|
|
|
const normalizedState = typeof state === 'string' && state !== '' ? state : 'idle';
|
|
giteaStatusNode.classList.add(`tray-pill-status--${normalizedState}`);
|
|
giteaStatusNode.setAttribute('title', typeof message === 'string' && message !== '' ? message : 'Gitea-Deploy-Status');
|
|
giteaStatusNode.textContent = '●';
|
|
};
|
|
|
|
const scheduleGiteaStatusPoll = (seconds) => {
|
|
if (giteaStatusTimer) {
|
|
window.clearTimeout(giteaStatusTimer);
|
|
}
|
|
|
|
const delayMs = Math.max(1, Number(seconds || 5)) * 1000;
|
|
giteaStatusTimer = window.setTimeout(() => {
|
|
void loadGiteaDeployStatus();
|
|
}, delayMs);
|
|
};
|
|
|
|
const loadGiteaDeployStatus = async () => {
|
|
if (!(giteaStatusNode instanceof HTMLElement)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/system/deploy-status/index.php', {
|
|
credentials: 'same-origin',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
const responsePayload = await response.json().catch(() => ({}));
|
|
const data = responsePayload && typeof responsePayload === 'object' ? (responsePayload.data || {}) : {};
|
|
const state = typeof data.state === 'string' ? data.state : (response.ok ? 'idle' : 'error');
|
|
const message = typeof data.message === 'string' && data.message !== ''
|
|
? data.message
|
|
: 'Gitea-Deploy-Status ist unbekannt.';
|
|
const nextPoll = Number(data.poll_seconds || 5);
|
|
|
|
updateGiteaTrayUi(state, message);
|
|
emitDebug({
|
|
type: 'gitea:deploy-status',
|
|
source: 'gitea-addon',
|
|
status: response.status,
|
|
ok: response.ok,
|
|
message,
|
|
state,
|
|
poll_seconds: nextPoll,
|
|
diagnostics: data.diagnostics || null,
|
|
running_jobs: Array.isArray(data.running_jobs) ? data.running_jobs.length : 0,
|
|
});
|
|
scheduleGiteaStatusPoll(nextPoll);
|
|
return data;
|
|
} catch (_error) {
|
|
updateGiteaTrayUi('error', 'Gitea-Deploy-Status konnte nicht geladen werden.');
|
|
emitDebug({
|
|
type: 'gitea:deploy-status:error',
|
|
source: 'gitea-addon',
|
|
message: 'Gitea-Deploy-Status konnte nicht geladen werden.',
|
|
});
|
|
scheduleGiteaStatusPoll(5);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const runFxTrayRefresh = async (force) => {
|
|
if (!(fxTrayStatusNode instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
fxTrayStatusNode.textContent = 'Aktualisierung laeuft ...';
|
|
try {
|
|
const response = await fetch('/api/fx-rates/index.php?path=v1/refresh', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
force: force === true,
|
|
trigger_source: 'tray',
|
|
}),
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
throw new Error(payload?.context?.message || payload?.error || `HTTP ${response.status}`);
|
|
}
|
|
|
|
const result = payload?.data || {};
|
|
fxTrayStatusNode.textContent = result.reused
|
|
? 'Kein neuer API-Abruf. Snapshot ist noch jung genug.'
|
|
: `Kurse aktualisiert. ${result.updated_count || 0} Werte gespeichert.`;
|
|
await loadFxTrayStatus();
|
|
} catch (error) {
|
|
fxTrayStatusNode.textContent = error instanceof Error ? error.message : 'Aktualisierung fehlgeschlagen.';
|
|
}
|
|
};
|
|
|
|
const findAppById = (appId) => payload.apps.find((app) => app.app_id === appId) || null;
|
|
|
|
const dispatchUserSettingsUpdate = (detail) => {
|
|
window.dispatchEvent(new CustomEvent('desktop:user-settings-updated', { detail }));
|
|
};
|
|
|
|
const persistSettingsPatch = async (patch) => {
|
|
if (!settingsApi) {
|
|
return null;
|
|
}
|
|
|
|
const response = await window.fetch(settingsApi, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
},
|
|
body: JSON.stringify(patch),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`settings-save-failed:${response.status}`);
|
|
}
|
|
|
|
return response.json();
|
|
};
|
|
|
|
const buildTaskbarButtonContent = (record) => `
|
|
<span class="taskbar-app-button-badge">${buildAppIconMarkup(record.definition, 'taskbar-app-icon')}</span>
|
|
<span class="taskbar-app-button-label">${escapeHtml(record.title)}</span>
|
|
`;
|
|
|
|
const buildWindowActions = () => {
|
|
if (windowControlsMode === 'mac') {
|
|
return `
|
|
<div class="window-actions window-actions--mac">
|
|
<button class="window-action" data-action="close" type="button" aria-label="Schliessen"></button>
|
|
<button class="window-action" data-action="minimize" type="button" aria-label="Minimieren"></button>
|
|
<button class="window-action" data-action="maximize" type="button" aria-label="Maximieren"></button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
const maximizeGlyph = windowControlsMode === 'linux' ? '▢' : '□';
|
|
|
|
return `
|
|
<div class="window-actions window-actions--${windowControlsMode}">
|
|
<button class="window-action" data-action="minimize" type="button" aria-label="Minimieren"><span>-</span></button>
|
|
<button class="window-action" data-action="maximize" type="button" aria-label="Maximieren"><span>${maximizeGlyph}</span></button>
|
|
<button class="window-action" data-action="close" type="button" aria-label="Schliessen"><span>×</span></button>
|
|
</div>
|
|
`;
|
|
};
|
|
|
|
const buildWindowHeader = (definition) => {
|
|
const title = escapeHtml(definition.title);
|
|
|
|
if (windowControlsMode === 'mac') {
|
|
return `
|
|
<header class="window-header window-header--mac">
|
|
${buildWindowActions()}
|
|
<strong class="window-title">${title}</strong>
|
|
<span class="window-header-spacer" aria-hidden="true"></span>
|
|
</header>
|
|
`;
|
|
}
|
|
|
|
return `
|
|
<header class="window-header window-header--${windowControlsMode}">
|
|
<strong class="window-title">${title}</strong>
|
|
${buildWindowActions()}
|
|
</header>
|
|
`;
|
|
};
|
|
|
|
const buildWindowBody = (definition) => {
|
|
if (definition.content_mode === 'desktop-debug') {
|
|
const debugCopy = escapeHtml(definition.ui?.copy || 'Live-Debug fuer Desktop und Native-Module. Tracking laeuft nur, solange dieses Fenster offen ist.');
|
|
const clearLabel = escapeHtml(definition.ui?.clear_label || 'Log leeren');
|
|
return `
|
|
<div class="window-content window-content--desktop-debug">
|
|
<div class="desktop-debug-window" data-debug-window="1">
|
|
<div class="desktop-debug-toolbar">
|
|
<p class="desktop-debug-copy">${debugCopy}</p>
|
|
<div class="desktop-debug-actions">
|
|
<button class="window-app-button desktop-debug-action" type="button" data-debug-action="clear">${clearLabel}</button>
|
|
</div>
|
|
</div>
|
|
<div class="desktop-debug-log" data-debug-log></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (definition.content_mode === 'native-module') {
|
|
const hostId = `${String(definition.app_id || 'module').replace(/[^a-z0-9_-]/gi, '-')}-app`;
|
|
return `
|
|
<div class="window-content window-content--embedded window-content--module">
|
|
<div id="${hostId}" class="window-module-host" data-module-app="${escapeHtml(definition.app_id || 'module')}"></div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (definition.content_mode === 'iframe' && typeof definition.entry_route === 'string' && definition.entry_route !== '') {
|
|
const title = escapeHtml(definition.title);
|
|
const src = escapeHtml(definition.entry_route);
|
|
|
|
return `
|
|
<div class="window-content window-content--embedded">
|
|
<iframe class="window-app-frame" src="${src}" title="${title}"></iframe>
|
|
<div class="window-embed-fallback">
|
|
<p><strong>${title}</strong> wird geladen.</p>
|
|
<p>Falls die Einbettung leer bleibt, direkt oeffnen: <a href="${src}" target="_blank" rel="noopener noreferrer">${src}</a></p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return `
|
|
<div class="window-content">
|
|
<p>${escapeHtml(definition.content)}</p>
|
|
<p><strong>Route:</strong> ${escapeHtml(definition.app_id)}</p>
|
|
</div>
|
|
`;
|
|
};
|
|
|
|
const loadWindowState = () => {
|
|
try {
|
|
const raw = window.localStorage.getItem(storageKey);
|
|
return raw ? JSON.parse(raw) : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
};
|
|
|
|
const saveWindowState = () => {
|
|
const snapshot = {};
|
|
|
|
windows.forEach((record) => {
|
|
snapshot[record.definition.app_id] = {
|
|
layout: record.layout,
|
|
state: record.state,
|
|
};
|
|
});
|
|
|
|
try {
|
|
window.localStorage.setItem(storageKey, JSON.stringify(snapshot));
|
|
} catch {
|
|
// Ignore storage failures for now.
|
|
}
|
|
};
|
|
|
|
const persistedWindowState = loadWindowState();
|
|
const loadIconState = () => {
|
|
try {
|
|
const raw = window.localStorage.getItem(iconStorageKey);
|
|
const parsed = raw ? JSON.parse(raw) : null;
|
|
|
|
if (!parsed || typeof parsed !== 'object') {
|
|
return {
|
|
mode: 'auto',
|
|
positions: {},
|
|
};
|
|
}
|
|
|
|
return {
|
|
mode: parsed.mode === 'manual' ? 'manual' : 'auto',
|
|
positions: typeof parsed.positions === 'object' && parsed.positions !== null ? parsed.positions : {},
|
|
};
|
|
} catch {
|
|
return {
|
|
mode: 'auto',
|
|
positions: {},
|
|
};
|
|
}
|
|
};
|
|
|
|
const persistedIconState = loadIconState();
|
|
const iconState = {
|
|
mode: persistedIconState.mode,
|
|
positions: { ...persistedIconState.positions },
|
|
};
|
|
|
|
const saveIconState = () => {
|
|
try {
|
|
window.localStorage.setItem(iconStorageKey, JSON.stringify(iconState));
|
|
} catch {
|
|
// Ignore storage failures for now.
|
|
}
|
|
};
|
|
|
|
const measureDesktopBounds = () => {
|
|
if (!windowsRoot) {
|
|
return;
|
|
}
|
|
|
|
const taskbar = document.querySelector('.taskbar');
|
|
const systemBar = document.querySelector('.system-bar');
|
|
const stageRect = windowsRoot.getBoundingClientRect();
|
|
const taskbarRect = taskbar ? taskbar.getBoundingClientRect() : null;
|
|
const systemBarRect = systemBar ? systemBar.getBoundingClientRect() : null;
|
|
|
|
topDesktopInset = systemBarRect ? Math.round(systemBarRect.height) : 0;
|
|
sideDesktopInset = 0;
|
|
bottomDesktopInset = taskbarRect
|
|
? Math.max(0, Math.round(stageRect.bottom - taskbarRect.top))
|
|
: 120;
|
|
};
|
|
|
|
const shouldUseDesktopGrid = () => !window.matchMedia('(max-width: 1100px)').matches;
|
|
|
|
const getDesktopStageRect = () => desktopStage instanceof HTMLElement
|
|
? desktopStage.getBoundingClientRect()
|
|
: new DOMRect(0, 0, window.innerWidth, window.innerHeight);
|
|
|
|
const getIconMetrics = () => ({
|
|
width: 148,
|
|
height: 108,
|
|
marginX: 18,
|
|
marginY: 18,
|
|
});
|
|
|
|
const getIconLayoutBounds = () => {
|
|
const stageRect = getDesktopStageRect();
|
|
const { width, height, marginX, marginY } = getIconMetrics();
|
|
const top = topDesktopInset + Math.max(24, marginY);
|
|
const left = Math.max(24, marginX);
|
|
const right = Math.max(24, marginX);
|
|
const bottom = Math.max(24, bottomDesktopInset + 12);
|
|
const availableWidth = Math.max(width, Math.round(stageRect.width - left - right));
|
|
const availableHeight = Math.max(height, Math.round(stageRect.height - top - bottom));
|
|
|
|
return {
|
|
top,
|
|
left,
|
|
right,
|
|
bottom,
|
|
width,
|
|
height,
|
|
availableWidth,
|
|
availableHeight,
|
|
maxX: Math.max(left, Math.round(stageRect.width - right - width)),
|
|
maxY: Math.max(top, Math.round(stageRect.height - bottom - height)),
|
|
};
|
|
};
|
|
|
|
const buildAutoIconPositions = () => {
|
|
const bounds = getIconLayoutBounds();
|
|
const metrics = getIconMetrics();
|
|
const columns = Math.max(1, Math.floor((bounds.availableWidth + metrics.marginX) / (metrics.width + metrics.marginX)));
|
|
const rows = Math.max(1, Math.floor((bounds.availableHeight + metrics.marginY) / (metrics.height + metrics.marginY)));
|
|
const rightAligned = activeSkin === 'apple' || skinProfile.family === 'apple';
|
|
const positions = {};
|
|
|
|
payload.apps.forEach((app, index) => {
|
|
const column = Math.floor(index / rows);
|
|
const row = index % rows;
|
|
const x = rightAligned
|
|
? bounds.maxX - (column * (metrics.width + metrics.marginX))
|
|
: bounds.left + (column * (metrics.width + metrics.marginX));
|
|
const y = bounds.top + (row * (metrics.height + metrics.marginY));
|
|
|
|
positions[app.app_id] = {
|
|
x: Math.max(bounds.left, Math.min(bounds.maxX, x)),
|
|
y: Math.max(bounds.top, Math.min(bounds.maxY, y)),
|
|
};
|
|
});
|
|
|
|
return positions;
|
|
};
|
|
|
|
const clampIconPosition = (position) => {
|
|
const bounds = getIconLayoutBounds();
|
|
|
|
return {
|
|
x: Math.max(bounds.left, Math.min(bounds.maxX, Math.round(position.x))),
|
|
y: Math.max(bounds.top, Math.min(bounds.maxY, Math.round(position.y))),
|
|
};
|
|
};
|
|
|
|
const applyIconPosition = (icon, position) => {
|
|
const next = clampIconPosition(position);
|
|
icon.style.left = `${next.x}px`;
|
|
icon.style.top = `${next.y}px`;
|
|
};
|
|
|
|
const getIconPositionMap = () => {
|
|
if (iconState.mode === 'manual') {
|
|
const autoPositions = buildAutoIconPositions();
|
|
const positions = {};
|
|
|
|
payload.apps.forEach((app) => {
|
|
positions[app.app_id] = clampIconPosition(iconState.positions[app.app_id] || autoPositions[app.app_id] || { x: 24, y: 24 });
|
|
});
|
|
|
|
return positions;
|
|
}
|
|
|
|
return buildAutoIconPositions();
|
|
};
|
|
|
|
const applyStoredIconLayout = () => {
|
|
if (!iconsRoot) {
|
|
return;
|
|
}
|
|
|
|
const icons = Array.from(iconsRoot.querySelectorAll('.desktop-icon'));
|
|
|
|
if (!shouldUseDesktopGrid()) {
|
|
icons.forEach((icon) => {
|
|
icon.style.left = '';
|
|
icon.style.top = '';
|
|
});
|
|
applyDesktopIconContrast();
|
|
return;
|
|
}
|
|
|
|
const positions = getIconPositionMap();
|
|
|
|
icons.forEach((icon) => {
|
|
const appId = icon.dataset.appId;
|
|
const position = appId ? positions[appId] : null;
|
|
|
|
if (!position) {
|
|
return;
|
|
}
|
|
|
|
applyIconPosition(icon, position);
|
|
});
|
|
|
|
applyDesktopIconContrast();
|
|
};
|
|
|
|
const resetIconsToAutoLayout = () => {
|
|
iconState.mode = 'auto';
|
|
iconState.positions = {};
|
|
saveIconState();
|
|
applyStoredIconLayout();
|
|
};
|
|
|
|
const setStartMenuOpen = (isOpen) => {
|
|
if (!startMenu) {
|
|
return;
|
|
}
|
|
|
|
if (isOpen) {
|
|
startMenu.removeAttribute('hidden');
|
|
} else {
|
|
startMenu.setAttribute('hidden', 'hidden');
|
|
}
|
|
|
|
startButton?.setAttribute('aria-expanded', String(isOpen));
|
|
};
|
|
|
|
const ensureDesktopContextMenu = () => {
|
|
if (iconContextMenu || !(desktopStage instanceof HTMLElement)) {
|
|
return iconContextMenu;
|
|
}
|
|
|
|
const menu = document.createElement('div');
|
|
menu.className = 'desktop-context-menu';
|
|
menu.hidden = true;
|
|
menu.innerHTML = `
|
|
<button class="desktop-context-menu-button" type="button" data-action="auto-arrange-icons">
|
|
Symbole automatisch anordnen
|
|
</button>
|
|
`;
|
|
|
|
menu.addEventListener('click', (event) => {
|
|
const action = event.target instanceof Element
|
|
? event.target.closest('.desktop-context-menu-button')?.dataset.action
|
|
: null;
|
|
|
|
if (action === 'auto-arrange-icons') {
|
|
resetIconsToAutoLayout();
|
|
}
|
|
|
|
menu.hidden = true;
|
|
});
|
|
|
|
desktopStage.appendChild(menu);
|
|
iconContextMenu = menu;
|
|
return menu;
|
|
};
|
|
|
|
const closeDesktopContextMenu = () => {
|
|
if (iconContextMenu) {
|
|
iconContextMenu.hidden = true;
|
|
}
|
|
};
|
|
|
|
const openDesktopContextMenu = (clientX, clientY) => {
|
|
const menu = ensureDesktopContextMenu();
|
|
|
|
if (!(menu instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
menu.hidden = false;
|
|
|
|
const menuRect = menu.getBoundingClientRect();
|
|
const maxLeft = Math.max(12, window.innerWidth - menuRect.width - 12);
|
|
const maxTop = Math.max(12, window.innerHeight - menuRect.height - 12);
|
|
|
|
menu.style.left = `${Math.min(clientX, maxLeft)}px`;
|
|
menu.style.top = `${Math.min(clientY, maxTop)}px`;
|
|
};
|
|
|
|
const renderClock = () => {
|
|
const formatted = new Intl.DateTimeFormat('de-DE', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
}).format(new Date());
|
|
|
|
if (clockNode) {
|
|
clockNode.textContent = formatted;
|
|
}
|
|
|
|
if (clockTopNode) {
|
|
clockTopNode.textContent = formatted;
|
|
}
|
|
};
|
|
|
|
const focusWindow = (windowId) => {
|
|
windows.forEach((record) => {
|
|
record.node.classList.remove('is-focused');
|
|
});
|
|
|
|
const record = windows.get(windowId);
|
|
if (!record) {
|
|
return;
|
|
}
|
|
|
|
record.state.minimized = false;
|
|
record.node.classList.remove('is-minimized');
|
|
zIndex += 1;
|
|
record.node.style.zIndex = String(record.state.maximized && activeSkin === 'apple' ? 1000 + zIndex : zIndex);
|
|
record.node.classList.add('is-focused');
|
|
syncTaskbar();
|
|
};
|
|
|
|
const closeStartMenu = () => {
|
|
setStartMenuOpen(false);
|
|
};
|
|
|
|
const openAccountSettings = () => {
|
|
const app = findAppById('user-self-management');
|
|
if (app) {
|
|
openApp(app);
|
|
}
|
|
closeStartMenu();
|
|
setAccountMenuOpen(false);
|
|
};
|
|
|
|
const renderDebugEntries = (container, entries) => {
|
|
if (!(container instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
container.innerHTML = '<div class="desktop-debug-empty">Noch keine Debug-Eintraege.</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = entries.slice().reverse().map((entry) => {
|
|
const meta = [];
|
|
if (entry.source) {
|
|
meta.push(String(entry.source));
|
|
}
|
|
if (entry.method) {
|
|
meta.push(String(entry.method));
|
|
}
|
|
if (entry.status !== undefined && entry.status !== null) {
|
|
meta.push(`HTTP ${entry.status}`);
|
|
}
|
|
if (entry.duration_ms !== undefined && entry.duration_ms !== null) {
|
|
meta.push(`${entry.duration_ms} ms`);
|
|
}
|
|
|
|
const reservedKeys = new Set([
|
|
'id', 'time', 'type', 'source', 'message', 'method', 'url', 'status',
|
|
'ok', 'duration_ms', 'request_id', 'server_time', 'file', 'line', 'column',
|
|
]);
|
|
const context = Object.fromEntries(
|
|
Object.entries(entry).filter(([key]) => !reservedKeys.has(key))
|
|
);
|
|
const contextText = Object.keys(context).length > 0
|
|
? escapeHtml(JSON.stringify(context, null, 2))
|
|
: '';
|
|
|
|
return `
|
|
<article class="desktop-debug-entry">
|
|
<div class="desktop-debug-entry-head">
|
|
<strong>${escapeHtml(entry.type || 'debug')}</strong>
|
|
<span>${escapeHtml(new Date(entry.time || Date.now()).toLocaleTimeString('de-DE'))}</span>
|
|
</div>
|
|
${meta.length ? `<p class="desktop-debug-entry-meta">${escapeHtml(meta.join(' · '))}</p>` : ''}
|
|
${entry.url ? `<p class="desktop-debug-entry-url">${escapeHtml(String(entry.url))}</p>` : ''}
|
|
${entry.message ? `<p class="desktop-debug-entry-message">${escapeHtml(String(entry.message))}</p>` : ''}
|
|
${contextText ? `<pre class="desktop-debug-entry-context">${contextText}</pre>` : ''}
|
|
</article>
|
|
`;
|
|
}).join('');
|
|
container.scrollTop = 0;
|
|
};
|
|
|
|
const initializeDesktopDebugWindow = (record) => {
|
|
const host = record.node.querySelector('[data-debug-window="1"]');
|
|
const logNode = record.node.querySelector('[data-debug-log]');
|
|
const clearButton = record.node.querySelector('[data-debug-action="clear"]');
|
|
|
|
if (!(host instanceof HTMLElement) || !(logNode instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
renderDebugEntries(logNode, debugBus.entries);
|
|
debugBus.subscribe((_entry, entries) => {
|
|
renderDebugEntries(logNode, entries);
|
|
});
|
|
|
|
if (clearButton instanceof HTMLButtonElement) {
|
|
clearButton.addEventListener('click', () => {
|
|
debugBus.clear();
|
|
});
|
|
}
|
|
};
|
|
|
|
const findDebugWindow = () => Array.from(windows.values()).find((record) => record.definition.app_id === debugShellAppId) || null;
|
|
|
|
const closeDebugWindow = () => {
|
|
const existing = findDebugWindow();
|
|
if (existing) {
|
|
closeWindow(existing.id);
|
|
} else {
|
|
debugState.open = false;
|
|
saveDebugState();
|
|
debugBus.setEnabled(false);
|
|
updateDebugToggleUi();
|
|
}
|
|
};
|
|
|
|
const openDebugWindow = () => {
|
|
if (!isAdmin) {
|
|
return;
|
|
}
|
|
|
|
const existing = findDebugWindow();
|
|
debugState.open = true;
|
|
saveDebugState();
|
|
debugBus.setEnabled(true);
|
|
updateDebugToggleUi();
|
|
|
|
if (existing) {
|
|
focusWindow(existing.id);
|
|
return;
|
|
}
|
|
|
|
const windowDefaults = debugShellApp && typeof debugShellApp.window === 'object' ? debugShellApp.window : {};
|
|
createWindow({
|
|
window_id: `window-${debugShellAppId}-${Date.now()}`,
|
|
app_id: debugShellAppId,
|
|
title: String(debugShellApp?.title || 'Desktop Debug'),
|
|
x: Number(windowDefaults.x ?? 120),
|
|
y: Number(windowDefaults.y ?? 88),
|
|
width: Number(windowDefaults.width ?? 720),
|
|
height: Number(windowDefaults.height ?? 520),
|
|
content: String(debugShellApp?.content || ''),
|
|
entry_route: String(debugShellApp?.entry_route || ''),
|
|
content_mode: String(debugShellApp?.content_mode || 'desktop-debug'),
|
|
ui: debugShellApp && typeof debugShellApp.ui === 'object' ? debugShellApp.ui : {},
|
|
});
|
|
};
|
|
|
|
const toggleDebugWindow = () => {
|
|
if (debugState.open === true) {
|
|
closeDebugWindow();
|
|
return;
|
|
}
|
|
|
|
openDebugWindow();
|
|
};
|
|
|
|
const applyWindowLayout = (record) => {
|
|
const { x, y, width, height } = record.layout;
|
|
record.node.style.left = `${x}px`;
|
|
record.node.style.top = `${y}px`;
|
|
record.node.style.width = `${width}px`;
|
|
record.node.style.height = `${height}px`;
|
|
};
|
|
|
|
const clampWindowLayout = (record) => {
|
|
const minX = 0;
|
|
const minY = maximizeOverSystemBar ? 0 : topDesktopInset;
|
|
const maxWidth = Math.max(320, window.innerWidth - minX - sideDesktopInset);
|
|
|
|
record.layout.width = Math.max(320, Math.min(record.layout.width, maxWidth));
|
|
record.layout.x = Math.max(minX, Math.min(record.layout.x, window.innerWidth - record.layout.width));
|
|
record.layout.y = Math.max(minY, Math.min(record.layout.y, window.innerHeight - bottomDesktopInset - 220));
|
|
|
|
const maxHeight = Math.max(220, window.innerHeight - bottomDesktopInset - record.layout.y);
|
|
record.layout.height = Math.max(220, Math.min(record.layout.height, maxHeight));
|
|
record.layout.y = Math.max(minY, Math.min(record.layout.y, window.innerHeight - bottomDesktopInset - record.layout.height));
|
|
};
|
|
|
|
const toggleMinimize = (windowId) => {
|
|
const record = windows.get(windowId);
|
|
if (!record) {
|
|
return;
|
|
}
|
|
|
|
record.state.minimized = !record.state.minimized;
|
|
record.node.classList.toggle('is-minimized', record.state.minimized);
|
|
|
|
if (!record.state.minimized) {
|
|
focusWindow(windowId);
|
|
} else {
|
|
syncTaskbar();
|
|
}
|
|
|
|
saveWindowState();
|
|
};
|
|
|
|
const toggleMaximize = (windowId) => {
|
|
const record = windows.get(windowId);
|
|
if (!record) {
|
|
return;
|
|
}
|
|
|
|
if (!record.state.maximized) {
|
|
record.restoreLayout = { ...record.layout };
|
|
record.state.maximized = true;
|
|
record.node.classList.add('is-maximized');
|
|
measureDesktopBounds();
|
|
const maximizedTopInset = maximizeOverSystemBar ? 0 : topDesktopInset;
|
|
record.node.style.left = `${sideDesktopInset}px`;
|
|
record.node.style.top = `${maximizedTopInset}px`;
|
|
record.node.style.width = sideDesktopInset === 0 ? '100%' : `calc(100% - ${sideDesktopInset * 2}px)`;
|
|
record.node.style.height = maximizeOverSystemBar
|
|
? '100%'
|
|
: `calc(100% - ${maximizedTopInset + bottomDesktopInset}px)`;
|
|
focusWindow(windowId);
|
|
saveWindowState();
|
|
return;
|
|
}
|
|
|
|
record.state.maximized = false;
|
|
record.node.classList.remove('is-maximized');
|
|
if (record.restoreLayout) {
|
|
record.layout = { ...record.restoreLayout };
|
|
clampWindowLayout(record);
|
|
applyWindowLayout(record);
|
|
}
|
|
focusWindow(windowId);
|
|
saveWindowState();
|
|
};
|
|
|
|
const closeWindow = (windowId) => {
|
|
const record = windows.get(windowId);
|
|
if (!record) {
|
|
return;
|
|
}
|
|
|
|
if (record.definition.app_id === debugShellAppId) {
|
|
debugState.open = false;
|
|
saveDebugState();
|
|
debugBus.setEnabled(false);
|
|
updateDebugToggleUi();
|
|
}
|
|
|
|
windows.delete(windowId);
|
|
record.node.remove();
|
|
syncTaskbar();
|
|
saveWindowState();
|
|
};
|
|
|
|
const syncTaskbar = () => {
|
|
if (!taskbarRoot) {
|
|
return;
|
|
}
|
|
|
|
taskbarRoot.innerHTML = '';
|
|
|
|
windows.forEach((record) => {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'taskbar-app-button';
|
|
if (record.state.minimized) {
|
|
button.classList.add('is-minimized');
|
|
} else if (record.node.classList.contains('is-focused')) {
|
|
button.classList.add('is-focused');
|
|
}
|
|
button.innerHTML = buildTaskbarButtonContent(record);
|
|
button.addEventListener('click', () => {
|
|
if (record.state.minimized) {
|
|
toggleMinimize(record.id);
|
|
return;
|
|
}
|
|
|
|
focusWindow(record.id);
|
|
});
|
|
taskbarRoot.appendChild(button);
|
|
});
|
|
};
|
|
|
|
const wireDragging = (record) => {
|
|
const header = record.node.querySelector('.window-header');
|
|
if (!header || window.matchMedia('(max-width: 1100px)').matches) {
|
|
return;
|
|
}
|
|
|
|
let startX = 0;
|
|
let startY = 0;
|
|
let originX = 0;
|
|
let originY = 0;
|
|
let dragging = false;
|
|
|
|
header.addEventListener('pointerdown', (event) => {
|
|
if (record.state.maximized) {
|
|
return;
|
|
}
|
|
|
|
if (event.target instanceof Element && event.target.closest('.window-action')) {
|
|
return;
|
|
}
|
|
|
|
dragging = true;
|
|
startX = event.clientX;
|
|
startY = event.clientY;
|
|
originX = record.layout.x;
|
|
originY = record.layout.y;
|
|
header.setPointerCapture(event.pointerId);
|
|
focusWindow(record.id);
|
|
});
|
|
|
|
header.addEventListener('pointermove', (event) => {
|
|
if (!dragging) {
|
|
return;
|
|
}
|
|
|
|
record.layout.x = originX + event.clientX - startX;
|
|
record.layout.y = originY + event.clientY - startY;
|
|
clampWindowLayout(record);
|
|
applyWindowLayout(record);
|
|
});
|
|
|
|
header.addEventListener('pointerup', () => {
|
|
dragging = false;
|
|
saveWindowState();
|
|
});
|
|
};
|
|
|
|
const wireResizing = (record) => {
|
|
const handles = record.node.querySelectorAll('.window-resize-handle');
|
|
if (!handles.length || window.matchMedia('(max-width: 1100px)').matches) {
|
|
return;
|
|
}
|
|
|
|
handles.forEach((handle) => {
|
|
let startX = 0;
|
|
let startY = 0;
|
|
let originX = 0;
|
|
let originY = 0;
|
|
let originWidth = 0;
|
|
let originHeight = 0;
|
|
let resizing = false;
|
|
const edge = handle.dataset.edge;
|
|
|
|
handle.addEventListener('pointerdown', (event) => {
|
|
if (record.state.maximized) {
|
|
return;
|
|
}
|
|
|
|
resizing = true;
|
|
startX = event.clientX;
|
|
startY = event.clientY;
|
|
originX = record.layout.x;
|
|
originY = record.layout.y;
|
|
originWidth = record.layout.width;
|
|
originHeight = record.layout.height;
|
|
handle.setPointerCapture(event.pointerId);
|
|
focusWindow(record.id);
|
|
event.stopPropagation();
|
|
});
|
|
|
|
handle.addEventListener('pointermove', (event) => {
|
|
if (!resizing) {
|
|
return;
|
|
}
|
|
|
|
const deltaX = event.clientX - startX;
|
|
const deltaY = event.clientY - startY;
|
|
|
|
if (edge === 'right' || edge === 'corner') {
|
|
record.layout.width = originWidth + deltaX;
|
|
}
|
|
|
|
if (edge === 'bottom' || edge === 'corner') {
|
|
record.layout.height = originHeight + deltaY;
|
|
}
|
|
|
|
if (edge === 'left') {
|
|
record.layout.x = originX + deltaX;
|
|
record.layout.width = originWidth - deltaX;
|
|
}
|
|
|
|
if (edge === 'top') {
|
|
record.layout.y = originY + deltaY;
|
|
record.layout.height = originHeight - deltaY;
|
|
}
|
|
|
|
clampWindowLayout(record);
|
|
applyWindowLayout(record);
|
|
});
|
|
|
|
handle.addEventListener('pointerup', () => {
|
|
resizing = false;
|
|
saveWindowState();
|
|
});
|
|
});
|
|
};
|
|
|
|
const wireDesktopIconDragging = (icon, app) => {
|
|
let startX = 0;
|
|
let startY = 0;
|
|
let originX = 0;
|
|
let originY = 0;
|
|
let dragging = false;
|
|
let moved = false;
|
|
|
|
icon.addEventListener('pointerdown', (event) => {
|
|
if (event.button !== 0 || !shouldUseDesktopGrid()) {
|
|
return;
|
|
}
|
|
|
|
startX = event.clientX;
|
|
startY = event.clientY;
|
|
originX = parseFloat(icon.style.left || '0');
|
|
originY = parseFloat(icon.style.top || '0');
|
|
dragging = true;
|
|
moved = false;
|
|
icon.classList.add('is-dragging');
|
|
icon.setPointerCapture(event.pointerId);
|
|
closeDesktopContextMenu();
|
|
});
|
|
|
|
icon.addEventListener('pointermove', (event) => {
|
|
if (!dragging) {
|
|
return;
|
|
}
|
|
|
|
const nextX = originX + (event.clientX - startX);
|
|
const nextY = originY + (event.clientY - startY);
|
|
const distance = Math.abs(event.clientX - startX) + Math.abs(event.clientY - startY);
|
|
|
|
if (distance > 4) {
|
|
moved = true;
|
|
}
|
|
|
|
applyIconPosition(icon, {
|
|
x: nextX,
|
|
y: nextY,
|
|
});
|
|
});
|
|
|
|
icon.addEventListener('pointerup', () => {
|
|
if (!dragging) {
|
|
return;
|
|
}
|
|
|
|
dragging = false;
|
|
icon.classList.remove('is-dragging');
|
|
|
|
if (moved) {
|
|
const nextPosition = clampIconPosition({
|
|
x: parseFloat(icon.style.left || '0'),
|
|
y: parseFloat(icon.style.top || '0'),
|
|
});
|
|
|
|
iconState.mode = 'manual';
|
|
iconState.positions[app.app_id] = nextPosition;
|
|
saveIconState();
|
|
applyIconPosition(icon, nextPosition);
|
|
icon.dataset.suppressClick = 'true';
|
|
applyDesktopIconContrast();
|
|
}
|
|
});
|
|
|
|
icon.addEventListener('pointercancel', () => {
|
|
dragging = false;
|
|
icon.classList.remove('is-dragging');
|
|
});
|
|
|
|
icon.addEventListener('click', (event) => {
|
|
if (icon.dataset.suppressClick === 'true') {
|
|
icon.dataset.suppressClick = 'false';
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
|
|
openApp(app);
|
|
});
|
|
};
|
|
|
|
const createWindow = (definition) => {
|
|
const node = document.createElement('article');
|
|
node.className = 'window';
|
|
node.dataset.windowControls = windowControlsMode;
|
|
node.innerHTML = `
|
|
${buildWindowHeader(definition)}
|
|
${buildWindowBody(definition)}
|
|
<div class="window-resize-handle" data-edge="top" aria-hidden="true"></div>
|
|
<div class="window-resize-handle" data-edge="right" aria-hidden="true"></div>
|
|
<div class="window-resize-handle" data-edge="bottom" aria-hidden="true"></div>
|
|
<div class="window-resize-handle" data-edge="left" aria-hidden="true"></div>
|
|
<div class="window-resize-handle" data-edge="corner" aria-hidden="true"></div>
|
|
`;
|
|
|
|
const persisted = persistedWindowState[definition.app_id] || {};
|
|
const persistedLayout = persisted.layout || {};
|
|
const persistedState = persisted.state || {};
|
|
|
|
const record = {
|
|
id: definition.window_id,
|
|
title: definition.title,
|
|
node,
|
|
definition,
|
|
layout: {
|
|
x: persistedLayout.x ?? definition.x,
|
|
y: persistedLayout.y ?? definition.y,
|
|
width: persistedLayout.width ?? definition.width,
|
|
height: persistedLayout.height ?? definition.height,
|
|
},
|
|
restoreLayout: null,
|
|
state: {
|
|
minimized: Boolean(persistedState.minimized),
|
|
maximized: Boolean(persistedState.maximized),
|
|
},
|
|
};
|
|
|
|
clampWindowLayout(record);
|
|
applyWindowLayout(record);
|
|
if (record.state.minimized) {
|
|
record.node.classList.add('is-minimized');
|
|
}
|
|
if (record.state.maximized) {
|
|
record.node.classList.add('is-maximized');
|
|
measureDesktopBounds();
|
|
const maximizedTopInset = maximizeOverSystemBar ? 0 : topDesktopInset;
|
|
record.node.style.left = `${sideDesktopInset}px`;
|
|
record.node.style.top = `${maximizedTopInset}px`;
|
|
record.node.style.width = sideDesktopInset === 0 ? '100%' : `calc(100% - ${sideDesktopInset * 2}px)`;
|
|
record.node.style.height = maximizeOverSystemBar
|
|
? '100%'
|
|
: `calc(100% - ${maximizedTopInset + bottomDesktopInset}px)`;
|
|
}
|
|
|
|
node.addEventListener('mousedown', () => focusWindow(record.id));
|
|
|
|
const frame = node.querySelector('.window-app-frame');
|
|
const frameFallback = node.querySelector('.window-embed-fallback');
|
|
if (frame && frameFallback) {
|
|
frame.addEventListener('load', () => {
|
|
frameFallback.remove();
|
|
}, { once: true });
|
|
}
|
|
|
|
const moduleHost = node.querySelector('.window-module-host');
|
|
if (moduleHost && definition.content_mode === 'native-module') {
|
|
const nativeModule = definition.native_module || {};
|
|
const initializer = nativeModule.initializer;
|
|
const baseOptions = nativeModule.options && typeof nativeModule.options === 'object' ? nativeModule.options : {};
|
|
const moduleOptions = {
|
|
...baseOptions,
|
|
apiBase: baseOptions.apiBase || settingsApi,
|
|
activeSkin,
|
|
};
|
|
|
|
if (typeof initializer === 'string' && typeof window[initializer] === 'function') {
|
|
window[initializer](moduleHost, moduleOptions);
|
|
} else {
|
|
moduleHost.innerHTML = `<div class="window-module-error">${escapeHtml(definition.title || 'Modul')} konnte nicht initialisiert werden.</div>`;
|
|
}
|
|
}
|
|
|
|
if (definition.content_mode === 'desktop-debug') {
|
|
initializeDesktopDebugWindow(record);
|
|
}
|
|
|
|
node.querySelectorAll('.window-action').forEach((action) => {
|
|
action.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
|
|
const actionName = action.dataset.action;
|
|
if (actionName === 'close') {
|
|
closeWindow(record.id);
|
|
return;
|
|
}
|
|
|
|
if (actionName === 'minimize') {
|
|
toggleMinimize(record.id);
|
|
return;
|
|
}
|
|
|
|
if (actionName === 'maximize') {
|
|
toggleMaximize(record.id);
|
|
}
|
|
});
|
|
});
|
|
|
|
windows.set(record.id, record);
|
|
windowsRoot?.appendChild(node);
|
|
wireDragging(record);
|
|
wireResizing(record);
|
|
if (!record.state.minimized) {
|
|
focusWindow(record.id);
|
|
} else {
|
|
syncTaskbar();
|
|
}
|
|
};
|
|
|
|
const openApp = (app) => {
|
|
const existing = Array.from(windows.values()).find((record) => record.definition.app_id === app.app_id);
|
|
|
|
if (existing) {
|
|
focusWindow(existing.id);
|
|
return;
|
|
}
|
|
|
|
createWindow({
|
|
window_id: `window-${app.app_id}-${Date.now()}`,
|
|
app_id: app.app_id,
|
|
title: app.title,
|
|
x: 64 + Math.round(Math.random() * 80),
|
|
y: 70 + Math.round(Math.random() * 60),
|
|
width: app.default_width || 640,
|
|
height: app.default_height || 420,
|
|
content: app.summary || '',
|
|
entry_route: app.entry_route || '',
|
|
content_mode: app.content_mode || 'summary',
|
|
module_options: app.module_options || null,
|
|
native_module: app.native_module || null,
|
|
});
|
|
};
|
|
|
|
const renderFxRatesWidget = async (card, widget) => {
|
|
const statusNode = card.querySelector('[data-widget-status]');
|
|
const messageNode = card.querySelector('[data-widget-message]');
|
|
const refreshButton = card.querySelector('[data-widget-refresh]');
|
|
const forceButton = card.querySelector('[data-widget-force-refresh]');
|
|
|
|
const loadStatus = async () => {
|
|
if (!(statusNode instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
statusNode.textContent = 'Kursstatus wird geladen ...';
|
|
try {
|
|
const response = await fetch(widget.status_api, {
|
|
credentials: 'same-origin',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
const rows = Array.isArray(payload?.data) ? payload.data : [];
|
|
const latest = rows.slice().sort((left, right) => {
|
|
const leftTs = new Date(left?.fetched_at || 0).getTime();
|
|
const rightTs = new Date(right?.fetched_at || 0).getTime();
|
|
return rightTs - leftTs;
|
|
})[0] || null;
|
|
statusNode.textContent = latest
|
|
? `Letzter Abruf: ${latest.fetched_at_display || latest.fetched_at} · ${latest.base_currency} · ${latest.provider}`
|
|
: 'Noch keine gespeicherten Kurse vorhanden.';
|
|
} catch (_error) {
|
|
statusNode.textContent = 'Kursstatus konnte nicht geladen werden.';
|
|
}
|
|
};
|
|
|
|
const runRefresh = async (force) => {
|
|
if (!(messageNode instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
messageNode.textContent = 'Aktualisierung laeuft ...';
|
|
try {
|
|
const response = await fetch(widget.refresh_api, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
force: force === true,
|
|
trigger_source: 'widget',
|
|
}),
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
throw new Error(payload?.context?.message || payload?.error || `HTTP ${response.status}`);
|
|
}
|
|
const result = payload?.data || {};
|
|
messageNode.textContent = result.reused
|
|
? 'Kein neuer API-Abruf. Der letzte gespeicherte Snapshot ist noch jung genug.'
|
|
: `Kurse aktualisiert. ${result.updated_count || 0} Werte gespeichert.`;
|
|
await loadStatus();
|
|
} catch (error) {
|
|
messageNode.textContent = error instanceof Error ? error.message : 'Aktualisierung fehlgeschlagen.';
|
|
}
|
|
};
|
|
|
|
refreshButton?.addEventListener('click', () => runRefresh(false));
|
|
forceButton?.addEventListener('click', () => {
|
|
const confirmed = window.confirm('Der Kursabruf wird jetzt trotz jungem Snapshot erzwungen. Wirklich fortfahren?');
|
|
if (confirmed) {
|
|
runRefresh(true);
|
|
}
|
|
});
|
|
|
|
await loadStatus();
|
|
};
|
|
|
|
const renderActionPanelWidget = async (card, widget) => {
|
|
const statusNode = card.querySelector('[data-widget-status]');
|
|
const messageNode = card.querySelector('[data-widget-message]');
|
|
const actionButtons = Array.from(card.querySelectorAll('[data-widget-action]'));
|
|
|
|
const loadStatus = async () => {
|
|
if (!(statusNode instanceof HTMLElement) || !widget.status_api) {
|
|
return;
|
|
}
|
|
|
|
statusNode.textContent = 'Status wird geladen ...';
|
|
|
|
try {
|
|
const response = await fetch(widget.status_api, {
|
|
credentials: 'same-origin',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
throw new Error(payload?.message || payload?.error || `HTTP ${response.status}`);
|
|
}
|
|
|
|
const data = payload?.data || {};
|
|
statusNode.textContent = String(data.status_text || data.message || 'Status geladen.');
|
|
} catch (error) {
|
|
statusNode.textContent = error instanceof Error ? error.message : 'Status konnte nicht geladen werden.';
|
|
}
|
|
};
|
|
|
|
const runAction = async (actionConfig) => {
|
|
if (!(messageNode instanceof HTMLElement) || !actionConfig || typeof actionConfig !== 'object') {
|
|
return;
|
|
}
|
|
|
|
const method = String(actionConfig.method || 'POST').toUpperCase();
|
|
const endpoint = String(actionConfig.endpoint || '');
|
|
if (endpoint === '') {
|
|
return;
|
|
}
|
|
|
|
messageNode.textContent = 'Aktion wird ausgefuehrt ...';
|
|
|
|
try {
|
|
const response = await fetch(endpoint, {
|
|
method,
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: method === 'GET'
|
|
? null
|
|
: JSON.stringify(actionConfig.payload && typeof actionConfig.payload === 'object' ? actionConfig.payload : {}),
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
throw new Error(payload?.message || payload?.error || `HTTP ${response.status}`);
|
|
}
|
|
|
|
messageNode.textContent = String(
|
|
payload?.message
|
|
|| payload?.data?.message
|
|
|| payload?.data?.status_text
|
|
|| 'Aktion erfolgreich ausgefuehrt.'
|
|
);
|
|
await loadStatus();
|
|
} catch (error) {
|
|
messageNode.textContent = error instanceof Error ? error.message : 'Aktion fehlgeschlagen.';
|
|
}
|
|
};
|
|
|
|
actionButtons.forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
const actionId = button.getAttribute('data-widget-action') || '';
|
|
const actionConfig = Array.isArray(widget.actions)
|
|
? widget.actions.find((entry) => String(entry?.action_id || '') === actionId)
|
|
: null;
|
|
if (actionConfig) {
|
|
runAction(actionConfig);
|
|
}
|
|
});
|
|
});
|
|
|
|
await loadStatus();
|
|
};
|
|
|
|
const renderWidgets = () => {
|
|
if (!widgetZoneRoot) {
|
|
return;
|
|
}
|
|
|
|
widgetZoneRoot.innerHTML = '';
|
|
|
|
payload.widgets.registry
|
|
.filter((widget) => activeWidgets.has(widget.widget_id))
|
|
.forEach((widget) => {
|
|
const card = document.createElement('article');
|
|
card.className = 'widget-card';
|
|
const actionMarkup = widget.launch_app_id
|
|
? `
|
|
<div class="widget-card-actions">
|
|
<button class="widget-card-action" type="button" data-app-id="${escapeHtml(widget.launch_app_id)}">
|
|
${escapeHtml(widget.action_label || 'Oeffnen')}
|
|
</button>
|
|
</div>
|
|
`
|
|
: '';
|
|
const widgetBodyMarkup = widget.widget_type === 'fx-rates-refresh'
|
|
? `
|
|
<div class="widget-card-status" data-widget-status>Wird geladen ...</div>
|
|
<p class="widget-card-message" data-widget-message>${widget.content || ''}</p>
|
|
<div class="widget-card-actions">
|
|
<button class="widget-card-action" type="button" data-widget-refresh>Kurse aktualisieren</button>
|
|
<button class="widget-card-action widget-card-action--secondary" type="button" data-widget-force-refresh>Force</button>
|
|
${widget.launch_app_id ? `<button class="widget-card-action widget-card-action--secondary" type="button" data-app-id="${escapeHtml(widget.launch_app_id)}">${escapeHtml(widget.action_label || 'Oeffnen')}</button>` : ''}
|
|
</div>
|
|
`
|
|
: widget.widget_type === 'action-panel'
|
|
? `
|
|
<div class="widget-card-status" data-widget-status>${widget.content || 'Status wird geladen ...'}</div>
|
|
<p class="widget-card-message" data-widget-message>${widget.summary || ''}</p>
|
|
<div class="widget-card-actions">
|
|
${(Array.isArray(widget.actions) ? widget.actions : []).map((action) => `
|
|
<button
|
|
class="widget-card-action${action?.variant === 'secondary' ? ' widget-card-action--secondary' : ''}"
|
|
type="button"
|
|
data-widget-action="${escapeHtml(String(action?.action_id || ''))}"
|
|
>${escapeHtml(String(action?.label || 'Aktion'))}</button>
|
|
`).join('')}
|
|
${widget.launch_app_id ? `<button class="widget-card-action widget-card-action--secondary" type="button" data-app-id="${escapeHtml(widget.launch_app_id)}">${escapeHtml(widget.action_label || 'Oeffnen')}</button>` : ''}
|
|
</div>
|
|
`
|
|
: `<p>${widget.content || ''}</p>${actionMarkup}`;
|
|
card.innerHTML = `
|
|
<div class="widget-card-header">
|
|
<div>
|
|
<h3>${widget.title}</h3>
|
|
<p>${widget.summary || ''}</p>
|
|
</div>
|
|
<span class="widget-badge">${widget.icon || 'WG'}</span>
|
|
</div>
|
|
${widgetBodyMarkup}
|
|
`;
|
|
|
|
card.querySelectorAll('[data-app-id]').forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
const appId = button.getAttribute('data-app-id') || '';
|
|
const app = findAppById(appId);
|
|
if (app) {
|
|
openApp(app);
|
|
}
|
|
});
|
|
});
|
|
|
|
widgetZoneRoot.appendChild(card);
|
|
if (widget.widget_type === 'fx-rates-refresh') {
|
|
renderFxRatesWidget(card, widget);
|
|
} else if (widget.widget_type === 'action-panel') {
|
|
renderActionPanelWidget(card, widget);
|
|
}
|
|
});
|
|
};
|
|
|
|
const renderFunctionArea = () => {
|
|
if (!startMenuFunctions) {
|
|
return;
|
|
}
|
|
|
|
startMenuFunctions.querySelectorAll('[data-function-id]').forEach((button) => {
|
|
const isActive = button instanceof HTMLElement && button.dataset.functionId === activeFunctionArea;
|
|
button.classList.toggle('is-active', isActive);
|
|
});
|
|
};
|
|
|
|
const renderSelectionArea = () => {
|
|
if (!startMenuApps) {
|
|
return;
|
|
}
|
|
|
|
if (startMenuSelectionHeader) {
|
|
startMenuSelectionHeader.innerHTML = `
|
|
<div class="start-menu-function is-active start-menu-function--summary">
|
|
<span class="start-menu-function-icon" aria-hidden="true">📁</span>
|
|
<span class="start-menu-function-copy">
|
|
<strong>Programme</strong>
|
|
<small>Installierte Apps</small>
|
|
</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
startMenuApps.innerHTML = '';
|
|
|
|
if (activeFunctionArea !== 'programs') {
|
|
return;
|
|
}
|
|
|
|
const groups = [
|
|
{ id: 'core', label: 'Desktop', description: 'Globale Bereiche und Shell-Funktionen' },
|
|
{ id: 'system_tool', label: 'Systemtools', description: 'Verwaltung und Setup' },
|
|
{ id: 'module', label: 'Module', description: 'Installierte Fachanwendungen' },
|
|
];
|
|
|
|
groups.forEach((group) => {
|
|
const apps = payload.apps.filter((app) => {
|
|
if (app.show_in_start_menu === false) {
|
|
return false;
|
|
}
|
|
|
|
return String(app.app_scope || 'module') === group.id;
|
|
});
|
|
|
|
if (apps.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const section = document.createElement('section');
|
|
section.className = 'start-menu-group';
|
|
section.innerHTML = `
|
|
<header class="start-menu-group-header">
|
|
<strong>${group.label}</strong>
|
|
<small>${group.description}</small>
|
|
</header>
|
|
`;
|
|
|
|
const list = document.createElement('div');
|
|
list.className = 'start-menu-group-list';
|
|
|
|
apps.forEach((app) => {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'start-menu-entry start-menu-entry--app';
|
|
button.innerHTML = `
|
|
<span class="start-menu-entry-badge">${buildAppIconMarkup(app, 'start-menu-app-icon')}</span>
|
|
<span>
|
|
<strong>${app.title}</strong>
|
|
<small>${app.summary || ''}</small>
|
|
</span>
|
|
<span class="start-menu-entry-toggle">Open</span>
|
|
`;
|
|
button.addEventListener('click', () => {
|
|
openApp(app);
|
|
closeStartMenu();
|
|
});
|
|
list.appendChild(button);
|
|
});
|
|
|
|
section.appendChild(list);
|
|
startMenuApps.appendChild(section);
|
|
});
|
|
};
|
|
|
|
const runQuickAction = (actionId) => {
|
|
if (actionId === 'open-user-self-management') {
|
|
const userSetup = findAppById('user-self-management');
|
|
if (userSetup) {
|
|
openApp(userSetup);
|
|
closeStartMenu();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (actionId === 'open-public-dashboard') {
|
|
const publicDashboard = findAppById('public-dashboard');
|
|
if (publicDashboard) {
|
|
openApp(publicDashboard);
|
|
closeStartMenu();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (actionId === 'toggle-all-widgets') {
|
|
const shouldShowAll = activeWidgets.size !== payload.widgets.registry.length;
|
|
|
|
activeWidgets.clear();
|
|
if (shouldShowAll) {
|
|
payload.widgets.registry.forEach((widget) => activeWidgets.add(widget.widget_id));
|
|
}
|
|
|
|
renderWidgets();
|
|
renderStartMenu();
|
|
persistSettingsPatch({
|
|
widgets: {
|
|
active_ids: Array.from(activeWidgets),
|
|
},
|
|
}).catch(() => {
|
|
// Keep current desktop state even if persistence fails.
|
|
});
|
|
}
|
|
};
|
|
|
|
const renderStartMenu = () => {
|
|
if (!startMenuApps) {
|
|
return;
|
|
}
|
|
|
|
renderFunctionArea();
|
|
renderSelectionArea();
|
|
};
|
|
|
|
payload.apps
|
|
.filter((app) => app.show_on_desktop !== false)
|
|
.forEach((app) => {
|
|
const icon = document.createElement('button');
|
|
icon.type = 'button';
|
|
icon.className = 'desktop-icon is-contrast-dark';
|
|
icon.dataset.appId = app.app_id;
|
|
icon.innerHTML = `
|
|
<span class="desktop-icon-badge">${buildAppIconMarkup(app, 'desktop-app-icon')}</span>
|
|
<span class="desktop-icon-label">${app.title}</span>
|
|
`;
|
|
wireDesktopIconDragging(icon, app);
|
|
iconsRoot?.appendChild(icon);
|
|
});
|
|
|
|
updateSessionDisplay();
|
|
|
|
desktopStage?.addEventListener('contextmenu', (event) => {
|
|
if (!shouldUseDesktopGrid()) {
|
|
return;
|
|
}
|
|
|
|
if (event.target instanceof Element && event.target.closest('.window, .taskbar, .start-menu, .system-bar')) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
openDesktopContextMenu(event.clientX, event.clientY);
|
|
});
|
|
|
|
document.addEventListener('click', (event) => {
|
|
if (!(event.target instanceof Element) || !event.target.closest('.desktop-context-menu')) {
|
|
closeDesktopContextMenu();
|
|
}
|
|
|
|
if (!(event.target instanceof Element) || !event.target.closest('#start-menu, #start-button')) {
|
|
setStartMenuOpen(false);
|
|
}
|
|
|
|
if (!(event.target instanceof Element) || !event.target.closest('.tray-pill[data-tray-id="account"], #tray-account-menu')) {
|
|
setAccountMenuOpen(false);
|
|
}
|
|
|
|
if (!(event.target instanceof Element) || !event.target.closest('.tray-pill[data-tray-app-id="fx-rates"], #tray-fx-menu')) {
|
|
setFxTrayMenuOpen(false);
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Escape') {
|
|
closeDesktopContextMenu();
|
|
setAccountMenuOpen(false);
|
|
setFxTrayMenuOpen(false);
|
|
}
|
|
});
|
|
|
|
startButton?.setAttribute('aria-expanded', 'false');
|
|
startButton?.addEventListener('click', () => {
|
|
if (!startMenu || !startButton) {
|
|
return;
|
|
}
|
|
|
|
const isClosed = startMenu.hasAttribute('hidden');
|
|
setStartMenuOpen(isClosed);
|
|
setAccountMenuOpen(false);
|
|
setFxTrayMenuOpen(false);
|
|
});
|
|
|
|
document.querySelector('.tray-pill[data-tray-id="account"]')?.addEventListener('click', () => {
|
|
if (!(accountMenu instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
setStartMenuOpen(false);
|
|
setAccountMenuOpen(accountMenu.hidden);
|
|
setFxTrayMenuOpen(false);
|
|
});
|
|
|
|
document.querySelectorAll('.tray-pill[data-tray-app-id]').forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
const appId = button.getAttribute('data-tray-app-id') || '';
|
|
if (appId === 'fx-rates') {
|
|
setStartMenuOpen(false);
|
|
setAccountMenuOpen(false);
|
|
setFxTrayMenuOpen(fxTrayMenu instanceof HTMLElement ? fxTrayMenu.hidden : false);
|
|
void loadFxTrayStatus();
|
|
return;
|
|
}
|
|
const app = findAppById(appId);
|
|
if (app) {
|
|
openApp(app);
|
|
setStartMenuOpen(false);
|
|
setAccountMenuOpen(false);
|
|
setFxTrayMenuOpen(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
fxTrayMenu?.addEventListener('click', (event) => {
|
|
const target = event.target instanceof Element ? event.target.closest('[data-fx-action]') : null;
|
|
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
const action = target.getAttribute('data-fx-action');
|
|
|
|
if (action === 'refresh') {
|
|
void runFxTrayRefresh(false);
|
|
return;
|
|
}
|
|
|
|
if (action === 'force-refresh') {
|
|
void runFxTrayRefresh(true);
|
|
}
|
|
});
|
|
|
|
giteaStatusNode?.addEventListener('click', () => {
|
|
void loadGiteaDeployStatus();
|
|
});
|
|
|
|
void loadFxTrayStatus();
|
|
void loadGiteaDeployStatus();
|
|
|
|
debugToggleNode?.addEventListener('click', () => {
|
|
toggleDebugWindow();
|
|
});
|
|
|
|
debugToggleTopNode?.addEventListener('click', () => {
|
|
toggleDebugWindow();
|
|
});
|
|
|
|
accountMenu?.addEventListener('click', (event) => {
|
|
const target = event.target instanceof Element ? event.target.closest('[data-account-action]') : null;
|
|
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
const action = target.getAttribute('data-account-action');
|
|
|
|
if (action === 'settings') {
|
|
event.preventDefault();
|
|
openAccountSettings();
|
|
return;
|
|
}
|
|
|
|
if (action === 'login') {
|
|
event.preventDefault();
|
|
const loginApp = findAppById('desktop-login');
|
|
if (loginApp) {
|
|
openApp(loginApp);
|
|
}
|
|
setAccountMenuOpen(false);
|
|
}
|
|
});
|
|
|
|
startMenuUserCard?.addEventListener('click', () => {
|
|
openAccountSettings();
|
|
});
|
|
|
|
startMenuSessionAction?.addEventListener('click', (event) => {
|
|
const target = event.currentTarget;
|
|
|
|
if (!(target instanceof HTMLElement) || target.dataset.sessionAction !== 'login') {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
const loginApp = findAppById('desktop-login');
|
|
if (loginApp) {
|
|
openApp(loginApp);
|
|
}
|
|
closeStartMenu();
|
|
});
|
|
|
|
startMenuFunctions?.addEventListener('click', (event) => {
|
|
const target = event.target instanceof Element ? event.target.closest('[data-function-id]') : null;
|
|
|
|
if (!(target instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
activeFunctionArea = target.dataset.functionId || 'programs';
|
|
renderStartMenu();
|
|
});
|
|
|
|
startMenuFunctions?.addEventListener('mouseover', (event) => {
|
|
const target = event.target instanceof Element ? event.target.closest('[data-function-id]') : null;
|
|
|
|
if (!(target instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const nextFunctionArea = target.dataset.functionId || 'programs';
|
|
if (nextFunctionArea === activeFunctionArea) {
|
|
return;
|
|
}
|
|
|
|
activeFunctionArea = nextFunctionArea;
|
|
renderStartMenu();
|
|
});
|
|
|
|
window.addEventListener('desktop:user-settings-updated', (event) => {
|
|
const preferences = event.detail?.preferences;
|
|
const requiresReload = Boolean(event.detail?.requiresReload);
|
|
|
|
if (preferences && typeof preferences === 'object') {
|
|
currentUserPreferences = preferences;
|
|
|
|
const nextWidgetIds = Array.isArray(preferences.widgets?.active_ids)
|
|
? preferences.widgets.active_ids
|
|
: [];
|
|
|
|
activeWidgets.clear();
|
|
nextWidgetIds.forEach((widgetId) => activeWidgets.add(widgetId));
|
|
renderWidgets();
|
|
updateSessionDisplay();
|
|
renderStartMenu();
|
|
}
|
|
|
|
if (requiresReload) {
|
|
const nextSkin = preferences?.desktop?.active_skin || activeSkin;
|
|
const nextUrl = `${window.location.pathname}?skin=${encodeURIComponent(nextSkin)}`;
|
|
window.location.assign(nextUrl);
|
|
}
|
|
});
|
|
|
|
window.addEventListener('desktop:open-app', (event) => {
|
|
const appId = typeof event.detail?.appId === 'string' ? event.detail.appId : '';
|
|
if (!appId) {
|
|
return;
|
|
}
|
|
|
|
const app = findAppById(appId);
|
|
if (app) {
|
|
openApp(app);
|
|
}
|
|
});
|
|
|
|
renderWidgets();
|
|
renderStartMenu();
|
|
updateDebugToggleUi();
|
|
setStartMenuOpen(false);
|
|
measureDesktopBounds();
|
|
applyStoredIconLayout();
|
|
if (isAdmin && debugState.open === true) {
|
|
openDebugWindow();
|
|
}
|
|
payload.windows.forEach(createWindow);
|
|
renderClock();
|
|
window.setInterval(renderClock, 1000);
|
|
window.addEventListener('resize', () => {
|
|
measureDesktopBounds();
|
|
|
|
windows.forEach((record) => {
|
|
clampWindowLayout(record);
|
|
|
|
if (record.state.maximized) {
|
|
const maximizedTopInset = maximizeOverSystemBar ? 0 : topDesktopInset;
|
|
record.node.style.left = `${sideDesktopInset}px`;
|
|
record.node.style.top = `${maximizedTopInset}px`;
|
|
record.node.style.width = sideDesktopInset === 0 ? '100%' : `calc(100% - ${sideDesktopInset * 2}px)`;
|
|
record.node.style.height = maximizeOverSystemBar
|
|
? '100%'
|
|
: `calc(100% - ${maximizedTopInset + bottomDesktopInset}px)`;
|
|
return;
|
|
}
|
|
|
|
applyWindowLayout(record);
|
|
});
|
|
|
|
saveWindowState();
|
|
applyStoredIconLayout();
|
|
closeDesktopContextMenu();
|
|
});
|
|
|
|
window.refreshDesktopIconContrast = applyDesktopIconContrast;
|
|
window.desktopShell = {
|
|
openAppById: (appId) => {
|
|
const app = findAppById(appId);
|
|
if (app) {
|
|
openApp(app);
|
|
}
|
|
},
|
|
persistSettingsPatch,
|
|
dispatchUserSettingsUpdate,
|
|
};
|
|
}
|