Updateees

This commit is contained in:
2026-03-06 11:07:13 +02:00
parent f72875bef1
commit f580775cc2
6 changed files with 693 additions and 68 deletions

View File

@@ -16,13 +16,17 @@
</head>
<body data-page="config">
<div class="mobile-control-strip">
<div class="ui-controls">
<select class="theme-picker" data-role="theme-picker" aria-label="Theme picker">
<option value="win98">Win98</option>
<option value="modern">Modern</option>
<option value="none">No Theme</option>
</select>
<button class="btn" type="button" data-role="mode-toggle">Dark Mode</button>
<div class="taskbar-shell">
<div class="taskbar-program-list">
<button class="taskbar-program-btn" type="button" data-role="open-window" data-target="theme-tool-window" aria-label="Open theme picker">
<img class="taskbar-icon" src="/static/img/Windows Icons - PNG/main.cpl_14_109-1.png" alt="">
<span>ThemePicker.exe</span>
</button>
<button class="taskbar-program-btn" type="button" data-role="open-window" data-target="mode-tool-window" aria-label="Open display mode settings">
<img class="taskbar-icon" data-role="mode-icon" src="/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png" alt="">
<span>DisplayMode.exe</span>
</button>
</div>
</div>
</div>
@@ -181,16 +185,62 @@
</div>
</section>
</div>
<section id="theme-tool-window" class="window ui-tool-window hidden" role="dialog" aria-modal="false" aria-labelledby="theme-tool-title">
<div class="title-bar ui-tool-title-bar" data-role="drag-handle">
<span id="theme-tool-title">ThemePicker.exe</span>
<div class="title-bar-controls">
<button type="button" data-role="close-window" data-target="theme-tool-window">×</button>
</div>
</div>
<div class="window-content">
<p class="tool-copy">Pick a visual style for this workspace.</p>
<div class="theme-option-list">
<button type="button" class="btn theme-option-btn" data-role="theme-option" data-theme="win98">
<img class="taskbar-icon" src="/static/img/Windows Icons - PNG/main.cpl_14_100-0.png" alt="">
<span>Win98</span>
</button>
<button type="button" class="btn theme-option-btn" data-role="theme-option" data-theme="modern">
<img class="taskbar-icon" src="/static/img/Windows Icons - PNG/msconfig.exe_14_128-0.png" alt="">
<span>Modern</span>
</button>
<button type="button" class="btn theme-option-btn" data-role="theme-option" data-theme="none">
<img class="taskbar-icon" src="/static/img/Windows Icons - PNG/taskmgr.exe_14_118-1.png" alt="">
<span>No Theme</span>
</button>
</div>
</div>
</section>
<section id="mode-tool-window" class="window ui-tool-window hidden" role="dialog" aria-modal="false" aria-labelledby="mode-tool-title">
<div class="title-bar ui-tool-title-bar" data-role="drag-handle">
<span id="mode-tool-title">DisplayMode.exe</span>
<div class="title-bar-controls">
<button type="button" data-role="close-window" data-target="mode-tool-window">×</button>
</div>
</div>
<div class="window-content">
<p class="tool-copy" id="mode-status-text">Current mode: Light</p>
<button class="btn mode-toggle-btn" type="button" data-role="mode-toggle-action">
<img class="taskbar-icon" data-role="mode-icon" src="/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png" alt="">
<span data-role="mode-toggle-label">Switch to Dark Mode</span>
</button>
</div>
</section>
</main>
<footer class="taskbar desktop-taskbar" aria-label="Desktop taskbar">
<div class="ui-controls">
<select class="theme-picker" data-role="theme-picker" aria-label="Theme picker">
<option value="win98">Win98</option>
<option value="modern">Modern</option>
<option value="none">No Theme</option>
</select>
<button class="btn" type="button" data-role="mode-toggle">Dark Mode</button>
<div class="taskbar-shell">
<div class="taskbar-program-list">
<button class="taskbar-program-btn" type="button" data-role="open-window" data-target="theme-tool-window" aria-label="Open theme picker">
<img class="taskbar-icon" src="/static/img/Windows Icons - PNG/main.cpl_14_109-1.png" alt="">
<span>ThemePicker.exe</span>
</button>
<button class="taskbar-program-btn" type="button" data-role="open-window" data-target="mode-tool-window" aria-label="Open display mode settings">
<img class="taskbar-icon" data-role="mode-icon" src="/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png" alt="">
<span>DisplayMode.exe</span>
</button>
</div>
</div>
</footer>

View File

@@ -16,18 +16,22 @@
</head>
<body data-page="room" data-room-id="{{ .RoomID }}" class="prejoin">
<div class="mobile-control-strip">
<div class="ui-controls">
<select class="theme-picker" data-role="theme-picker" aria-label="Theme picker">
<option value="win98">Win98</option>
<option value="modern">Modern</option>
<option value="none">No Theme</option>
</select>
<button class="btn" type="button" data-role="mode-toggle">Dark Mode</button>
<div class="taskbar-shell">
<div class="taskbar-program-list">
<button class="taskbar-program-btn" type="button" data-role="open-window" data-target="theme-tool-window" aria-label="Open theme picker">
<img class="taskbar-icon" src="/static/img/Windows Icons - PNG/main.cpl_14_109-1.png" alt="">
<span>ThemePicker.exe</span>
</button>
<button class="taskbar-program-btn" type="button" data-role="open-window" data-target="mode-tool-window" aria-label="Open display mode settings">
<img class="taskbar-icon" data-role="mode-icon" src="/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png" alt="">
<span>DisplayMode.exe</span>
</button>
</div>
</div>
</div>
<main id="desktop" class="room-desktop">
<section id="room-skeleton" class="room-grid skeleton-grid" aria-hidden="true">
<section id="room-skeleton" class="room-grid skeleton-grid hidden" aria-hidden="true">
<article class="window room-main-window">
<div class="title-bar"><span>Loading room...</span></div>
<div class="window-content">
@@ -86,16 +90,27 @@
</div>
</article>
<aside class="window side-panel-window">
<div class="title-bar">
<span>Participants & Controls</span>
</div>
<div class="window-content side-panel-content">
<div class="participants-scroll">
<ul id="participant-list" class="participant-list"></ul>
<div class="side-stack">
<aside class="window side-panel-window participants-window">
<div class="title-bar">
<span>Participants</span>
</div>
<div class="window-content side-panel-content">
<div class="participants-scroll">
<ul id="participant-list" class="participant-list"></ul>
</div>
<div class="participants-footer">
<p id="votes-counter" class="status-line">Votes: 0/0</p>
<button type="button" id="change-name-btn" class="btn">Change Name</button>
</div>
</div>
</aside>
<section class="side-controls">
<aside class="window side-panel-window admin-window">
<div class="title-bar">
<span>Admin Controls</span>
</div>
<div class="window-content side-panel-content">
<div class="links-block">
<label for="share-link">Share Link</label>
<div class="share-link-row">
@@ -111,10 +126,10 @@
<button type="button" id="reset-btn" class="btn">Reset</button>
<button type="button" id="terminal-btn" class="btn">Terminal</button>
</div>
<p id="room-status" class="status-line">Waiting for join...</p>
</section>
</div>
</aside>
<p id="room-message" class="status-line">Waiting for join...</p>
</div>
</aside>
</div>
</section>
<section id="join-panel" class="window join-window" aria-label="Join room">
@@ -163,16 +178,62 @@
</div>
</section>
</div>
<section id="theme-tool-window" class="window ui-tool-window hidden" role="dialog" aria-modal="false" aria-labelledby="theme-tool-title">
<div class="title-bar ui-tool-title-bar" data-role="drag-handle">
<span id="theme-tool-title">ThemePicker.exe</span>
<div class="title-bar-controls">
<button type="button" data-role="close-window" data-target="theme-tool-window">×</button>
</div>
</div>
<div class="window-content">
<p class="tool-copy">Pick a visual style for this workspace.</p>
<div class="theme-option-list">
<button type="button" class="btn theme-option-btn" data-role="theme-option" data-theme="win98">
<img class="taskbar-icon" src="/static/img/Windows Icons - PNG/main.cpl_14_100-0.png" alt="">
<span>Win98</span>
</button>
<button type="button" class="btn theme-option-btn" data-role="theme-option" data-theme="modern">
<img class="taskbar-icon" src="/static/img/Windows Icons - PNG/msconfig.exe_14_128-0.png" alt="">
<span>Modern</span>
</button>
<button type="button" class="btn theme-option-btn" data-role="theme-option" data-theme="none">
<img class="taskbar-icon" src="/static/img/Windows Icons - PNG/taskmgr.exe_14_118-1.png" alt="">
<span>No Theme</span>
</button>
</div>
</div>
</section>
<section id="mode-tool-window" class="window ui-tool-window hidden" role="dialog" aria-modal="false" aria-labelledby="mode-tool-title">
<div class="title-bar ui-tool-title-bar" data-role="drag-handle">
<span id="mode-tool-title">DisplayMode.exe</span>
<div class="title-bar-controls">
<button type="button" data-role="close-window" data-target="mode-tool-window">×</button>
</div>
</div>
<div class="window-content">
<p class="tool-copy" id="mode-status-text">Current mode: Light</p>
<button class="btn mode-toggle-btn" type="button" data-role="mode-toggle-action">
<img class="taskbar-icon" data-role="mode-icon" src="/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png" alt="">
<span data-role="mode-toggle-label">Switch to Dark Mode</span>
</button>
</div>
</section>
</main>
<footer class="taskbar desktop-taskbar" aria-label="Desktop taskbar">
<div class="ui-controls">
<select class="theme-picker" data-role="theme-picker" aria-label="Theme picker">
<option value="win98">Win98</option>
<option value="modern">Modern</option>
<option value="none">No Theme</option>
</select>
<button class="btn" type="button" data-role="mode-toggle">Dark Mode</button>
<div class="taskbar-shell">
<div class="taskbar-program-list">
<button class="taskbar-program-btn" type="button" data-role="open-window" data-target="theme-tool-window" aria-label="Open theme picker">
<img class="taskbar-icon" src="/static/img/Windows Icons - PNG/main.cpl_14_109-1.png" alt="">
<span>ThemePicker.exe</span>
</button>
<button class="taskbar-program-btn" type="button" data-role="open-window" data-target="mode-tool-window" aria-label="Open display mode settings">
<img class="taskbar-icon" data-role="mode-icon" src="/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png" alt="">
<span>DisplayMode.exe</span>
</button>
</div>
</div>
</footer>

View File

@@ -18,23 +18,112 @@
left: 0;
right: 0;
z-index: 50;
padding: 0.45rem 0.55rem;
padding: 0.25rem 0.45rem;
}
.ui-controls {
.taskbar-shell {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
min-height: 2.15rem;
}
.theme-picker {
min-width: 9rem;
.taskbar-program-list {
display: flex;
align-items: center;
gap: 0.3rem;
}
.taskbar-program-btn {
min-height: 1.8rem;
max-width: min(14rem, 42vw);
padding: 0.18rem 0.5rem 0.18rem 0.34rem;
border: var(--control-border-width) solid var(--border-outer);
background: var(--surface-control);
color: var(--text-primary);
box-shadow: var(--button-shadow);
display: inline-flex;
align-items: center;
gap: 0.38rem;
cursor: pointer;
overflow: hidden;
}
.taskbar-program-btn.is-active {
box-shadow: var(--button-shadow-active);
transform: translateY(1px);
}
.taskbar-program-btn span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.taskbar-icon {
width: 1rem;
height: 1rem;
flex: 0 0 auto;
image-rendering: pixelated;
}
.desktop-taskbar {
display: none;
}
.ui-tool-window {
position: fixed;
z-index: 80;
top: 6.2rem;
left: 1rem;
width: min(24rem, calc(100vw - 2rem));
height: 14rem;
min-width: 16.25rem;
min-height: 10rem;
resize: both;
overflow: auto;
max-width: calc(100vw - 1rem);
max-height: calc(100vh - 1rem);
}
.ui-tool-title-bar {
cursor: grab;
}
.ui-tool-window .title-bar-controls button {
cursor: pointer;
}
.tool-copy {
margin-bottom: 0.45rem;
}
.theme-option-list {
display: grid;
gap: 0.35rem;
}
.theme-option-btn,
.mode-toggle-btn {
justify-content: flex-start;
align-items: center;
gap: 0.4rem;
width: 100%;
}
.theme-option-btn.is-selected {
outline: var(--selected-outline);
outline-offset: -0.28rem;
}
body.is-dragging-window {
user-select: none;
}
body.is-dragging-window .ui-tool-title-bar {
cursor: grabbing;
}
.config-window {
width: min(78rem, 100%);
}
@@ -262,11 +351,25 @@
grid-template-columns: 2fr 1fr;
}
.room-main-window,
.side-panel-window {
.room-main-window {
min-height: 40rem;
}
.side-stack {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
}
.participants-window {
min-height: 24rem;
}
.admin-window {
min-height: 15rem;
}
.room-meta {
display: flex;
justify-content: space-between;
@@ -327,6 +430,14 @@
overflow-y: auto;
}
.participants-footer {
margin-top: 0.5rem;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.35rem;
align-items: center;
}
.participant-item {
display: flex;
justify-content: space-between;
@@ -365,7 +476,8 @@
min-width: 0;
}
#room-status {
#votes-counter,
#room-message {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
@@ -449,6 +561,16 @@
padding: 0.3rem 0.55rem;
}
.desktop-taskbar .taskbar-program-btn {
min-width: 11.2rem;
max-width: 15rem;
}
.ui-tool-window {
top: 4.25rem;
left: 1rem;
}
#desktop {
padding: 1rem 1rem 3.4rem;
}
@@ -461,7 +583,8 @@
}
.room-main-window,
.side-panel-window {
.participants-window,
.admin-window {
min-height: unset;
}
}
@@ -472,6 +595,17 @@
padding-top: 3.65rem;
}
.taskbar-program-btn span {
display: none;
}
.taskbar-program-btn {
max-width: none;
width: 2rem;
justify-content: center;
padding: 0.2rem;
}
.field-row {
grid-template-columns: 1fr;
}

View File

@@ -18,9 +18,22 @@ body {
.mobile-control-strip,
.taskbar {
background: var(--surface-window);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--surface-window) 87%, #ffffff 13%) 0%,
color-mix(in srgb, var(--surface-window) 92%, #000000 8%) 100%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.45),
0 -1px 0 rgba(0, 0, 0, 0.35);
}
.mobile-control-strip {
border-bottom: var(--window-border-width) solid var(--border-outer);
}
.taskbar {
border-top: var(--window-border-width) solid var(--border-outer);
box-shadow: var(--window-shadow);
}
.taskbar {

View File

@@ -19,7 +19,9 @@ const resetBtn = document.getElementById('reset-btn');
const terminalBtn = document.getElementById('terminal-btn');
const shareLinkInput = document.getElementById('share-link');
const shareAdminToggle = document.getElementById('share-admin-toggle');
const roomStatus = document.getElementById('room-status');
const votesCounter = document.getElementById('votes-counter');
const roomMessage = document.getElementById('room-message');
const changeNameBtn = document.getElementById('change-name-btn');
const terminalModalOverlay = document.getElementById('terminal-modal-overlay');
const terminalCloseBtn = document.getElementById('terminal-close-btn');
const terminalLogOutput = document.getElementById('terminal-log-output');
@@ -37,6 +39,7 @@ const prefillUsername = params.get('username') || '';
let eventSource = null;
let latestLinks = { participantLink: '', adminLink: '' };
let latestAdminLogs = [];
let latestRole = joinRoleInput.value || 'participant';
const savedUsername = localStorage.getItem(USERNAME_KEY) || '';
joinUsernameInput.value = prefillUsername || savedUsername;
@@ -84,6 +87,10 @@ function activateRoomView() {
joinPanel.classList.add('hidden');
}
function setRoomMessage(message) {
roomMessage.textContent = message;
}
async function joinRoom({ username, role, password, participantIdOverride }) {
const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/join`, {
method: 'POST',
@@ -326,6 +333,11 @@ function renderState(state) {
renderCards(state.cards, state.participants, state.revealed);
renderSummary(state);
const self = state.participants.find((participant) => participant.id === participantID && participant.connected);
if (self && self.role) {
latestRole = self.role;
}
latestLinks = state.links || { participantLink: '', adminLink: '' };
updateShareLink();
@@ -344,7 +356,7 @@ function renderState(state) {
const votedCount = state.participants.filter((p) => p.connected && p.role === 'participant' && p.hasVoted).length;
const totalParticipants = state.participants.filter((p) => p.connected && p.role === 'participant').length;
roomStatus.textContent = `Votes: ${votedCount}/${totalParticipants}`;
votesCounter.textContent = `Votes: ${votedCount}/${totalParticipants}`;
}
function updateShareLink() {
@@ -364,13 +376,14 @@ function connectSSE() {
const payload = JSON.parse(event.data);
renderState(payload);
activateRoomView();
setRoomMessage('Connected.');
} catch (_err) {
roomStatus.textContent = 'Failed to parse room update.';
setRoomMessage('Failed to parse room update.');
}
});
eventSource.onerror = () => {
roomStatus.textContent = 'Connection interrupted. Retrying...';
setRoomMessage('Connection interrupted. Retrying...');
};
}
@@ -388,10 +401,10 @@ async function castVote(card) {
if (!response.ok) {
const data = await response.json();
roomStatus.textContent = data.error || 'Vote rejected.';
setRoomMessage(data.error || 'Vote rejected.');
}
} catch (_err) {
roomStatus.textContent = 'Network error while casting vote.';
setRoomMessage('Network error while casting vote.');
}
}
@@ -409,10 +422,45 @@ async function adminAction(action) {
if (!response.ok) {
const data = await response.json();
roomStatus.textContent = data.error || `Unable to ${action}.`;
setRoomMessage(data.error || `Unable to ${action}.`);
}
} catch (_err) {
roomStatus.textContent = 'Network error while sending admin action.';
setRoomMessage('Network error while sending admin action.');
}
}
async function changeName() {
if (!participantID) {
return;
}
const current = joinUsernameInput.value.trim() || localStorage.getItem(USERNAME_KEY) || '';
const next = window.prompt('Enter your new name:', current);
if (next === null) {
return;
}
const username = next.trim();
if (!username) {
setRoomMessage('Name cannot be empty.');
return;
}
if (username === current) {
return;
}
try {
const result = await joinRoom({
username,
role: latestRole || joinRoleInput.value || 'participant',
password: joinPasswordInput.value,
participantIdOverride: participantID,
});
joinUsernameInput.value = result.username;
setRoomMessage('Name updated.');
} catch (err) {
setRoomMessage(err.message || 'Unable to change name.');
}
}
@@ -426,6 +474,9 @@ terminalModalOverlay.addEventListener('click', (event) => {
}
});
shareAdminToggle.addEventListener('change', updateShareLink);
changeNameBtn.addEventListener('click', () => {
void changeName();
});
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeTerminal();

View File

@@ -1,7 +1,16 @@
(() => {
const THEME_KEY = 'scrumPoker.ui.theme';
const MODE_KEY = 'scrumPoker.ui.mode';
const WINDOW_LAYOUTS_KEY = 'scrumPoker.ui.windowLayouts.v1';
const DEFAULT_THEME = 'win98';
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';
const DEFAULT_WINDOW_LAYOUTS = {
'theme-tool-window': { left: 16, top: 88, width: 390, height: 250 },
'mode-tool-window': { left: 424, top: 88, width: 340, height: 190 },
};
let floatingWindowZ = 80;
let windowLayouts = {};
function applyTheme(theme) {
const normalized = theme || DEFAULT_THEME;
@@ -20,16 +29,273 @@
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
}
function isWindowOpen(id) {
const win = document.getElementById(id);
return Boolean(win && !win.classList.contains('hidden'));
}
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 = document.documentElement.getAttribute('data-ui-theme') || DEFAULT_THEME;
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-picker"]').forEach((el) => {
el.value = theme;
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"]').forEach((el) => {
el.textContent = mode === 'dark' ? 'Light Mode' : 'Dark Mode';
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'}`;
});
syncTaskButtons();
}
function bringWindowToFront(windowEl) {
floatingWindowZ += 1;
windowEl.style.zIndex = String(floatingWindowZ);
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function loadWindowLayouts() {
try {
const raw = localStorage.getItem(WINDOW_LAYOUTS_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
return {};
}
return parsed;
} 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));
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,
};
}
function persistWindowLayout(windowEl) {
const id = windowEl.id;
if (!id) {
return;
}
const next = clampLayoutToViewport(readLayoutFromDOM(windowEl));
windowLayouts[id] = next;
saveWindowLayouts();
}
function ensureWindowLayout(windowEl) {
const id = windowEl.id;
if (!id) {
return;
}
const defaults = DEFAULT_WINDOW_LAYOUTS[id];
const saved = windowLayouts[id];
const normalized = normalizeLayout(saved, defaults);
const clamped = clampLayoutToViewport(normalized);
applyLayout(windowEl, clamped);
windowLayouts[id] = clamped;
}
function openToolWindow(id) {
const windowEl = document.getElementById(id);
if (!windowEl) {
return;
}
ensureWindowLayout(windowEl);
windowEl.classList.remove('hidden');
bringWindowToFront(windowEl);
syncTaskButtons();
}
function closeToolWindow(id) {
const windowEl = document.getElementById(id);
if (!windowEl) {
return;
}
windowEl.classList.add('hidden');
syncTaskButtons();
}
function initDraggableWindows() {
let dragState = null;
document.querySelectorAll('[data-role="drag-handle"]').forEach((handle) => {
handle.addEventListener('pointerdown', (event) => {
if (event.button !== 0) {
return;
}
if (event.target.closest('button')) {
return;
}
const windowEl = handle.closest('.ui-tool-window');
if (!windowEl || windowEl.classList.contains('hidden')) {
return;
}
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();
});
});
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;
if (windowEl.classList.contains('hidden')) {
return;
}
persistWindowLayout(windowEl);
});
});
document.querySelectorAll('.ui-tool-window').forEach((windowEl) => {
observer.observe(windowEl);
});
}
function initWindowLayouts() {
windowLayouts = loadWindowLayouts();
document.querySelectorAll('.ui-tool-window').forEach((windowEl) => {
ensureWindowLayout(windowEl);
});
window.addEventListener('resize', () => {
document.querySelectorAll('.ui-tool-window').forEach((windowEl) => {
const id = windowEl.id;
if (!id) {
return;
}
const normalized = normalizeLayout(windowLayouts[id], DEFAULT_WINDOW_LAYOUTS[id]);
const clamped = clampLayoutToViewport(normalized);
applyLayout(windowEl, clamped);
windowLayouts[id] = clamped;
});
saveWindowLayouts();
});
}
@@ -44,18 +310,19 @@
const savedMode = localStorage.getItem(MODE_KEY) || 'light';
applyTheme(savedTheme);
applyMode(savedMode);
initWindowLayouts();
syncControls();
document.querySelectorAll('[data-role="theme-picker"]').forEach((picker) => {
picker.addEventListener('change', (event) => {
const value = event.target.value || DEFAULT_THEME;
document.querySelectorAll('[data-role="theme-option"]').forEach((button) => {
button.addEventListener('click', () => {
const value = button.dataset.theme || DEFAULT_THEME;
localStorage.setItem(THEME_KEY, value);
applyTheme(value);
syncControls();
});
});
document.querySelectorAll('[data-role="mode-toggle"]').forEach((button) => {
document.querySelectorAll('[data-role="mode-toggle-action"]').forEach((button) => {
button.addEventListener('click', () => {
const next = getCurrentMode() === 'dark' ? 'light' : 'dark';
localStorage.setItem(MODE_KEY, next);
@@ -63,6 +330,55 @@
syncControls();
});
});
document.querySelectorAll('[data-role="open-window"]').forEach((button) => {
button.addEventListener('click', () => {
const target = button.dataset.target;
if (!target) {
return;
}
if (isWindowOpen(target)) {
closeToolWindow(target);
return;
}
openToolWindow(target);
});
});
document.querySelectorAll('[data-role="close-window"]').forEach((button) => {
button.addEventListener('click', () => {
const target = button.dataset.target;
if (!target) {
return;
}
closeToolWindow(target);
});
});
document.querySelectorAll('.ui-tool-window').forEach((windowEl) => {
windowEl.addEventListener('pointerdown', () => {
if (windowEl.classList.contains('hidden')) {
return;
}
bringWindowToFront(windowEl);
});
});
window.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') {
return;
}
document.querySelectorAll('.ui-tool-window').forEach((windowEl) => {
if (windowEl.classList.contains('hidden')) {
return;
}
windowEl.classList.add('hidden');
});
syncTaskButtons();
});
initDraggableWindows();
initResizableWindows();
}
window.initUIControls = initUIControls;