Files
desktop/public/assets/desktop/desktop.js
Lars Gebhardt-Kusche 30fe39cd6c
All checks were successful
Deploy / deploy-staging (push) Successful in 25s
Deploy / deploy-production (push) Has been skipped
sdasd
2026-06-25 01:19:00 +02:00

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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
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>&times;</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,
};
}