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:
@@ -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;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { CollectionData, CollectionRule } from './types/api';
|
||||
|
||||
async function loadCollections(): Promise<void> {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { UnlinkedBookData, PotentialMatchData } from './types/api';
|
||||
|
||||
async function loadUnlinkedBooks(): Promise<void> {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Vendored
+30
-45
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user