From b61583478943eba021bc714ef358824407daef98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Tue, 28 Apr 2026 15:00:13 +0200 Subject: [PATCH] refactor: redesign dashboard UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace large 200x200 icon tiles with compact horizontal nav cards (icon + title + description, grid-wrapped, responsive) - Split single Settings card into separate Overlay and Integrations cards, each with its own Save button and status indicator - Fix canvas save wiring bug: add missing #save-canvas-btn and #canvas-status elements that were referenced in JS but absent from HTML - Remove silent saveCanvasSettings() side-effect from saveScriptSettings() - Replace window.prompt delete confirmation with inline two-step confirm flow (Delete button → confirm panel → cancel/confirm) - Add danger-zone collapsible section with danger-border styling - Add responsive media queries: topbar stacks on narrow viewports, nav cards and dashboard grid collapse to single column below 700px - Remove dead CSS classes (dashboard-action, dashboard-tile, dashboard-toggle-tile, large-dashboard-tiles, etc.) - Merge near-duplicate renderAdmins/renderSuggestedAdmins into single parameterised renderAdminList() helper - Remove unused addVersionAttributes() call from dashboard route --- .../imgfloat/controller/ViewController.java | 1 - src/main/resources/static/css/styles.css | 266 ++++++++++-------- src/main/resources/static/js/dashboard.js | 120 ++++---- src/main/resources/templates/dashboard.html | 115 ++++++-- 4 files changed, 285 insertions(+), 217 deletions(-) diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java index 2afc1d1..31c4c02 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java @@ -75,7 +75,6 @@ public class ViewController { model.addAttribute("adminChannels", channelDirectoryService.adminChannelsFor(sessionUsername)); model.addAttribute("isSystemAdmin", authorizationService.userIsSystemAdministrator(sessionUsername)); addStagingAttribute(model); - addVersionAttributes(model); return "dashboard"; } addStagingAttribute(model); diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index bb39280..24ba9a2 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -1041,6 +1041,7 @@ button:disabled:hover { .dashboard-grid { display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 20px; align-items: start; } @@ -1054,7 +1055,7 @@ button:disabled:hover { align-items: center; justify-content: space-between; gap: 16px; - padding: 14px 18px; + padding: 14px 20px; background: var(--color-surface-1); border: 1px solid var(--color-border); border-radius: 14px; @@ -1092,167 +1093,147 @@ button:disabled:hover { color: var(--color-text); } -.dashboard-action { - display: inline-flex; - align-items: center; - gap: 12px; - padding: 12px 14px; - border-radius: 12px; - background: var(--color-accent-subtle); - border: 1px solid var(--color-accent-border); - color: var(--color-text); - text-decoration: none; - transition: - background 120ms ease, - border-color 120ms ease, - transform 120ms ease; -} - -.dashboard-action:hover { - background: var(--color-accent-subtle-hover); - border-color: rgba(124, 58, 237, 0.35); - transform: translateY(-1px); -} - -.dashboard-action-icon { - min-width: 44px; - height: 44px; - border-radius: 10px; +/* ── Navigation cards ────────────────────────────────── */ +.dashboard-nav-cards { display: grid; - place-items: center; - background: rgba(124, 58, 237, 0.18); - color: var(--color-accent-icon); - font-weight: 700; - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.5px; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; } -.dashboard-action-copy { +.dashboard-nav-card { display: flex; - flex-direction: column; - gap: 4px; -} - -.large-dashboard-tiles { - display: flex; - justify-content: space-evenly -} - -.large-dashboard-tiles a { - width: 200px; - height: 200px; - display: flex; - flex-direction: column; - gap: 8px; - padding: 16px; + align-items: center; + gap: 14px; + padding: 14px 16px; border-radius: 12px; border: 1px solid var(--color-border); background: var(--color-surface-1); color: var(--color-text); text-decoration: none; - min-height: 140px; transition: - border-color 0.2s ease, - background-color 0.2s ease, - transform 0.2s ease; - justify-content: space-evenly; - align-items: center; + border-color 0.15s ease, + background-color 0.15s ease, + transform 0.15s ease; } -.large-dashboard-tiles a:hover, -.large-dashboard-tiles a:focus-visible { +.dashboard-nav-card:hover, +.dashboard-nav-card:focus-visible { border-color: var(--color-border-strong); background: var(--color-surface-4); transform: translateY(-1px); } -.large-dashboard-tiles a i { - font-size: 72px; -} - -.large-dashboard-tiles a span { +.dashboard-nav-card-icon { + flex-shrink: 0; + width: 42px; + height: 42px; + border-radius: 10px; + display: grid; + place-items: center; + background: rgba(124, 58, 237, 0.18); + color: var(--color-accent-icon); font-size: 18px; } -.dashboard-action-copy strong { - font-size: 15px; -} - -.dashboard-action-copy small { +.dashboard-nav-card-icon--muted { + background: var(--color-surface-3); color: var(--color-text-2); - font-size: 12px; } -.dashboard-tile-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 12px; - margin-top: 12px; -} - -.dashboard-tile { +.dashboard-nav-card-copy { display: flex; flex-direction: column; - gap: 8px; - padding: 16px; - border-radius: 12px; - border: 1px solid var(--color-border); - background: var(--color-surface-1); - color: var(--color-text); - text-decoration: none; - min-height: 140px; - transition: - border-color 0.2s ease, - background-color 0.2s ease, - transform 0.2s ease; + gap: 3px; + min-width: 0; } -.dashboard-tile:hover, -.dashboard-tile:focus-visible { - border-color: var(--color-border-strong); - background: var(--color-surface-4); - transform: translateY(-1px); -} - -.dashboard-tile .tile-icon { - min-width: 44px; - height: 44px; - border-radius: 10px; - display: grid; - place-items: center; - background: rgba(124, 58, 237, 0.18); - color: var(--color-accent-icon); - font-weight: 700; - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.dashboard-tile .tile-title { +.dashboard-nav-card-copy strong { + font-size: 14px; font-weight: 600; - font-size: 15px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.dashboard-tile .tile-subtitle { - color: var(--color-text-2); +.dashboard-nav-card-copy span { font-size: 12px; - line-height: 1.4; + color: var(--color-text-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.dashboard-toggle-tile { +/* ── Danger zone ─────────────────────────────────────── */ +.danger-zone { + border-color: var(--color-danger-border, rgba(239, 68, 68, 0.25)); + padding: 0; + overflow: hidden; +} + +.danger-zone > details > summary { + list-style: none; cursor: pointer; + padding: 14px 18px; + display: flex; + align-items: center; + user-select: none; + color: var(--color-danger, #ef4444); + font-size: 14px; + font-weight: 600; + gap: 8px; } -.dashboard-toggle-tile .tile-row { +.danger-zone > details > summary::-webkit-details-marker { + display: none; +} + +.danger-zone > details > summary:hover { + background: rgba(239, 68, 68, 0.04); +} + +.danger-zone-summary-content { display: flex; align-items: center; gap: 8px; - width: 100%; } -.dashboard-toggle-tile input[type="checkbox"] { - margin-left: auto; +.danger-zone-body { + padding: 16px 18px 18px; + border-top: 1px solid var(--color-danger-border, rgba(239, 68, 68, 0.2)); +} + +.danger-zone-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + flex-wrap: wrap; +} + +.danger-confirm { + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-end; + flex-shrink: 0; +} + +.danger-confirm-step { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + padding: 12px 14px; + border-radius: 10px; + background: rgba(239, 68, 68, 0.06); + border: 1px solid var(--color-danger-border, rgba(239, 68, 68, 0.25)); +} + +.danger-confirm-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; } .user-display { @@ -3136,3 +3117,46 @@ button:disabled:hover { gap: 8px; flex-shrink: 0; } + +/* ── Dashboard responsive ───────────────────────────── */ +@media (max-width: 700px) { + .dashboard-body { + padding: 16px 12px 48px; + } + + .dashboard-topbar { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .user-pill { + width: 100%; + justify-content: space-between; + } + + .user-pill-copy { + align-items: flex-start; + } + + .dashboard-nav-cards { + grid-template-columns: 1fr; + } + + .dashboard-grid { + grid-template-columns: 1fr; + } + + .danger-zone-item { + flex-direction: column; + } + + .danger-confirm { + align-items: flex-start; + width: 100%; + } + + .danger-confirm-actions { + justify-content: flex-start; + } +} diff --git a/src/main/resources/static/js/dashboard.js b/src/main/resources/static/js/dashboard.js index 3934d84..5c13bbc 100644 --- a/src/main/resources/static/js/dashboard.js +++ b/src/main/resources/static/js/dashboard.js @@ -1,4 +1,3 @@ -// TODO: Code smell Dashboard script uses broad shared state and imperative DOM updates instead of focused components. const elements = { adminList: document.getElementById("admin-list"), suggestionList: document.getElementById("admin-suggestions"), @@ -16,6 +15,9 @@ const elements = { scriptSettingsStatus: document.getElementById("script-settings-status"), scriptSettingsSaveButton: document.getElementById("save-script-settings-btn"), deleteAccountButton: document.getElementById("delete-account-btn"), + deleteConfirmStep: document.getElementById("delete-confirm-step"), + deleteConfirmButton: document.getElementById("delete-account-confirm-btn"), + deleteCancelButton: document.getElementById("delete-account-cancel-btn"), }; const apiBase = `/api/channels/${encodeURIComponent(broadcaster)}`; @@ -50,68 +52,52 @@ function buildIdentity(admin) { return identity; } -function renderAdmins(list) { - if (!elements.adminList) return; - elements.adminList.innerHTML = ""; +function renderAdminList(listEl, list, { actionLabel, actionClass, onAction, emptyMessage }) { + if (!listEl) return; + listEl.innerHTML = ""; if (!list || list.length === 0) { const empty = document.createElement("li"); empty.className = "stacked-list-item empty"; - empty.textContent = "No channel admins yet."; - elements.adminList.appendChild(empty); + empty.textContent = emptyMessage; + listEl.appendChild(empty); return; } list.forEach((admin) => { const li = document.createElement("li"); li.className = "stacked-list-item"; - li.appendChild(buildIdentity(admin)); const actions = document.createElement("div"); actions.className = "actions"; - const removeBtn = document.createElement("button"); - removeBtn.type = "button"; - removeBtn.className = "secondary"; - removeBtn.textContent = "Remove"; - removeBtn.addEventListener("click", () => removeAdmin(admin.login)); + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = actionClass; + btn.textContent = actionLabel; + btn.addEventListener("click", () => onAction(admin.login)); - actions.appendChild(removeBtn); + actions.appendChild(btn); li.appendChild(actions); - elements.adminList.appendChild(li); + listEl.appendChild(li); + }); +} + +function renderAdmins(list) { + renderAdminList(elements.adminList, list, { + actionLabel: "Remove", + actionClass: "secondary", + onAction: removeAdmin, + emptyMessage: "No channel admins yet.", }); } function renderSuggestedAdmins(list) { - if (!elements.suggestionList) return; - - elements.suggestionList.innerHTML = ""; - if (!list || list.length === 0) { - const empty = document.createElement("li"); - empty.className = "stacked-list-item empty"; - empty.textContent = "No moderator suggestions right now."; - elements.suggestionList.appendChild(empty); - return; - } - - list.forEach((admin) => { - const li = document.createElement("li"); - li.className = "stacked-list-item"; - - li.appendChild(buildIdentity(admin)); - - const actions = document.createElement("div"); - actions.className = "actions"; - - const addBtn = document.createElement("button"); - addBtn.type = "button"; - addBtn.className = "ghost"; - addBtn.textContent = "Add channel admin"; - addBtn.addEventListener("click", () => addAdmin(admin.login)); - - actions.appendChild(addBtn); - li.appendChild(actions); - elements.suggestionList.appendChild(li); + renderAdminList(elements.suggestionList, list, { + actionLabel: "Add channel admin", + actionClass: "ghost", + onAction: addAdmin, + emptyMessage: "No moderator suggestions right now.", }); } @@ -401,7 +387,6 @@ async function fetchScriptSettings() { } async function saveScriptSettings() { - saveCanvasSettings() const allowChannelEmotesForAssets = elements.allowChannelEmotes?.checked ?? true; const allowSevenTvEmotesForAssets = elements.allowSevenTvEmotes?.checked ?? true; const allowScriptChatAccess = elements.allowScriptChat?.checked ?? true; @@ -423,30 +408,30 @@ async function saveScriptSettings() { ); renderScriptSettings(settings); if (elements.scriptSettingsStatus) elements.scriptSettingsStatus.textContent = "Saved."; - showToast("Script settings saved successfully.", "success"); + showToast("Integration settings saved successfully.", "success"); setTimeout(() => { if (elements.scriptSettingsStatus) elements.scriptSettingsStatus.textContent = ""; }, 2000); } catch (error) { if (elements.scriptSettingsStatus) elements.scriptSettingsStatus.textContent = "Unable to save right now."; - showToast("Unable to save script settings. Please retry.", "error"); + showToast("Unable to save integration settings. Please retry.", "error"); } finally { setButtonBusy(elements.scriptSettingsSaveButton, false, "Saving..."); } } -async function deleteAccount() { - const confirmation = window.prompt( - "Type DELETE to permanently remove your account, assets, and session.", - ); - if (confirmation !== "DELETE") { - if (confirmation !== null) { - showToast("Account deletion cancelled.", "info"); - } - return; - } +function showDeleteConfirm() { + if (elements.deleteAccountButton) elements.deleteAccountButton.classList.add("hidden"); + if (elements.deleteConfirmStep) elements.deleteConfirmStep.classList.remove("hidden"); +} - setButtonBusy(elements.deleteAccountButton, true, "Deleting..."); +function hideDeleteConfirm() { + if (elements.deleteAccountButton) elements.deleteAccountButton.classList.remove("hidden"); + if (elements.deleteConfirmStep) elements.deleteConfirmStep.classList.add("hidden"); +} + +async function deleteAccount() { + setButtonBusy(elements.deleteConfirmButton, true, "Deleting..."); try { const response = await fetch("/api/account", { method: "DELETE" }); if (!response.ok) { @@ -456,7 +441,8 @@ async function deleteAccount() { window.location.href = "/"; } catch (error) { showToast("Unable to delete account right now. Please retry.", "error"); - setButtonBusy(elements.deleteAccountButton, false, "Deleting..."); + setButtonBusy(elements.deleteConfirmButton, false, "Deleting..."); + hideDeleteConfirm(); } } @@ -469,14 +455,20 @@ if (elements.adminInput) { }); } -fetchAdmins(); -fetchSuggestedAdmins(); -fetchCanvasSettings(); -fetchScriptSettings(); - if (elements.deleteAccountButton) { - elements.deleteAccountButton.addEventListener("click", deleteAccount); + elements.deleteAccountButton.addEventListener("click", showDeleteConfirm); +} +if (elements.deleteCancelButton) { + elements.deleteCancelButton.addEventListener("click", hideDeleteConfirm); +} +if (elements.deleteConfirmButton) { + elements.deleteConfirmButton.addEventListener("click", deleteAccount); } if (elements.maxVolumeDb) { elements.maxVolumeDb.addEventListener("input", handleVolumeSliderInput); } + +fetchAdmins(); +fetchSuggestedAdmins(); +fetchCanvasSettings(); +fetchScriptSettings(); diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index 37dc55e..90162a9 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -54,29 +54,51 @@ -
- - - Broadcast Overlay + +
+
+

Settings

-

Overlay dimensions

+

Overlay

Match these with your OBS resolution.

+
+ + +
+
+ +
+

Settings

Integrations

Set access policy for script assets.

-
+
@@ -162,6 +194,7 @@
+
@@ -182,21 +215,41 @@
-
-
-
-

Account

-

Delete account

-

- Permanently remove your account, assets, and session. This cannot be undone. -

+ +
+
+ + + + Danger zone + + +
+
+
+

Account

+

Delete account

+

Permanently remove your account, assets, and session. This cannot be undone.

+
+
+ + +
+
-
-
- -
+