refactor: redesign dashboard UI

- 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
This commit is contained in:
2026-04-28 15:00:13 +02:00
parent f5ffbb99dd
commit b615834789
4 changed files with 285 additions and 217 deletions
@@ -75,7 +75,6 @@ public class ViewController {
model.addAttribute("adminChannels", channelDirectoryService.adminChannelsFor(sessionUsername)); model.addAttribute("adminChannels", channelDirectoryService.adminChannelsFor(sessionUsername));
model.addAttribute("isSystemAdmin", authorizationService.userIsSystemAdministrator(sessionUsername)); model.addAttribute("isSystemAdmin", authorizationService.userIsSystemAdministrator(sessionUsername));
addStagingAttribute(model); addStagingAttribute(model);
addVersionAttributes(model);
return "dashboard"; return "dashboard";
} }
addStagingAttribute(model); addStagingAttribute(model);
+145 -121
View File
@@ -1041,6 +1041,7 @@ button:disabled:hover {
.dashboard-grid { .dashboard-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 20px; gap: 20px;
align-items: start; align-items: start;
} }
@@ -1054,7 +1055,7 @@ button:disabled:hover {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
padding: 14px 18px; padding: 14px 20px;
background: var(--color-surface-1); background: var(--color-surface-1);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 14px; border-radius: 14px;
@@ -1092,167 +1093,147 @@ button:disabled:hover {
color: var(--color-text); color: var(--color-text);
} }
.dashboard-action { /* ── Navigation cards ────────────────────────────────── */
display: inline-flex; .dashboard-nav-cards {
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;
display: grid; display: grid;
place-items: center; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
background: rgba(124, 58, 237, 0.18); gap: 12px;
color: var(--color-accent-icon);
font-weight: 700;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
} }
.dashboard-action-copy { .dashboard-nav-card {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 4px; gap: 14px;
} padding: 14px 16px;
.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;
border-radius: 12px; border-radius: 12px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
background: var(--color-surface-1); background: var(--color-surface-1);
color: var(--color-text); color: var(--color-text);
text-decoration: none; text-decoration: none;
min-height: 140px;
transition: transition:
border-color 0.2s ease, border-color 0.15s ease,
background-color 0.2s ease, background-color 0.15s ease,
transform 0.2s ease; transform 0.15s ease;
justify-content: space-evenly;
align-items: center;
} }
.large-dashboard-tiles a:hover, .dashboard-nav-card:hover,
.large-dashboard-tiles a:focus-visible { .dashboard-nav-card:focus-visible {
border-color: var(--color-border-strong); border-color: var(--color-border-strong);
background: var(--color-surface-4); background: var(--color-surface-4);
transform: translateY(-1px); transform: translateY(-1px);
} }
.large-dashboard-tiles a i { .dashboard-nav-card-icon {
font-size: 72px; flex-shrink: 0;
} width: 42px;
height: 42px;
.large-dashboard-tiles a span { border-radius: 10px;
display: grid;
place-items: center;
background: rgba(124, 58, 237, 0.18);
color: var(--color-accent-icon);
font-size: 18px; font-size: 18px;
} }
.dashboard-action-copy strong { .dashboard-nav-card-icon--muted {
font-size: 15px; background: var(--color-surface-3);
}
.dashboard-action-copy small {
color: var(--color-text-2); color: var(--color-text-2);
font-size: 12px;
} }
.dashboard-tile-grid { .dashboard-nav-card-copy {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-top: 12px;
}
.dashboard-tile {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 3px;
padding: 16px; min-width: 0;
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;
} }
.dashboard-tile:hover, .dashboard-nav-card-copy strong {
.dashboard-tile:focus-visible { font-size: 14px;
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 {
font-weight: 600; font-weight: 600;
font-size: 15px; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.dashboard-tile .tile-subtitle { .dashboard-nav-card-copy span {
color: var(--color-text-2);
font-size: 12px; 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; 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; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
width: 100%;
} }
.dashboard-toggle-tile input[type="checkbox"] { .danger-zone-body {
margin-left: auto; 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 { .user-display {
@@ -3136,3 +3117,46 @@ button:disabled:hover {
gap: 8px; gap: 8px;
flex-shrink: 0; 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;
}
}
+56 -64
View File
@@ -1,4 +1,3 @@
// TODO: Code smell Dashboard script uses broad shared state and imperative DOM updates instead of focused components.
const elements = { const elements = {
adminList: document.getElementById("admin-list"), adminList: document.getElementById("admin-list"),
suggestionList: document.getElementById("admin-suggestions"), suggestionList: document.getElementById("admin-suggestions"),
@@ -16,6 +15,9 @@ const elements = {
scriptSettingsStatus: document.getElementById("script-settings-status"), scriptSettingsStatus: document.getElementById("script-settings-status"),
scriptSettingsSaveButton: document.getElementById("save-script-settings-btn"), scriptSettingsSaveButton: document.getElementById("save-script-settings-btn"),
deleteAccountButton: document.getElementById("delete-account-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)}`; const apiBase = `/api/channels/${encodeURIComponent(broadcaster)}`;
@@ -50,68 +52,52 @@ function buildIdentity(admin) {
return identity; return identity;
} }
function renderAdmins(list) { function renderAdminList(listEl, list, { actionLabel, actionClass, onAction, emptyMessage }) {
if (!elements.adminList) return; if (!listEl) return;
elements.adminList.innerHTML = ""; listEl.innerHTML = "";
if (!list || list.length === 0) { if (!list || list.length === 0) {
const empty = document.createElement("li"); const empty = document.createElement("li");
empty.className = "stacked-list-item empty"; empty.className = "stacked-list-item empty";
empty.textContent = "No channel admins yet."; empty.textContent = emptyMessage;
elements.adminList.appendChild(empty); listEl.appendChild(empty);
return; return;
} }
list.forEach((admin) => { list.forEach((admin) => {
const li = document.createElement("li"); const li = document.createElement("li");
li.className = "stacked-list-item"; li.className = "stacked-list-item";
li.appendChild(buildIdentity(admin)); li.appendChild(buildIdentity(admin));
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "actions"; actions.className = "actions";
const removeBtn = document.createElement("button"); const btn = document.createElement("button");
removeBtn.type = "button"; btn.type = "button";
removeBtn.className = "secondary"; btn.className = actionClass;
removeBtn.textContent = "Remove"; btn.textContent = actionLabel;
removeBtn.addEventListener("click", () => removeAdmin(admin.login)); btn.addEventListener("click", () => onAction(admin.login));
actions.appendChild(removeBtn); actions.appendChild(btn);
li.appendChild(actions); 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) { function renderSuggestedAdmins(list) {
if (!elements.suggestionList) return; renderAdminList(elements.suggestionList, list, {
actionLabel: "Add channel admin",
elements.suggestionList.innerHTML = ""; actionClass: "ghost",
if (!list || list.length === 0) { onAction: addAdmin,
const empty = document.createElement("li"); emptyMessage: "No moderator suggestions right now.",
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);
}); });
} }
@@ -401,7 +387,6 @@ async function fetchScriptSettings() {
} }
async function saveScriptSettings() { async function saveScriptSettings() {
saveCanvasSettings()
const allowChannelEmotesForAssets = elements.allowChannelEmotes?.checked ?? true; const allowChannelEmotesForAssets = elements.allowChannelEmotes?.checked ?? true;
const allowSevenTvEmotesForAssets = elements.allowSevenTvEmotes?.checked ?? true; const allowSevenTvEmotesForAssets = elements.allowSevenTvEmotes?.checked ?? true;
const allowScriptChatAccess = elements.allowScriptChat?.checked ?? true; const allowScriptChatAccess = elements.allowScriptChat?.checked ?? true;
@@ -423,30 +408,30 @@ async function saveScriptSettings() {
); );
renderScriptSettings(settings); renderScriptSettings(settings);
if (elements.scriptSettingsStatus) elements.scriptSettingsStatus.textContent = "Saved."; if (elements.scriptSettingsStatus) elements.scriptSettingsStatus.textContent = "Saved.";
showToast("Script settings saved successfully.", "success"); showToast("Integration settings saved successfully.", "success");
setTimeout(() => { setTimeout(() => {
if (elements.scriptSettingsStatus) elements.scriptSettingsStatus.textContent = ""; if (elements.scriptSettingsStatus) elements.scriptSettingsStatus.textContent = "";
}, 2000); }, 2000);
} catch (error) { } catch (error) {
if (elements.scriptSettingsStatus) elements.scriptSettingsStatus.textContent = "Unable to save right now."; 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 { } finally {
setButtonBusy(elements.scriptSettingsSaveButton, false, "Saving..."); setButtonBusy(elements.scriptSettingsSaveButton, false, "Saving...");
} }
} }
async function deleteAccount() { function showDeleteConfirm() {
const confirmation = window.prompt( if (elements.deleteAccountButton) elements.deleteAccountButton.classList.add("hidden");
"Type DELETE to permanently remove your account, assets, and session.", if (elements.deleteConfirmStep) elements.deleteConfirmStep.classList.remove("hidden");
); }
if (confirmation !== "DELETE") {
if (confirmation !== null) {
showToast("Account deletion cancelled.", "info");
}
return;
}
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 { try {
const response = await fetch("/api/account", { method: "DELETE" }); const response = await fetch("/api/account", { method: "DELETE" });
if (!response.ok) { if (!response.ok) {
@@ -456,7 +441,8 @@ async function deleteAccount() {
window.location.href = "/"; window.location.href = "/";
} catch (error) { } catch (error) {
showToast("Unable to delete account right now. Please retry.", "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) { 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) { if (elements.maxVolumeDb) {
elements.maxVolumeDb.addEventListener("input", handleVolumeSliderInput); elements.maxVolumeDb.addEventListener("input", handleVolumeSliderInput);
} }
fetchAdmins();
fetchSuggestedAdmins();
fetchCanvasSettings();
fetchScriptSettings();
+78 -25
View File
@@ -54,29 +54,51 @@
<ul id="copyright-notices-list" class="copyright-notices-list"></ul> <ul id="copyright-notices-list" class="copyright-notices-list"></ul>
</section> </section>
<section class="large-dashboard-tiles"> <!-- Navigation cards -->
<a th:href="@{'/view/' + ${channel} + '/broadcast'}"> <nav class="dashboard-nav-cards">
<i class="fa-solid fa-tower-broadcast"></i> <a class="dashboard-nav-card" th:href="@{'/view/' + ${channel} + '/broadcast'}">
<span>Broadcast Overlay</span> <div class="dashboard-nav-card-icon">
<i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i>
</div>
<div class="dashboard-nav-card-copy">
<strong>Broadcast Overlay</strong>
<span>Add to OBS as a browser source</span>
</div>
</a> </a>
<a th:href="@{'/view/' + ${channel} + '/admin'}"> <a class="dashboard-nav-card" th:href="@{'/view/' + ${channel} + '/admin'}">
<i class="fa-solid fa-layer-group"></i> <div class="dashboard-nav-card-icon">
<span>Admin Console</span> <i class="fa-solid fa-layer-group" aria-hidden="true"></i>
</div>
<div class="dashboard-nav-card-copy">
<strong>Admin Console</strong>
<span>Manage assets and scripts</span>
</div>
</a> </a>
<a th:href="@{'/view/' + ${channel} + '/audit'}"> <a class="dashboard-nav-card" th:href="@{'/view/' + ${channel} + '/audit'}">
<i class="fa-solid fa-user-shield"></i> <div class="dashboard-nav-card-icon">
<span>Audit Log</span> <i class="fa-solid fa-user-shield" aria-hidden="true"></i>
</div>
<div class="dashboard-nav-card-copy">
<strong>Audit Log</strong>
<span>Review channel activity</span>
</div>
</a> </a>
<a th:if="${isSystemAdmin}" href="/settings"> <a th:if="${isSystemAdmin}" class="dashboard-nav-card" href="/settings">
<i class="fa-solid fa-gear"></i> <div class="dashboard-nav-card-icon dashboard-nav-card-icon--muted">
<span>Application Settings</span> <i class="fa-solid fa-gear" aria-hidden="true"></i>
</div>
<div class="dashboard-nav-card-copy">
<strong>Application Settings</strong>
<span>System-wide configuration</span>
</div>
</a> </a>
</section> </nav>
<div class="dashboard-grid"> <div class="dashboard-grid">
<!-- Overlay settings -->
<section class="card"> <section class="card">
<p class="eyebrow">Settings</p> <p class="eyebrow">Settings</p>
<h3>Overlay dimensions</h3> <h3>Overlay</h3>
<p class="muted">Match these with your OBS resolution.</p> <p class="muted">Match these with your OBS resolution.</p>
<div class="control-grid"> <div class="control-grid">
<label> <label>
@@ -94,10 +116,19 @@
<span class="form-helper">Drag to preview. Left = much quieter, right = louder.</span> <span class="form-helper">Drag to preview. Left = much quieter, right = louder.</span>
</label> </label>
</div> </div>
<div class="control-actions">
<button id="save-canvas-btn" type="button" onclick="saveCanvasSettings()">
Save overlay settings
</button>
<span id="canvas-status" class="muted" role="status" aria-live="polite"></span>
</div>
</section>
<!-- Integration settings -->
<section class="card">
<p class="eyebrow">Settings</p>
<h3>Integrations</h3> <h3>Integrations</h3>
<p class="muted">Set access policy for script assets.</p> <p class="muted">Set access policy for script assets.</p>
<div class="control-list"> <div class="control-list">
<label class="control"> <label class="control">
<div class="title-block"> <div class="title-block">
@@ -123,12 +154,13 @@
</div> </div>
<div class="control-actions"> <div class="control-actions">
<button id="save-script-settings-btn" type="button" onclick="saveScriptSettings()"> <button id="save-script-settings-btn" type="button" onclick="saveScriptSettings()">
Save settings Save integration settings
</button> </button>
<span id="script-settings-status" class="muted" role="status" aria-live="polite"></span> <span id="script-settings-status" class="muted" role="status" aria-live="polite"></span>
</div> </div>
</section> </section>
<!-- Channel admins -->
<section class="card-grid two-col dashboard-span-full"> <section class="card-grid two-col dashboard-span-full">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
@@ -162,6 +194,7 @@
</div> </div>
</section> </section>
<!-- Channels you administer -->
<section th:if="${adminChannels != null}" class="card dashboard-span-full"> <section th:if="${adminChannels != null}" class="card dashboard-span-full">
<div class="card-header"> <div class="card-header">
<div> <div>
@@ -182,21 +215,41 @@
</ul> </ul>
</section> </section>
<section class="card dashboard-span-full"> <!-- Danger zone -->
<div class="card-header"> <section class="card danger-zone dashboard-span-full">
<details>
<summary>
<span class="danger-zone-summary-content">
<i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
<span>Danger zone</span>
</span>
</summary>
<div class="danger-zone-body">
<div class="danger-zone-item">
<div> <div>
<p class="eyebrow">Account</p> <p class="eyebrow">Account</p>
<h3>Delete account</h3> <h4>Delete account</h4>
<p class="muted"> <p class="muted">Permanently remove your account, assets, and session. This cannot be undone.</p>
Permanently remove your account, assets, and session. This cannot be undone.
</p>
</div> </div>
</div> <div class="danger-confirm" id="delete-confirm-area">
<div class="control-actions">
<button id="delete-account-btn" class="secondary danger" type="button"> <button id="delete-account-btn" class="secondary danger" type="button">
Delete my account Delete my account
</button> </button>
<div class="danger-confirm-step hidden" id="delete-confirm-step">
<p class="muted">Are you sure? This is permanent and cannot be reversed.</p>
<div class="danger-confirm-actions">
<button id="delete-account-confirm-btn" class="danger" type="button">
Yes, delete my account
</button>
<button id="delete-account-cancel-btn" class="ghost" type="button">
Cancel
</button>
</div> </div>
</div>
</div>
</div>
</div>
</details>
</section> </section>
</div> </div>
</div> </div>