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:
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ require (
|
|||||||
github.com/labstack/echo/v4 v4.15.1
|
github.com/labstack/echo/v4 v4.15.1
|
||||||
github.com/nwaples/rardecode v1.1.3
|
github.com/nwaples/rardecode v1.1.3
|
||||||
github.com/pierrec/lz4/v4 v4.1.25
|
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/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
github.com/spf13/afero v1.15.0
|
github.com/spf13/afero v1.15.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
|
|||||||
@@ -139,12 +139,16 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
|
|
||||||
prefs, _ := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
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
|
var sections []services.DashboardSection
|
||||||
sections, err = cfg.DashboardService.GetDashboardSections(
|
sections, err = cfg.DashboardService.GetDashboardSections(
|
||||||
c.Request().Context(),
|
c.Request().Context(),
|
||||||
userUUID,
|
userUUID,
|
||||||
libUUID,
|
libUUID,
|
||||||
int(prefs.ItemsPerSection.Int32),
|
limit,
|
||||||
prefs.CollectionOrder,
|
prefs.CollectionOrder,
|
||||||
prefs.HiddenCollections,
|
prefs.HiddenCollections,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func Admin(user User) templ.Component {
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ func Admin(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+174
-140
@@ -8,81 +8,87 @@ import (
|
|||||||
templ Dashboard(user User, sections []handlers.SectionData, libData []LibraryData, currentLibraryID string, errorMessage string) {
|
templ Dashboard(user User, sections []handlers.SectionData, libData []LibraryData, currentLibraryID string, errorMessage string) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Dashboard - Bookhoard</title>
|
<title>Dashboard - Bookhoard</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/toast.js"></script>
|
<script src="/static/toast.js"></script>
|
||||||
<script src="/static/api.js"></script>
|
<script src="/static/api.js"></script>
|
||||||
<script src="/static/events.js"></script>
|
<script src="/static/events.js"></script>
|
||||||
<script src="/static/dashboard.js"></script>
|
<script src="/static/dashboard.js"></script>
|
||||||
<link href="/static/style.css" rel="stylesheet">
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
</head>
|
</head>
|
||||||
<body class="theme-{ user.Theme }">
|
<body class="theme-{ user.Theme }">
|
||||||
@Header(user, "/dashboard")
|
@Header(user, "/dashboard")
|
||||||
|
<!-- Sticky Library Selector -->
|
||||||
<!-- 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="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="w-full px-4 py-3 flex items-center justify-between">
|
<div class="flex items-center gap-4">
|
||||||
<div class="flex items-center gap-4">
|
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library:</label>
|
||||||
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library:</label>
|
<select
|
||||||
<select id="library-select" name="library_id"
|
id="library-select"
|
||||||
|
name="library_id"
|
||||||
class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
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);"
|
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 {
|
for _, lib := range libData {
|
||||||
<option value={ lib.ID } selected>{ lib.Name }</option>
|
if lib.ID == currentLibraryID {
|
||||||
} else {
|
<option value={ lib.ID } selected>{ lib.Name }</option>
|
||||||
<option value={ lib.ID }>{ lib.Name }</option>
|
} else {
|
||||||
|
<option value={ lib.ID }>{ lib.Name }</option>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
</select>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
<div class="flex items-center gap-2">
|
data-action="open-dashboard-settings"
|
||||||
<button data-action="open-dashboard-settings"
|
|
||||||
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||||||
style="background-color: var(--bg-secondary);"
|
style="background-color: var(--bg-secondary);"
|
||||||
title="Customize Dashboard">
|
title="Customize Dashboard"
|
||||||
⚙️
|
>
|
||||||
</button>
|
⚙️
|
||||||
<button data-action="reload-page"
|
</button>
|
||||||
|
<button
|
||||||
|
data-action="reload-page"
|
||||||
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||||||
style="background-color: var(--bg-secondary);"
|
style="background-color: var(--bg-secondary);"
|
||||||
title="Refresh">
|
title="Refresh"
|
||||||
🔄
|
>
|
||||||
</button>
|
🔄
|
||||||
|
</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 class="animate-spin rounded-full h-12 w-12 border-b-2" style="border-color: var(--accent);"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Collections Container -->
|
||||||
<div id="loading-spinner" class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50"
|
<main id="collections-container" class="w-full px-4 py-8">
|
||||||
style="background-color: var(--bg-primary);">
|
for _, section := range sections {
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2" style="border-color: var(--accent);"></div>
|
@CollectionCarousel(section)
|
||||||
</div>
|
}
|
||||||
</div>
|
</main>
|
||||||
|
<!-- Dashboard Settings Modal -->
|
||||||
<!-- Collections Container -->
|
@DashboardSettingsModal(sections)
|
||||||
<main id="collections-container" class="w-full px-4 py-8">
|
@ErrorToast(errorMessage)
|
||||||
for _, section := range sections {
|
<script src="/static/woodPanelingInit.js"></script>
|
||||||
@CollectionCarousel(section)
|
</body>
|
||||||
}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Dashboard Settings Modal -->
|
|
||||||
@DashboardSettingsModal(sections)
|
|
||||||
|
|
||||||
@ErrorToast(errorMessage)
|
|
||||||
<script src="/static/woodPanelingInit.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ CollectionCarousel(section handlers.SectionData) {
|
templ CollectionCarousel(section handlers.SectionData) {
|
||||||
<div class="dashboard-collection mb-8"
|
<div
|
||||||
data-collection-id={ section.ID }
|
class="dashboard-collection mb-8"
|
||||||
data-is-system={ section.IsSystem }>
|
data-collection-id={ section.ID }
|
||||||
|
data-is-system={ section.IsSystem }
|
||||||
|
>
|
||||||
<!-- Collection Header -->
|
<!-- Collection Header -->
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -94,53 +100,56 @@ templ CollectionCarousel(section handlers.SectionData) {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
if section.ViewAllURL != "" {
|
if section.ViewAllURL != "" {
|
||||||
<a href={ section.ViewAllURL }
|
<a
|
||||||
class="text-sm font-medium hover:underline transition-colors"
|
href={ section.ViewAllURL }
|
||||||
style="color: var(--accent);">
|
class="text-sm font-medium hover:underline transition-colors"
|
||||||
|
style="color: var(--accent);"
|
||||||
|
>
|
||||||
View All →
|
View All →
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Carousel -->
|
<!-- Carousel -->
|
||||||
<div class="carousel-container relative group">
|
<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
|
w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
|
||||||
flex items-center justify-start opacity-0 group-hover:opacity-100
|
flex items-center justify-start opacity-0 group-hover:opacity-100
|
||||||
transition-opacity duration-200"
|
transition-opacity duration-200"
|
||||||
data-action="scroll-carousel"
|
data-action="scroll-carousel"
|
||||||
data-collection-id={ section.ID }
|
data-collection-id={ section.ID }
|
||||||
data-direction="-1"
|
data-direction="-1"
|
||||||
aria-label="Scroll left">
|
aria-label="Scroll left"
|
||||||
|
>
|
||||||
<span class="text-3xl pl-2" style="color: var(--text-primary);">‹</span>
|
<span class="text-3xl pl-2" style="color: var(--text-primary);">‹</span>
|
||||||
</button>
|
</button>
|
||||||
|
<div
|
||||||
<div id="carousel-track-{ section.ID }"
|
id={ "carousel-track-" + section.ID }
|
||||||
class="carousel-track flex gap-4 overflow-x-auto
|
class="carousel-track flex gap-4 overflow-x-auto
|
||||||
scroll-smooth snap-x snap-mandatory
|
scroll-smooth snap-x snap-mandatory
|
||||||
px-12 pb-4"
|
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 {
|
for _, item := range section.Items {
|
||||||
@BookCard(item)
|
@BookCard(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(section.Items) == 0 {
|
if len(section.Items) == 0 {
|
||||||
<div class="text-center py-8 w-full" style="color: var(--text-secondary);">
|
<div class="text-center py-8 w-full" style="color: var(--text-secondary);">
|
||||||
<p>No items in this collection</p>
|
<p>No items in this collection</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
<button class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
|
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
|
w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
|
||||||
flex items-center justify-end opacity-0 group-hover:opacity-100
|
flex items-center justify-end opacity-0 group-hover:opacity-100
|
||||||
transition-opacity duration-200"
|
transition-opacity duration-200"
|
||||||
data-action="scroll-carousel"
|
data-action="scroll-carousel"
|
||||||
data-collection-id={ section.ID }
|
data-collection-id={ section.ID }
|
||||||
data-direction="1"
|
data-direction="1"
|
||||||
aria-label="Scroll right">
|
aria-label="Scroll right"
|
||||||
|
>
|
||||||
<span class="text-3xl pr-2" style="color: var(--text-primary);">›</span>
|
<span class="text-3xl pr-2" style="color: var(--text-primary);">›</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,32 +157,38 @@ templ CollectionCarousel(section handlers.SectionData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ BookCard(item handlers.BookInfo) {
|
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"
|
transition-transform duration-200 hover:scale-105"
|
||||||
data-action="view-book"
|
data-action="view-book"
|
||||||
data-book-id={ item.MediaItemID }
|
data-book-id={ item.MediaItemID }
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
role="button"
|
||||||
aria-label={ "View " + item.Title }>
|
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">
|
<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 != "" {
|
if item.CoverImagePath != "" {
|
||||||
<img src={ item.CoverImagePath }
|
<img
|
||||||
alt={ item.Title }
|
src={ item.CoverImagePath }
|
||||||
class="w-full h-full object-cover"
|
alt={ item.Title }
|
||||||
loading="lazy"
|
class="w-full h-full object-cover"
|
||||||
onerror="this.src='/static/placeholder-book.svg'">
|
loading="lazy"
|
||||||
|
onerror="this.src='/static/placeholder-book.svg'"
|
||||||
|
/>
|
||||||
} else {
|
} else {
|
||||||
<img src="/static/placeholder-book.svg"
|
<img
|
||||||
alt={ item.Title }
|
src="/static/placeholder-book.svg"
|
||||||
class="w-full h-full object-cover">
|
alt={ item.Title }
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
|
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
|
||||||
{ item.Title }
|
{ item.Title }
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
if item.Author != "" {
|
if item.Author != "" {
|
||||||
<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">
|
<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">
|
||||||
{ item.Author }
|
{ item.Author }
|
||||||
@@ -183,31 +198,38 @@ templ BookCard(item handlers.BookInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ DashboardSettingsModal(sections []handlers.SectionData) {
|
templ DashboardSettingsModal(sections []handlers.SectionData) {
|
||||||
<div id="dashboard-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center"
|
<div
|
||||||
style="background-color: rgba(0, 0, 0, 0.7);">
|
id="dashboard-settings-modal"
|
||||||
<div class="rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl"
|
class="hidden fixed inset-0 z-50 flex items-center justify-center"
|
||||||
style="background-color: var(--bg-secondary);">
|
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">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
|
||||||
<button data-action="close-dashboard-settings"
|
<button
|
||||||
class="p-2 hover:bg-gray-700 rounded transition-colors">
|
data-action="close-dashboard-settings"
|
||||||
|
class="p-2 hover:bg-gray-700 rounded transition-colors"
|
||||||
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm mb-4" style="color: var(--text-secondary);">
|
<p class="text-sm mb-4" style="color: var(--text-secondary);">
|
||||||
Drag to reorder collections, toggle visibility with the switch.
|
Drag to reorder collections, toggle visibility with the switch.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Draggable Collection List -->
|
<!-- Draggable Collection List -->
|
||||||
<div id="collection-list" class="space-y-2 mb-6">
|
<div id="collection-list" class="space-y-2 mb-6">
|
||||||
for _, section := range sections {
|
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"
|
cursor-move select-none"
|
||||||
data-collection-id={ section.ID }
|
data-collection-id={ section.ID }
|
||||||
data-is-system={ fmt.Sprintf("%v", section.IsSystem) }
|
data-is-system={ fmt.Sprintf("%v", section.IsSystem) }
|
||||||
draggable="true"
|
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">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-xl" style="color: var(--text-secondary);">☰</span>
|
<span class="text-xl" style="color: var(--text-secondary);">☰</span>
|
||||||
<span class="text-xl">{ section.Icon }</span>
|
<span class="text-xl">{ section.Icon }</span>
|
||||||
@@ -218,55 +240,67 @@ templ DashboardSettingsModal(sections []handlers.SectionData) {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
if section.IsSystem {
|
if section.IsSystem {
|
||||||
<button data-action="restore-system-collection"
|
<button
|
||||||
data-collection-name={ section.ID }
|
data-action="restore-system-collection"
|
||||||
class="text-xs px-3 py-1 rounded border hover:opacity-80 transition-opacity"
|
data-collection-name={ section.ID }
|
||||||
style="border-color: var(--border); color: var(--text-secondary);"
|
class="text-xs px-3 py-1 rounded border hover:opacity-80 transition-opacity"
|
||||||
title="Restore { section.Title } to defaults">
|
style="border-color: var(--border); color: var(--text-secondary);"
|
||||||
|
title="Restore { section.Title } to defaults"
|
||||||
|
>
|
||||||
Restore
|
Restore
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
<label class="relative inline-flex items-center cursor-pointer">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<input type="checkbox"
|
<input
|
||||||
class="sr-only peer"
|
type="checkbox"
|
||||||
checked
|
class="sr-only peer"
|
||||||
data-action="toggle-collection-visibility"
|
checked
|
||||||
data-collection-id={ section.ID }>
|
data-action="toggle-collection-visibility"
|
||||||
<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
|
peer-checked:after:translate-x-full peer-checked:after:border-white
|
||||||
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
|
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
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Items Per Section Slider -->
|
<!-- Items Per Section Slider -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
<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>
|
Items per Collection: <span id="items-count-display" class="font-bold">20</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="range" min="10" max="50" step="5" value="20"
|
<input
|
||||||
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
|
type="range"
|
||||||
data-action="update-items-count"
|
min="10"
|
||||||
target="items-count-display">
|
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"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
<button data-action="close-dashboard-settings"
|
<button
|
||||||
class="px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors"
|
data-action="close-dashboard-settings"
|
||||||
style="border-color: var(--border); color: var(--text-primary);">
|
class="px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors"
|
||||||
|
style="border-color: var(--border); color: var(--text-primary);"
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button data-action="save-dashboard-settings"
|
<button
|
||||||
class="px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
|
data-action="save-dashboard-settings"
|
||||||
style="background-color: var(--accent);">
|
class="px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
|
||||||
|
style="background-color: var(--accent);"
|
||||||
|
>
|
||||||
Save Changes
|
Save Changes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { ReadingStatsResponse, DeviceUsageResponse, PopularBooksResponse } from './types/api';
|
|
||||||
|
|
||||||
async function loadAnalytics(): Promise<void> {
|
async function loadAnalytics(): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { CollectionData, CollectionRule } from './types/api';
|
|
||||||
|
|
||||||
async function loadCollections(): Promise<void> {
|
async function loadCollections(): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { ConflictDetailResponse, ConflictListResponse, BulkResolveResponse } from './types/api';
|
|
||||||
|
|
||||||
async function refreshConflicts(): Promise<void> {
|
async function refreshConflicts(): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { BookInfo } from './types/api';
|
|
||||||
|
|
||||||
interface FilterField {
|
interface FilterField {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -176,7 +174,7 @@ const FILTER_FIELDS: FilterField[] = [
|
|||||||
|
|
||||||
let ruleCounter = 0;
|
let ruleCounter = 0;
|
||||||
let selectedBooks: Map<string, BookInfo> = new Map();
|
let selectedBooks: Map<string, BookInfo> = new Map();
|
||||||
let searchTimeout: number | null = null;
|
let customSectionTimeout: number | null = null;
|
||||||
|
|
||||||
function initCustomSectionBuilder(): void {
|
function initCustomSectionBuilder(): void {
|
||||||
const addRuleBtn = document.getElementById('add-rule-btn');
|
const addRuleBtn = document.getElementById('add-rule-btn');
|
||||||
@@ -312,10 +310,10 @@ function removeFilterRule(ruleId: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onBookSearchInput(): void {
|
function onBookSearchInput(): void {
|
||||||
if (searchTimeout) {
|
if (customSectionTimeout) {
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(customSectionTimeout);
|
||||||
}
|
}
|
||||||
searchTimeout = window.setTimeout(() => {
|
customSectionTimeout = window.setTimeout(() => {
|
||||||
searchBooks();
|
searchBooks();
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
@@ -363,13 +361,13 @@ function displaySearchResults(books: BookInfo[]): void {
|
|||||||
resultsContainer.innerHTML = books.map(book => `
|
resultsContainer.innerHTML = books.map(book => `
|
||||||
<div class="flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer"
|
<div class="flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer"
|
||||||
data-book-id="${book.media_item_id}"
|
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'}"
|
<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">
|
class="w-10 h-15 object-cover rounded">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="text-sm font-medium" style="color: var(--text-primary);">${escapeHtml(book.title)}</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);">${escapeHtml(book.author)}</p>
|
<p class="text-xs" style="color: var(--text-secondary);">${builderEscapeHtml(book.author)}</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="text-green-500 hover:text-green-700 text-xl">+</button>
|
<button type="button" class="text-green-500 hover:text-green-700 text-xl">+</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -412,7 +410,7 @@ function updateSelectedBooksDisplay(): void {
|
|||||||
container.innerHTML = Array.from(selectedBooks.values()).map(book => `
|
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"
|
<div class="inline-flex items-center gap-2 px-3 py-1 m-1 rounded-full text-sm"
|
||||||
style="background-color: var(--accent);">
|
style="background-color: var(--accent);">
|
||||||
<span>${escapeHtml(book.title)}</span>
|
<span>${builderEscapeHtml(book.title)}</span>
|
||||||
<button type="button" onclick="removeBookFromSelection('${book.media_item_id}')"
|
<button type="button" onclick="removeBookFromSelection('${book.media_item_id}')"
|
||||||
class="hover:opacity-70">×</button>
|
class="hover:opacity-70">×</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -495,13 +493,13 @@ function displayPreview(items: BookInfo[]): void {
|
|||||||
<div class="flex-shrink-0 w-32">
|
<div class="flex-shrink-0 w-32">
|
||||||
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2">
|
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2">
|
||||||
<img src="${item.cover_image_path || '/static/placeholder-book.svg'}"
|
<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">
|
class="w-full h-full object-cover">
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-sm font-semibold line-clamp-2" style="color: var(--text-primary);">
|
<h3 class="text-sm font-semibold line-clamp-2" style="color: var(--text-primary);">
|
||||||
${escapeHtml(item.title)}
|
${builderEscapeHtml(item.title)}
|
||||||
</h3>
|
</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>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</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');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
|
|||||||
+252
-200
@@ -1,148 +1,184 @@
|
|||||||
// Dashboard functionality with unified collections architecture
|
// Dashboard functionality with unified collections architecture
|
||||||
// Procedural/imperative style (no OOP)
|
// Procedural/imperative style (no OOP)
|
||||||
|
|
||||||
import type { SectionData, BookInfo } from './types/api';
|
|
||||||
|
|
||||||
const SCROLL_AMOUNT = 300;
|
const SCROLL_AMOUNT = 300;
|
||||||
|
|
||||||
function scrollCarousel(collectionId: string, direction: number): void {
|
function scrollCarousel(collectionId: string, direction: number): void {
|
||||||
const track = document.getElementById(`carousel-track-${collectionId}`) as HTMLElement;
|
const track = document.getElementById(
|
||||||
if (!track) return;
|
`carousel-track-${collectionId}`,
|
||||||
|
) as HTMLElement;
|
||||||
|
if (!track) return;
|
||||||
|
|
||||||
const scrollAmount = direction * SCROLL_AMOUNT;
|
const scrollAmount = direction * SCROLL_AMOUNT;
|
||||||
track.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
track.scrollBy({ left: scrollAmount, behavior: "smooth" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDashboardSettings(): void {
|
function openDashboardSettings(): void {
|
||||||
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
|
const modal = document.getElementById(
|
||||||
if (modal) {
|
"dashboard-settings-modal",
|
||||||
modal.classList.remove('hidden');
|
) as HTMLElement;
|
||||||
}
|
if (modal) {
|
||||||
|
modal.classList.remove("hidden");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDashboardSettings(): void {
|
function closeDashboardSettings(): void {
|
||||||
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
|
const modal = document.getElementById(
|
||||||
if (modal) {
|
"dashboard-settings-modal",
|
||||||
modal.classList.add('hidden');
|
) as HTMLElement;
|
||||||
}
|
if (modal) {
|
||||||
|
modal.classList.add("hidden");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCollectionVisibility(collectionId: string): void {
|
function toggleCollectionVisibility(collectionId: string): void {
|
||||||
const checkbox = document.querySelector(`input[data-collection-id="${collectionId}"]`) as HTMLInputElement;
|
const checkbox = document.querySelector(
|
||||||
if (checkbox) {
|
`input[data-collection-id="${collectionId}"]`,
|
||||||
checkbox.checked = !checkbox.checked;
|
) as HTMLInputElement;
|
||||||
}
|
if (checkbox) {
|
||||||
|
checkbox.checked = !checkbox.checked;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveDashboardSettings(): Promise<void> {
|
async function saveDashboardSettings(): Promise<void> {
|
||||||
const collectionList = document.getElementById('collection-list') as HTMLElement;
|
const collectionList = document.getElementById(
|
||||||
if (!collectionList) return;
|
"collection-list",
|
||||||
|
) as HTMLElement;
|
||||||
|
if (!collectionList) return;
|
||||||
|
|
||||||
const collectionItems = collectionList.querySelectorAll('[data-collection-id]') as NodeListOf<HTMLElement>;
|
const collectionItems = collectionList.querySelectorAll(
|
||||||
const hiddenCollections: string[] = [];
|
"[data-collection-id]",
|
||||||
const collectionOrder: string[] = [];
|
) as NodeListOf<HTMLElement>;
|
||||||
|
const hiddenCollections: string[] = [];
|
||||||
|
const collectionOrder: string[] = [];
|
||||||
|
|
||||||
collectionItems.forEach((item) => {
|
collectionItems.forEach((item) => {
|
||||||
const collectionId = item.dataset.collectionId;
|
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) {
|
if (collectionId) {
|
||||||
collectionOrder.push(collectionId);
|
collectionOrder.push(collectionId);
|
||||||
if (checkbox && !checkbox.checked) {
|
if (checkbox && !checkbox.checked) {
|
||||||
hiddenCollections.push(collectionId);
|
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';
|
if (response.ok) {
|
||||||
|
(window as any).showToast.success("Dashboard settings saved");
|
||||||
try {
|
closeDashboardSettings();
|
||||||
const response = await (window as any).api.put('/dashboard/preferences', {
|
window.location.reload();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
} 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> {
|
async function restoreSystemCollection(
|
||||||
if (!confirm(`Are you sure you want to reset "${collectionTitle}" to its default state? Any customizations will be lost.`)) {
|
collectionName: string,
|
||||||
return;
|
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 {
|
try {
|
||||||
const response = await (window as any).api.post('/dashboard/restore-system-collection', {
|
const response = await (window as any).api.post(
|
||||||
collection_name: collectionName,
|
"/dashboard/restore-system-collection",
|
||||||
});
|
{
|
||||||
|
collection_name: collectionName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
(window as any).showToast.success(`"${collectionTitle}" restored to defaults`);
|
(window as any).showToast.success(
|
||||||
setTimeout(() => window.location.reload(), 1000);
|
`"${collectionTitle}" restored to defaults`,
|
||||||
}
|
);
|
||||||
} catch (error) {
|
setTimeout(() => window.location.reload(), 1000);
|
||||||
(window as any).showToast.error('Failed to restore system collection');
|
|
||||||
console.error('Restore system collection error:', error);
|
|
||||||
}
|
}
|
||||||
|
} 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> {
|
async function switchLibrary(libraryId: string): Promise<void> {
|
||||||
const container = document.getElementById('collections-container') as HTMLElement;
|
const container = document.getElementById(
|
||||||
const loading = document.getElementById('loading-spinner') as HTMLElement;
|
"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 {
|
try {
|
||||||
const response = await fetch(`/api/dashboard/sections?library_id=${libraryId}`, {
|
const response = await fetch(
|
||||||
headers: {
|
`/api/dashboard/sections?library_id=${libraryId}`,
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
{
|
||||||
'Content-Type': 'application/json'
|
headers: {
|
||||||
}
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
});
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
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);
|
|
||||||
} catch (error) {
|
|
||||||
(window as any).showToast.error('Failed to load library');
|
|
||||||
console.error('Switch library error:', error);
|
|
||||||
} finally {
|
|
||||||
loading.classList.add('hidden');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function renderDashboardCollections(sections: SectionData[]): void {
|
||||||
const container = document.getElementById('collections-container') as HTMLElement;
|
const container = document.getElementById(
|
||||||
if (!container) return;
|
"collections-container",
|
||||||
|
) as HTMLElement;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
// Preserve wood paneling attribute
|
// 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="dashboard-collection mb-8" data-collection-id="${section.id}">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-2xl">${section.icon}</span>
|
<span class="text-2xl">${section.icon}</span>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">${section.title}</h2>
|
<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>
|
||||||
</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>
|
||||||
|
|
||||||
<div class="carousel-container relative group">
|
<div class="carousel-container relative group">
|
||||||
@@ -162,8 +198,11 @@ function renderCollections(sections: SectionData[]): void {
|
|||||||
scroll-smooth snap-x snap-mandatory
|
scroll-smooth snap-x snap-mandatory
|
||||||
px-12 pb-4"
|
px-12 pb-4"
|
||||||
style="scrollbar-width: none; -ms-overflow-style: none;">
|
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 class="text-center py-8 w-full" style="color: var(--text-secondary);"><p>No items in this collection</p></div>'
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -180,17 +219,19 @@ function renderCollections(sections: SectionData[]): void {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
if(currentWood) {
|
if (currentWood) {
|
||||||
container.setAttribute('data-wood', currentWood)
|
container.setAttribute("data-wood", currentWood);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBookCard(book: BookInfo): string {
|
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
|
<div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
|
||||||
transition-transform duration-200 hover:scale-105"
|
transition-transform duration-200 hover:scale-105"
|
||||||
data-action="view-book"
|
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)">
|
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
|
||||||
${book.title}
|
${book.title}
|
||||||
</h3>
|
</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function viewBook(bookId: string): void {
|
function viewBook(bookId: string): void {
|
||||||
console.log('View book:', bookId);
|
console.log("View book:", bookId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function reloadPage(): void {
|
function reloadPage(): void {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateItemsCount(input: HTMLInputElement, targetId: string): void {
|
function updateItemsCount(input: HTMLInputElement, targetId: string): void {
|
||||||
const display = document.getElementById(targetId) as HTMLElement;
|
const display = document.getElementById(targetId) as HTMLElement;
|
||||||
if (display) {
|
if (display) {
|
||||||
display.textContent = input.value;
|
display.textContent = input.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initDragAndDrop(): void {
|
function initDragAndDrop(): void {
|
||||||
const collectionList = document.getElementById('collection-list') as HTMLElement;
|
const collectionList = document.getElementById(
|
||||||
if (!collectionList) return;
|
"collection-list",
|
||||||
|
) as HTMLElement;
|
||||||
|
if (!collectionList) return;
|
||||||
|
|
||||||
let draggedItem: HTMLElement | null = null;
|
let draggedItem: HTMLElement | null = null;
|
||||||
|
|
||||||
collectionList.addEventListener('dragstart', (e: Event) => {
|
collectionList.addEventListener("dragstart", (e: Event) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.classList.contains('collection-item')) {
|
if (target.classList.contains("collection-item")) {
|
||||||
draggedItem = target;
|
draggedItem = target;
|
||||||
target.style.opacity = '0.5';
|
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', () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
initDragAndDrop();
|
initDragAndDrop();
|
||||||
|
|
||||||
document.addEventListener('click', (e: Event) => {
|
document.addEventListener("click", (e: Event) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
const actionElem = target.closest('[data-action]') as HTMLElement;
|
const actionElem = target.closest("[data-action]") as HTMLElement;
|
||||||
const action = actionElem?.getAttribute('data-action');
|
const action = actionElem?.getAttribute("data-action");
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'scroll-carousel': {
|
case "scroll-carousel": {
|
||||||
const collectionId = target.dataset.collectionId || actionElem?.dataset.collectionId;
|
const collectionId =
|
||||||
const direction = parseInt(target.dataset.direction || actionElem?.dataset.direction || '0');
|
target.dataset.collectionId || actionElem?.dataset.collectionId;
|
||||||
if (collectionId) scrollCarousel(collectionId, direction);
|
const direction = parseInt(
|
||||||
break;
|
target.dataset.direction || actionElem?.dataset.direction || "0",
|
||||||
}
|
);
|
||||||
|
if (collectionId) scrollCarousel(collectionId, direction);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'open-dashboard-settings':
|
case "open-dashboard-settings":
|
||||||
openDashboardSettings();
|
openDashboardSettings();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'close-dashboard-settings':
|
case "close-dashboard-settings":
|
||||||
closeDashboardSettings();
|
closeDashboardSettings();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'toggle-collection-visibility': {
|
case "toggle-collection-visibility": {
|
||||||
const checkbox = target as HTMLInputElement;
|
const checkbox = target as HTMLInputElement;
|
||||||
const colId = checkbox.dataset.collectionId;
|
const colId = checkbox.dataset.collectionId;
|
||||||
if (colId) toggleCollectionVisibility(colId);
|
if (colId) toggleCollectionVisibility(colId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'save-dashboard-settings':
|
case "save-dashboard-settings":
|
||||||
saveDashboardSettings();
|
saveDashboardSettings();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'restore-system-collection': {
|
case "restore-system-collection": {
|
||||||
const colName = actionElem?.dataset.collectionName || target.dataset.collectionName;
|
const colName =
|
||||||
const colTitle = actionElem?.dataset.collectionTitle || target.dataset.collectionTitle || 'System Collection';
|
actionElem?.dataset.collectionName || target.dataset.collectionName;
|
||||||
if (colName) restoreSystemCollection(colName, colTitle);
|
const colTitle =
|
||||||
break;
|
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;
|
const bookId = target.dataset.bookId || actionElem?.dataset.bookId;
|
||||||
if (bookId) viewBook(bookId);
|
if (bookId) viewBook(bookId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'reload-page':
|
case "reload-page":
|
||||||
reloadPage();
|
reloadPage();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'switch-library': {
|
case "switch-library": {
|
||||||
const select = target as HTMLSelectElement;
|
const select = target as HTMLSelectElement;
|
||||||
if (select.value) switchLibrary(select.value);
|
if (select.value) switchLibrary(select.value);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'update-items-count': {
|
case "update-items-count": {
|
||||||
const input = target as HTMLInputElement;
|
const input = target as HTMLInputElement;
|
||||||
const displayTarget = input.getAttribute('target');
|
const displayTarget = input.getAttribute("target");
|
||||||
if (displayTarget) updateItemsCount(input, displayTarget);
|
if (displayTarget) updateItemsCount(input, displayTarget);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export {};
|
|
||||||
|
|||||||
+4
-4
@@ -15,13 +15,13 @@ function initializeDocsSearch(): void {
|
|||||||
|
|
||||||
if (!searchInput || !searchResults) return;
|
if (!searchInput || !searchResults) return;
|
||||||
|
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
let docsSearchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
searchInput.addEventListener('input', () => {
|
searchInput.addEventListener('input', () => {
|
||||||
const query = searchInput.value.trim();
|
const query = searchInput.value.trim();
|
||||||
|
|
||||||
if (searchTimeout) {
|
if (docsSearchTimeout) {
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(docsSearchTimeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.length < 2) {
|
if (query.length < 2) {
|
||||||
@@ -30,7 +30,7 @@ function initializeDocsSearch(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
searchTimeout = setTimeout(() => {
|
docsSearchTimeout = setTimeout(() => {
|
||||||
performDocsSearch(query);
|
performDocsSearch(query);
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
|||||||
+110
-92
@@ -1,117 +1,135 @@
|
|||||||
function onDelegatedClick(selector: string, handler: (element: HTMLElement, event: MouseEvent) => void): void {
|
function onDelegatedClick(
|
||||||
document.addEventListener('click', (event: MouseEvent) => {
|
selector: string,
|
||||||
const target = event.target as HTMLElement;
|
handler: (element: HTMLElement, event: MouseEvent) => void,
|
||||||
const element = target.closest(selector) as HTMLElement | null;
|
): void {
|
||||||
if (element) {
|
document.addEventListener("click", (event: MouseEvent) => {
|
||||||
handler(element, event);
|
const target = event.target as HTMLElement;
|
||||||
}
|
const element = target.closest(selector) as HTMLElement | null;
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
if (element) {
|
if (element) {
|
||||||
element.addEventListener('click', handler);
|
handler(element, event);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSubmit(element: HTMLFormElement | null, handler: (event: Event) => void): void {
|
function onDelegatedSubmit(
|
||||||
if (element) {
|
selector: string,
|
||||||
element.addEventListener('submit', handler);
|
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) {
|
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) {
|
if (element) {
|
||||||
element.addEventListener('keydown', handler);
|
handler(element, event);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onInput(element: HTMLElement | null, handler: (event: Event) => void): void {
|
function getDataAttribute(
|
||||||
if (element) {
|
element: HTMLElement,
|
||||||
element.addEventListener('input', handler);
|
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 {
|
function preventDefault(event: Event): void {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPropagation(event: Event): void {
|
function stopPropagation(event: Event): void {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
(window as any).events = {
|
(window as any).events = {
|
||||||
onDelegatedClick,
|
onDelegatedClick,
|
||||||
onDelegatedSubmit,
|
onDelegatedSubmit,
|
||||||
onDelegatedChange,
|
onDelegatedChange,
|
||||||
onDelegatedKeydown,
|
onDelegatedKeydown,
|
||||||
getDataAttribute,
|
getDataAttribute,
|
||||||
setDataAttribute,
|
setDataAttribute,
|
||||||
onClick,
|
onClick,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onChange,
|
onChange,
|
||||||
onKeydown,
|
onKeydown,
|
||||||
onInput,
|
onInput,
|
||||||
preventDefault,
|
preventDefault,
|
||||||
stopPropagation
|
stopPropagation,
|
||||||
};
|
|
||||||
|
|
||||||
export {
|
|
||||||
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> {
|
async function loadUnlinkedBooks(): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { QueueItemResponse } from './types/api';
|
|
||||||
|
|
||||||
async function refreshQueue(): Promise<void> {
|
async function refreshQueue(): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|||||||
+9
-11
@@ -1,6 +1,4 @@
|
|||||||
import type { MediaItemSummary } from './types/api';
|
let searchInputTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
const SEARCH_MIN_CHARS = 2;
|
const SEARCH_MIN_CHARS = 2;
|
||||||
|
|
||||||
@@ -33,8 +31,8 @@ function handleSearchInput(e: Event): void {
|
|||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
const query = target.value.trim();
|
const query = target.value.trim();
|
||||||
|
|
||||||
if (searchTimeout) {
|
if (searchInputTimeout) {
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchInputTimeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.length < SEARCH_MIN_CHARS) {
|
if (query.length < SEARCH_MIN_CHARS) {
|
||||||
@@ -42,7 +40,7 @@ function handleSearchInput(e: Event): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
searchTimeout = setTimeout(() => {
|
searchInputTimeout = setTimeout(() => {
|
||||||
performSearch(query);
|
performSearch(query);
|
||||||
}, SEARCH_DEBOUNCE_MS);
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
@@ -164,7 +162,7 @@ function showSearchResults(results: MediaItemSummary[], query: string): void {
|
|||||||
let html = `
|
let html = `
|
||||||
<div class="p-3 border-b" style="border-color: var(--border)">
|
<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)">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="max-h-96 overflow-y-auto">
|
<div class="max-h-96 overflow-y-auto">
|
||||||
@@ -190,7 +188,7 @@ function showSearchResults(results: MediaItemSummary[], query: string): void {
|
|||||||
</h4>
|
</h4>
|
||||||
${authorHtml ? `<p class="text-xs truncate" style="color: var(--text-secondary)">${authorHtml}</p>` : ''}
|
${authorHtml ? `<p class="text-xs truncate" style="color: var(--text-secondary)">${authorHtml}</p>` : ''}
|
||||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">
|
||||||
${escapeHtml(item.library_name)}
|
${searchEscapeHtml(item.library_name)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,7 +219,7 @@ function showNoResults(query: string): void {
|
|||||||
searchResults.innerHTML = `
|
searchResults.innerHTML = `
|
||||||
<div class="p-4 text-center">
|
<div class="p-4 text-center">
|
||||||
<div class="text-4xl mb-2">🔍</div>
|
<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>
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Try different keywords</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -272,10 +270,10 @@ function highlightMatch(text: string, query: string): string {
|
|||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
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');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
|
|||||||
+2
-2
@@ -18,7 +18,7 @@ const createToastContainer = (): HTMLElement => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Escape HTML to prevent XSS
|
// Escape HTML to prevent XSS
|
||||||
const escapeHtml = (text: string): string => {
|
const toastEscapeHtml = (text: string): string => {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
@@ -52,7 +52,7 @@ const createToastElement = (message: string, type: ToastType): HTMLElement => {
|
|||||||
|
|
||||||
toast.innerHTML = `
|
toast.innerHTML = `
|
||||||
<span class="text-xl flex-shrink-0">${config.icon}</span>
|
<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 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>
|
</button>
|
||||||
|
|||||||
Vendored
+30
-45
@@ -1,23 +1,8 @@
|
|||||||
// ============================================
|
// ============================================
|
||||||
// API Type Definitions
|
// API Type Definitions
|
||||||
// ============================================
|
// ============================================
|
||||||
// These types match the JSON responses from /api/* endpoints.
|
// These types are globally available in all .ts files.
|
||||||
// Source of truth: Check what the endpoint ACTUALLY returns:
|
// No imports needed - just use the type names directly.
|
||||||
// 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
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// Matches database.SearchMediaItemsRow from /api/media-items/search
|
// Matches database.SearchMediaItemsRow from /api/media-items/search
|
||||||
@@ -25,7 +10,7 @@
|
|||||||
// Endpoint: internal/handlers/media.go:SearchMediaItems()
|
// Endpoint: internal/handlers/media.go:SearchMediaItems()
|
||||||
// Note: internal/handlers/search.go has an unused MediaItemSummary - ignore it
|
// Note: internal/handlers/search.go has an unused MediaItemSummary - ignore it
|
||||||
// Used in: search.ts
|
// Used in: search.ts
|
||||||
export interface MediaItemSummary {
|
interface MediaItemSummary {
|
||||||
id: string;
|
id: string;
|
||||||
library_id: string;
|
library_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -77,7 +62,7 @@ export interface MediaItemSummary {
|
|||||||
// Matches handlers.CollectionData / CollectionResponse JSON response
|
// Matches handlers.CollectionData / CollectionResponse JSON response
|
||||||
// Source: internal/handlers/collections.go:123-131 CollectionResponse
|
// Source: internal/handlers/collections.go:123-131 CollectionResponse
|
||||||
// Used in: collections.ts
|
// Used in: collections.ts
|
||||||
export interface CollectionData {
|
interface CollectionData {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -90,7 +75,7 @@ export interface CollectionData {
|
|||||||
// Matches handlers.BookInfo JSON response (internal/handlers/collections.go:66-71)
|
// Matches handlers.BookInfo JSON response (internal/handlers/collections.go:66-71)
|
||||||
// JSON tags: media_item_id, title, author, cover_image_path
|
// JSON tags: media_item_id, title, author, cover_image_path
|
||||||
// Used in: collections.templ (server-rendered), collections.ts
|
// Used in: collections.templ (server-rendered), collections.ts
|
||||||
export interface BookInfo {
|
interface BookInfo {
|
||||||
media_item_id: string;
|
media_item_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
author: string;
|
author: string;
|
||||||
@@ -101,7 +86,7 @@ export interface BookInfo {
|
|||||||
// CRITICAL: Must match Go handler return types EXACTLY
|
// CRITICAL: Must match Go handler return types EXACTLY
|
||||||
// Source: handlers.SectionData in collections.go (lines 73-81)
|
// Source: handlers.SectionData in collections.go (lines 73-81)
|
||||||
// Used in: dashboard API responses, TypeScript dashboard components
|
// Used in: dashboard API responses, TypeScript dashboard components
|
||||||
export interface SectionData {
|
interface SectionData {
|
||||||
id: string;
|
id: string;
|
||||||
is_system: boolean;
|
is_system: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -115,7 +100,7 @@ export interface SectionData {
|
|||||||
// Matches database.UserDashboardPreferences and dashboard preferences API
|
// Matches database.UserDashboardPreferences and dashboard preferences API
|
||||||
// Source: internal/database/models.go:381-390
|
// Source: internal/database/models.go:381-390
|
||||||
// Used in: dashboard preferences API
|
// Used in: dashboard preferences API
|
||||||
export interface DashboardPreferences {
|
interface DashboardPreferences {
|
||||||
library_id: string;
|
library_id: string;
|
||||||
hidden_collections: string[];
|
hidden_collections: string[];
|
||||||
collection_order: string[];
|
collection_order: string[];
|
||||||
@@ -124,7 +109,7 @@ export interface DashboardPreferences {
|
|||||||
|
|
||||||
// Matches handlers.UnlinkedBookData JSON response
|
// Matches handlers.UnlinkedBookData JSON response
|
||||||
// Used in: unlinked_books.ts, unlinked_books.templ
|
// Used in: unlinked_books.ts, unlinked_books.templ
|
||||||
export interface UnlinkedBookData {
|
interface UnlinkedBookData {
|
||||||
progress_id: string;
|
progress_id: string;
|
||||||
device_id: string;
|
device_id: string;
|
||||||
device_name: string;
|
device_name: string;
|
||||||
@@ -137,7 +122,7 @@ export interface UnlinkedBookData {
|
|||||||
potential_matches: PotentialMatchData[];
|
potential_matches: PotentialMatchData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PotentialMatchData {
|
interface PotentialMatchData {
|
||||||
media_item_id: string;
|
media_item_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
author: string;
|
author: string;
|
||||||
@@ -147,7 +132,7 @@ export interface PotentialMatchData {
|
|||||||
|
|
||||||
// Matches collection rule objects
|
// Matches collection rule objects
|
||||||
// Used in: collection_rules.ts
|
// Used in: collection_rules.ts
|
||||||
export interface CollectionRule {
|
interface CollectionRule {
|
||||||
id: string;
|
id: string;
|
||||||
field: 'genre' | 'series' | 'author' | 'language' | 'publisher' | 'copyright_year' | 'tags';
|
field: 'genre' | 'series' | 'author' | 'language' | 'publisher' | 'copyright_year' | 'tags';
|
||||||
operator: 'equals' | 'not_equals' | 'contains' | 'not_contains' | 'starts_with' | 'ends_with' | 'greater_than' | 'less_than';
|
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
|
// Matches API test rule responses
|
||||||
// Used in: collection_rules.ts (test results)
|
// Used in: collection_rules.ts (test results)
|
||||||
export interface TestRuleMatch {
|
interface TestRuleMatch {
|
||||||
title: string;
|
title: string;
|
||||||
author: string;
|
author: string;
|
||||||
cover_image_path?: string;
|
cover_image_path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matches handlers.SearchResponse (internal/handlers/search.go)
|
// Matches handlers.SearchResponse (internal/handlers/search.go)
|
||||||
export interface SearchResponse {
|
interface SearchResponse {
|
||||||
results: SearchBookResponse[];
|
results: SearchBookResponse[];
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchBookResponse {
|
interface SearchBookResponse {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
authors: SearchAuthor[];
|
authors: SearchAuthor[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchAuthor {
|
interface SearchAuthor {
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matches AuthResponse (internal/handlers/auth.go:59-65)
|
// Matches AuthResponse (internal/handlers/auth.go:59-65)
|
||||||
export interface AuthResponse {
|
interface AuthResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
@@ -190,7 +175,7 @@ export interface AuthResponse {
|
|||||||
user: UserProfile;
|
user: UserProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserProfile {
|
interface UserProfile {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -202,7 +187,7 @@ export interface UserProfile {
|
|||||||
|
|
||||||
// Matches handlers.ReadingStatsResponse (internal/handlers/analytics.go:26-35)
|
// Matches handlers.ReadingStatsResponse (internal/handlers/analytics.go:26-35)
|
||||||
// Used in: analytics.ts
|
// Used in: analytics.ts
|
||||||
export interface ReadingStatsResponse {
|
interface ReadingStatsResponse {
|
||||||
total_books_read: number;
|
total_books_read: number;
|
||||||
total_pages_read: number;
|
total_pages_read: number;
|
||||||
total_reading_time_minutes: number;
|
total_reading_time_minutes: number;
|
||||||
@@ -213,7 +198,7 @@ export interface ReadingStatsResponse {
|
|||||||
daily_reading_minutes: DailyReading[];
|
daily_reading_minutes: DailyReading[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DailyReading {
|
interface DailyReading {
|
||||||
date: string;
|
date: string;
|
||||||
minutes: number;
|
minutes: number;
|
||||||
pages: number;
|
pages: number;
|
||||||
@@ -222,11 +207,11 @@ export interface DailyReading {
|
|||||||
// Matches handlers.DeviceUsageResponse (internal/handlers/analytics.go:43-45)
|
// Matches handlers.DeviceUsageResponse (internal/handlers/analytics.go:43-45)
|
||||||
// Note: Response is wrapped: { devices: DeviceUsage[] }
|
// Note: Response is wrapped: { devices: DeviceUsage[] }
|
||||||
// Used in: analytics.ts
|
// Used in: analytics.ts
|
||||||
export interface DeviceUsageResponse {
|
interface DeviceUsageResponse {
|
||||||
devices: DeviceUsage[];
|
devices: DeviceUsage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceUsage {
|
interface DeviceUsage {
|
||||||
device_id: string;
|
device_id: string;
|
||||||
device_name: string;
|
device_name: string;
|
||||||
device_type: string;
|
device_type: string;
|
||||||
@@ -239,11 +224,11 @@ export interface DeviceUsage {
|
|||||||
// Matches handlers.PopularBooksResponse (internal/handlers/analytics.go:57-59)
|
// Matches handlers.PopularBooksResponse (internal/handlers/analytics.go:57-59)
|
||||||
// Note: Response is wrapped: { books: PopularBook[] }
|
// Note: Response is wrapped: { books: PopularBook[] }
|
||||||
// Used in: analytics.ts
|
// Used in: analytics.ts
|
||||||
export interface PopularBooksResponse {
|
interface PopularBooksResponse {
|
||||||
books: PopularBook[];
|
books: PopularBook[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PopularBook {
|
interface PopularBook {
|
||||||
media_item_id: string;
|
media_item_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
author: string;
|
author: string;
|
||||||
@@ -254,7 +239,7 @@ export interface PopularBook {
|
|||||||
|
|
||||||
// Matches handlers.QueueItemResponse (internal/handlers/queue.go:35-51)
|
// Matches handlers.QueueItemResponse (internal/handlers/queue.go:35-51)
|
||||||
// Used in: queue.ts
|
// Used in: queue.ts
|
||||||
export interface QueueItemResponse {
|
interface QueueItemResponse {
|
||||||
id: string;
|
id: string;
|
||||||
device_id: string;
|
device_id: string;
|
||||||
device_name: string;
|
device_name: string;
|
||||||
@@ -274,7 +259,7 @@ export interface QueueItemResponse {
|
|||||||
|
|
||||||
// Matches handlers.QueueStatsResponse (internal/handlers/queue.go:27-33)
|
// Matches handlers.QueueStatsResponse (internal/handlers/queue.go:27-33)
|
||||||
// Used in: queue.ts
|
// Used in: queue.ts
|
||||||
export interface QueueStatsResponse {
|
interface QueueStatsResponse {
|
||||||
pending_count: number;
|
pending_count: number;
|
||||||
processing_count: number;
|
processing_count: number;
|
||||||
failed_count: number;
|
failed_count: number;
|
||||||
@@ -284,7 +269,7 @@ export interface QueueStatsResponse {
|
|||||||
|
|
||||||
// Matches handlers.ConflictDetailResponse (internal/handlers/conflicts.go:42-53)
|
// Matches handlers.ConflictDetailResponse (internal/handlers/conflicts.go:42-53)
|
||||||
// Used in: conflicts.ts
|
// Used in: conflicts.ts
|
||||||
export interface ConflictDetailResponse {
|
interface ConflictDetailResponse {
|
||||||
id: string;
|
id: string;
|
||||||
media_item_id: string;
|
media_item_id: string;
|
||||||
media_item_title: string;
|
media_item_title: string;
|
||||||
@@ -298,7 +283,7 @@ export interface ConflictDetailResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Matches handlers.ConflictSourceData (internal/handlers/conflicts.go:36-40)
|
// Matches handlers.ConflictSourceData (internal/handlers/conflicts.go:36-40)
|
||||||
export interface ConflictSourceData {
|
interface ConflictSourceData {
|
||||||
source: string;
|
source: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
@@ -306,7 +291,7 @@ export interface ConflictSourceData {
|
|||||||
|
|
||||||
// Matches handlers.ConflictListResponse (internal/handlers/conflicts.go:55-59)
|
// Matches handlers.ConflictListResponse (internal/handlers/conflicts.go:55-59)
|
||||||
// Used in: conflicts.ts
|
// Used in: conflicts.ts
|
||||||
export interface ConflictListResponse {
|
interface ConflictListResponse {
|
||||||
conflicts: ConflictDetailResponse[];
|
conflicts: ConflictDetailResponse[];
|
||||||
total: number;
|
total: number;
|
||||||
unresolved: number;
|
unresolved: number;
|
||||||
@@ -314,7 +299,7 @@ export interface ConflictListResponse {
|
|||||||
|
|
||||||
// Matches handlers.ConflictResolveResponse (internal/handlers/conflicts.go:61-65)
|
// Matches handlers.ConflictResolveResponse (internal/handlers/conflicts.go:61-65)
|
||||||
// Used in: conflicts.ts
|
// Used in: conflicts.ts
|
||||||
export interface ConflictResolveResponse {
|
interface ConflictResolveResponse {
|
||||||
conflict_resolved: boolean;
|
conflict_resolved: boolean;
|
||||||
applied_to: Record<string, boolean>;
|
applied_to: Record<string, boolean>;
|
||||||
devices_synced: string[];
|
devices_synced: string[];
|
||||||
@@ -322,7 +307,7 @@ export interface ConflictResolveResponse {
|
|||||||
|
|
||||||
// Matches handlers.BulkResolveResponse (internal/handlers/conflicts.go:424-429)
|
// Matches handlers.BulkResolveResponse (internal/handlers/conflicts.go:424-429)
|
||||||
// Used in: conflicts.ts
|
// Used in: conflicts.ts
|
||||||
export interface BulkResolveResponse {
|
interface BulkResolveResponse {
|
||||||
results: ConflictResult[];
|
results: ConflictResult[];
|
||||||
total: number;
|
total: number;
|
||||||
success: number;
|
success: number;
|
||||||
@@ -330,7 +315,7 @@ export interface BulkResolveResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Matches handlers.ConflictResult (internal/handlers/conflicts.go:431-436)
|
// Matches handlers.ConflictResult (internal/handlers/conflicts.go:431-436)
|
||||||
export interface ConflictResult {
|
interface ConflictResult {
|
||||||
conflict_id: string;
|
conflict_id: string;
|
||||||
status: string;
|
status: string;
|
||||||
error?: 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";
|
"use strict";
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
let searchInputTimeout = null;
|
||||||
let searchTimeout = null;
|
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
const SEARCH_MIN_CHARS = 2;
|
const SEARCH_MIN_CHARS = 2;
|
||||||
function initializeSearch() {
|
function initializeSearch() {
|
||||||
@@ -27,14 +26,14 @@ function initializeSearch() {
|
|||||||
function handleSearchInput(e) {
|
function handleSearchInput(e) {
|
||||||
const target = e.target;
|
const target = e.target;
|
||||||
const query = target.value.trim();
|
const query = target.value.trim();
|
||||||
if (searchTimeout) {
|
if (searchInputTimeout) {
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchInputTimeout);
|
||||||
}
|
}
|
||||||
if (query.length < SEARCH_MIN_CHARS) {
|
if (query.length < SEARCH_MIN_CHARS) {
|
||||||
hideSearchResults();
|
hideSearchResults();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
searchTimeout = setTimeout(() => {
|
searchInputTimeout = setTimeout(() => {
|
||||||
performSearch(query);
|
performSearch(query);
|
||||||
}, SEARCH_DEBOUNCE_MS);
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
@@ -150,7 +149,7 @@ function showSearchResults(results, query) {
|
|||||||
let html = `
|
let html = `
|
||||||
<div class="p-3 border-b" style="border-color: var(--border)">
|
<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)">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="max-h-96 overflow-y-auto">
|
<div class="max-h-96 overflow-y-auto">
|
||||||
@@ -174,7 +173,7 @@ function showSearchResults(results, query) {
|
|||||||
</h4>
|
</h4>
|
||||||
${authorHtml ? `<p class="text-xs truncate" style="color: var(--text-secondary)">${authorHtml}</p>` : ''}
|
${authorHtml ? `<p class="text-xs truncate" style="color: var(--text-secondary)">${authorHtml}</p>` : ''}
|
||||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">
|
||||||
${escapeHtml(item.library_name)}
|
${searchEscapeHtml(item.library_name)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,7 +201,7 @@ function showNoResults(query) {
|
|||||||
searchResults.innerHTML = `
|
searchResults.innerHTML = `
|
||||||
<div class="p-4 text-center">
|
<div class="p-4 text-center">
|
||||||
<div class="text-4xl mb-2">🔍</div>
|
<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>
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Try different keywords</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -249,9 +248,9 @@ function highlightMatch(text, query) {
|
|||||||
return '';
|
return '';
|
||||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
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');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
|
|||||||
Reference in New Issue
Block a user