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
-2
View File
@@ -1,5 +1,3 @@
import type { ReadingStatsResponse, DeviceUsageResponse, PopularBooksResponse } from './types/api';
async function loadAnalytics(): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
-2
View File
@@ -1,5 +1,3 @@
import type { CollectionData, CollectionRule } from './types/api';
async function loadCollections(): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
-2
View File
@@ -1,5 +1,3 @@
import type { ConflictDetailResponse, ConflictListResponse, BulkResolveResponse } from './types/api';
async function refreshConflicts(): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
+13 -15
View File
@@ -1,5 +1,3 @@
import type { BookInfo } from './types/api';
interface FilterField {
id: string;
label: string;
@@ -176,7 +174,7 @@ const FILTER_FIELDS: FilterField[] = [
let ruleCounter = 0;
let selectedBooks: Map<string, BookInfo> = new Map();
let searchTimeout: number | null = null;
let customSectionTimeout: number | null = null;
function initCustomSectionBuilder(): void {
const addRuleBtn = document.getElementById('add-rule-btn');
@@ -312,10 +310,10 @@ function removeFilterRule(ruleId: string): void {
}
function onBookSearchInput(): void {
if (searchTimeout) {
clearTimeout(searchTimeout);
if (customSectionTimeout) {
clearTimeout(customSectionTimeout);
}
searchTimeout = window.setTimeout(() => {
customSectionTimeout = window.setTimeout(() => {
searchBooks();
}, 300);
}
@@ -363,13 +361,13 @@ function displaySearchResults(books: BookInfo[]): void {
resultsContainer.innerHTML = books.map(book => `
<div class="flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer"
data-book-id="${book.media_item_id}"
onclick="addBookToSelection('${book.media_item_id}', '${escapeHtml(book.title)}', '${escapeHtml(book.author)}')">
onclick="addBookToSelection('${book.media_item_id}', '${builderEscapeHtml(book.title)}', '${builderEscapeHtml(book.author)}')">
<img src="${book.cover_image_path || '/static/placeholder-book.svg'}"
alt="${escapeHtml(book.title)}"
alt="${builderEscapeHtml(book.title)}"
class="w-10 h-15 object-cover rounded">
<div class="flex-1">
<p class="text-sm font-medium" style="color: var(--text-primary);">${escapeHtml(book.title)}</p>
<p class="text-xs" style="color: var(--text-secondary);">${escapeHtml(book.author)}</p>
<p class="text-sm font-medium" style="color: var(--text-primary);">${builderEscapeHtml(book.title)}</p>
<p class="text-xs" style="color: var(--text-secondary);">${builderEscapeHtml(book.author)}</p>
</div>
<button type="button" class="text-green-500 hover:text-green-700 text-xl">+</button>
</div>
@@ -412,7 +410,7 @@ function updateSelectedBooksDisplay(): void {
container.innerHTML = Array.from(selectedBooks.values()).map(book => `
<div class="inline-flex items-center gap-2 px-3 py-1 m-1 rounded-full text-sm"
style="background-color: var(--accent);">
<span>${escapeHtml(book.title)}</span>
<span>${builderEscapeHtml(book.title)}</span>
<button type="button" onclick="removeBookFromSelection('${book.media_item_id}')"
class="hover:opacity-70">×</button>
</div>
@@ -495,13 +493,13 @@ function displayPreview(items: BookInfo[]): void {
<div class="flex-shrink-0 w-32">
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2">
<img src="${item.cover_image_path || '/static/placeholder-book.svg'}"
alt="${escapeHtml(item.title)}"
alt="${builderEscapeHtml(item.title)}"
class="w-full h-full object-cover">
</div>
<h3 class="text-sm font-semibold line-clamp-2" style="color: var(--text-primary);">
${escapeHtml(item.title)}
${builderEscapeHtml(item.title)}
</h3>
${item.author ? `<p class="text-xs line-clamp-1" style="color: var(--text-secondary);">${escapeHtml(item.author)}</p>` : ''}
${item.author ? `<p class="text-xs line-clamp-1" style="color: var(--text-secondary);">${builderEscapeHtml(item.author)}</p>` : ''}
</div>
`).join('')}
</div>
@@ -560,7 +558,7 @@ async function saveCustomSection(event: Event): Promise<void> {
}
}
function escapeHtml(text: string): string {
function builderEscapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
+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 {};
+4 -4
View File
@@ -15,13 +15,13 @@ function initializeDocsSearch(): void {
if (!searchInput || !searchResults) return;
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
let docsSearchTimeout: ReturnType<typeof setTimeout> | null = null;
searchInput.addEventListener('input', () => {
const query = searchInput.value.trim();
if (searchTimeout) {
clearTimeout(searchTimeout);
if (docsSearchTimeout) {
clearTimeout(docsSearchTimeout);
}
if (query.length < 2) {
@@ -30,7 +30,7 @@ function initializeDocsSearch(): void {
return;
}
searchTimeout = setTimeout(() => {
docsSearchTimeout = setTimeout(() => {
performDocsSearch(query);
}, 300);
});
+110 -92
View File
@@ -1,117 +1,135 @@
function onDelegatedClick(selector: string, handler: (element: HTMLElement, event: MouseEvent) => void): void {
document.addEventListener('click', (event: MouseEvent) => {
const target = event.target as HTMLElement;
const element = target.closest(selector) as HTMLElement | null;
if (element) {
handler(element, event);
}
});
}
function onDelegatedSubmit(selector: string, handler: (form: HTMLFormElement, event: Event) => void): void {
document.addEventListener('submit', (event: Event) => {
const target = event.target as HTMLElement;
const form = target.closest(selector) as HTMLFormElement | null;
if (form) {
handler(form, event);
}
});
}
function onDelegatedChange(selector: string, handler: (element: HTMLElement, event: Event) => void): void {
document.addEventListener('change', (event: Event) => {
const target = event.target as HTMLElement;
const element = target.closest(selector) as HTMLElement | null;
if (element) {
handler(element, event);
}
});
}
function onDelegatedKeydown(selector: string, handler: (element: HTMLElement, event: KeyboardEvent) => void): void {
document.addEventListener('keydown', (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
const element = target.closest(selector) as HTMLElement | null;
if (element) {
handler(element, event);
}
});
}
function getDataAttribute(element: HTMLElement, name: string): string | undefined {
return element.dataset[name];
}
function setDataAttribute(element: HTMLElement, name: string, value: string): void {
element.dataset[name] = value;
}
function onClick(element: HTMLElement | null, handler: (event: MouseEvent) => void): void {
function onDelegatedClick(
selector: string,
handler: (element: HTMLElement, event: MouseEvent) => void,
): void {
document.addEventListener("click", (event: MouseEvent) => {
const target = event.target as HTMLElement;
const element = target.closest(selector) as HTMLElement | null;
if (element) {
element.addEventListener('click', handler);
handler(element, event);
}
});
}
function onSubmit(element: HTMLFormElement | null, handler: (event: Event) => void): void {
if (element) {
element.addEventListener('submit', handler);
function onDelegatedSubmit(
selector: string,
handler: (form: HTMLFormElement, event: Event) => void,
): void {
document.addEventListener("submit", (event: Event) => {
const target = event.target as HTMLElement;
const form = target.closest(selector) as HTMLFormElement | null;
if (form) {
handler(form, event);
}
});
}
function onChange(element: HTMLElement | null, handler: (event: Event) => void): void {
function onDelegatedChange(
selector: string,
handler: (element: HTMLElement, event: Event) => void,
): void {
document.addEventListener("change", (event: Event) => {
const target = event.target as HTMLElement;
const element = target.closest(selector) as HTMLElement | null;
if (element) {
element.addEventListener('change', handler);
handler(element, event);
}
});
}
function onKeydown(element: HTMLElement | null, handler: (event: KeyboardEvent) => void): void {
function onDelegatedKeydown(
selector: string,
handler: (element: HTMLElement, event: KeyboardEvent) => void,
): void {
document.addEventListener("keydown", (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
const element = target.closest(selector) as HTMLElement | null;
if (element) {
element.addEventListener('keydown', handler);
handler(element, event);
}
});
}
function onInput(element: HTMLElement | null, handler: (event: Event) => void): void {
if (element) {
element.addEventListener('input', handler);
}
function getDataAttribute(
element: HTMLElement,
name: string,
): string | undefined {
return element.dataset[name];
}
function setDataAttribute(
element: HTMLElement,
name: string,
value: string,
): void {
element.dataset[name] = value;
}
function onClick(
element: HTMLElement | null,
handler: (event: MouseEvent) => void,
): void {
if (element) {
element.addEventListener("click", handler);
}
}
function onSubmit(
element: HTMLFormElement | null,
handler: (event: Event) => void,
): void {
if (element) {
element.addEventListener("submit", handler);
}
}
function onChange(
element: HTMLElement | null,
handler: (event: Event) => void,
): void {
if (element) {
element.addEventListener("change", handler);
}
}
function onKeydown(
element: HTMLElement | null,
handler: (event: KeyboardEvent) => void,
): void {
if (element) {
element.addEventListener("keydown", handler);
}
}
function onInput(
element: HTMLElement | null,
handler: (event: Event) => void,
): void {
if (element) {
element.addEventListener("input", handler);
}
}
function preventDefault(event: Event): void {
event.preventDefault();
event.preventDefault();
}
function stopPropagation(event: Event): void {
event.stopPropagation();
event.stopPropagation();
}
(window as any).events = {
onDelegatedClick,
onDelegatedSubmit,
onDelegatedChange,
onDelegatedKeydown,
getDataAttribute,
setDataAttribute,
onClick,
onSubmit,
onChange,
onKeydown,
onInput,
preventDefault,
stopPropagation
};
export {
onDelegatedClick,
onDelegatedSubmit,
onDelegatedChange,
onDelegatedKeydown,
getDataAttribute,
setDataAttribute,
onClick,
onSubmit,
onChange,
onKeydown,
onInput,
preventDefault,
stopPropagation
onDelegatedClick,
onDelegatedSubmit,
onDelegatedChange,
onDelegatedKeydown,
getDataAttribute,
setDataAttribute,
onClick,
onSubmit,
onChange,
onKeydown,
onInput,
preventDefault,
stopPropagation,
};
-2
View File
@@ -1,5 +1,3 @@
import type { UnlinkedBookData, PotentialMatchData } from './types/api';
async function loadUnlinkedBooks(): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
-2
View File
@@ -1,5 +1,3 @@
import type { QueueItemResponse } from './types/api';
async function refreshQueue(): Promise<void> {
const token = localStorage.getItem('token');
if (!token) return;
+9 -11
View File
@@ -1,6 +1,4 @@
import type { MediaItemSummary } from './types/api';
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
let searchInputTimeout: ReturnType<typeof setTimeout> | null = null;
const SEARCH_DEBOUNCE_MS = 300;
const SEARCH_MIN_CHARS = 2;
@@ -33,8 +31,8 @@ function handleSearchInput(e: Event): void {
const target = e.target as HTMLInputElement;
const query = target.value.trim();
if (searchTimeout) {
clearTimeout(searchTimeout);
if (searchInputTimeout) {
clearTimeout(searchInputTimeout);
}
if (query.length < SEARCH_MIN_CHARS) {
@@ -42,7 +40,7 @@ function handleSearchInput(e: Event): void {
return;
}
searchTimeout = setTimeout(() => {
searchInputTimeout = setTimeout(() => {
performSearch(query);
}, SEARCH_DEBOUNCE_MS);
}
@@ -164,7 +162,7 @@ function showSearchResults(results: MediaItemSummary[], query: string): void {
let html = `
<div class="p-3 border-b" style="border-color: var(--border)">
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">
${results.length} result${results.length !== 1 ? 's' : ''} for "${escapeHtml(query)}"
${results.length} result${results.length !== 1 ? 's' : ''} for "${searchEscapeHtml(query)}"
</p>
</div>
<div class="max-h-96 overflow-y-auto">
@@ -190,7 +188,7 @@ function showSearchResults(results: MediaItemSummary[], query: string): void {
</h4>
${authorHtml ? `<p class="text-xs truncate" style="color: var(--text-secondary)">${authorHtml}</p>` : ''}
<p class="text-xs mt-1" style="color: var(--text-secondary)">
${escapeHtml(item.library_name)}
${searchEscapeHtml(item.library_name)}
</p>
</div>
</div>
@@ -221,7 +219,7 @@ function showNoResults(query: string): void {
searchResults.innerHTML = `
<div class="p-4 text-center">
<div class="text-4xl mb-2">🔍</div>
<p class="text-sm" style="color: var(--text-primary)">No results found for "${escapeHtml(query)}"</p>
<p class="text-sm" style="color: var(--text-primary)">No results found for "${searchEscapeHtml(query)}"</p>
<p class="text-xs mt-1" style="color: var(--text-secondary)">Try different keywords</p>
</div>
`;
@@ -272,10 +270,10 @@ function highlightMatch(text: string, query: string): string {
if (!text) return '';
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedQuery})`, 'gi');
return escapeHtml(text).replace(regex, '<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">$1</mark>');
return searchEscapeHtml(text).replace(regex, '<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">$1</mark>');
}
function escapeHtml(text: string): string {
function searchEscapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
+2 -2
View File
@@ -18,7 +18,7 @@ const createToastContainer = (): HTMLElement => {
};
// Escape HTML to prevent XSS
const escapeHtml = (text: string): string => {
const toastEscapeHtml = (text: string): string => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
@@ -52,7 +52,7 @@ const createToastElement = (message: string, type: ToastType): HTMLElement => {
toast.innerHTML = `
<span class="text-xl flex-shrink-0">${config.icon}</span>
<span class="flex-1 break-words">${escapeHtml(message)}</span>
<span class="flex-1 break-words">${toastEscapeHtml(message)}</span>
<button class="toast-close bg-transparent border-0 text-white cursor-pointer text-lg p-0 w-5 h-5 flex items-center justify-center opacity-70 hover:opacity-100 flex-shrink-0 transition-opacity">
×
</button>
+30 -45
View File
@@ -1,23 +1,8 @@
// ============================================
// API Type Definitions
// ============================================
// These types match the JSON responses from /api/* endpoints.
// Source of truth: Check what the endpoint ACTUALLY returns:
// 1. Database layer: internal/database/queries.sql.go (SearchMediaItemsRow, etc.)
// 2. Handler structs: internal/handlers/*.go (check json:"..." tags)
// 3. Test by calling endpoint and inspecting JSON response
//
// When API contracts change:
// 1. Find the endpoint function in internal/handlers/*.go
// 2. Check what it returns (database row or struct)
// 3. Check the JSON tags: `json:"field_name"`
// 4. Map pgtype fields to TypeScript types:
// - pgtype.Text → string | undefined
// - pgtype.UUID → string
// - pgtype.Timestamp → string (ISO datetime)
// - pgtype.Numeric → number or string (for precision)
// 5. Update the interface below with snake_case field names
// 6. Run Bruno tests to verify
// These types are globally available in all .ts files.
// No imports needed - just use the type names directly.
// ============================================
// Matches database.SearchMediaItemsRow from /api/media-items/search
@@ -25,7 +10,7 @@
// Endpoint: internal/handlers/media.go:SearchMediaItems()
// Note: internal/handlers/search.go has an unused MediaItemSummary - ignore it
// Used in: search.ts
export interface MediaItemSummary {
interface MediaItemSummary {
id: string;
library_id: string;
title: string;
@@ -77,7 +62,7 @@ export interface MediaItemSummary {
// Matches handlers.CollectionData / CollectionResponse JSON response
// Source: internal/handlers/collections.go:123-131 CollectionResponse
// Used in: collections.ts
export interface CollectionData {
interface CollectionData {
id: string;
name: string;
description: string;
@@ -90,7 +75,7 @@ export interface CollectionData {
// Matches handlers.BookInfo JSON response (internal/handlers/collections.go:66-71)
// JSON tags: media_item_id, title, author, cover_image_path
// Used in: collections.templ (server-rendered), collections.ts
export interface BookInfo {
interface BookInfo {
media_item_id: string;
title: string;
author: string;
@@ -101,7 +86,7 @@ export interface BookInfo {
// CRITICAL: Must match Go handler return types EXACTLY
// Source: handlers.SectionData in collections.go (lines 73-81)
// Used in: dashboard API responses, TypeScript dashboard components
export interface SectionData {
interface SectionData {
id: string;
is_system: boolean;
title: string;
@@ -115,7 +100,7 @@ export interface SectionData {
// Matches database.UserDashboardPreferences and dashboard preferences API
// Source: internal/database/models.go:381-390
// Used in: dashboard preferences API
export interface DashboardPreferences {
interface DashboardPreferences {
library_id: string;
hidden_collections: string[];
collection_order: string[];
@@ -124,7 +109,7 @@ export interface DashboardPreferences {
// Matches handlers.UnlinkedBookData JSON response
// Used in: unlinked_books.ts, unlinked_books.templ
export interface UnlinkedBookData {
interface UnlinkedBookData {
progress_id: string;
device_id: string;
device_name: string;
@@ -137,7 +122,7 @@ export interface UnlinkedBookData {
potential_matches: PotentialMatchData[];
}
export interface PotentialMatchData {
interface PotentialMatchData {
media_item_id: string;
title: string;
author: string;
@@ -147,7 +132,7 @@ export interface PotentialMatchData {
// Matches collection rule objects
// Used in: collection_rules.ts
export interface CollectionRule {
interface CollectionRule {
id: string;
field: 'genre' | 'series' | 'author' | 'language' | 'publisher' | 'copyright_year' | 'tags';
operator: 'equals' | 'not_equals' | 'contains' | 'not_contains' | 'starts_with' | 'ends_with' | 'greater_than' | 'less_than';
@@ -158,31 +143,31 @@ export interface CollectionRule {
// Matches API test rule responses
// Used in: collection_rules.ts (test results)
export interface TestRuleMatch {
interface TestRuleMatch {
title: string;
author: string;
cover_image_path?: string;
}
// Matches handlers.SearchResponse (internal/handlers/search.go)
export interface SearchResponse {
interface SearchResponse {
results: SearchBookResponse[];
total: number;
}
export interface SearchBookResponse {
interface SearchBookResponse {
id: string;
title: string;
authors: SearchAuthor[];
}
export interface SearchAuthor {
interface SearchAuthor {
first_name: string;
last_name: string;
}
// Matches AuthResponse (internal/handlers/auth.go:59-65)
export interface AuthResponse {
interface AuthResponse {
access_token: string;
refresh_token?: string;
token_type: string;
@@ -190,7 +175,7 @@ export interface AuthResponse {
user: UserProfile;
}
export interface UserProfile {
interface UserProfile {
id: string;
email: string;
username: string;
@@ -202,7 +187,7 @@ export interface UserProfile {
// Matches handlers.ReadingStatsResponse (internal/handlers/analytics.go:26-35)
// Used in: analytics.ts
export interface ReadingStatsResponse {
interface ReadingStatsResponse {
total_books_read: number;
total_pages_read: number;
total_reading_time_minutes: number;
@@ -213,7 +198,7 @@ export interface ReadingStatsResponse {
daily_reading_minutes: DailyReading[];
}
export interface DailyReading {
interface DailyReading {
date: string;
minutes: number;
pages: number;
@@ -222,11 +207,11 @@ export interface DailyReading {
// Matches handlers.DeviceUsageResponse (internal/handlers/analytics.go:43-45)
// Note: Response is wrapped: { devices: DeviceUsage[] }
// Used in: analytics.ts
export interface DeviceUsageResponse {
interface DeviceUsageResponse {
devices: DeviceUsage[];
}
export interface DeviceUsage {
interface DeviceUsage {
device_id: string;
device_name: string;
device_type: string;
@@ -239,11 +224,11 @@ export interface DeviceUsage {
// Matches handlers.PopularBooksResponse (internal/handlers/analytics.go:57-59)
// Note: Response is wrapped: { books: PopularBook[] }
// Used in: analytics.ts
export interface PopularBooksResponse {
interface PopularBooksResponse {
books: PopularBook[];
}
export interface PopularBook {
interface PopularBook {
media_item_id: string;
title: string;
author: string;
@@ -254,7 +239,7 @@ export interface PopularBook {
// Matches handlers.QueueItemResponse (internal/handlers/queue.go:35-51)
// Used in: queue.ts
export interface QueueItemResponse {
interface QueueItemResponse {
id: string;
device_id: string;
device_name: string;
@@ -274,7 +259,7 @@ export interface QueueItemResponse {
// Matches handlers.QueueStatsResponse (internal/handlers/queue.go:27-33)
// Used in: queue.ts
export interface QueueStatsResponse {
interface QueueStatsResponse {
pending_count: number;
processing_count: number;
failed_count: number;
@@ -284,7 +269,7 @@ export interface QueueStatsResponse {
// Matches handlers.ConflictDetailResponse (internal/handlers/conflicts.go:42-53)
// Used in: conflicts.ts
export interface ConflictDetailResponse {
interface ConflictDetailResponse {
id: string;
media_item_id: string;
media_item_title: string;
@@ -298,7 +283,7 @@ export interface ConflictDetailResponse {
}
// Matches handlers.ConflictSourceData (internal/handlers/conflicts.go:36-40)
export interface ConflictSourceData {
interface ConflictSourceData {
source: string;
timestamp: string;
data: Record<string, unknown>;
@@ -306,7 +291,7 @@ export interface ConflictSourceData {
// Matches handlers.ConflictListResponse (internal/handlers/conflicts.go:55-59)
// Used in: conflicts.ts
export interface ConflictListResponse {
interface ConflictListResponse {
conflicts: ConflictDetailResponse[];
total: number;
unresolved: number;
@@ -314,7 +299,7 @@ export interface ConflictListResponse {
// Matches handlers.ConflictResolveResponse (internal/handlers/conflicts.go:61-65)
// Used in: conflicts.ts
export interface ConflictResolveResponse {
interface ConflictResolveResponse {
conflict_resolved: boolean;
applied_to: Record<string, boolean>;
devices_synced: string[];
@@ -322,7 +307,7 @@ export interface ConflictResolveResponse {
// Matches handlers.BulkResolveResponse (internal/handlers/conflicts.go:424-429)
// Used in: conflicts.ts
export interface BulkResolveResponse {
interface BulkResolveResponse {
results: ConflictResult[];
total: number;
success: number;
@@ -330,7 +315,7 @@ export interface BulkResolveResponse {
}
// Matches handlers.ConflictResult (internal/handlers/conflicts.go:431-436)
export interface ConflictResult {
interface ConflictResult {
conflict_id: string;
status: string;
error?: string;
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 192" width="128" height="192">
<defs>
<linearGradient id="bookGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4B5563"/>
<stop offset="100%" style="stop-color:#1F2937"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="128" height="192" rx="8" fill="url(#bookGrad)"/>
<rect x="8" y="8" width="112" height="176" rx="4" fill="none" stroke="#6B7280" stroke-width="2"/>
<text x="64" y="100" text-anchor="middle" fill="#9CA3AF" font-family="sans-serif" font-size="40">📚</text>
</svg>

After

Width:  |  Height:  |  Size: 602 B

+9 -10
View File
@@ -1,6 +1,5 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
let searchTimeout = null;
let searchInputTimeout = null;
const SEARCH_DEBOUNCE_MS = 300;
const SEARCH_MIN_CHARS = 2;
function initializeSearch() {
@@ -27,14 +26,14 @@ function initializeSearch() {
function handleSearchInput(e) {
const target = e.target;
const query = target.value.trim();
if (searchTimeout) {
clearTimeout(searchTimeout);
if (searchInputTimeout) {
clearTimeout(searchInputTimeout);
}
if (query.length < SEARCH_MIN_CHARS) {
hideSearchResults();
return;
}
searchTimeout = setTimeout(() => {
searchInputTimeout = setTimeout(() => {
performSearch(query);
}, SEARCH_DEBOUNCE_MS);
}
@@ -150,7 +149,7 @@ function showSearchResults(results, query) {
let html = `
<div class="p-3 border-b" style="border-color: var(--border)">
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">
${results.length} result${results.length !== 1 ? 's' : ''} for "${escapeHtml(query)}"
${results.length} result${results.length !== 1 ? 's' : ''} for "${searchEscapeHtml(query)}"
</p>
</div>
<div class="max-h-96 overflow-y-auto">
@@ -174,7 +173,7 @@ function showSearchResults(results, query) {
</h4>
${authorHtml ? `<p class="text-xs truncate" style="color: var(--text-secondary)">${authorHtml}</p>` : ''}
<p class="text-xs mt-1" style="color: var(--text-secondary)">
${escapeHtml(item.library_name)}
${searchEscapeHtml(item.library_name)}
</p>
</div>
</div>
@@ -202,7 +201,7 @@ function showNoResults(query) {
searchResults.innerHTML = `
<div class="p-4 text-center">
<div class="text-4xl mb-2">🔍</div>
<p class="text-sm" style="color: var(--text-primary)">No results found for "${escapeHtml(query)}"</p>
<p class="text-sm" style="color: var(--text-primary)">No results found for "${searchEscapeHtml(query)}"</p>
<p class="text-xs mt-1" style="color: var(--text-secondary)">Try different keywords</p>
</div>
`;
@@ -249,9 +248,9 @@ function highlightMatch(text, query) {
return '';
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedQuery})`, 'gi');
return escapeHtml(text).replace(regex, '<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">$1</mark>');
return searchEscapeHtml(text).replace(regex, '<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">$1</mark>');
}
function escapeHtml(text) {
function searchEscapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;