refactor: migrate dashboard to SSR-first Alpine.js pattern

Update dashboard to follow SSR-first Alpine.js guidelines:
- Add x-data="dashboard" and x-init="initDashboard()" to body tag
- Wrap initialization in initDashboard() function instead of executing at load time
- Alpine.js only manages UI state, data fetching happens via HTMX/SSR
- Remove immediate initDragAndDrop() call (now called from initDashboard)

This fixes DOM Content Loaded timing issues and follows the established pattern
used in analytics and docs pages. The dashboard now properly supports:
- SSR with initial data rendered server-side
- Alpine.js for interactive UI (drag-drop, modals)
- HTMX for dynamic updates without page reload
- Progressive enhancement (works without JavaScript)
This commit is contained in:
2026-03-20 22:58:18 -04:00
parent e74eeb5c5b
commit b77da3a289
3 changed files with 94 additions and 85 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
</head> </head>
<body class="theme-{ user.Theme }"> <body x-data="dashboard" x-init="initDashboard()" class="theme-{ user.Theme }">
@Header(user, "/dashboard") @Header(user, "/dashboard")
<!-- Sticky Library Selector --> <!-- Sticky Library Selector -->
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);"> <div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
+1 -1
View File
@@ -34,7 +34,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Dashboard - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-{ user.Theme }\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Dashboard - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"dashboard\" x-init=\"initDashboard()\" class=\"theme-{ user.Theme }\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+92 -83
View File
@@ -1,3 +1,4 @@
import { Alpine } from "./alpine";
import { apiPost, apiPut } from "./api"; import { apiPost, apiPut } from "./api";
import { showToast } from "./toast"; import { showToast } from "./toast";
@@ -425,95 +426,103 @@ function initDragAndDrop(): void {
}); });
} }
initDragAndDrop(); function initDashboard() {
initDragAndDrop();
document.addEventListener("click", (e: Event) => { document.addEventListener("click", (e: Event) => {
const target = e.target as HTMLElement;
const actionElem = target.closest("[data-action]") as HTMLElement;
const action = actionElem?.getAttribute("data-action");
switch (action) {
case "scroll-carousel": {
const collectionId =
target.dataset.collectionId || actionElem?.dataset.collectionId;
const direction = parseInt(
target.dataset.direction || actionElem?.dataset.direction || "0",
);
if (collectionId) scrollCarousel(collectionId, direction);
break;
}
case "open-dashboard-settings":
openDashboardSettings();
break;
case "close-dashboard-settings":
closeDashboardSettings();
break;
case "save-dashboard-settings":
saveDashboardSettings();
break;
case "restore-system-collection": {
const colName =
actionElem?.dataset.collectionName || target.dataset.collectionName;
const colTitle =
actionElem?.dataset.collectionTitle ||
target.dataset.collectionTitle ||
"System Collection";
if (colName) restoreSystemCollection(colName, colTitle);
break;
}
case "view-book": {
const bookId = target.dataset.bookId || actionElem?.dataset.bookId;
if (bookId) viewBook(bookId);
break;
}
case "reload-page":
reloadPage();
break;
case "switch-library": {
const select = target as HTMLSelectElement;
if (select.value) switchLibrary(select.value);
break;
}
}
document.addEventListener("input", (e: Event) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
const actionElem = target.closest("[data-input-action]") as HTMLElement; const actionElem = target.closest("[data-action]") as HTMLElement;
const action = actionElem?.getAttribute("data-input-action"); const action = actionElem?.getAttribute("data-action");
switch (action) { switch (action) {
case "update-items-count": { case "scroll-carousel": {
const input = target as HTMLInputElement; const collectionId =
const displayTarget = input.getAttribute("target"); target.dataset.collectionId || actionElem?.dataset.collectionId;
if (displayTarget) updateItemsCount(input, displayTarget); const direction = parseInt(
target.dataset.direction || actionElem?.dataset.direction || "0",
);
if (collectionId) scrollCarousel(collectionId, direction);
break;
}
case "open-dashboard-settings":
openDashboardSettings();
break;
case "close-dashboard-settings":
closeDashboardSettings();
break;
case "save-dashboard-settings":
saveDashboardSettings();
break;
case "restore-system-collection": {
const colName =
actionElem?.dataset.collectionName || target.dataset.collectionName;
const colTitle =
actionElem?.dataset.collectionTitle ||
target.dataset.collectionTitle ||
"System Collection";
if (colName) restoreSystemCollection(colName, colTitle);
break;
}
case "view-book": {
const bookId = target.dataset.bookId || actionElem?.dataset.bookId;
if (bookId) viewBook(bookId);
break;
}
case "reload-page":
reloadPage();
break;
case "switch-library": {
const select = target as HTMLSelectElement;
if (select.value) switchLibrary(select.value);
break; break;
} }
} }
});
// Load saved library on page load document.addEventListener("input", (e: Event) => {
const librarySelect = document.getElementById( const target = e.target as HTMLElement;
"library-select", const actionElem = target.closest("[data-input-action]") as HTMLElement;
) as HTMLSelectElement; const action = actionElem?.getAttribute("data-input-action");
if (librarySelect) {
librarySelect.addEventListener("change", (e) => { switch (action) {
const target = e.target as HTMLSelectElement; case "update-items-count": {
if (target.value) { const input = target as HTMLInputElement;
switchLibrary(target.value); const displayTarget = input.getAttribute("target");
if (displayTarget) updateItemsCount(input, displayTarget);
break;
}
} }
}); });
} // Load saved library on page load
const savedLibrary = localStorage.getItem("selectedLibrary"); const librarySelect = document.getElementById(
const currentLibrary = new URLSearchParams(window.location.search).get( "library-select",
"library_id", ) as HTMLSelectElement;
); if (librarySelect) {
if (savedLibrary && savedLibrary !== currentLibrary) { librarySelect.addEventListener("change", (e) => {
window.location.href = `/dashboard?library_id=${savedLibrary}`; const target = e.target as HTMLSelectElement;
} if (target.value) {
}); switchLibrary(target.value);
}
});
}
const savedLibrary = localStorage.getItem("selectedLibrary");
const currentLibrary = new URLSearchParams(window.location.search).get(
"library_id",
);
if (savedLibrary && savedLibrary !== currentLibrary) {
window.location.href = `/dashboard?library_id=${savedLibrary}`;
}
});
}
export { initDashboard };
Alpine.data("dashboard", () => ({
initDashboard,
}));