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
File diff suppressed because it is too large Load Diff
+1
View File
@@ -20,6 +20,7 @@ require (
github.com/labstack/echo/v4 v4.15.1
github.com/nwaples/rardecode v1.1.3
github.com/pierrec/lz4/v4 v4.1.25
github.com/pdfcpu/pdfcpu v0.9.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.11.1
+5 -1
View File
@@ -139,12 +139,16 @@ func registerFrontendRoutes(cfg *Config) {
prefs, _ := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
limit := 20
if prefs.ItemsPerSection.Int32 > 0 {
limit = int(prefs.ItemsPerSection.Int32)
}
var sections []services.DashboardSection
sections, err = cfg.DashboardService.GetDashboardSections(
c.Request().Context(),
userUUID,
libUUID,
int(prefs.ItemsPerSection.Int32),
limit,
prefs.CollectionOrder,
prefs.HiddenCollections,
)
+2 -2
View File
@@ -29,7 +29,7 @@ func Admin(user User) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Admin Dashboard - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><script src=\"/static/toast.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-tokyo-night\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Admin Dashboard - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><script src=\"/static/toast.js\"></script><script src=\"/static/admin.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-tokyo-night\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -45,7 +45,7 @@ func Admin(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">Dashboard</h1><p style=\"color: var(--text-secondary)\">Overview of your Bookhoard library and settings</p></div><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6 mb-8\"><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><div class=\"flex items-center space-x-3\"><div class=\"text-3xl\">📖</div><div><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Library</h3><p style=\"color: var(--text-secondary)\" class=\"text-sm\">Manage your ebook collection</p></div></div><a href=\"/\" class=\"mt-4 inline-block text-sm btn-secondary px-3 py-1 rounded\">View Library</a></div><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><div class=\"flex items-center space-x-3\"><div class=\"text-3xl\">⚙️</div><div><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Settings</h3><p style=\"color: var(--text-secondary)\" class=\"text-sm\">Configure your preferences</p></div></div><a href=\"/profile\" class=\"mt-4 inline-block text-sm btn-secondary px-3 py-1 rounded\">Manage Settings</a></div></div><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><h3 class=\"text-xl font-semibold mb-4\" style=\"color: var(--text-primary)\">Quick Actions</h3><div class=\"grid grid-cols-1 sm:grid-cols-2 gap-4\"><button onclick=\"quickScan()\" class=\"btn-primary p-4 rounded-lg text-left\"><div class=\"font-medium\">Scan Library</div><div style=\"color: var(--text-secondary)\" class=\"text-sm\">Find new ebooks in your folders</div></button> <a href=\"/admin/library\" class=\"btn-secondary p-4 rounded-lg text-left block\"><div class=\"font-medium\">Manage Folders</div><div style=\"color: var(--text-secondary)\" class=\"text-sm\">Add or remove scan directories</div></a></div></div></div></main></div><script>\n function quickScan() {\n fetch('/api/scanner/scan', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': 'Bearer ' + localStorage.getItem('token')\n },\n body: JSON.stringify({\n folder_paths: []\n })\n }).then(res => res.json()).then(data => {\n alert(data.message || 'Scan completed successfully!');\n }).catch(err => {\n console.error('Scan error:', err);\n alert('Scan failed. Please check your folder configuration.');\n });\n }\n\n function logout() {\n localStorage.removeItem('token');\n localStorage.removeItem('user');\n window.location.href = '/';\n }\n\n document.addEventListener('DOMContentLoaded', function() {\n loadTheme();\n });\n </script></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">Dashboard</h1><p style=\"color: var(--text-secondary)\">Overview of your Bookhoard library and settings</p></div><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6 mb-8\"><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><div class=\"flex items-center space-x-3\"><div class=\"text-3xl\">📖</div><div><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Library</h3><p style=\"color: var(--text-secondary)\" class=\"text-sm\">Manage your ebook collection</p></div></div><a href=\"/\" class=\"mt-4 inline-block text-sm btn-secondary px-3 py-1 rounded\">View Library</a></div><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><div class=\"flex items-center space-x-3\"><div class=\"text-3xl\">⚙️</div><div><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Settings</h3><p style=\"color: var(--text-secondary)\" class=\"text-sm\">Configure your preferences</p></div></div><a href=\"/profile\" class=\"mt-4 inline-block text-sm btn-secondary px-3 py-1 rounded\">Manage Settings</a></div></div><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><h3 class=\"text-xl font-semibold mb-4\" style=\"color: var(--text-primary)\">Quick Actions</h3><div class=\"grid grid-cols-1 sm:grid-cols-2 gap-4\"><button onclick=\"scanAllLibraries()\" class=\"btn-primary p-4 rounded-lg text-left\"><div class=\"font-medium\">Scan Library</div><div style=\"color: var(--text-secondary)\" class=\"text-sm\">Find new ebooks in your folders</div></button> <a href=\"/admin/library\" class=\"btn-secondary p-4 rounded-lg text-left block\"><div class=\"font-medium\">Manage Folders</div><div style=\"color: var(--text-secondary)\" class=\"text-sm\">Add or remove scan directories</div></a></div></div><!-- Scan Progress Section --><div id=\"scan-progress-container\" class=\"hidden mt-6 p-6 rounded-lg border opacity-0 -translate-y-2.5 transition-all duration-300 ease-out\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-4\"><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">📚 Scanning Libraries</h3><button onclick=\"hideScanProgress()\" class=\"p-2 hover:bg-gray-700 rounded\">✕</button></div><!-- Overall Progress --><div class=\"mb-4\"><div class=\"flex justify-between text-sm mb-2\"><span style=\"color: var(--text-secondary)\">Overall Progress</span> <span id=\"scan-progress-text\" style=\"color: var(--text-primary)\">0%</span></div><div class=\"w-full bg-gray-700 rounded-full h-3\"><div id=\"scan-progress-bar\" class=\"h-3 rounded-full transition-all duration-500\" style=\"width: 0%; background-color: var(--accent);\"></div></div><div id=\"scan-status\" class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">Starting scan...</div></div><!-- Per-Library Progress --><div id=\"library-progress-list\" class=\"space-y-3\"><!-- Dynamically populated --></div><!-- Results Summary --><div id=\"scan-results\" class=\"hidden mt-6 p-4 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><h4 class=\"font-semibold mb-2\" style=\"color: var(--text-primary)\">✅ Scan Complete!</h4><div id=\"scan-results-content\" style=\"color: var(--text-secondary)\"><!-- Results populated by JS --></div><div class=\"mt-4 flex gap-2\"><button onclick=\"window.location.reload()\" class=\"btn-primary px-4 py-2 rounded-lg\">Refresh to View Books</button> <button onclick=\"hideScanProgress()\" class=\"btn-secondary px-4 py-2 rounded-lg\">Dismiss</button></div></div></div></div></main></div><script>\n function logout() {\n localStorage.removeItem('token');\n localStorage.removeItem('user');\n window.location.href = '/';\n }\n\n document.addEventListener('DOMContentLoaded', function() {\n loadTheme();\n });\n </script></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+102 -68
View File
@@ -9,28 +9,30 @@ templ Dashboard(user User, sections []handlers.SectionData, libData []LibraryDat
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Dashboard - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/dashboard.js"></script>
<link href="/static/style.css" rel="stylesheet">
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/dashboard")
<!-- Sticky Library Selector -->
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
<div class="w-full px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-4">
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library:</label>
<select id="library-select" name="library_id"
<select
id="library-select"
name="library_id"
class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-secondary); color: var(--text-primary);"
data-action="switch-library">
data-action="switch-library"
>
for _, lib := range libData {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name }</option>
@@ -40,39 +42,41 @@ templ Dashboard(user User, sections []handlers.SectionData, libData []LibraryDat
}
</select>
</div>
<div class="flex items-center gap-2">
<button data-action="open-dashboard-settings"
<button
data-action="open-dashboard-settings"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-secondary);"
title="Customize Dashboard">
title="Customize Dashboard"
>
⚙️
</button>
<button data-action="reload-page"
<button
data-action="reload-page"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-secondary);"
title="Refresh">
title="Refresh"
>
🔄
</button>
</div>
</div>
<div id="loading-spinner" class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50"
style="background-color: var(--bg-primary);">
<div
id="loading-spinner"
class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50"
style="background-color: var(--bg-primary);"
>
<div class="animate-spin rounded-full h-12 w-12 border-b-2" style="border-color: var(--accent);"></div>
</div>
</div>
<!-- Collections Container -->
<main id="collections-container" class="w-full px-4 py-8">
for _, section := range sections {
@CollectionCarousel(section)
}
</main>
<!-- Dashboard Settings Modal -->
@DashboardSettingsModal(sections)
@ErrorToast(errorMessage)
<script src="/static/woodPanelingInit.js"></script>
</body>
@@ -80,9 +84,11 @@ templ Dashboard(user User, sections []handlers.SectionData, libData []LibraryDat
}
templ CollectionCarousel(section handlers.SectionData) {
<div class="dashboard-collection mb-8"
<div
class="dashboard-collection mb-8"
data-collection-id={ section.ID }
data-is-system={ section.IsSystem }>
data-is-system={ section.IsSystem }
>
<!-- Collection Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
@@ -94,53 +100,56 @@ templ CollectionCarousel(section handlers.SectionData) {
}
</div>
</div>
if section.ViewAllURL != "" {
<a href={ section.ViewAllURL }
<a
href={ section.ViewAllURL }
class="text-sm font-medium hover:underline transition-colors"
style="color: var(--accent);">
style="color: var(--accent);"
>
View All
</a>
}
</div>
<!-- Carousel -->
<div class="carousel-container relative group">
<button class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
<button
class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
flex items-center justify-start opacity-0 group-hover:opacity-100
transition-opacity duration-200"
data-action="scroll-carousel"
data-collection-id={ section.ID }
data-direction="-1"
aria-label="Scroll left">
aria-label="Scroll left"
>
<span class="text-3xl pl-2" style="color: var(--text-primary);"></span>
</button>
<div id="carousel-track-{ section.ID }"
<div
id={ "carousel-track-" + section.ID }
class="carousel-track flex gap-4 overflow-x-auto
scroll-smooth snap-x snap-mandatory
px-12 pb-4"
style="scrollbar-width: none; -ms-overflow-style: none;">
style="scrollbar-width: none; -ms-overflow-style: none;"
>
for _, item := range section.Items {
@BookCard(item)
}
if len(section.Items) == 0 {
<div class="text-center py-8 w-full" style="color: var(--text-secondary);">
<p>No items in this collection</p>
</div>
}
</div>
<button class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
<button
class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
flex items-center justify-end opacity-0 group-hover:opacity-100
transition-opacity duration-200"
data-action="scroll-carousel"
data-collection-id={ section.ID }
data-direction="1"
aria-label="Scroll right">
aria-label="Scroll right"
>
<span class="text-3xl pr-2" style="color: var(--text-primary);"></span>
</button>
</div>
@@ -148,32 +157,38 @@ templ CollectionCarousel(section handlers.SectionData) {
}
templ BookCard(item handlers.BookInfo) {
<div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
<div
class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
transition-transform duration-200 hover:scale-105"
data-action="view-book"
data-book-id={ item.MediaItemID }
tabindex="0"
role="button"
aria-label={ "View " + item.Title }>
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2
bg-gradient-to-br from-gray-700 to-gray-900">
aria-label={ "View " + item.Title }
>
<div
class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2
bg-gradient-to-br from-gray-700 to-gray-900"
>
if item.CoverImagePath != "" {
<img src={ item.CoverImagePath }
<img
src={ item.CoverImagePath }
alt={ item.Title }
class="w-full h-full object-cover"
loading="lazy"
onerror="this.src='/static/placeholder-book.svg'">
onerror="this.src='/static/placeholder-book.svg'"
/>
} else {
<img src="/static/placeholder-book.svg"
<img
src="/static/placeholder-book.svg"
alt={ item.Title }
class="w-full h-full object-cover">
class="w-full h-full object-cover"
/>
}
</div>
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
{ item.Title }
</h3>
if item.Author != "" {
<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">
{ item.Author }
@@ -183,31 +198,38 @@ templ BookCard(item handlers.BookInfo) {
}
templ DashboardSettingsModal(sections []handlers.SectionData) {
<div id="dashboard-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.7);">
<div class="rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl"
style="background-color: var(--bg-secondary);">
<div
id="dashboard-settings-modal"
class="hidden fixed inset-0 z-50 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.7);"
>
<div
class="rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl"
style="background-color: var(--bg-secondary);"
>
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
<button data-action="close-dashboard-settings"
class="p-2 hover:bg-gray-700 rounded transition-colors">
<button
data-action="close-dashboard-settings"
class="p-2 hover:bg-gray-700 rounded transition-colors"
>
</button>
</div>
<p class="text-sm mb-4" style="color: var(--text-secondary);">
Drag to reorder collections, toggle visibility with the switch.
</p>
<!-- Draggable Collection List -->
<div id="collection-list" class="space-y-2 mb-6">
for _, section := range sections {
<div class="collection-item flex items-center justify-between p-3 rounded border
<div
class="collection-item flex items-center justify-between p-3 rounded border
cursor-move select-none"
data-collection-id={ section.ID }
data-is-system={ fmt.Sprintf("%v", section.IsSystem) }
draggable="true"
style="background-color: var(--bg-primary); border-color: var(--border);">
style="background-color: var(--bg-primary); border-color: var(--border);"
>
<div class="flex items-center gap-3">
<span class="text-xl" style="color: var(--text-secondary);"></span>
<span class="text-xl">{ section.Icon }</span>
@@ -218,55 +240,67 @@ templ DashboardSettingsModal(sections []handlers.SectionData) {
}
</div>
</div>
<div class="flex items-center gap-3">
if section.IsSystem {
<button data-action="restore-system-collection"
<button
data-action="restore-system-collection"
data-collection-name={ section.ID }
class="text-xs px-3 py-1 rounded border hover:opacity-80 transition-opacity"
style="border-color: var(--border); color: var(--text-secondary);"
title="Restore { section.Title } to defaults">
title="Restore { section.Title } to defaults"
>
Restore
</button>
}
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox"
<input
type="checkbox"
class="sr-only peer"
checked
data-action="toggle-collection-visibility"
data-collection-id={ section.ID }>
<div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer
data-collection-id={ section.ID }
/>
<div
class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all
peer-checked:bg-blue-600"></div>
peer-checked:bg-blue-600"
></div>
</label>
</div>
</div>
}
</div>
<!-- Items Per Section Slider -->
<div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Items per Collection: <span id="items-count-display" class="font-bold">20</span>
</label>
<input type="range" min="10" max="50" step="5" value="20"
<input
type="range"
min="10"
max="50"
step="5"
value="20"
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
data-action="update-items-count"
target="items-count-display">
target="items-count-display"
/>
</div>
<div class="flex justify-end gap-3">
<button data-action="close-dashboard-settings"
<button
data-action="close-dashboard-settings"
class="px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors"
style="border-color: var(--border); color: var(--text-primary);">
style="border-color: var(--border); color: var(--text-primary);"
>
Cancel
</button>
<button data-action="save-dashboard-settings"
<button
data-action="save-dashboard-settings"
class="px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);">
style="background-color: var(--accent);"
>
Save Changes
</button>
</div>
-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;
+133 -81
View File
@@ -1,50 +1,62 @@
// 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;
const track = document.getElementById(
`carousel-track-${collectionId}`,
) as HTMLElement;
if (!track) return;
const scrollAmount = direction * SCROLL_AMOUNT;
track.scrollBy({ left: scrollAmount, behavior: 'smooth' });
track.scrollBy({ left: scrollAmount, behavior: "smooth" });
}
function openDashboardSettings(): void {
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
const modal = document.getElementById(
"dashboard-settings-modal",
) as HTMLElement;
if (modal) {
modal.classList.remove('hidden');
modal.classList.remove("hidden");
}
}
function closeDashboardSettings(): void {
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
const modal = document.getElementById(
"dashboard-settings-modal",
) as HTMLElement;
if (modal) {
modal.classList.add('hidden');
modal.classList.add("hidden");
}
}
function toggleCollectionVisibility(collectionId: string): void {
const checkbox = document.querySelector(`input[data-collection-id="${collectionId}"]`) as HTMLInputElement;
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;
const collectionList = document.getElementById(
"collection-list",
) as HTMLElement;
if (!collectionList) return;
const collectionItems = collectionList.querySelectorAll('[data-collection-id]') as NodeListOf<HTMLElement>;
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;
const checkbox = item.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement;
if (collectionId) {
collectionOrder.push(collectionId);
@@ -54,95 +66,119 @@ async function saveDashboardSettings(): Promise<void> {
}
});
const itemsPerCollection = (document.querySelector('#items-count-display') as HTMLElement)?.textContent || '20';
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') || '',
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');
(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);
(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.`)) {
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', {
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`);
(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);
(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;
loading.classList.remove('hidden');
loading.classList.remove("hidden");
try {
const response = await fetch(`/api/dashboard/sections?library_id=${libraryId}`, {
const response = await fetch(
`/api/dashboard/sections?library_id=${libraryId}`,
{
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
}
});
Authorization: `Bearer ${localStorage.getItem("token")}`,
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
throw new Error('Failed to load sections');
throw new Error("Failed to load sections");
}
const data = await response.json();
renderCollections(data.sections);
renderDashboardCollections(data.sections);
} catch (error) {
(window as any).showToast.error('Failed to load library');
console.error('Switch library error:', error);
(window as any).showToast.error("Failed to load library");
console.error("Switch library error:", error);
} finally {
loading.classList.add('hidden');
loading.classList.add("hidden");
}
}
function renderCollections(sections: SectionData[]): void {
const container = document.getElementById('collections-container') as HTMLElement;
function renderDashboardCollections(sections: SectionData[]): void {
const container = document.getElementById(
"collections-container",
) as HTMLElement;
if (!container) return;
// Preserve wood paneling attribute
const currentWood = container.getAttribute('data-wood');
const currentWood = container.getAttribute("data-wood");
container.innerHTML = sections.map(section => `
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,15 +219,17 @@ function renderCollections(sections: SectionData[]): void {
</button>
</div>
</div>
`).join('');
`,
)
.join("");
if (currentWood) {
container.setAttribute('data-wood', 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 `
<div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
@@ -209,13 +250,13 @@ 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 {
@@ -230,31 +271,37 @@ function updateItemsCount(input: HTMLInputElement, targetId: string): void {
}
function initDragAndDrop(): void {
const collectionList = document.getElementById('collection-list') as HTMLElement;
const collectionList = document.getElementById(
"collection-list",
) as HTMLElement;
if (!collectionList) return;
let draggedItem: HTMLElement | null = null;
collectionList.addEventListener('dragstart', (e: Event) => {
collectionList.addEventListener("dragstart", (e: Event) => {
const target = e.target as HTMLElement;
if (target.classList.contains('collection-item')) {
if (target.classList.contains("collection-item")) {
draggedItem = target;
target.style.opacity = '0.5';
target.style.opacity = "0.5";
}
});
collectionList.addEventListener('dragend', (e: Event) => {
collectionList.addEventListener("dragend", (e: Event) => {
const target = e.target as HTMLElement;
if (target.classList.contains('collection-item')) {
target.style.opacity = '1';
if (target.classList.contains("collection-item")) {
target.style.opacity = "1";
draggedItem = null;
}
});
collectionList.addEventListener('dragover', (e: Event) => {
collectionList.addEventListener("dragover", (e: Event) => {
e.preventDefault();
const target = e.target as HTMLElement;
if (target.classList.contains('collection-item') && target !== draggedItem && draggedItem) {
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) {
@@ -268,72 +315,77 @@ function initDragAndDrop(): void {
});
}
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener("DOMContentLoaded", () => {
initDragAndDrop();
document.addEventListener('click', (e: Event) => {
document.addEventListener("click", (e: Event) => {
const target = e.target as HTMLElement;
const actionElem = target.closest('[data-action]') as HTMLElement;
const action = actionElem?.getAttribute('data-action');
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');
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':
case "open-dashboard-settings":
openDashboardSettings();
break;
case 'close-dashboard-settings':
case "close-dashboard-settings":
closeDashboardSettings();
break;
case 'toggle-collection-visibility': {
case "toggle-collection-visibility": {
const checkbox = target as HTMLInputElement;
const colId = checkbox.dataset.collectionId;
if (colId) toggleCollectionVisibility(colId);
break;
}
case 'save-dashboard-settings':
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';
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': {
case "view-book": {
const bookId = target.dataset.bookId || actionElem?.dataset.bookId;
if (bookId) viewBook(bookId);
break;
}
case 'reload-page':
case "reload-page":
reloadPage();
break;
case 'switch-library': {
case "switch-library": {
const select = target as HTMLSelectElement;
if (select.value) switchLibrary(select.value);
break;
}
case 'update-items-count': {
case "update-items-count": {
const input = target as HTMLInputElement;
const displayTarget = input.getAttribute('target');
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);
});
+55 -37
View File
@@ -1,5 +1,8 @@
function onDelegatedClick(selector: string, handler: (element: HTMLElement, event: MouseEvent) => void): void {
document.addEventListener('click', (event: MouseEvent) => {
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) {
@@ -8,8 +11,11 @@ function onDelegatedClick(selector: string, handler: (element: HTMLElement, even
});
}
function onDelegatedSubmit(selector: string, handler: (form: HTMLFormElement, event: Event) => void): void {
document.addEventListener('submit', (event: 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) {
@@ -18,8 +24,11 @@ function onDelegatedSubmit(selector: string, handler: (form: HTMLFormElement, ev
});
}
function onDelegatedChange(selector: string, handler: (element: HTMLElement, event: Event) => void): void {
document.addEventListener('change', (event: 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) {
@@ -28,8 +37,11 @@ function onDelegatedChange(selector: string, handler: (element: HTMLElement, eve
});
}
function onDelegatedKeydown(selector: string, handler: (element: HTMLElement, event: KeyboardEvent) => void): void {
document.addEventListener('keydown', (event: KeyboardEvent) => {
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) {
@@ -38,41 +50,63 @@ function onDelegatedKeydown(selector: string, handler: (element: HTMLElement, ev
});
}
function getDataAttribute(element: HTMLElement, name: string): string | undefined {
function getDataAttribute(
element: HTMLElement,
name: string,
): string | undefined {
return element.dataset[name];
}
function setDataAttribute(element: HTMLElement, name: string, value: string): void {
function setDataAttribute(
element: HTMLElement,
name: string,
value: string,
): void {
element.dataset[name] = value;
}
function onClick(element: HTMLElement | null, handler: (event: MouseEvent) => void): void {
function onClick(
element: HTMLElement | null,
handler: (event: MouseEvent) => void,
): void {
if (element) {
element.addEventListener('click', handler);
element.addEventListener("click", handler);
}
}
function onSubmit(element: HTMLFormElement | null, handler: (event: Event) => void): void {
function onSubmit(
element: HTMLFormElement | null,
handler: (event: Event) => void,
): void {
if (element) {
element.addEventListener('submit', handler);
element.addEventListener("submit", handler);
}
}
function onChange(element: HTMLElement | null, handler: (event: Event) => void): void {
function onChange(
element: HTMLElement | null,
handler: (event: Event) => void,
): void {
if (element) {
element.addEventListener('change', handler);
element.addEventListener("change", handler);
}
}
function onKeydown(element: HTMLElement | null, handler: (event: KeyboardEvent) => void): void {
function onKeydown(
element: HTMLElement | null,
handler: (event: KeyboardEvent) => void,
): void {
if (element) {
element.addEventListener('keydown', handler);
element.addEventListener("keydown", handler);
}
}
function onInput(element: HTMLElement | null, handler: (event: Event) => void): void {
function onInput(
element: HTMLElement | null,
handler: (event: Event) => void,
): void {
if (element) {
element.addEventListener('input', handler);
element.addEventListener("input", handler);
}
}
@@ -97,21 +131,5 @@ function stopPropagation(event: Event): void {
onKeydown,
onInput,
preventDefault,
stopPropagation
};
export {
onDelegatedClick,
onDelegatedSubmit,
onDelegatedChange,
onDelegatedKeydown,
getDataAttribute,
setDataAttribute,
onClick,
onSubmit,
onChange,
onKeydown,
onInput,
preventDefault,
stopPropagation
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;