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("isSystemAdmin", authorizationService.userIsSystemAdministrator(sessionUsername));
addStagingAttribute(model);
addVersionAttributes(model);
return "dashboard";
}
addStagingAttribute(model);
+145 -121
View File
@@ -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;
}
}
+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 = {
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();
+84 -31
View File
@@ -54,29 +54,51 @@
<ul id="copyright-notices-list" class="copyright-notices-list"></ul>
</section>
<section class="large-dashboard-tiles">
<a th:href="@{'/view/' + ${channel} + '/broadcast'}">
<i class="fa-solid fa-tower-broadcast"></i>
<span>Broadcast Overlay</span>
<!-- Navigation cards -->
<nav class="dashboard-nav-cards">
<a class="dashboard-nav-card" th:href="@{'/view/' + ${channel} + '/broadcast'}">
<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 th:href="@{'/view/' + ${channel} + '/admin'}">
<i class="fa-solid fa-layer-group"></i>
<span>Admin Console</span>
<a class="dashboard-nav-card" th:href="@{'/view/' + ${channel} + '/admin'}">
<div class="dashboard-nav-card-icon">
<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 th:href="@{'/view/' + ${channel} + '/audit'}">
<i class="fa-solid fa-user-shield"></i>
<span>Audit Log</span>
<a class="dashboard-nav-card" th:href="@{'/view/' + ${channel} + '/audit'}">
<div class="dashboard-nav-card-icon">
<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 th:if="${isSystemAdmin}" href="/settings">
<i class="fa-solid fa-gear"></i>
<span>Application Settings</span>
<a th:if="${isSystemAdmin}" class="dashboard-nav-card" href="/settings">
<div class="dashboard-nav-card-icon dashboard-nav-card-icon--muted">
<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>
</section>
</nav>
<div class="dashboard-grid">
<!-- Overlay settings -->
<section class="card">
<p class="eyebrow">Settings</p>
<h3>Overlay dimensions</h3>
<h3>Overlay</h3>
<p class="muted">Match these with your OBS resolution.</p>
<div class="control-grid">
<label>
@@ -94,10 +116,19 @@
<span class="form-helper">Drag to preview. Left = much quieter, right = louder.</span>
</label>
</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>
<p class="muted">Set access policy for script assets.</p>
<div class="control-list">
<label class="control">
<div class="title-block">
@@ -123,12 +154,13 @@
</div>
<div class="control-actions">
<button id="save-script-settings-btn" type="button" onclick="saveScriptSettings()">
Save settings
Save integration settings
</button>
<span id="script-settings-status" class="muted" role="status" aria-live="polite"></span>
</div>
</section>
<!-- Channel admins -->
<section class="card-grid two-col dashboard-span-full">
<div class="card">
<div class="card-header">
@@ -162,6 +194,7 @@
</div>
</section>
<!-- Channels you administer -->
<section th:if="${adminChannels != null}" class="card dashboard-span-full">
<div class="card-header">
<div>
@@ -182,21 +215,41 @@
</ul>
</section>
<section class="card dashboard-span-full">
<div class="card-header">
<div>
<p class="eyebrow">Account</p>
<h3>Delete account</h3>
<p class="muted">
Permanently remove your account, assets, and session. This cannot be undone.
</p>
<!-- Danger zone -->
<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>
<p class="eyebrow">Account</p>
<h4>Delete account</h4>
<p class="muted">Permanently remove your account, assets, and session. This cannot be undone.</p>
</div>
<div class="danger-confirm" id="delete-confirm-area">
<button id="delete-account-btn" class="secondary danger" type="button">
Delete my account
</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>
<div class="control-actions">
<button id="delete-account-btn" class="secondary danger" type="button">
Delete my account
</button>
</div>
</details>
</section>
</div>
</div>