From f580775cc267ed0a605617093a82e6fbf7c150d6 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Fri, 6 Mar 2026 11:07:13 +0200 Subject: [PATCH] Updateees --- src/templates/index.html | 78 +++++++-- src/templates/room.html | 115 ++++++++++---- static/css/layout.css | 152 ++++++++++++++++-- static/css/main.css | 17 +- static/js/room.js | 67 +++++++- static/js/ui-controls.js | 332 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 693 insertions(+), 68 deletions(-) diff --git a/src/templates/index.html b/src/templates/index.html index 162d0d3..18c76dc 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -16,13 +16,17 @@
-
- - +
+
+ + +
@@ -181,16 +185,62 @@
+ + + + diff --git a/src/templates/room.html b/src/templates/room.html index f73808d..1c09c98 100644 --- a/src/templates/room.html +++ b/src/templates/room.html @@ -16,18 +16,22 @@
-
- - +
+
+ + +
-
+ +
@@ -163,16 +178,62 @@
+ + + + diff --git a/static/css/layout.css b/static/css/layout.css index 5d09b60..c6850b9 100644 --- a/static/css/layout.css +++ b/static/css/layout.css @@ -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; } diff --git a/static/css/main.css b/static/css/main.css index 19fdab3..b52b89e 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -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 { diff --git a/static/js/room.js b/static/js/room.js index d8fdbda..5ad23f0 100644 --- a/static/js/room.js +++ b/static/js/room.js @@ -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(); diff --git a/static/js/ui-controls.js b/static/js/ui-controls.js index 73761aa..0c7ba2d 100644 --- a/static/js/ui-controls.js +++ b/static/js/ui-controls.js @@ -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;