Files
desktop/public/assets/desktop/desktop.js
Lars Gebhardt-Kusche 162ec5322f
All checks were successful
Deploy / deploy-staging (push) Successful in 24s
Deploy / deploy-production (push) Has been skipped
csdfsdf
2026-06-20 01:06:46 +02:00

1469 lines
50 KiB
JavaScript

const payloadNode = document.getElementById('desktop-payload');
if (payloadNode) {
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 startMenuFunctions = document.getElementById('start-menu-functions');
const startMenuSettingsButton = document.getElementById('start-menu-settings-button');
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 clockNode = document.getElementById('clock');
const clockTopNode = document.getElementById('clock-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 || '';
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}`;
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;
const escapeHtml = (value) => String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
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 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 === 'native-module' && definition.app_id === 'mining-checker') {
return `
<div class="window-content window-content--embedded window-content--module">
<div id="mining-checker-app" class="window-module-host" data-module-app="mining-checker"></div>
</div>
`;
}
if (definition.content_mode === 'native-module' && definition.app_id === 'user-self-management') {
return `
<div class="window-content window-content--embedded window-content--module">
<div id="user-self-management-app" class="window-module-host" data-module-app="user-self-management"></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);
}
setAccountMenuOpen(false);
};
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 maxWidth = Math.max(320, window.innerWidth - 48);
const maxHeight = Math.max(220, window.innerHeight - bottomDesktopInset - 24);
record.layout.width = Math.max(320, Math.min(record.layout.width, maxWidth));
record.layout.height = Math.max(220, Math.min(record.layout.height, maxHeight));
record.layout.x = Math.max(0, Math.min(record.layout.x, window.innerWidth - record.layout.width));
record.layout.y = Math.max(0, 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;
}
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' && definition.app_id === 'mining-checker') {
const moduleOptions = definition.module_options || {};
if (typeof window.initMiningCheckerApp === 'function') {
window.initMiningCheckerApp(moduleHost, {
apiBase: '/api/mining-checker/index.php/v1',
defaultProjectKey: moduleOptions.default_project_key || 'doge-main',
activeView: moduleOptions.active_view || 'overview',
sections: Array.isArray(moduleOptions.sections) ? moduleOptions.sections : [],
moduleDebugEnabled: false,
});
} else {
moduleHost.innerHTML = '<div class="window-module-error">Mining-Checker Assets konnten nicht initialisiert werden.</div>';
}
}
if (moduleHost && definition.content_mode === 'native-module' && definition.app_id === 'user-self-management') {
if (typeof window.initUserSelfManagementApp === 'function') {
window.initUserSelfManagementApp(moduleHost, {
apiBase: settingsApi,
activeSkin,
});
} else {
moduleHost.innerHTML = '<div class="window-module-error">User Self Management konnte nicht initialisiert werden.</div>';
}
}
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,
});
};
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>
`
: '';
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>
<p>${widget.content || ''}</p>
${actionMarkup}
`;
const actionButton = card.querySelector('.widget-card-action');
if (actionButton instanceof HTMLButtonElement && widget.launch_app_id) {
actionButton.addEventListener('click', () => {
const app = findAppById(widget.launch_app_id);
if (app) {
openApp(app);
}
});
}
widgetZoneRoot.appendChild(card);
});
};
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;
}
startMenuApps.innerHTML = '';
if (activeFunctionArea !== 'programs') {
return;
}
payload.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();
});
startMenuApps.appendChild(button);
});
};
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.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);
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeDesktopContextMenu();
setAccountMenuOpen(false);
}
});
startButton?.setAttribute('aria-expanded', 'false');
startButton?.addEventListener('click', () => {
if (!startMenu || !startButton) {
return;
}
const isClosed = startMenu.hasAttribute('hidden');
setStartMenuOpen(isClosed);
setAccountMenuOpen(false);
});
document.querySelector('.tray-pill[data-tray-id="account"]')?.addEventListener('click', () => {
if (!(accountMenu instanceof HTMLElement)) {
return;
}
setStartMenuOpen(false);
setAccountMenuOpen(accountMenu.hidden);
});
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);
}
});
startMenuSettingsButton?.addEventListener('click', () => {
openAccountSettings();
});
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);
}
});
renderWidgets();
renderStartMenu();
setStartMenuOpen(false);
measureDesktopBounds();
applyStoredIconLayout();
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,
};
}