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:
+253
-201
@@ -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 {};
|
||||
|
||||
Reference in New Issue
Block a user