2026-03-05 22:30:37 +02:00
|
|
|
(() => {
|
|
|
|
|
const THEME_KEY = 'scrumPoker.ui.theme';
|
|
|
|
|
const MODE_KEY = 'scrumPoker.ui.mode';
|
2026-03-06 11:07:13 +02:00
|
|
|
const WINDOW_LAYOUTS_KEY = 'scrumPoker.ui.windowLayouts.v1';
|
2026-03-05 22:30:37 +02:00
|
|
|
const DEFAULT_THEME = 'win98';
|
2026-03-06 11:07:13 +02:00
|
|
|
const MODE_ICON_LIGHT = '/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png';
|
|
|
|
|
const MODE_ICON_DARK = '/static/img/Windows Icons - PNG/desk.cpl_14_40-6.png';
|
2026-03-06 11:15:39 +02:00
|
|
|
|
2026-03-06 11:07:13 +02:00
|
|
|
let floatingWindowZ = 80;
|
|
|
|
|
let windowLayouts = {};
|
2026-03-06 11:15:39 +02:00
|
|
|
let windowDefs = [];
|
|
|
|
|
let accessState = { admin: false };
|
2026-03-05 22:30:37 +02:00
|
|
|
|
2026-03-06 11:11:08 +02:00
|
|
|
function isMobileViewport() {
|
|
|
|
|
return window.matchMedia('(max-width: 899px)').matches;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 22:30:37 +02:00
|
|
|
function applyTheme(theme) {
|
|
|
|
|
const normalized = theme || DEFAULT_THEME;
|
|
|
|
|
document.documentElement.setAttribute('data-ui-theme', normalized);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyMode(mode) {
|
|
|
|
|
if (mode === 'dark') {
|
|
|
|
|
document.documentElement.setAttribute('data-theme', 'dark');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
document.documentElement.removeAttribute('data-theme');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
function getCurrentTheme() {
|
|
|
|
|
return document.documentElement.getAttribute('data-ui-theme') || DEFAULT_THEME;
|
2026-03-06 11:07:13 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
function getCurrentMode() {
|
|
|
|
|
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
2026-03-06 11:07:13 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
function clamp(value, min, max) {
|
|
|
|
|
return Math.min(max, Math.max(min, value));
|
2026-03-06 11:07:13 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-07 01:07:43 +02:00
|
|
|
function hasOwn(obj, key) {
|
|
|
|
|
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
function parseJSON(value, fallback) {
|
|
|
|
|
if (!value) {
|
|
|
|
|
return fallback;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(value);
|
|
|
|
|
} catch (_err) {
|
|
|
|
|
return fallback;
|
|
|
|
|
}
|
2026-03-06 11:07:13 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
function hasWindowAccess(def) {
|
|
|
|
|
const rights = def.rights || 'all';
|
|
|
|
|
if (rights === 'admin') {
|
|
|
|
|
return Boolean(accessState.admin);
|
|
|
|
|
}
|
|
|
|
|
return true;
|
2026-03-06 11:07:13 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadWindowLayouts() {
|
|
|
|
|
try {
|
|
|
|
|
const raw = localStorage.getItem(WINDOW_LAYOUTS_KEY);
|
|
|
|
|
if (!raw) {
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
const parsed = JSON.parse(raw);
|
2026-03-06 11:15:39 +02:00
|
|
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
2026-03-06 11:07:13 +02:00
|
|
|
} catch (_err) {
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function saveWindowLayouts() {
|
|
|
|
|
localStorage.setItem(WINDOW_LAYOUTS_KEY, JSON.stringify(windowLayouts));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeLayout(raw, defaults) {
|
|
|
|
|
const fallback = defaults || { left: 16, top: 88, width: 360, height: 220 };
|
|
|
|
|
const minWidth = 260;
|
|
|
|
|
const minHeight = 160;
|
|
|
|
|
|
|
|
|
|
const width = Number(raw?.width);
|
|
|
|
|
const height = Number(raw?.height);
|
|
|
|
|
const left = Number(raw?.left);
|
|
|
|
|
const top = Number(raw?.top);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
width: Number.isFinite(width) ? Math.max(minWidth, width) : fallback.width,
|
|
|
|
|
height: Number.isFinite(height) ? Math.max(minHeight, height) : fallback.height,
|
|
|
|
|
left: Number.isFinite(left) ? left : fallback.left,
|
|
|
|
|
top: Number.isFinite(top) ? top : fallback.top,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clampLayoutToViewport(layout) {
|
|
|
|
|
const margin = 8;
|
|
|
|
|
const minWidth = 260;
|
|
|
|
|
const minHeight = 160;
|
|
|
|
|
const maxWidth = Math.max(minWidth, window.innerWidth - margin * 2);
|
|
|
|
|
const maxHeight = Math.max(minHeight, window.innerHeight - margin * 2);
|
|
|
|
|
|
|
|
|
|
const width = clamp(layout.width, minWidth, maxWidth);
|
|
|
|
|
const height = clamp(layout.height, minHeight, maxHeight);
|
|
|
|
|
const left = clamp(layout.left, margin, Math.max(margin, window.innerWidth - width - margin));
|
|
|
|
|
const top = clamp(layout.top, margin, Math.max(margin, window.innerHeight - height - margin));
|
2026-03-06 11:15:39 +02:00
|
|
|
|
2026-03-06 11:07:13 +02:00
|
|
|
return { left, top, width, height };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyLayout(windowEl, layout) {
|
|
|
|
|
windowEl.style.left = `${layout.left}px`;
|
|
|
|
|
windowEl.style.top = `${layout.top}px`;
|
|
|
|
|
windowEl.style.width = `${layout.width}px`;
|
|
|
|
|
windowEl.style.height = `${layout.height}px`;
|
|
|
|
|
windowEl.style.right = 'auto';
|
|
|
|
|
windowEl.style.bottom = 'auto';
|
|
|
|
|
windowEl.style.transform = 'none';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readLayoutFromDOM(windowEl) {
|
|
|
|
|
const rect = windowEl.getBoundingClientRect();
|
|
|
|
|
return {
|
|
|
|
|
left: rect.left,
|
|
|
|
|
top: rect.top,
|
|
|
|
|
width: rect.width,
|
|
|
|
|
height: rect.height,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
function bringWindowToFront(windowEl) {
|
|
|
|
|
floatingWindowZ += 1;
|
|
|
|
|
windowEl.style.zIndex = String(floatingWindowZ);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getDefaultLayout(el, index) {
|
|
|
|
|
const step = 28 * index;
|
|
|
|
|
return normalizeLayout({
|
|
|
|
|
left: Number(el.dataset.windowDefaultLeft) || 16 + step,
|
|
|
|
|
top: Number(el.dataset.windowDefaultTop) || 88 + step,
|
|
|
|
|
width: Number(el.dataset.windowDefaultWidth) || 360,
|
|
|
|
|
height: Number(el.dataset.windowDefaultHeight) || 220,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:07:13 +02:00
|
|
|
function persistWindowLayout(windowEl) {
|
|
|
|
|
const id = windowEl.id;
|
2026-03-06 11:15:39 +02:00
|
|
|
if (!id || isMobileViewport()) {
|
2026-03-06 11:07:13 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-03-06 11:15:39 +02:00
|
|
|
windowLayouts[id] = clampLayoutToViewport(readLayoutFromDOM(windowEl));
|
2026-03-06 11:07:13 +02:00
|
|
|
saveWindowLayouts();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 01:07:43 +02:00
|
|
|
function measureLayoutFromContent(def) {
|
|
|
|
|
const windowEl = def.el;
|
|
|
|
|
const fallback = def.defaultLayout;
|
|
|
|
|
const wasHidden = windowEl.classList.contains('hidden');
|
|
|
|
|
const previousStyles = {
|
|
|
|
|
visibility: windowEl.style.visibility,
|
|
|
|
|
left: windowEl.style.left,
|
|
|
|
|
top: windowEl.style.top,
|
|
|
|
|
width: windowEl.style.width,
|
|
|
|
|
height: windowEl.style.height,
|
|
|
|
|
right: windowEl.style.right,
|
|
|
|
|
bottom: windowEl.style.bottom,
|
|
|
|
|
transform: windowEl.style.transform,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (wasHidden) {
|
|
|
|
|
windowEl.classList.remove('hidden');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
windowEl.style.visibility = 'hidden';
|
|
|
|
|
windowEl.style.left = '-10000px';
|
|
|
|
|
windowEl.style.top = '-10000px';
|
|
|
|
|
windowEl.style.width = 'max-content';
|
|
|
|
|
windowEl.style.height = 'auto';
|
|
|
|
|
windowEl.style.right = 'auto';
|
|
|
|
|
windowEl.style.bottom = 'auto';
|
|
|
|
|
windowEl.style.transform = 'none';
|
|
|
|
|
|
|
|
|
|
const rect = windowEl.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
windowEl.style.visibility = previousStyles.visibility;
|
|
|
|
|
windowEl.style.left = previousStyles.left;
|
|
|
|
|
windowEl.style.top = previousStyles.top;
|
|
|
|
|
windowEl.style.width = previousStyles.width;
|
|
|
|
|
windowEl.style.height = previousStyles.height;
|
|
|
|
|
windowEl.style.right = previousStyles.right;
|
|
|
|
|
windowEl.style.bottom = previousStyles.bottom;
|
|
|
|
|
windowEl.style.transform = previousStyles.transform;
|
|
|
|
|
|
|
|
|
|
if (wasHidden) {
|
|
|
|
|
windowEl.classList.add('hidden');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return normalizeLayout({
|
|
|
|
|
left: fallback.left,
|
|
|
|
|
top: fallback.top,
|
|
|
|
|
width: Number.isFinite(rect.width) && rect.width > 0 ? Math.ceil(rect.width) : fallback.width,
|
|
|
|
|
height: Number.isFinite(rect.height) && rect.height > 0 ? Math.ceil(rect.height) : fallback.height,
|
|
|
|
|
}, fallback);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ensureWindowLayout(def, options = {}) {
|
2026-03-06 11:11:08 +02:00
|
|
|
if (isMobileViewport()) {
|
2026-03-06 11:15:39 +02:00
|
|
|
def.el.style.right = 'auto';
|
|
|
|
|
def.el.style.bottom = 'auto';
|
|
|
|
|
def.el.style.transform = 'none';
|
2026-03-06 11:11:08 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-03-06 11:15:39 +02:00
|
|
|
|
2026-03-07 01:07:43 +02:00
|
|
|
const hasSavedLayout = hasOwn(options, 'hasSavedLayout')
|
|
|
|
|
? options.hasSavedLayout
|
|
|
|
|
: hasOwn(windowLayouts, def.id);
|
|
|
|
|
const normalized = hasSavedLayout
|
|
|
|
|
? normalizeLayout(windowLayouts[def.id], def.defaultLayout)
|
|
|
|
|
: measureLayoutFromContent(def);
|
2026-03-06 11:07:13 +02:00
|
|
|
const clamped = clampLayoutToViewport(normalized);
|
2026-03-06 11:15:39 +02:00
|
|
|
applyLayout(def.el, clamped);
|
|
|
|
|
windowLayouts[def.id] = clamped;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isWindowOpen(id) {
|
|
|
|
|
const def = windowDefs.find((item) => item.id === id);
|
|
|
|
|
return Boolean(def && !def.el.classList.contains('hidden'));
|
2026-03-06 11:07:13 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
function closeWindowById(id) {
|
|
|
|
|
const def = windowDefs.find((item) => item.id === id);
|
|
|
|
|
if (!def) {
|
2026-03-06 11:07:13 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-03-06 11:15:39 +02:00
|
|
|
def.el.classList.add('hidden');
|
2026-03-06 11:07:13 +02:00
|
|
|
syncTaskButtons();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
function openWindowById(id) {
|
|
|
|
|
const def = windowDefs.find((item) => item.id === id);
|
|
|
|
|
if (!def || !hasWindowAccess(def)) {
|
2026-03-06 11:07:13 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-03-06 11:15:39 +02:00
|
|
|
ensureWindowLayout(def);
|
|
|
|
|
def.el.classList.remove('hidden');
|
|
|
|
|
bringWindowToFront(def.el);
|
|
|
|
|
syncTaskButtons();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveWindowIcon(def, theme) {
|
|
|
|
|
return def.icons[theme] || def.icons.default || '/static/img/Windows Icons - PNG/main.cpl_14_109-1.png';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderTaskbarButtons() {
|
|
|
|
|
const theme = getCurrentTheme();
|
|
|
|
|
const visibleDefs = windowDefs
|
|
|
|
|
.filter(hasWindowAccess)
|
|
|
|
|
.sort((a, b) => a.order - b.order);
|
|
|
|
|
|
|
|
|
|
const html = visibleDefs.map((def) => {
|
|
|
|
|
const icon = resolveWindowIcon(def, theme);
|
|
|
|
|
const safeTitle = def.title;
|
|
|
|
|
return [
|
|
|
|
|
`<button class="taskbar-program-btn" type="button" data-role="open-window" data-target="${def.id}" aria-label="Open ${safeTitle}">`,
|
|
|
|
|
`<img class="taskbar-icon" src="${icon}" alt="">`,
|
|
|
|
|
`<span>${safeTitle}</span>`,
|
|
|
|
|
'</button>',
|
|
|
|
|
].join('');
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('[data-role="taskbar-program-list"]').forEach((container) => {
|
|
|
|
|
container.innerHTML = html;
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-06 11:07:13 +02:00
|
|
|
syncTaskButtons();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
function syncTaskButtons() {
|
|
|
|
|
document.querySelectorAll('[data-role="open-window"]').forEach((button) => {
|
|
|
|
|
const target = button.dataset.target;
|
|
|
|
|
button.classList.toggle('is-active', isWindowOpen(target));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function syncControls() {
|
|
|
|
|
const theme = getCurrentTheme();
|
|
|
|
|
const mode = getCurrentMode();
|
|
|
|
|
const modeLabel = mode === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode';
|
|
|
|
|
const modeIcon = mode === 'dark' ? MODE_ICON_DARK : MODE_ICON_LIGHT;
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('[data-role="theme-option"]').forEach((button) => {
|
|
|
|
|
const selected = button.dataset.theme === theme;
|
|
|
|
|
button.classList.toggle('is-selected', selected);
|
|
|
|
|
button.setAttribute('aria-pressed', selected ? 'true' : 'false');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('[data-role="mode-toggle-label"]').forEach((el) => {
|
|
|
|
|
el.textContent = modeLabel;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('[data-role="mode-icon"]').forEach((el) => {
|
|
|
|
|
el.src = modeIcon;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('#mode-status-text').forEach((el) => {
|
|
|
|
|
el.textContent = `Current mode: ${mode === 'dark' ? 'Dark' : 'Light'}`;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
renderTaskbarButtons();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectWindowDefinitions() {
|
|
|
|
|
const windows = Array.from(document.querySelectorAll('[data-ui-window]'));
|
|
|
|
|
windowDefs = windows.map((el, index) => ({
|
|
|
|
|
id: el.id,
|
|
|
|
|
el,
|
|
|
|
|
title: el.dataset.windowTitle || el.id,
|
|
|
|
|
rights: el.dataset.windowRights || 'all',
|
|
|
|
|
order: Number(el.dataset.windowOrder) || (index + 1) * 10,
|
|
|
|
|
icons: parseJSON(el.dataset.windowIcons, {}),
|
|
|
|
|
defaultLayout: getDefaultLayout(el, index),
|
|
|
|
|
})).filter((def) => Boolean(def.id));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setWindowAccess(nextAccess) {
|
|
|
|
|
accessState = {
|
|
|
|
|
...accessState,
|
|
|
|
|
...(nextAccess || {}),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
windowDefs.forEach((def) => {
|
|
|
|
|
if (!hasWindowAccess(def)) {
|
|
|
|
|
def.el.classList.add('hidden');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
syncControls();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function initWindowLayouts() {
|
|
|
|
|
windowLayouts = loadWindowLayouts();
|
2026-03-07 01:07:43 +02:00
|
|
|
let shouldPersistSeededLayouts = false;
|
2026-03-06 11:15:39 +02:00
|
|
|
|
|
|
|
|
windowDefs.forEach((def) => {
|
2026-03-07 01:07:43 +02:00
|
|
|
const hasSavedLayout = hasOwn(windowLayouts, def.id);
|
|
|
|
|
ensureWindowLayout(def, { hasSavedLayout });
|
|
|
|
|
if (!hasSavedLayout) {
|
|
|
|
|
shouldPersistSeededLayouts = true;
|
|
|
|
|
}
|
2026-03-06 11:15:39 +02:00
|
|
|
});
|
|
|
|
|
|
2026-03-07 01:07:43 +02:00
|
|
|
if (shouldPersistSeededLayouts) {
|
|
|
|
|
saveWindowLayouts();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
window.addEventListener('resize', () => {
|
|
|
|
|
if (isMobileViewport()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
windowDefs.forEach((def) => {
|
|
|
|
|
const normalized = normalizeLayout(windowLayouts[def.id], def.defaultLayout);
|
|
|
|
|
const clamped = clampLayoutToViewport(normalized);
|
|
|
|
|
applyLayout(def.el, clamped);
|
|
|
|
|
windowLayouts[def.id] = clamped;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
saveWindowLayouts();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:07:13 +02:00
|
|
|
function initDraggableWindows() {
|
|
|
|
|
let dragState = null;
|
|
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
document.addEventListener('pointerdown', (event) => {
|
|
|
|
|
const handle = event.target.closest('[data-role="drag-handle"]');
|
|
|
|
|
if (!handle || event.button !== 0 || event.target.closest('button')) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (isMobileViewport()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-06 11:07:13 +02:00
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
const windowEl = handle.closest('.ui-tool-window[data-ui-window]');
|
|
|
|
|
if (!windowEl || windowEl.classList.contains('hidden')) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-06 11:07:13 +02:00
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
bringWindowToFront(windowEl);
|
|
|
|
|
const rect = windowEl.getBoundingClientRect();
|
|
|
|
|
windowEl.style.left = `${rect.left}px`;
|
|
|
|
|
windowEl.style.top = `${rect.top}px`;
|
|
|
|
|
windowEl.style.right = 'auto';
|
|
|
|
|
windowEl.style.bottom = 'auto';
|
|
|
|
|
windowEl.style.transform = 'none';
|
|
|
|
|
|
|
|
|
|
dragState = {
|
|
|
|
|
pointerId: event.pointerId,
|
|
|
|
|
windowEl,
|
|
|
|
|
offsetX: event.clientX - rect.left,
|
|
|
|
|
offsetY: event.clientY - rect.top,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
document.body.classList.add('is-dragging-window');
|
|
|
|
|
handle.setPointerCapture(event.pointerId);
|
|
|
|
|
event.preventDefault();
|
2026-03-06 11:07:13 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
window.addEventListener('pointermove', (event) => {
|
|
|
|
|
if (!dragState || dragState.pointerId !== event.pointerId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const windowEl = dragState.windowEl;
|
|
|
|
|
const maxLeft = Math.max(0, window.innerWidth - windowEl.offsetWidth);
|
|
|
|
|
const maxTop = Math.max(0, window.innerHeight - windowEl.offsetHeight);
|
|
|
|
|
const nextLeft = clamp(event.clientX - dragState.offsetX, 0, maxLeft);
|
|
|
|
|
const nextTop = clamp(event.clientY - dragState.offsetY, 0, maxTop);
|
|
|
|
|
|
|
|
|
|
windowEl.style.left = `${nextLeft}px`;
|
|
|
|
|
windowEl.style.top = `${nextTop}px`;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function finishDrag(event) {
|
|
|
|
|
if (!dragState || dragState.pointerId !== event.pointerId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
persistWindowLayout(dragState.windowEl);
|
|
|
|
|
dragState = null;
|
|
|
|
|
document.body.classList.remove('is-dragging-window');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.addEventListener('pointerup', finishDrag);
|
|
|
|
|
window.addEventListener('pointercancel', finishDrag);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function initResizableWindows() {
|
|
|
|
|
if (typeof ResizeObserver !== 'function') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const observer = new ResizeObserver((entries) => {
|
|
|
|
|
entries.forEach((entry) => {
|
|
|
|
|
const windowEl = entry.target;
|
2026-03-06 11:15:39 +02:00
|
|
|
if (windowEl.classList.contains('hidden') || isMobileViewport()) {
|
2026-03-06 11:11:08 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-03-06 11:07:13 +02:00
|
|
|
persistWindowLayout(windowEl);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
windowDefs.forEach((def) => observer.observe(def.el));
|
2026-03-06 11:07:13 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
function initTaskbarAndWindowEvents() {
|
|
|
|
|
document.addEventListener('click', (event) => {
|
|
|
|
|
const openBtn = event.target.closest('[data-role="open-window"]');
|
|
|
|
|
if (openBtn) {
|
|
|
|
|
const target = openBtn.dataset.target;
|
|
|
|
|
if (!target) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (isWindowOpen(target)) {
|
|
|
|
|
closeWindowById(target);
|
|
|
|
|
} else {
|
|
|
|
|
openWindowById(target);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-06 11:07:13 +02:00
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
const closeBtn = event.target.closest('[data-role="close-window"]');
|
|
|
|
|
if (!closeBtn) {
|
2026-03-06 11:11:08 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-03-06 11:15:39 +02:00
|
|
|
|
|
|
|
|
const target = closeBtn.dataset.target || closeBtn.closest('.ui-tool-window')?.id;
|
|
|
|
|
if (target) {
|
|
|
|
|
closeWindowById(target);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
windowDefs.forEach((def) => {
|
|
|
|
|
def.el.addEventListener('pointerdown', () => {
|
|
|
|
|
if (def.el.classList.contains('hidden')) {
|
2026-03-06 11:07:13 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-03-06 11:15:39 +02:00
|
|
|
bringWindowToFront(def.el);
|
2026-03-06 11:07:13 +02:00
|
|
|
});
|
2026-03-05 22:30:37 +02:00
|
|
|
});
|
|
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
window.addEventListener('keydown', (event) => {
|
|
|
|
|
if (event.key !== 'Escape') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
windowDefs.forEach((def) => {
|
|
|
|
|
def.el.classList.add('hidden');
|
|
|
|
|
});
|
|
|
|
|
syncTaskButtons();
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-05 22:30:37 +02:00
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
function initThemeAndModeHandlers() {
|
2026-03-06 11:07:13 +02:00
|
|
|
document.querySelectorAll('[data-role="theme-option"]').forEach((button) => {
|
|
|
|
|
button.addEventListener('click', () => {
|
|
|
|
|
const value = button.dataset.theme || DEFAULT_THEME;
|
2026-03-05 22:30:37 +02:00
|
|
|
localStorage.setItem(THEME_KEY, value);
|
|
|
|
|
applyTheme(value);
|
|
|
|
|
syncControls();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-06 11:07:13 +02:00
|
|
|
document.querySelectorAll('[data-role="mode-toggle-action"]').forEach((button) => {
|
2026-03-05 22:30:37 +02:00
|
|
|
button.addEventListener('click', () => {
|
|
|
|
|
const next = getCurrentMode() === 'dark' ? 'light' : 'dark';
|
|
|
|
|
localStorage.setItem(MODE_KEY, next);
|
|
|
|
|
applyMode(next);
|
|
|
|
|
syncControls();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-03-06 11:15:39 +02:00
|
|
|
}
|
2026-03-06 11:07:13 +02:00
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
function initUIControls() {
|
|
|
|
|
if (window.__uiControlsInitialized) {
|
|
|
|
|
syncControls();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
window.__uiControlsInitialized = true;
|
2026-03-06 11:07:13 +02:00
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
const savedTheme = localStorage.getItem(THEME_KEY) || DEFAULT_THEME;
|
|
|
|
|
const savedMode = localStorage.getItem(MODE_KEY) || 'light';
|
|
|
|
|
applyTheme(savedTheme);
|
|
|
|
|
applyMode(savedMode);
|
2026-03-06 11:07:13 +02:00
|
|
|
|
2026-03-06 11:15:39 +02:00
|
|
|
collectWindowDefinitions();
|
|
|
|
|
initWindowLayouts();
|
|
|
|
|
initTaskbarAndWindowEvents();
|
|
|
|
|
initThemeAndModeHandlers();
|
2026-03-06 11:07:13 +02:00
|
|
|
initDraggableWindows();
|
|
|
|
|
initResizableWindows();
|
2026-03-06 11:15:39 +02:00
|
|
|
syncControls();
|
2026-03-05 22:30:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.initUIControls = initUIControls;
|
2026-03-06 11:15:39 +02:00
|
|
|
window.isUIWindowOpen = isWindowOpen;
|
|
|
|
|
window.openUIWindow = openWindowById;
|
|
|
|
|
window.closeUIWindow = closeWindowById;
|
|
|
|
|
window.setUIWindowAccess = setWindowAccess;
|
2026-03-05 22:30:37 +02:00
|
|
|
|
|
|
|
|
if (document.readyState === 'loading') {
|
|
|
|
|
document.addEventListener('DOMContentLoaded', initUIControls, { once: true });
|
|
|
|
|
} else {
|
|
|
|
|
initUIControls();
|
|
|
|
|
}
|
|
|
|
|
})();
|