Add dashboard redesign, custom section builder, and enhanced search functionality

Features:
- Complete dashboard redesign with improved UI components and layout
- Implement custom section builder for personalized book organization
- Add new events tracking system for user interactions
- Enhance search functionality with better static search.js
- Update TypeScript type definitions for API responses

Backend:
- Update Go dependencies in go.mod
- Add new frontend routes in router

Templates:
- Update admin and dashboard templates with new components

Frontend:
- Refactor analytics, collections, conflicts, and queue modules
- Add new documentation features in docs.ts
- Implement linking between books and collections
- Add toast notifications for user feedback
- Include placeholder book SVG asset

This commit consolidates multiple feature additions and improvements
across the entire stack including backend, templates, and frontend.
This commit is contained in:
2026-02-25 16:56:10 -05:00
parent 5864710e4f
commit a8920a8f6c
19 changed files with 1718 additions and 533 deletions
+253 -201
View File
@@ -1,148 +1,184 @@
// Dashboard functionality with unified collections architecture
// Procedural/imperative style (no OOP)
import type { SectionData, BookInfo } from './types/api';
const SCROLL_AMOUNT = 300;
function scrollCarousel(collectionId: string, direction: number): void {
const track = document.getElementById(`carousel-track-${collectionId}`) as HTMLElement;
if (!track) return;
const track = document.getElementById(
`carousel-track-${collectionId}`,
) as HTMLElement;
if (!track) return;
const scrollAmount = direction * SCROLL_AMOUNT;
track.scrollBy({ left: scrollAmount, behavior: 'smooth' });
const scrollAmount = direction * SCROLL_AMOUNT;
track.scrollBy({ left: scrollAmount, behavior: "smooth" });
}
function openDashboardSettings(): void {
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
if (modal) {
modal.classList.remove('hidden');
}
const modal = document.getElementById(
"dashboard-settings-modal",
) as HTMLElement;
if (modal) {
modal.classList.remove("hidden");
}
}
function closeDashboardSettings(): void {
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
if (modal) {
modal.classList.add('hidden');
}
const modal = document.getElementById(
"dashboard-settings-modal",
) as HTMLElement;
if (modal) {
modal.classList.add("hidden");
}
}
function toggleCollectionVisibility(collectionId: string): void {
const checkbox = document.querySelector(`input[data-collection-id="${collectionId}"]`) as HTMLInputElement;
if (checkbox) {
checkbox.checked = !checkbox.checked;
}
const checkbox = document.querySelector(
`input[data-collection-id="${collectionId}"]`,
) as HTMLInputElement;
if (checkbox) {
checkbox.checked = !checkbox.checked;
}
}
async function saveDashboardSettings(): Promise<void> {
const collectionList = document.getElementById('collection-list') as HTMLElement;
if (!collectionList) return;
const collectionList = document.getElementById(
"collection-list",
) as HTMLElement;
if (!collectionList) return;
const collectionItems = collectionList.querySelectorAll('[data-collection-id]') as NodeListOf<HTMLElement>;
const hiddenCollections: string[] = [];
const collectionOrder: string[] = [];
const collectionItems = collectionList.querySelectorAll(
"[data-collection-id]",
) as NodeListOf<HTMLElement>;
const hiddenCollections: string[] = [];
const collectionOrder: string[] = [];
collectionItems.forEach((item) => {
const collectionId = item.dataset.collectionId;
const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement;
collectionItems.forEach((item) => {
const collectionId = item.dataset.collectionId;
const checkbox = item.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement;
if (collectionId) {
collectionOrder.push(collectionId);
if (checkbox && !checkbox.checked) {
hiddenCollections.push(collectionId);
}
}
if (collectionId) {
collectionOrder.push(collectionId);
if (checkbox && !checkbox.checked) {
hiddenCollections.push(collectionId);
}
}
});
const itemsPerCollection =
(document.querySelector("#items-count-display") as HTMLElement)
?.textContent || "20";
try {
const response = await (window as any).api.put("/dashboard/preferences", {
library_id:
new URLSearchParams(window.location.search).get("library_id") || "",
hidden_collections: hiddenCollections,
collection_order: collectionOrder,
items_per_section: parseInt(itemsPerCollection),
});
const itemsPerCollection = (document.querySelector('#items-count-display') as HTMLElement)?.textContent || '20';
try {
const response = await (window as any).api.put('/dashboard/preferences', {
library_id: new URLSearchParams(window.location.search).get('library_id') || '',
hidden_collections: hiddenCollections,
collection_order: collectionOrder,
items_per_section: parseInt(itemsPerCollection),
});
if (response.ok) {
(window as any).showToast.success('Dashboard settings saved');
closeDashboardSettings();
window.location.reload();
}
} catch (error) {
(window as any).showToast.error('Failed to save settings');
console.error('Save dashboard settings error:', error);
if (response.ok) {
(window as any).showToast.success("Dashboard settings saved");
closeDashboardSettings();
window.location.reload();
}
} catch (error) {
(window as any).showToast.error("Failed to save settings");
console.error("Save dashboard settings error:", error);
}
}
async function restoreSystemCollection(collectionName: string, collectionTitle: string): Promise<void> {
if (!confirm(`Are you sure you want to reset "${collectionTitle}" to its default state? Any customizations will be lost.`)) {
return;
}
async function restoreSystemCollection(
collectionName: string,
collectionTitle: string,
): Promise<void> {
if (
!confirm(
`Are you sure you want to reset "${collectionTitle}" to its default state? Any customizations will be lost.`,
)
) {
return;
}
try {
const response = await (window as any).api.post('/dashboard/restore-system-collection', {
collection_name: collectionName,
});
try {
const response = await (window as any).api.post(
"/dashboard/restore-system-collection",
{
collection_name: collectionName,
},
);
if (response.ok) {
(window as any).showToast.success(`"${collectionTitle}" restored to defaults`);
setTimeout(() => window.location.reload(), 1000);
}
} catch (error) {
(window as any).showToast.error('Failed to restore system collection');
console.error('Restore system collection error:', error);
if (response.ok) {
(window as any).showToast.success(
`"${collectionTitle}" restored to defaults`,
);
setTimeout(() => window.location.reload(), 1000);
}
} catch (error) {
(window as any).showToast.error("Failed to restore system collection");
console.error("Restore system collection error:", error);
}
}
async function switchLibrary(libraryId: string): Promise<void> {
const container = document.getElementById('collections-container') as HTMLElement;
const loading = document.getElementById('loading-spinner') as HTMLElement;
const container = document.getElementById(
"collections-container",
) as HTMLElement;
const loading = document.getElementById("loading-spinner") as HTMLElement;
if (!container || !loading) return;
if (!container || !loading) return;
loading.classList.remove('hidden');
loading.classList.remove("hidden");
try {
const response = await fetch(`/api/dashboard/sections?library_id=${libraryId}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
}
});
try {
const response = await fetch(
`/api/dashboard/sections?library_id=${libraryId}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
throw new Error('Failed to load sections');
}
const data = await response.json();
renderCollections(data.sections);
} catch (error) {
(window as any).showToast.error('Failed to load library');
console.error('Switch library error:', error);
} finally {
loading.classList.add('hidden');
if (!response.ok) {
throw new Error("Failed to load sections");
}
const data = await response.json();
renderDashboardCollections(data.sections);
} catch (error) {
(window as any).showToast.error("Failed to load library");
console.error("Switch library error:", error);
} finally {
loading.classList.add("hidden");
}
}
function renderCollections(sections: SectionData[]): void {
const container = document.getElementById('collections-container') as HTMLElement;
if (!container) return;
// Preserve wood paneling attribute
const currentWood = container.getAttribute('data-wood');
function renderDashboardCollections(sections: SectionData[]): void {
const container = document.getElementById(
"collections-container",
) as HTMLElement;
if (!container) return;
container.innerHTML = sections.map(section => `
// Preserve wood paneling attribute
const currentWood = container.getAttribute("data-wood");
container.innerHTML = sections
.map(
(section) => `
<div class="dashboard-collection mb-8" data-collection-id="${section.id}">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="text-2xl">${section.icon}</span>
<div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">${section.title}</h2>
${section.description ? `<p class="text-sm" style="color: var(--text-secondary)">${section.description}</p>` : ''}
${section.description ? `<p class="text-sm" style="color: var(--text-secondary)">${section.description}</p>` : ""}
</div>
</div>
${section.view_all_url ? `<a href="${section.view_all_url}" class="text-sm font-medium hover:underline" style="color: var(--accent);">View All →</a>` : ''}
${section.view_all_url ? `<a href="${section.view_all_url}" class="text-sm font-medium hover:underline" style="color: var(--accent);">View All →</a>` : ""}
</div>
<div class="carousel-container relative group">
@@ -162,8 +198,11 @@ function renderCollections(sections: SectionData[]): void {
scroll-smooth snap-x snap-mandatory
px-12 pb-4"
style="scrollbar-width: none; -ms-overflow-style: none;">
${section.items.length > 0
? section.items.map(item => renderBookCard(item)).join('')
${
section.items.length > 0
? section.items
.map((item) => renderBookCard(item))
.join("")
: '<div class="text-center py-8 w-full" style="color: var(--text-secondary);"><p>No items in this collection</p></div>'
}
</div>
@@ -180,17 +219,19 @@ function renderCollections(sections: SectionData[]): void {
</button>
</div>
</div>
`).join('');
`,
)
.join("");
if(currentWood) {
container.setAttribute('data-wood', currentWood)
}
if (currentWood) {
container.setAttribute("data-wood", currentWood);
}
}
function renderBookCard(book: BookInfo): string {
const coverUrl = book.cover_image_path || '/static/placeholder-book.svg';
const coverUrl = book.cover_image_path || "/static/placeholder-book.svg";
return `
return `
<div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
transition-transform duration-200 hover:scale-105"
data-action="view-book"
@@ -209,131 +250,142 @@ function renderBookCard(book: BookInfo): string {
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
${book.title}
</h3>
${book.author ? `<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">${book.author}</p>` : ''}
${book.author ? `<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">${book.author}</p>` : ""}
</div>
`;
}
function viewBook(bookId: string): void {
console.log('View book:', bookId);
console.log("View book:", bookId);
}
function reloadPage(): void {
window.location.reload();
window.location.reload();
}
function updateItemsCount(input: HTMLInputElement, targetId: string): void {
const display = document.getElementById(targetId) as HTMLElement;
if (display) {
display.textContent = input.value;
}
const display = document.getElementById(targetId) as HTMLElement;
if (display) {
display.textContent = input.value;
}
}
function initDragAndDrop(): void {
const collectionList = document.getElementById('collection-list') as HTMLElement;
if (!collectionList) return;
const collectionList = document.getElementById(
"collection-list",
) as HTMLElement;
if (!collectionList) return;
let draggedItem: HTMLElement | null = null;
let draggedItem: HTMLElement | null = null;
collectionList.addEventListener('dragstart', (e: Event) => {
const target = e.target as HTMLElement;
if (target.classList.contains('collection-item')) {
draggedItem = target;
target.style.opacity = '0.5';
collectionList.addEventListener("dragstart", (e: Event) => {
const target = e.target as HTMLElement;
if (target.classList.contains("collection-item")) {
draggedItem = target;
target.style.opacity = "0.5";
}
});
collectionList.addEventListener("dragend", (e: Event) => {
const target = e.target as HTMLElement;
if (target.classList.contains("collection-item")) {
target.style.opacity = "1";
draggedItem = null;
}
});
collectionList.addEventListener("dragover", (e: Event) => {
e.preventDefault();
const target = e.target as HTMLElement;
if (
target.classList.contains("collection-item") &&
target !== draggedItem &&
draggedItem
) {
const rect = target.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if ((e as DragEvent).clientY < midY) {
target.parentNode?.insertBefore(draggedItem, target);
} else {
if (target.parentNode) {
target.parentNode.insertBefore(draggedItem, target.nextSibling);
}
});
collectionList.addEventListener('dragend', (e: Event) => {
const target = e.target as HTMLElement;
if (target.classList.contains('collection-item')) {
target.style.opacity = '1';
draggedItem = null;
}
});
collectionList.addEventListener('dragover', (e: Event) => {
e.preventDefault();
const target = e.target as HTMLElement;
if (target.classList.contains('collection-item') && target !== draggedItem && draggedItem) {
const rect = target.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if ((e as DragEvent).clientY < midY) {
target.parentNode?.insertBefore(draggedItem, target);
} else {
if (target.parentNode) {
target.parentNode.insertBefore(draggedItem, target.nextSibling);
}
}
}
});
}
}
});
}
document.addEventListener('DOMContentLoaded', () => {
initDragAndDrop();
document.addEventListener("DOMContentLoaded", () => {
initDragAndDrop();
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');
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;
}
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 "open-dashboard-settings":
openDashboardSettings();
break;
case 'close-dashboard-settings':
closeDashboardSettings();
break;
case "close-dashboard-settings":
closeDashboardSettings();
break;
case 'toggle-collection-visibility': {
const checkbox = target as HTMLInputElement;
const colId = checkbox.dataset.collectionId;
if (colId) toggleCollectionVisibility(colId);
break;
}
case "toggle-collection-visibility": {
const checkbox = target as HTMLInputElement;
const colId = checkbox.dataset.collectionId;
if (colId) toggleCollectionVisibility(colId);
break;
}
case 'save-dashboard-settings':
saveDashboardSettings();
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 "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 "view-book": {
const bookId = target.dataset.bookId || actionElem?.dataset.bookId;
if (bookId) viewBook(bookId);
break;
}
case 'reload-page':
reloadPage();
break;
case "reload-page":
reloadPage();
break;
case 'switch-library': {
const select = target as HTMLSelectElement;
if (select.value) switchLibrary(select.value);
break;
}
case "switch-library": {
const select = target as HTMLSelectElement;
if (select.value) switchLibrary(select.value);
break;
}
case 'update-items-count': {
const input = target as HTMLInputElement;
const displayTarget = input.getAttribute('target');
if (displayTarget) updateItemsCount(input, displayTarget);
break;
}
}
});
case "update-items-count": {
const input = target as HTMLInputElement;
const displayTarget = input.getAttribute("target");
if (displayTarget) updateItemsCount(input, displayTarget);
break;
}
}
});
});
export {};