refactor: Convert template event handlers to Alpine.js

Converted all inline onclick/onsubmit handlers to Alpine.js @click/@submit directives and added x-data attributes to template body tags.

Templates updated:
- admin.templ: Added x-data="admin", converted scan/hide buttons
- analytics.templ: Added x-data, converted loadAnalytics button
- api_explorer.templ: Added x-data for API explorer page
- bookshelf.templ: Added x-data, converted library/pagination handlers
- collection_modal.templ: Added x-data for modal components
- collection_rules.templ: Added x-data, converted all rule handlers
- collections.templ: Added x-data, converted navigation/book handlers
- conflicts.templ: Added x-data, converted resolve/dismiss handlers
- header.templ: Converted theme dropdown and user menu handlers
- index.templ: Added x-data for theme/auth
- login.templ: Added x-data for theme switching
- profile.templ: Added x-data, converted delete account
- profile_form.templ: Converted modal close handler
- profile_modal.templ: Added x-data for modal
- progress.templ: Removed script (uses header.logout)
- queue.templ: Added x-data, converted queue handlers
- register.templ: Added x-data for theme
- restore_system_collection_modal.templ: Added x-data
- toast.templ: Converted to x-init with Alpine
- unlinked_books.templ: Added x-data for bulk operations

Dynamic content in devices.templ uses event delegation via data attributes.
This commit is contained in:
2026-03-08 21:36:02 -04:00
parent b78aacd320
commit 08d45617a7
20 changed files with 1523 additions and 2511 deletions
+116 -121
View File
@@ -1,125 +1,120 @@
package templates package templates
templ Admin(user User) { templ Admin(user User) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"/>
<title>Admin Dashboard - Bookhoard</title> <title>Admin 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/main.js"></script>
<script src="/static/admin.js"></script> <link href="/static/style.css" rel="stylesheet"/>
<link href="/static/style.css" rel="stylesheet"> </head>
</head> <body class="theme-tokyo-night" x-data="admin">
<body class="theme-tokyo-night"> @Header(user, "/admin")
@Header(user, "/admin") <div class="flex min-h-screen" style="background-color: var(--bg-primary)">
@AdminSidebar(user, "/admin")
<div class="flex min-h-screen" style="background-color: var(--bg-primary)"> <main class="flex-1 p-8">
@AdminSidebar(user, "/admin") <div class="max-w-4xl">
<main class="flex-1 p-8"> <div class="mb-8">
<div class="max-w-4xl"> <h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Dashboard</h1>
<div class="mb-8"> <p style="color: var(--text-secondary)">Overview of your Bookhoard library and settings</p>
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Dashboard</h1> </div>
<p style="color: var(--text-secondary)">Overview of your Bookhoard library and settings</p> <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
</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="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8"> <div class="text-3xl">📖</div>
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)"> <div>
<div class="flex items-center space-x-3"> <h3 class="font-semibold" style="color: var(--text-primary)">Library</h3>
<div class="text-3xl">📖</div> <p style="color: var(--text-secondary)" class="text-sm">Manage your ebook collection</p>
<div> </div>
<h3 class="font-semibold" style="color: var(--text-primary)">Library</h3> </div>
<p style="color: var(--text-secondary)" class="text-sm">Manage your ebook collection</p> <a href="/" class="mt-4 inline-block text-sm btn-secondary px-3 py-1 rounded">View Library</a>
</div> </div>
</div> <div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<a href="/" class="mt-4 inline-block text-sm btn-secondary px-3 py-1 rounded">View Library</a> <div class="flex items-center space-x-3">
</div> <div class="text-3xl">👁️</div>
<div>
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)"> <h3 class="font-semibold" style="color: var(--text-primary)">Scan Watch Status</h3>
<div class="flex items-center space-x-3"> <p style="color: var(--text-secondary)" class="text-sm">Auto-detecting new files</p>
<div class="text-3xl">👁️</div> </div>
<div> </div>
<h3 class="font-semibold" style="color: var(--text-primary)">Scan Watch Status</h3> <div id="watch-status" class="mt-4 text-sm" style="color: var(--text-secondary)">
<p style="color: var(--text-secondary)" class="text-sm">Auto-detecting new files</p> <span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-2"></span>
</div> Watching <span id="watch-count">0</span> libraries
</div> </div>
<div id="watch-status" class="mt-4 text-sm" style="color: var(--text-secondary)"> </div>
<span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-2"></span> </div>
Watching <span id="watch-count">0</span> libraries <div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
</div> <h3 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Quick Actions</h3>
</div> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<button @click="scanAllLibraries()" class="btn-primary p-4 rounded-lg text-left">
<div class="font-medium">Rescan Library</div>
</div> <div style="color: var(--text-secondary)" class="text-sm">Re-scan existing files and fix metadata</div>
</button>
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)"> <a href="/admin/library" class="btn-secondary p-4 rounded-lg text-left block">
<h3 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Quick Actions</h3> <div class="font-medium">Manage Folders</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div style="color: var(--text-secondary)" class="text-sm">Add or remove scan directories</div>
<button onclick="scanAllLibraries()" class="btn-primary p-4 rounded-lg text-left"> </a>
<div class="font-medium">Rescan Library</div> </div>
<div style="color: var(--text-secondary)" class="text-sm">Re-scan existing files and fix metadata</div> </div>
</button> <!-- Scan Progress Section -->
<a href="/admin/library" class="btn-secondary p-4 rounded-lg text-left block"> <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="font-medium">Manage Folders</div> <div class="flex justify-between items-center mb-4">
<div style="color: var(--text-secondary)" class="text-sm">Add or remove scan directories</div> <h3 class="text-lg font-semibold" style="color: var(--text-primary)">
</a> 📚 Scanning Libraries
</div> </h3>
</div> <button @click="hideScanProgress()" class="p-2 hover:bg-gray-700 rounded">
<!-- Scan Progress Section --> </button>
<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>
<div class="flex justify-between items-center mb-4"> <!-- Overall Progress -->
<h3 class="text-lg font-semibold" style="color: var(--text-primary)"> <div class="mb-4">
📚 Scanning Libraries <div class="flex justify-between text-sm mb-2">
</h3> <span style="color: var(--text-secondary)">Overall Progress</span>
<button onclick="hideScanProgress()" class="p-2 hover:bg-gray-700 rounded"> <span id="scan-progress-text" style="color: var(--text-primary)">0%</span>
</div>
</button> <div class="w-full bg-gray-700 rounded-full h-3">
</div> <div
id="scan-progress-bar"
<!-- Overall Progress --> class="h-3 rounded-full transition-all duration-500"
<div class="mb-4"> style="width: 0%; background-color: var(--accent);"
<div class="flex justify-between text-sm mb-2"> ></div>
<span style="color: var(--text-secondary)">Overall Progress</span> </div>
<span id="scan-progress-text" style="color: var(--text-primary)">0%</span> <div id="scan-status" class="text-sm mt-2" style="color: var(--text-secondary)">
</div> Starting scan...
<div class="w-full bg-gray-700 rounded-full h-3"> </div>
<div id="scan-progress-bar" </div>
class="h-3 rounded-full transition-all duration-500" <!-- Per-Library Progress -->
style="width: 0%; background-color: var(--accent);"> <div id="library-progress-list" class="space-y-3">
</div> <!-- Dynamically populated -->
</div> </div>
<div id="scan-status" class="text-sm mt-2" style="color: var(--text-secondary)"> <!-- Results Summary -->
Starting scan... <div id="scan-results" class="hidden mt-6 p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border);">
</div> <h4 class="font-semibold mb-2" style="color: var(--text-primary)"> Scan Complete!</h4>
</div> <div id="scan-results-content" style="color: var(--text-secondary)">
<!-- Results populated by JS -->
<!-- Per-Library Progress --> </div>
<div id="library-progress-list" class="space-y-3"> <div class="mt-4 flex gap-2">
<!-- Dynamically populated --> <button
</div> @click="window.location.reload()"
class="btn-primary px-4 py-2 rounded-lg"
<!-- 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);"> Refresh to View Books
<h4 class="font-semibold mb-2" style="color: var(--text-primary)"> Scan Complete!</h4> </button>
<div id="scan-results-content" style="color: var(--text-secondary)"> <button
<!-- Results populated by JS --> @click="hideScanProgress()"
</div> class="btn-secondary px-4 py-2 rounded-lg"
<div class="mt-4 flex gap-2"> >
<button onclick="window.location.reload()" Dismiss
class="btn-primary px-4 py-2 rounded-lg"> </button>
Refresh to View Books </div>
</button> </div>
<button onclick="hideScanProgress()" </div>
class="btn-secondary px-4 py-2 rounded-lg"> </div>
Dismiss </main>
</button> </div>
</div> </body>
</div> </html>
</div>
</div>
</main>
</div>
</body>
</html>
} }
+95 -96
View File
@@ -1,100 +1,99 @@
package templates package templates
templ Analytics(user User) { templ Analytics(user User) {
<!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>Reading Analytics - Bookhoard</title> <title>Reading Analytics - 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/main.js"></script>
<link href="/static/style.css" rel="stylesheet"> <link href="/static/style.css" rel="stylesheet"/>
<script src="/static/theme.js"></script> </head>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <body x-data="analytics" class="theme-{ user.Theme }">
</head> @Header(user, "/analytics")
<body class="theme-{ user.Theme }"> <div class="w-full px-4 sm:px-6 lg:px-8 py-8">
@Header(user, "/analytics") <div class="mb-8">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">📊 Reading Analytics</h1>
<div class="w-full px-4 sm:px-6 lg:px-8 py-8"> <p style="color: var(--text-secondary)">Track your reading habits and device usage</p>
<div class="mb-8"> </div>
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">📊 Reading Analytics</h1> <!-- Date Range Picker -->
<p style="color: var(--text-secondary)">Track your reading habits and device usage</p> <div class="card p-4 rounded-lg border mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
</div> <div class="flex flex-wrap gap-4 items-center">
<div class="flex-1 min-w-[200px]">
<!-- Date Range Picker --> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Start Date</label>
<div class="card p-4 rounded-lg border mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);"> <input
<div class="flex flex-wrap gap-4 items-center"> type="date"
<div class="flex-1 min-w-[200px]"> id="start-date"
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Start Date</label> onchange="loadAnalytics()"
<input type="date" id="start-date" onchange="loadAnalytics()" class="w-full px-4 py-2 border rounded-lg"
class="w-full px-4 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> />
</div> </div>
<div class="flex-1 min-w-[200px]"> <div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">End Date</label> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">End Date</label>
<input type="date" id="end-date" onchange="loadAnalytics()" <input
class="w-full px-4 py-2 border rounded-lg" type="date"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> id="end-date"
</div> onchange="loadAnalytics()"
<div class="flex items-end"> class="w-full px-4 py-2 border rounded-lg"
<button onclick="loadAnalytics()" class="btn-primary px-6 py-2 rounded-lg">Update</button> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
</div> />
</div> </div>
</div> <div class="flex items-end">
<button @click="loadAnalytics()" class="btn-primary px-6 py-2 rounded-lg">Update</button>
<!-- Stats Cards --> </div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8" id="stats-container"> </div>
<div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);"> </div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Books Read</h3> <!-- Stats Cards -->
<p id="total-books" class="text-3xl font-bold" style="color: var(--text-primary);">-</p> <div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8" id="stats-container">
</div> <div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);"> <h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Books Read</h3>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Pages Read</h3> <p id="total-books" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
<p id="total-pages" class="text-3xl font-bold" style="color: var(--text-primary);">-</p> </div>
</div> <div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);"> <h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Pages Read</h3>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Reading Time</h3> <p id="total-pages" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
<p id="reading-time" class="text-3xl font-bold" style="color: var(--text-primary);">-</p> </div>
</div> <div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);"> <h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Reading Time</h3>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Completion Rate</h3> <p id="reading-time" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
<p id="completion-rate" class="text-3xl font-bold" style="color: var(--text-primary);">-</p> </div>
</div> <div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
</div> <h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Completion Rate</h3>
<p id="completion-rate" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
<!-- Charts Row --> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8"> </div>
<!-- Daily Reading Chart --> <!-- Charts Row -->
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Daily Reading Minutes</h3> <!-- Daily Reading Chart -->
<div class="h-80"> <div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<canvas id="daily-reading-chart"></canvas> <h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Daily Reading Minutes</h3>
</div> <div class="h-80">
</div> <canvas id="daily-reading-chart"></canvas>
</div>
<!-- Device Usage Chart --> </div>
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);"> <!-- Device Usage Chart -->
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Device Usage</h3> <div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="h-80"> <h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Device Usage</h3>
<canvas id="device-usage-chart"></canvas> <div class="h-80">
</div> <canvas id="device-usage-chart"></canvas>
</div> </div>
</div> </div>
</div>
<!-- Popular Books --> <!-- Popular Books -->
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Most Read Books</h3> <h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Most Read Books</h3>
<div id="popular-books" class="space-y-3"> <div id="popular-books" class="space-y-3">
<div class="text-center py-8" style="color: var(--text-secondary);"> <div class="text-center py-8" style="color: var(--text-secondary);">
<div class="loading-spinner mx-auto mb-4"></div> <div class="loading-spinner mx-auto mb-4"></div>
<p>Loading analytics...</p> <p>Loading analytics...</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<script src="/static/analytics.js"></script>
<script src="/static/analytics.js"></script> </body>
</body> </html>
</html>
} }
+9 -113
View File
@@ -25,16 +25,16 @@ templ APIExplorer(explorer APIExplorerData) {
<pre class="bg-background-primary p-4 rounded-lg overflow-x-auto"><code class="text-text-primary text-sm">{ explorer.Endpoint.Response }</code></pre> <pre class="bg-background-primary p-4 rounded-lg overflow-x-auto"><code class="text-text-primary text-sm">{ explorer.Endpoint.Response }</code></pre>
</div> </div>
<div class="flex gap-2 mt-4"> <div class="flex gap-2 mt-4">
<button onclick="copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Request</button> @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Request</button>
<button onclick="copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Response</button> @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Response</button>
</div> </div>
</div> </div>
} else { } else {
// Show interactive explorer with real execution // Show interactive explorer with real execution
<div class="border border-border rounded-lg p-6 mt-8 bg-background-secondary"> <div class="border border-border rounded-lg p-6 mt-8 bg-background-secondary" x-data="apiExplorerDoc" x-init="initAPIExplorerDoc('{ explorer.Endpoint.Path }', '{ explorer.Endpoint.RequestBody }', '{ explorer.Endpoint.Response }')">
<div class="flex gap-2 mb-4"> <div class="flex gap-2 mb-4">
<button class="mode-btn px-4 py-2 border border-border rounded bg-background-primary text-text-primary cursor-pointer hover:bg-background-secondary text-sm" id="mock-btn">Mock Data</button> <button class="mode-btn px-4 py-2 border border-border rounded bg-background-primary text-text-primary cursor-pointer hover:bg-background-secondary text-sm" id="mock-btn" @click="showDocMode('mock')">Mock Data</button>
<button class="mode-btn px-4 py-2 bg-accent text-white rounded cursor-pointer text-sm" id="real-btn">Try It Out</button> <button class="mode-btn px-4 py-2 bg-accent text-white rounded cursor-pointer text-sm" id="real-btn" @click="showDocMode('real')">Try It Out</button>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<div class="mb-4"> <div class="mb-4">
@@ -48,7 +48,7 @@ templ APIExplorer(explorer APIExplorerData) {
<h4 class="text-text-secondary mb-2 text-sm font-medium">Request Body</h4> <h4 class="text-text-secondary mb-2 text-sm font-medium">Request Body</h4>
<textarea id="request-body" placeholder="Edit request body..." class="w-full min-h-[150px] px-3 py-2 font-mono text-sm bg-background-primary text-text-primary border border-border rounded focus:outline-none focus:ring-2 focus:ring-accent">{ explorer.Endpoint.RequestBody }</textarea> <textarea id="request-body" placeholder="Edit request body..." class="w-full min-h-[150px] px-3 py-2 font-mono text-sm bg-background-primary text-text-primary border border-border rounded focus:outline-none focus:ring-2 focus:ring-accent">{ explorer.Endpoint.RequestBody }</textarea>
</div> </div>
<button id="try-it-out" class="bg-accent text-white px-6 py-3 rounded cursor-pointer mt-4 hover:opacity-90">Try It Out</button> <button id="try-it-out" @click="tryDocEndpoint()" class="bg-accent text-white px-6 py-3 rounded cursor-pointer mt-4 hover:opacity-90">Try It Out</button>
<div class="api-response mt-4 hidden"> <div class="api-response mt-4 hidden">
<div class="flex justify-between mb-2 text-sm"> <div class="flex justify-between mb-2 text-sm">
<span id="response-status" class="font-semibold"></span> <span id="response-status" class="font-semibold"></span>
@@ -57,116 +57,12 @@ templ APIExplorer(explorer APIExplorerData) {
<pre class="bg-background-primary p-4 rounded-lg overflow-x-auto"><code id="response-body" class="text-text-primary text-sm"></code></pre> <pre class="bg-background-primary p-4 rounded-lg overflow-x-auto"><code id="response-body" class="text-text-primary text-sm"></code></pre>
</div> </div>
<div class="flex gap-2 mt-4 flex-wrap"> <div class="flex gap-2 mt-4 flex-wrap">
<button onclick="copyRequest()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Request</button> <button @click="copyDocRequest()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Request</button>
<button onclick="copyResponse()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Response</button> <button @click="copyDocResponse()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Response</button>
<button onclick="generateCURL()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Generate cURL</button> <button @click="generateDocCURL()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Generate cURL</button>
</div> </div>
</div> </div>
} }
<script>
const endpointPath = '{ explorer.Endpoint.Path }';
const exampleRequest = { explorer.Endpoint.RequestBody };
const exampleResponse = { explorer.Endpoint.Response };
// Mode toggle logic
document.getElementById('mock-btn')?.addEventListener('click', () => showMode('mock'));
document.getElementById('real-btn')?.addEventListener('click', () => showMode('real'));
function showMode(mode) {
const mockBtn = document.getElementById('mock-btn');
const realBtn = document.getElementById('real-btn');
const responseBody = document.getElementById('response-body');
const responseStatus = document.getElementById('response-status');
const responseTime = document.getElementById('response-time');
const apiResponse = document.querySelector('.api-response');
const requestBody = document.getElementById('request-body');
const tryItOut = document.getElementById('try-it-out');
if (mode === 'mock') {
mockBtn.classList.add('bg-accent', 'text-white');
mockBtn.classList.remove('bg-background-primary', 'text-text-primary');
realBtn.classList.remove('bg-accent', 'text-white');
realBtn.classList.add('bg-background-primary', 'text-text-primary');
apiResponse.classList.remove('hidden');
requestBody.readOnly = true;
tryItOut.classList.add('hidden');
responseBody.textContent = JSON.stringify(exampleResponse, null, 2);
responseStatus.textContent = '200 OK';
responseTime.textContent = 'Mock';
} else {
realBtn.classList.add('bg-accent', 'text-white');
realBtn.classList.remove('bg-background-primary', 'text-text-primary');
mockBtn.classList.remove('bg-accent', 'text-white');
mockBtn.classList.add('bg-background-primary', 'text-text-primary');
apiResponse.classList.add('hidden');
requestBody.readOnly = false;
tryItOut.classList.remove('hidden');
}
}
// Try it out logic
document.getElementById('try-it-out')?.addEventListener('click', async () => {
const method = document.getElementById('http-method').value;
const body = document.getElementById('request-body').value;
const startTime = Date.now();
try {
const response = await fetch(endpointPath, {
method: method,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: ['GET', 'DELETE'].includes(method) ? undefined : body
});
const duration = Date.now() - startTime;
const data = await response.json();
document.getElementById('response-status').textContent = `${response.status} (${response.statusText})`;
document.getElementById('response-time').textContent = `${duration}ms`;
document.getElementById('response-body').textContent = JSON.stringify(data, null, 2);
document.querySelector('.api-response').classList.remove('hidden');
} catch (error) {
document.getElementById('response-status').textContent = 'Error';
document.getElementById('response-body').textContent = error.message;
document.querySelector('.api-response').classList.remove('hidden');
}
});
function copyToClipboard(text) {
navigator.clipboard.writeText(text);
}
function copyRequest() {
const body = document.getElementById('request-body').value;
navigator.clipboard.writeText(body);
}
function copyResponse() {
const body = document.getElementById('response-body').textContent;
navigator.clipboard.writeText(body);
}
function generateCURL() {
const method = document.getElementById('http-method').value;
const body = document.getElementById('request-body').value;
const token = localStorage.getItem('token');
let curl = `curl -X ${method} \\n -H "Content-Type: application/json" \\n -H "Authorization: Bearer ${token}"`;
if (!['GET', 'DELETE'].includes(method) && body.trim()) {
curl += ` \\n -d '${body}'`;
}
curl += ` \\n ${endpointPath}`;
navigator.clipboard.writeText(curl);
}
</script>
} }
// APIExplorerData holds data for the API explorer component // APIExplorerData holds data for the API explorer component
+56 -262
View File
@@ -1,266 +1,60 @@
package templates package templates
templ BookShelf(user User, libraries []LibraryData) { templ BookShelf(user User, libraries []LibraryData) {
<!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>Library - Bookhoard</title> <title>Library - 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/main.js"></script>
<script src="/static/search.js"></script> <link href="/static/style.css" rel="stylesheet"/>
<link href="/static/style.css" rel="stylesheet"> </head>
</head> <body class="theme-{ user.Theme }" x-data="bookshelf" x-init="initBookshelf(); setupEventDelegation()">
<body class="theme-{ user.Theme }"> @Header(user, "/bookshelf")
@Header(user, "/bookshelf") <div class="w-full px-4 sm:px-6 lg:8 py-8">
<div class="w-full px-4 sm:px-6 lg:8 py-8"> <!-- Library Selector -->
<!-- Library Selector --> <div class="mb-8">
<div class="mb-8"> <div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between"> <div class="flex-1 w-full">
<div class="flex-1 w-full"> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Select Library</label>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Select Library</label> <select id="library-select" @change="selectLibrary()" class="w-full px-4 py-3 border rounded-lg text-lg" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);">
<select id="library-select" onchange="selectLibrary()" class="w-full px-4 py-3 border rounded-lg text-lg" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"> if len(libraries) == 0 {
if len(libraries) == 0 { <option value="">No libraries available</option>
<option value="">No libraries available</option> } else {
} else { for _, lib := range libraries {
for _, lib := range libraries { <option value={ lib.ID }>{ lib.Name }</option>
<option value={ lib.ID }>{ lib.Name }</option> }
} }
} </select>
</select> </div>
</div> <div class="flex gap-2">
<div class="flex gap-2"> <button @click="loadBookshelf()" class="btn-primary px-6 py-3 rounded-lg font-medium">
<button onclick="loadBookshelf()" class="btn-primary px-6 py-3 rounded-lg font-medium"> Refresh
Refresh </button>
</button> </div>
</div> </div>
</div> </div>
</div> <!-- Bookshelf Container -->
<div id="bookshelf-container">
<!-- Bookshelf Container --> <!-- Loading state -->
<div id="bookshelf-container"> <div id="loading" class="text-center py-16" style="color: var(--text-secondary)">
<!-- Loading state --> <div class="loading-spinner mx-auto mb-4"></div>
<div id="loading" class="text-center py-16" style="color: var(--text-secondary)"> <p>Loading your library...</p>
<div class="loading-spinner mx-auto mb-4"></div> </div>
<p>Loading your library...</p> <!-- Empty state -->
</div> <div id="empty-state" class="hidden text-center py-16" style="color: var(--text-secondary)">
<div class="text-6xl mb-4">📚</div>
<!-- Empty state --> <h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Books Yet</h3>
<div id="empty-state" class="hidden text-center py-16" style="color: var(--text-secondary)"> <p>Select a library to view your collection</p>
<div class="text-6xl mb-4">📚</div> </div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Books Yet</h3> <!-- Books Grid -->
<p>Select a library to view your collection</p> <div id="books-grid" class="hidden">
</div> <!-- Books will be loaded here on shelves -->
</div>
<!-- Books Grid --> </div>
<div id="books-grid" class="hidden"> </div>
<!-- Books will be loaded here on shelves --> </body>
</div> </html>
</div>
</div>
<script src="/static/theme.js"></script>
<script>
// Libraries already loaded server-side
const libraries = [
if len(libraries) > 0 {
for _, lib := range libraries {
{
id: "{ lib.ID }",
name: "{ lib.Name }",
description: "{ lib.Description }",
type_name: "{ lib.TypeName }"
},
}
}
];
let currentLibrary = null;
let mediaItems = [];
function selectLibrary() {
const librarySelect = document.getElementById('library-select');
const selectedId = librarySelect.value;
currentLibrary = libraries.find(lib => lib.id === selectedId);
if (currentLibrary) {
loadBookshelf();
}
}
// Auto-select first library if available
document.addEventListener('DOMContentLoaded', function() {
loadTheme();
if (libraries.length > 0) {
const librarySelect = document.getElementById('library-select');
currentLibrary = libraries[0];
librarySelect.value = currentLibrary.id;
loadBookshelf();
} else {
showEmptyState();
}
});
})
.then(response => response.json())
.then(data => {
loading.style.display = 'none';
libraries = data;
populateLibrarySelect();
// Auto-select first library if available
if (libraries.length > 0 && !currentLibrary) {
currentLibrary = libraries[0];
librarySelect.value = currentLibrary.id;
loadBookshelf();
}
})
.catch(error => {
loading.style.display = 'none';
console.error('Error loading libraries:', error);
showToast('Error loading libraries', 'error');
});
}
function populateLibrarySelect() {
const librarySelect = document.getElementById('library-select');
if (libraries.length === 0) {
librarySelect.innerHTML = '<option value="">No libraries available</option>';
return;
}
librarySelect.innerHTML = '<option value="">Select a library...</option>' +
libraries.map(library => {
const icon = getLibraryIcon(library.type_name);
return `<option value="${library.id}">${icon} ${library.name}</option>`;
}).join('');
}
function getLibraryIcon(typeName) {
switch (typeName) {
case 'ebooks': return '📚';
case 'comics': return '📖';
case 'manga': return '🗾';
default: return '📁';
}
}
function selectLibrary() {
const librarySelect = document.getElementById('library-select');
const libraryId = librarySelect.value;
currentLibrary = libraries.find(l => l.id === libraryId);
loadBookshelf();
}
function loadBookshelf() {
if (!currentLibrary) {
showEmptyState();
return;
}
const loading = document.getElementById('loading');
const booksGrid = document.getElementById('books-grid');
const emptyState = document.getElementById('empty-state');
loading.style.display = 'block';
booksGrid.classList.add('hidden');
emptyState.classList.add('hidden');
fetch(`/api/media-items?library_id=${currentLibrary.id}&limit=100&offset=0`, {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token'),
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
loading.style.display = 'none';
mediaItems = data;
if (mediaItems.length === 0) {
showEmptyState();
} else {
renderBookshelf();
}
})
.catch(error => {
loading.style.display = 'none';
console.error('Error loading bookshelf:', error);
showToast('Error loading books', 'error');
});
}
function showEmptyState() {
const booksGrid = document.getElementById('books-grid');
const emptyState = document.getElementById('empty-state');
const loading = document.getElementById('loading');
loading.style.display = 'none';
booksGrid.classList.add('hidden');
emptyState.classList.remove('hidden');
}
function renderBookshelf() {
const booksGrid = document.getElementById('books-grid');
const emptyState = document.getElementById('empty-state');
const loading = document.getElementById('loading');
loading.style.display = 'none';
emptyState.classList.add('hidden');
booksGrid.classList.remove('hidden');
// Group books into shelves (6 books per shelf for better display)
const booksPerShelf = 6;
const shelves = [];
for (let i = 0; i < mediaItems.length; i += booksPerShelf) {
shelves.push(mediaItems.slice(i, i + booksPerShelf));
}
let html = '';
shelves.forEach((shelfBooks, index) => {
html += `<div class="relative bg-gradient-to-b from-transparent to-black/10 p-8 mb-4 rounded-lg" style="padding-bottom: 3rem;">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
${shelfBooks.map(book => renderBookCard(book)).join('')}
</div>
<div class="absolute bottom-0 left-0 right-0 h-3 rounded-b-lg" style="background: linear-gradient(to bottom, rgba(107, 68, 35, 0.3) 0%, rgba(107, 68, 35, 0.5) 50%, rgba(107, 68, 35, 0.3) 100%); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);"></div>
</div>`;
});
booksGrid.innerHTML = html;
}
function renderBookCard(book) {
const coverUrl = book.cover_image_path || '/static/placeholder-book.svg';
const authorHtml = book.author ? `<p class="text-xs" style="color: var(--text-secondary)">${book.author}</p>` : '';
return `<div class="relative transition-all duration-200 ease hover:-translate-y-2 hover:-rotate-2 hover:shadow-2xl hover:z-10 cursor-pointer" onclick="viewBook('${book.id}')">
<div class="aspect-[2/3] overflow-hidden rounded shadow-[2px_2px_4px_rgba(0,0,0,0.2),-1px_-1px_2px_rgba(255,255,255,0.1)_inset] relative">
<div class="absolute left-0 top-0 bottom-0 w-1" style="background: linear-gradient(to right, rgba(0, 0, 0, 0.3) 0%, rgba(255, 255, 255, 0.1) 50%, transparent 100%);"></div>
<img src="${coverUrl}"
alt="${book.title}"
class="w-full h-full object-cover"
onerror="this.src='/static/placeholder-book.svg'"
>
</div>
<div class="mt-2">
<h3 class="text-sm font-semibold line-clamp-2" style="color: var(--text-primary)">${book.title}</h3>
${authorHtml}
</div>
</div>`;
}
function viewBook(bookId) {
// TODO: Navigate to book detail view
showToast('Book viewer coming soon!', 'info');
}
// Initialize
document.addEventListener('DOMContentLoaded', function() {
loadTheme();
loadLibraries();
});
</script>
</body>
</html>
} }
+14 -14
View File
@@ -1,7 +1,7 @@
package templates package templates
templ CollectionModal(collection CollectionData) { templ CollectionModal(collection CollectionData) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70"> <div x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
if collection.ID != "" { if collection.ID != "" {
@@ -9,7 +9,7 @@ templ CollectionModal(collection CollectionData) {
} else { } else {
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Collection</h2> <h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Collection</h2>
} }
<button type="button" onclick="closeCollectionModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button> <button type="button" @click="closeCollectionModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
</div> </div>
if collection.ID != "" { if collection.ID != "" {
<form <form
@@ -66,16 +66,16 @@ templ CollectionModal(collection CollectionData) {
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label>
<div class="flex gap-2"> <div class="flex gap-2">
<button type="button" onclick="selectColor('blue')" class="w-8 h-8 rounded-full color-option bg-blue-500"></button> <button type="button" @click=\"selectColor('blue')\" class="w-8 h-8 rounded-full color-option bg-blue-500"></button>
<button type="button" onclick="selectColor('red')" class="w-8 h-8 rounded-full color-option bg-red-500"></button> <button type="button" @click=\"selectColor('red')\" class="w-8 h-8 rounded-full color-option bg-red-500"></button>
<button type="button" onclick="selectColor('yellow')" class="w-8 h-8 rounded-full color-option bg-yellow-500"></button> <button type="button" @click=\"selectColor('yellow')\" class="w-8 h-8 rounded-full color-option bg-yellow-500"></button>
<button type="button" onclick="selectColor('green')" class="w-8 h-8 rounded-full color-option bg-green-500"></button> <button type="button" @click=\"selectColor('green')\" class="w-8 h-8 rounded-full color-option bg-green-500"></button>
<button type="button" onclick="selectColor('purple')" class="w-8 h-8 rounded-full color-option bg-purple-500"></button> <button type="button" @click=\"selectColor('purple')\" class="w-8 h-8 rounded-full color-option bg-purple-500"></button>
</div> </div>
<input type="hidden" name="color" id="collection-color" value={ collection.Color }/> <input type="hidden" name="color" id="collection-color" value={ collection.Color }/>
</div> </div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" onclick="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button> <button type="button" @click="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">Update Collection</button> <button type="submit" class="btn-primary px-4 py-2 rounded-lg">Update Collection</button>
</div> </div>
</form> </form>
@@ -132,16 +132,16 @@ templ CollectionModal(collection CollectionData) {
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label>
<div class="flex gap-2"> <div class="flex gap-2">
<button type="button" onclick="selectColor('blue')" class="w-8 h-8 rounded-full color-option bg-blue-500"></button> <button type="button" @click=\"selectColor('blue')\" class="w-8 h-8 rounded-full color-option bg-blue-500"></button>
<button type="button" onclick="selectColor('red')" class="w-8 h-8 rounded-full color-option bg-red-500"></button> <button type="button" @click=\"selectColor('red')\" class="w-8 h-8 rounded-full color-option bg-red-500"></button>
<button type="button" onclick="selectColor('yellow')" class="w-8 h-8 rounded-full color-option bg-yellow-500"></button> <button type="button" @click=\"selectColor('yellow')\" class="w-8 h-8 rounded-full color-option bg-yellow-500"></button>
<button type="button" onclick="selectColor('green')" class="w-8 h-8 rounded-full color-option bg-green-500"></button> <button type="button" @click=\"selectColor('green')\" class="w-8 h-8 rounded-full color-option bg-green-500"></button>
<button type="button" onclick="selectColor('purple')" class="w-8 h-8 rounded-full color-option bg-purple-500"></button> <button type="button" @click=\"selectColor('purple')\" class="w-8 h-8 rounded-full color-option bg-purple-500"></button>
</div> </div>
<input type="hidden" name="color" id="collection-color" value="blue"/> <input type="hidden" name="color" id="collection-color" value="blue"/>
</div> </div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" onclick="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button> <button type="button" @click="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">Create Collection</button> <button type="submit" class="btn-primary px-4 py-2 rounded-lg">Create Collection</button>
</div> </div>
</form> </form>
+194 -416
View File
@@ -1,420 +1,198 @@
package templates package templates
templ CollectionRules(user User, collection CollectionData) { templ CollectionRules(user User, collection CollectionData) {
<!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>Collection Rules - { collection.Name } - Bookhoard</title> <title>Collection Rules - { collection.Name } - 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/main.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 }" x-data="collectionRules" x-init="initCollectionRules('{ collection.ID }')">
@Header(user, "/collections") @Header(user, "/collections")
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="mb-6">
<div class="mb-6"> <button @click="backToCollection()" class="btn-secondary px-4 py-2 rounded-lg mb-4">
<button onclick="backToCollection()" class="btn-secondary px-4 py-2 rounded-lg mb-4"> Back to Collection
Back to Collection </button>
</button> <div class="flex items-center gap-4">
<div class="flex items-center gap-4"> <div class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div>
<div class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div> <div>
<div> <h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1> <p style="color: var(--text-secondary)">Auto-Assign Rules</p>
<p style="color: var(--text-secondary)">Auto-Assign Rules</p> </div>
</div> </div>
</div> </div>
</div> <div class="mb-8">
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Existing Rules</h2>
<div class="mb-8"> <div id="rules-container" class="space-y-4">
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Existing Rules</h2> <div id="no-rules" class="card p-6 rounded-lg border text-center" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div id="rules-container" class="space-y-4"> <div class="text-4xl mb-2">📋</div>
<div id="no-rules" class="card p-6 rounded-lg border text-center" style="background-color: var(--bg-secondary); border-color: var(--border);"> <h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">No Rules Yet</h3>
<div class="text-4xl mb-2">📋</div> <p style="color: var(--text-secondary)">Create auto-assign rules to automatically add books to this collection</p>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">No Rules Yet</h3> </div>
<p style="color: var(--text-secondary)">Create auto-assign rules to automatically add books to this collection</p> </div>
</div> </div>
</div> <div class="mb-8">
</div> <h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Create New Rule</h2>
<form id="rule-form" @submit="handleCreateRule($event)">
<div class="mb-8"> <input type="hidden" id="collection-id" value="{ collection.ID }"/>
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Create New Rule</h2> <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<form id="rule-form" onsubmit="handleCreateRule(event)"> <div>
<input type="hidden" id="collection-id" value="{ collection.ID }"> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Field</label>
<select
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6"> id="rule-field"
<div> required
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Field</label> class="w-full px-4 py-2 border rounded-lg"
<select id="rule-field" required style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="w-full px-4 py-2 border rounded-lg" >
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> <option value="">Select field...</option>
<option value="">Select field...</option> <option value="genre">Genre</option>
<option value="genre">Genre</option> <option value="series">Series</option>
<option value="series">Series</option> <option value="author">Author</option>
<option value="author">Author</option> <option value="language">Language</option>
<option value="language">Language</option> <option value="publisher">Publisher</option>
<option value="publisher">Publisher</option> <option value="copyright_year">Copyright Year</option>
<option value="copyright_year">Copyright Year</option> <option value="tags">Tags</option>
<option value="tags">Tags</option> </select>
</select> </div>
</div> <div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Operator</label>
<div> <select
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Operator</label> id="rule-operator"
<select id="rule-operator" required required
class="w-full px-4 py-2 border rounded-lg" class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
<option value="">Select operator...</option> >
<option value="equals">equals</option> <option value="">Select operator...</option>
<option value="not_equals">does not equal</option> <option value="equals">equals</option>
<option value="contains">contains</option> <option value="not_equals">does not equal</option>
<option value="not_contains">does not contain</option> <option value="contains">contains</option>
<option value="starts_with">starts with</option> <option value="not_contains">does not contain</option>
<option value="ends_with">ends with</option> <option value="starts_with">starts with</option>
<option value="greater_than">greater than</option> <option value="ends_with">ends with</option>
<option value="less_than">less than</option> <option value="greater_than">greater than</option>
</select> <option value="less_than">less than</option>
</div> </select>
</div> </div>
</div>
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Value</label> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Value</label>
<input type="text" id="rule-value" required <input
class="w-full px-4 py-2 border rounded-lg" type="text"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" id="rule-value"
placeholder="e.g., Science Fiction"> required
</div> class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
<div class="mb-6"> placeholder="e.g., Science Fiction"
<label class="flex items-center space-x-3 cursor-pointer"> />
<input type="checkbox" id="rule-enabled" checked </div>
class="w-5 h-5 rounded"> <div class="mb-6">
<span style="color: var(--text-primary)">Enable Rule</span> <label class="flex items-center space-x-3 cursor-pointer">
</label> <input
<p class="text-xs mt-1" style="color: var(--text-secondary)">Uncheck to disable without deleting</p> type="checkbox"
</div> id="rule-enabled"
checked
<div class="mb-6"> class="w-5 h-5 rounded"
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Priority</label> />
<div class="flex gap-4"> <span style="color: var(--text-primary)">Enable Rule</span>
<label class="flex items-center space-x-2 cursor-pointer"> </label>
<input type="radio" name="priority" value="1" class="w-4 h-4"> <p class="text-xs mt-1" style="color: var(--text-secondary)">Uncheck to disable without deleting</p>
<span style="color: var(--text-primary)">High</span> </div>
</label> <div class="mb-6">
<label class="flex items-center space-x-2 cursor-pointer"> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Priority</label>
<input type="radio" name="priority" value="2" checked <div class="flex gap-4">
class="w-4 h-4"> <label class="flex items-center space-x-2 cursor-pointer">
<span style="color: var(--text-primary)">Medium</span> <input type="radio" name="priority" value="1" class="w-4 h-4"/>
</label> <span style="color: var(--text-primary)">High</span>
<label class="flex items-center space-x-2 cursor-pointer"> </label>
<input type="radio" name="priority" value="3" <label class="flex items-center space-x-2 cursor-pointer">
class="w-4 h-4"> <input
<span style="color: var(--text-primary)">Low</span> type="radio"
</label> name="priority"
</div> value="2"
</div> checked
class="w-4 h-4"
<div class="flex justify-end space-x-3"> />
<button type="button" onclick="testRule()" class="btn-secondary px-4 py-2 rounded-lg"> <span style="color: var(--text-primary)">Medium</span>
🧪 Test Rule </label>
</button> <label class="flex items-center space-x-2 cursor-pointer">
<button type="button" onclick="clearForm()" class="btn-secondary px-4 py-2 rounded-lg"> <input
Clear type="radio"
</button> name="priority"
<button type="submit" class="btn-primary px-4 py-2 rounded-lg"> value="3"
Add Rule class="w-4 h-4"
</button> />
</div> <span style="color: var(--text-primary)">Low</span>
</form> </label>
</div>
<div id="test-results" class="hidden card p-6 rounded-lg border mb-6" style="background-color: var(--bg-primary); border-color: var(--border);"> </div>
<h3 class="font-semibold mb-3" style="color: var(--text-primary)">Rule Test Results</h3> <div class="flex justify-end space-x-3">
<p class="text-sm mb-2" style="color: var(--text-secondary)">Books that would be added by this rule:</p> <button type="button" @click="testRule()" class="btn-secondary px-4 py-2 rounded-lg">
<div id="test-results-list" class="max-h-64 overflow-y-auto space-y-2"></div> 🧪 Test Rule
</div> </button>
</div> <button type="button" @click="clearForm()" class="btn-secondary px-4 py-2 rounded-lg">
Clear
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border);"> </button>
<h2 class="text-xl font-bold mb-4" style="color: var(--text-primary)">Rule Examples</h2> <button type="submit" class="btn-primary px-4 py-2 rounded-lg">
<div class="space-y-4"> Add Rule
<div class="flex items-start gap-4"> </button>
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold" style="background-color: var(--accent);">1</div> </div>
<div> </form>
<p class="font-medium" style="color: var(--text-primary)">Add all Science Fiction books</p> <div id="test-results" class="hidden card p-6 rounded-lg border mb-6" style="background-color: var(--bg-primary); border-color: var(--border);">
<code class="block mt-1 text-sm" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;"> <h3 class="font-semibold mb-3" style="color: var(--text-primary)">Rule Test Results</h3>
Field: genre<br> <p class="text-sm mb-2" style="color: var(--text-secondary)">Books that would be added by this rule:</p>
Operator: equals<br> <div id="test-results-list" class="max-h-64 overflow-y-auto space-y-2"></div>
Value: Science Fiction </div>
</code> </div>
</div> <div class="card p-6 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border);">
</div> <h2 class="text-xl font-bold mb-4" style="color: var(--text-primary)">Rule Examples</h2>
<div class="flex items-start gap-4"> <div class="space-y-4">
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold" style="background-color: var(--accent);">2</div> <div class="flex items-start gap-4">
<div> <div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold" style="background-color: var(--accent);">1</div>
<p class="font-medium" style="color: var(--text-primary)">Add books from a specific series</p> <div>
<code class="block mt-1 text-sm" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;"> <p class="font-medium" style="color: var(--text-primary)">Add all Science Fiction books</p>
Field: series<br> <code class="block mt-1 text-sm" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;">
Operator: starts with<br> Field: genre
Value: Harry Potter <br/>
</code> Operator: equals
</div> <br/>
</div> Value: Science Fiction
<div class="flex items-start gap-4"> </code>
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold" style="background-color: var(--accent);">3</div> </div>
<div> </div>
<p class="font-medium" style="color: var(--text-primary)">Add books published in a year range</p> <div class="flex items-start gap-4">
<code class="block mt-1 text-sm" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;"> <div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold" style="background-color: var(--accent);">2</div>
Field: copyright_year<br> <div>
Operator: greater than<br> <p class="font-medium" style="color: var(--text-primary)">Add books from a specific series</p>
Value: 2020 <code class="block mt-1 text-sm" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;">
</code> Field: series
</div> <br/>
</div> Operator: starts with
</div> <br/>
</div> Value: Harry Potter
</div> </code>
</div>
<script> </div>
let collectionId = '{ collection.ID }'; <div class="flex items-start gap-4">
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold" style="background-color: var(--accent);">3</div>
function backToCollection() { <div>
window.location.href = `/collections/${collectionId}`; <p class="font-medium" style="color: var(--text-primary)">Add books published in a year range</p>
} <code class="block mt-1 text-sm" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;">
Field: copyright_year
function renderRules(rules) { <br/>
const container = document.getElementById('rules-container'); Operator: greater than
const noRulesDiv = document.getElementById('no-rules'); <br/>
Value: 2020
if (rules && rules.length > 0) { </code>
noRulesDiv.classList.add('hidden'); </div>
</div>
container.innerHTML = rules.map((rule, index) => ` </div>
<div class="card p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);"> </div>
<div class="flex justify-between items-start"> </div>
<div class="flex-1"> </body>
<div class="flex items-center gap-3 mb-2"> </html>
<span class="font-semibold" style="color: var(--text-primary)">
${getFieldLabel(rule.field)}
</span>
<span class="px-2 py-1 text-xs rounded" style="background-color: ${rule.enabled ? 'var(--accent)' : 'var(--text-secondary)'}; color: var(--bg-primary);">
${rule.enabled ? 'Enabled' : 'Disabled'}
</span>
<span class="px-2 py-1 text-xs rounded" style="background-color: var(--bg-secondary); color: var(--text-primary);">
Priority ${rule.priority}
</span>
</div>
<code class="block text-sm" style="color: var(--text-secondary);">
${getOperatorLabel(rule.operator)} "${rule.value}"
</code>
</div>
<div class="flex space-x-2">
<button onclick="toggleRule('${rule.id}', ${!rule.enabled})"
class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
${rule.enabled ? '⏸️' : '▶️'}
</button>
<button onclick="deleteRule('${rule.id}')"
class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
🗑️
</button>
</div>
</div>
</div>
`).join('');
} else {
noRulesDiv.classList.remove('hidden');
}
}
function getFieldLabel(field) {
const labels = {
'genre': 'Genre',
'series': 'Series',
'author': 'Author',
'language': 'Language',
'publisher': 'Publisher',
'copyright_year': 'Copyright Year',
'tags': 'Tags'
};
return labels[field] || field;
}
function getOperatorLabel(operator) {
const labels = {
'equals': 'equals',
'not_equals': 'does not equal',
'contains': 'contains',
'not_contains': 'does not contain',
'starts_with': 'starts with',
'ends_with': 'ends with',
'greater_than': 'greater than',
'less_than': 'less than'
};
return labels[operator] || operator;
}
function handleCreateRule(event) {
event.preventDefault();
const data = {
field: document.getElementById('rule-field').value,
operator: document.getElementById('rule-operator').value,
value: document.getElementById('rule-value').value,
enabled: document.getElementById('rule-enabled').checked,
priority: parseInt(document.querySelector('input[name="priority"]:checked').value)
};
fetch(`/api/collections/${collectionId}/rules`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
showToast('Rule created successfully', 'success');
clearForm();
loadRules();
})
.catch(error => {
showToast('Failed to create rule', 'error');
});
}
function testRule() {
const field = document.getElementById('rule-field').value;
const operator = document.getElementById('rule-operator').value;
const value = document.getElementById('rule-value').value;
if (!field || !operator || !value) {
showToast('Please fill in all rule fields', 'error');
return;
}
const testResultsDiv = document.getElementById('test-results');
const resultsList = document.getElementById('test-results-list');
testResultsDiv.classList.remove('hidden');
resultsList.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">Testing rule...</p>';
const rules = [{
field: field,
operator: operator,
value: value
}];
fetch('/api/collections/test-rules', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: JSON.stringify({ rules: rules })
})
.then(response => response.json())
.then(result => {
if (result.matches && result.matches.length > 0) {
let html = '<p style="color: var(--text-secondary); font-size: 0.875rem;">Found ' + result.matches.length + ' matching books:</p>';
html += '<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
result.matches.slice(0, 20).forEach(book => {
html += '<div style="display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem; border-radius: 0.25rem; background-color: var(--bg-secondary);">';
html += '<img src="' + (book.cover_image_path || '/static/placeholder-book.svg') + '" alt="Cover" style="width: 2rem; height: 3rem; object-fit: cover; border-radius: 0.25rem;">';
html += '<div style="flex: 1;">';
html += '<div style="font-size: 0.875rem; font-weight: 500; color: var(--text-primary);">' + book.title + '</div>';
if (book.author) {
html += '<div style="font-size: 0.75rem; color: var(--text-secondary);">' + book.author + '</div>';
}
html += '<div style="font-size: 0.75rem; color: var(--accent);">' + book.match_reason + '</div>';
html += '</div>';
html += '</div>';
});
if (result.matches.length > 20) {
html += '<p style="color: var(--text-secondary); font-size: 0.875rem;">...and ' + (result.matches.length - 20) + ' more</p>';
}
html += '</div>';
resultsList.innerHTML = html;
} else {
resultsList.innerHTML = '<p style="color: var(--text-secondary); font-size: 0.875rem;">No books match this rule</p>';
}
})
.catch(error => {
resultsList.innerHTML = '<p class="text-sm" style="color: var(--error)">Failed to test rule</p>';
});
}
function toggleRule(ruleId, enabled) {
fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: JSON.stringify({ enabled })
})
.then(response => response.json())
.then(result => {
showToast('Rule updated', 'success');
loadRules();
})
.catch(error => {
showToast('Failed to update rule', 'error');
});
}
function deleteRule(ruleId) {
if (!confirm('Are you sure you want to delete this rule?')) return;
fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
})
.then(response => {
if (response.ok) {
showToast('Rule deleted', 'success');
loadRules();
} else {
showToast('Failed to delete rule', 'error');
}
})
.catch(error => {
showToast('Failed to delete rule', 'error');
});
}
function clearForm() {
document.getElementById('rule-field').value = '';
document.getElementById('rule-operator').value = '';
document.getElementById('rule-value').value = '';
document.getElementById('rule-enabled').checked = true;
document.querySelector('input[name="priority"][value="2"]').checked = true;
document.getElementById('test-results').classList.add('hidden');
}
function loadRules() {
fetch(`/api/collections/${collectionId}`)
.then(response => response.json())
.then(result => {
renderRules(result.auto_assign_rules || []);
})
.catch(error => {
console.error('Failed to load rules', error);
});
}
function logout() {
localStorage.removeItem('token');
window.location.href = '/login';
}
// Load rules on page load
loadRules();
</script>
</body>
</html>
} }
+10 -10
View File
@@ -13,7 +13,7 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
<script src="/static/toast.js"></script> <script src="/static/toast.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
</head> </head>
<body class="theme-{ user.Theme }"> <body x-data="collections" class="theme-{ user.Theme }">
@Header(user, "/collections") @Header(user, "/collections")
<!-- Modal Container --> <!-- Modal Container -->
<div id="modal-container"></div> <div id="modal-container"></div>
@@ -60,7 +60,7 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
</div> </div>
} }
for _, col := range collections { for _, col := range collections {
<div onclick="navigateToCollection(this)" data-href={ "/collections/" + col.ID } class="block"> <div @click="navigateToCollection($el)" data-href={ "/collections/" + col.ID } class="block">
<div <div
class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow" class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow"
style="background-color: var(--bg-secondary);" style="background-color: var(--bg-secondary);"
@@ -112,11 +112,11 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
<script src="/static/toast.js"></script> <script src="/static/toast.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
</head> </head>
<body class="theme-{ user.Theme }"> <body x-data="collections" class="theme-{ user.Theme }">
@Header(user, "/collections") @Header(user, "/collections")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8"> <div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6"> <div class="mb-6">
<button onclick="backToCollections()" class="btn-secondary px-4 py-2 rounded-lg mb-4"> <button @click="backToCollections()" class="btn-secondary px-4 py-2 rounded-lg mb-4">
Back to Collections Back to Collections
</button> </button>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
@@ -147,13 +147,13 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
</div> </div>
<button <button
id="bulk-remove-btn" id="bulk-remove-btn"
onclick="removebooksToAdd()" @click="removebooksToAdd()"
disabled disabled
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed" class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
> >
🗑️ Remove Selected 🗑️ Remove Selected
</button> </button>
<button onclick="showAddBooksModal()" class="btn-primary px-4 py-2 rounded-lg"> <button @click="showAddBooksModal()" class="btn-primary px-4 py-2 rounded-lg">
Add Books Add Books
</button> </button>
</div> </div>
@@ -213,7 +213,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
<!-- Remove Button --> <!-- Remove Button -->
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);"> <div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button <button
onclick="removeBook('{ book.MediaItemID }')" @click=\"removeBook('{ book.MediaItemID }')\"
class="px-3 py-1 text-sm border rounded hover:opacity-80" class="px-3 py-1 text-sm border rounded hover:opacity-80"
style="border-color: var(--border); color: var(--text-secondary);" style="border-color: var(--border); color: var(--text-secondary);"
> >
@@ -228,7 +228,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
<div class="card rounded-lg p-6 w-full max-w-2xl mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div class="card rounded-lg p-6 w-full max-w-2xl mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<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)">Add Books to Collection</h2> <h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Books to Collection</h2>
<button onclick="hideAddBooksModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button> <button @click="hideAddBooksModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
</div> </div>
<p class="mb-4" style="color: var(--text-secondary)">Search and select books to add to this collection.</p> <p class="mb-4" style="color: var(--text-secondary)">Search and select books to add to this collection.</p>
<!-- Library filter toggle - hidden by default, shown by JS when library_id present --> <!-- Library filter toggle - hidden by default, shown by JS when library_id present -->
@@ -249,10 +249,10 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
</div> </div>
<div id="book-results" class="max-h-64 overflow-y-auto mb-4"></div> <div id="book-results" class="max-h-64 overflow-y-auto mb-4"></div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" onclick="hideAddBooksModal()" class="btn-secondary px-4 py-2 rounded-lg"> <button type="button" @click="hideAddBooksModal()" class="btn-secondary px-4 py-2 rounded-lg">
Cancel Cancel
</button> </button>
<button type="button" onclick="addSelectedBooks()" class="btn-primary px-4 py-2 rounded-lg"> <button type="button" @click="addSelectedBooks()" class="btn-primary px-4 py-2 rounded-lg">
Add Selected Books Add Selected Books
</button> </button>
</div> </div>
+239 -233
View File
@@ -3,237 +3,243 @@ package templates
import "bookhoard/internal/handlers" import "bookhoard/internal/handlers"
templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, errorMessage string) { templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, 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>Sync Conflicts - Bookhoard</title> <title>Sync Conflicts - 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/main.js"></script>
<link href="/static/style.css" rel="stylesheet"> <link href="/static/style.css" rel="stylesheet"/>
<script src="/static/theme.js"></script> </head>
</head> <body x-data="conflicts" class="theme-{ user.Theme }">
<body class="theme-{ user.Theme }"> @Header(user, "/conflicts")
@Header(user, "/conflicts") <div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<div class="w-full px-4 sm:px-6 lg:px-8 py-8"> <div class="flex justify-between items-center">
<div class="mb-8"> <div>
<div class="flex justify-between items-center"> <h1 class="text-3xl font-bold" style="color: var(--text-primary)">Sync Conflicts</h1>
<div> <p style="color: var(--text-secondary)">Resolve conflicts when reading progress differs across devices</p>
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Sync Conflicts</h1> </div>
<p style="color: var(--text-secondary)">Resolve conflicts when reading progress differs across devices</p> <div class="flex space-x-3">
</div> <select
<div class="flex space-x-3"> id="status-filter"
<select id="status-filter" onchange="filterConflicts()" onchange="filterConflicts()"
class="px-4 py-2 border rounded-lg" class="px-4 py-2 border rounded-lg"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"> style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
<option value="unresolved">Unresolved Only</option> >
<option value="all">All Conflicts</option> <option value="unresolved">Unresolved Only</option>
<option value="resolved">Resolved Only</option> <option value="all">All Conflicts</option>
</select> <option value="resolved">Resolved Only</option>
<button onclick="dismissAllResolved()" class="btn-secondary px-4 py-2 rounded-lg"> </select>
Dismiss All Resolved <button @click="dismissAllResolved()" class="btn-secondary px-4 py-2 rounded-lg">
</button> Dismiss All Resolved
</div> </button>
</div> </div>
</div>
<div id="stats-bar" class="mt-4 flex space-x-6 text-sm"> <div id="stats-bar" class="mt-4 flex space-x-6 text-sm">
<span style="color: var(--text-secondary)">Total: <strong style="color: var(--text-primary)">{ total }</strong></span> <span style="color: var(--text-secondary)">Total: <strong style="color: var(--text-primary)">{ total }</strong></span>
<span style="color: var(--text-secondary)">Unresolved: <strong style="color: #f59e0b;">{ unresolved }</strong></span> <span style="color: var(--text-secondary)">Unresolved: <strong style="color: #f59e0b;">{ unresolved }</strong></span>
<span style="color: var(--text-secondary)">Resolved: <strong style="color: #10b981;">{ total - unresolved }</strong></span> <span style="color: var(--text-secondary)">Resolved: <strong style="color: #10b981;">{ total - unresolved }</strong></span>
</div> </div>
<div id="bulk-actions" class="mt-4 hidden">
<div id="bulk-actions" class="mt-4 hidden"> <div class="flex items-center justify-between p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex items-center justify-between p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div class="flex items-center space-x-4">
<div class="flex items-center space-x-4"> <span class="text-sm font-semibold" style="color: var(--text-primary);">
<span class="text-sm font-semibold" style="color: var(--text-primary);"> <span id="selected-count">0</span> conflicts selected
<span id="selected-count">0</span> conflicts selected </span>
</span> <select
<select id="bulk-strategy" class="px-3 py-2 border rounded-lg text-sm" id="bulk-strategy"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> class="px-3 py-2 border rounded-lg text-sm"
<option value="most_recent">Most Recent</option> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
<option value="highest_progress">Highest Progress</option> >
</select> <option value="most_recent">Most Recent</option>
</div> <option value="highest_progress">Highest Progress</option>
<div class="flex space-x-2"> </select>
<button onclick="bulkResolve()" class="btn-primary px-4 py-2 rounded-lg text-sm"> </div>
Resolve Selected <div class="flex space-x-2">
</button> <button @click="bulkResolve()" class="btn-primary px-4 py-2 rounded-lg text-sm">
<button onclick="bulkDismiss()" class="btn-danger px-4 py-2 rounded-lg text-sm"> Resolve Selected
Dismiss Selected </button>
</button> <button @click="bulkDismiss()" class="btn-danger px-4 py-2 rounded-lg text-sm">
</div> Dismiss Selected
</div> </button>
</div> </div>
</div> </div>
</div>
<div id="loading" class="hidden text-center py-16" style="color: var(--text-secondary)"> </div>
<div class="loading-spinner mx-auto mb-4"></div> <div id="loading" class="hidden text-center py-16" style="color: var(--text-secondary)">
<p>Loading conflicts...</p> <div class="loading-spinner mx-auto mb-4"></div>
</div> <p>Loading conflicts...</p>
</div>
<div id="empty-state" class="hidden text-center py-16" style="color: var(--text-secondary)"> <div id="empty-state" class="hidden text-center py-16" style="color: var(--text-secondary)">
<div class="text-6xl mb-4"></div> <div class="text-6xl mb-4"></div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3> <h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
<p>Your devices are in sync! No conflicts to resolve.</p> <p>Your devices are in sync! No conflicts to resolve.</p>
</div> </div>
<div id="conflicts-list" class="space-y-6">
<div id="conflicts-list" class="space-y-6"> <div id="select-all-container" class="mb-4 hidden">
<div id="select-all-container" class="mb-4 hidden"> <label class="flex items-center space-x-2 cursor-pointer">
<label class="flex items-center space-x-2 cursor-pointer"> <input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" onchange="toggleAllConflicts()"/>
<input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" onchange="toggleAllConflicts()"> <span class="text-sm font-semibold" style="color: var(--text-primary);">Select All</span>
<span class="text-sm font-semibold" style="color: var(--text-primary);">Select All</span> </label>
</label> </div>
</div> if len(conflicts) == 0 {
<div class="text-center py-16" style="color: var(--text-secondary)">
if len(conflicts) == 0 { <div class="text-6xl mb-4"></div>
<div class="text-center py-16" style="color: var(--text-secondary)"> <h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
<div class="text-6xl mb-4"></div> <p>Your devices are in sync! No conflicts to resolve.</p>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3> </div>
<p>Your devices are in sync! No conflicts to resolve.</p> }
</div> for _, conflict := range conflicts {
} <div class="conflict-item card p-6 rounded-lg border" data-conflict-id="{ conflict.ID }" style="background-color: var(--bg-secondary); border-color: var(--border); border-left-width: 4px; border-left-style: solid; border-left-color: rgb(245, 158, 11);">
<div class="flex justify-between items-start mb-4">
for _, conflict := range conflicts { <div class="flex items-start space-x-4">
<div class="conflict-item card p-6 rounded-lg border" data-conflict-id="{ conflict.ID }" style="background-color: var(--bg-secondary); border-color: var(--border); border-left-width: 4px; border-left-style: solid; border-left-color: rgb(245, 158, 11);"> <input
<div class="flex justify-between items-start mb-4"> type="checkbox"
<div class="flex items-start space-x-4"> class="conflict-checkbox w-5 h-5 rounded mt-1"
<input type="checkbox" class="conflict-checkbox w-5 h-5 rounded mt-1" data-conflict-id="{ conflict.ID }"
data-conflict-id="{ conflict.ID }" onchange="updateBulkActions()"
onchange="updateBulkActions()"> />
<div> <div>
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">{ conflict.MediaItemTitle }</h3> <h3 class="text-lg font-semibold" style="color: var(--text-primary)">{ conflict.MediaItemTitle }</h3>
<p class="text-sm" style="color: var(--text-secondary)">Type: { conflict.ConflictType }</p> <p class="text-sm" style="color: var(--text-secondary)">Type: { conflict.ConflictType }</p>
</div> </div>
</div> </div>
<button onclick="showConflictModal('{ conflict.ID }')" class="btn-primary px-4 py-2 rounded-lg"> <button @click=\"showConflictModal('{ conflict.ID }')\" class="btn-primary px-4 py-2 rounded-lg">
Resolve Resolve
</button> </button>
</div> </div>
<div class="text-sm" style="color: var(--text-secondary)"> <div class="text-sm" style="color: var(--text-secondary)">
Created: { conflict.CreatedAt.Format("2006-01-02 15:04:05") } Created: { conflict.CreatedAt.Format("2006-01-02 15:04:05") }
if conflict.ResolvedBy != "" { if conflict.ResolvedBy != "" {
| Resolved by: { conflict.ResolvedBy } | Resolved by: { conflict.ResolvedBy }
} }
</div> </div>
</div> </div>
} }
</div> </div>
<div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto" style="background-color: rgba(0, 0, 0, 0.7);">
<div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto" style="background-color: rgba(0, 0, 0, 0.7);"> <div class="card rounded-lg p-6 w-full max-w-4xl mx-4 my-8" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="card rounded-lg p-6 w-full max-w-4xl mx-4 my-8" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div class="flex justify-between items-center mb-6">
<div class="flex justify-between items-center mb-6"> <div>
<div> <h2 class="text-2xl font-bold" style="color: var(--text-primary)">Resolve Conflict</h2>
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Resolve Conflict</h2> <p class="text-sm" id="conflict-book-title" style="color: var(--text-secondary);">Book Title</p>
<p class="text-sm" id="conflict-book-title" style="color: var(--text-secondary);">Book Title</p> </div>
</div> <button @click="hideConflictModal()" class="p-2 hover:opacity-80 rounded-lg" style="color: var(--text-primary); background-color: var(--bg-primary);">
<button onclick="hideConflictModal()" class="p-2 hover:opacity-80 rounded-lg" style="color: var(--text-primary); background-color: var(--bg-primary);"> X
X </button>
</button> </div>
</div> <form id="conflict-resolution-form" @submit="handleResolveConflict($event)">
<input type="hidden" id="conflict-id"/>
<form id="conflict-resolution-form" onsubmit="handleResolveConflict(event)"> <div id="conflict-details" class="mb-6"></div>
<input type="hidden" id="conflict-id"> <div class="card p-4 rounded-lg border mb-6" style="background-color: var(--bg-primary); border-color: var(--border);">
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Resolution Options</h3>
<div id="conflict-details" class="mb-6"></div> <div class="space-y-3 mb-6">
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
<div class="card p-4 rounded-lg border mb-6" style="background-color: var(--bg-primary); border-color: var(--border);"> <input type="radio" name="winner" value="koreader" class="mr-3" required/>
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Resolution Options</h3> <div class="flex-1">
<span class="font-semibold" style="color: var(--text-primary);">Keep KOReader Progress</span>
<div class="space-y-3 mb-6"> <p class="text-xs" style="color: var(--text-secondary);">Use progress from your KOReader device</p>
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);"> </div>
<input type="radio" name="winner" value="koreader" class="mr-3" required> </label>
<div class="flex-1"> <label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
<span class="font-semibold" style="color: var(--text-primary);">Keep KOReader Progress</span> <input type="radio" name="winner" value="kobo" class="mr-3"/>
<p class="text-xs" style="color: var(--text-secondary);">Use progress from your KOReader device</p> <div class="flex-1">
</div> <span class="font-semibold" style="color: var(--text-primary);">Keep Kobo Progress</span>
</label> <p class="text-xs" style="color: var(--text-secondary);">Use progress from your Kobo device</p>
</div>
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);"> </label>
<input type="radio" name="winner" value="kobo" class="mr-3"> <label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
<div class="flex-1"> <input type="radio" name="winner" value="web" class="mr-3"/>
<span class="font-semibold" style="color: var(--text-primary);">Keep Kobo Progress</span> <div class="flex-1">
<p class="text-xs" style="color: var(--text-secondary);">Use progress from your Kobo device</p> <span class="font-semibold" style="color: var(--text-primary);">Keep Web Progress</span>
</div> <p class="text-xs" style="color: var(--text-secondary);">Use progress from the web interface</p>
</label> </div>
</label>
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);"> <label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
<input type="radio" name="winner" value="web" class="mr-3"> <input type="radio" name="winner" value="manual" class="mr-3"/>
<div class="flex-1"> <div class="flex-1">
<span class="font-semibold" style="color: var(--text-primary);">Keep Web Progress</span> <span class="font-semibold" style="color: var(--text-primary);">Manual Override</span>
<p class="text-xs" style="color: var(--text-secondary);">Use progress from the web interface</p> <p class="text-xs" style="color: var(--text-secondary);">Specify a custom progress value</p>
</div> </div>
</label> </label>
</div>
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);"> <div id="manual-override" class="hidden mb-4 p-4 border rounded-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
<input type="radio" name="winner" value="manual" class="mr-3"> <h4 class="font-semibold mb-3" style="color: var(--text-primary);">Manual Progress</h4>
<div class="flex-1"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<span class="font-semibold" style="color: var(--text-primary);">Manual Override</span> <div>
<p class="text-xs" style="color: var(--text-secondary);">Specify a custom progress value</p> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Percentage (0-100)</label>
</div> <input
</label> type="number"
</div> id="manual-percentage"
min="0"
<div id="manual-override" class="hidden mb-4 p-4 border rounded-lg" style="background-color: var(--bg-secondary); border-color: var(--border);"> max="100"
<h4 class="font-semibold mb-3" style="color: var(--text-primary);">Manual Progress</h4> step="0.01"
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> class="w-full px-4 py-2 border rounded-lg"
<div> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Percentage (0-100)</label> />
<input type="number" id="manual-percentage" min="0" max="100" step="0.01" </div>
class="w-full px-4 py-2 border rounded-lg" <div>
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Page Number</label>
</div> <input
<div> type="number"
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Page Number</label> id="manual-page"
<input type="number" id="manual-page" min="1" min="1"
class="w-full px-4 py-2 border rounded-lg" class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
</div> />
<div> </div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">EPUB CFI</label> <div>
<input type="text" id="manual-epubcfi" <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">EPUB CFI</label>
class="w-full px-4 py-2 border rounded-lg" <input
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> type="text"
</div> id="manual-epubcfi"
<div> class="w-full px-4 py-2 border rounded-lg"
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Chapter</label> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
<input type="number" id="manual-chapter" min="1" />
class="w-full px-4 py-2 border rounded-lg" </div>
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> <div>
</div> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Chapter</label>
</div> <input
</div> type="number"
id="manual-chapter"
<div class="mb-6"> min="1"
<label class="flex items-center space-x-3 cursor-pointer"> class="w-full px-4 py-2 border rounded-lg"
<input type="checkbox" id="apply-to-all" class="w-5 h-5 rounded"> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
<span style="color: var(--text-primary);">Apply this resolution to all future conflicts</span> />
</label> </div>
</div> </div>
</div>
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Resolution Reason (optional)</label> <label class="flex items-center space-x-3 cursor-pointer">
<input type="text" id="resolution-reason" <input type="checkbox" id="apply-to-all" class="w-5 h-5 rounded"/>
class="w-full px-4 py-2 border rounded-lg" <span style="color: var(--text-primary);">Apply this resolution to all future conflicts</span>
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" </label>
placeholder="e.g., Device was offline, using most recent progress"> </div>
</div> <div class="mb-6">
</div> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Resolution Reason (optional)</label>
<input
<div class="flex justify-end space-x-3"> type="text"
<button type="button" onclick="hideConflictModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button> id="resolution-reason"
<button type="button" onclick="deleteConflict()" class="btn-danger px-4 py-2 rounded-lg">Dismiss</button> class="w-full px-4 py-2 border rounded-lg"
<button type="submit" class="btn-primary px-6 py-2 rounded-lg">Resolve Conflict</button> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
</div> placeholder="e.g., Device was offline, using most recent progress"
</form> />
</div> </div>
</div> </div>
</div> <div class="flex justify-end space-x-3">
<button type="button" @click="hideConflictModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
<script src="/static/conflicts.js"></script> <button type="button" @click="deleteConflict()" class="btn-danger px-4 py-2 rounded-lg">Dismiss</button>
<button type="submit" class="btn-primary px-6 py-2 rounded-lg">Resolve Conflict</button>
@ErrorToast(errorMessage) </div>
</body> </form>
</html> </div>
</div>
</div>
<script src="/static/conflicts.js"></script>
@ErrorToast(errorMessage)
</body>
</html>
} }
+193 -181
View File
@@ -2,187 +2,199 @@ package templates
templ Header(user User, currentPath string) { templ Header(user User, currentPath string) {
<nav class="border-b header-nav" style="border-color: var(--border); background-color: var(--bg-secondary);"> <nav class="border-b header-nav" style="border-color: var(--border); background-color: var(--bg-secondary);">
<div class="w-full px-4 sm:px-6 lg:px-8"> <div class="w-full px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16"> <div class="flex justify-between items-center h-16">
<!-- Left: App Title & Navigation --> <!-- Left: App Title & Navigation -->
<div class="flex items-center space-x-6"> <div class="flex items-center space-x-6">
<a href="/dashboard" class="text-xl font-bold hover:opacity-80 transition-opacity" style="color: var(--text-primary); text-decoration: none;"> <a href="/dashboard" class="text-xl font-bold hover:opacity-80 transition-opacity" style="color: var(--text-primary); text-decoration: none;">
📚 Bookhoard 📚 Bookhoard
</a> </a>
<div class="hidden md:flex items-center space-x-4"> <div class="hidden md:flex items-center space-x-4">
<a href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;"> <a href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Library Library
</a> </a>
<a href="/collections" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;"> <a href="/collections" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Collections Collections
</a> </a>
<a href="/progress" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;"> <a href="/progress" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Progress Progress
</a> </a>
<a href="/devices" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;"> <a href="/devices" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Devices Devices
</a> </a>
</div> </div>
</div> </div>
<!-- Center: Search Box -->
<!-- Center: Search Box --> <div class="flex-1 max-w-2xl mx-8">
<div class="flex-1 max-w-2xl mx-8"> <div class="relative">
<div class="relative"> <input
<input type="text" type="text"
id="header-search" id="header-search"
placeholder="Search your library..." placeholder="Search your library..."
class="w-full px-4 py-2 pl-10 border rounded-lg" class="w-full px-4 py-2 pl-10 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
autocomplete="off" autocomplete="off"
> />
<svg class="absolute left-3 top-2.5 h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="absolute left-3 top-2.5 h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg> </svg>
</div> </div>
</div> </div>
<!-- Right: Theme Switcher & User Menu -->
<!-- Right: Theme Switcher & User Menu --> <div class="flex items-center space-x-4">
<div class="flex items-center space-x-4"> <!-- Theme Switcher -->
<!-- Theme Switcher --> <div class="relative">
<div class="relative"> <button @click="toggleThemeDropdown()" class="p-2 rounded-lg hover:bg-gray-700 transition-colors" style="background-color: var(--bg-primary);">
<button onclick="toggleThemeDropdown()" class="p-2 rounded-lg hover:bg-gray-700 transition-colors" style="background-color: var(--bg-primary);"> <svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"/> </svg>
</svg> </button>
</button> <div id="theme-dropdown" class="hidden absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50" style="background-color: var(--bg-secondary); border: 1px solid var(--border);">
<div id="theme-dropdown" class="hidden absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50" style="background-color: var(--bg-secondary); border: 1px solid var(--border);"> <div class="p-4">
<div class="p-4"> <h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">Select Theme</h3>
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">Select Theme</h3> <div class="space-y-2">
<div class="space-y-2"> <button @click=\"changeThemeTo('tokyo-night')\" class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary); background-color: var(--bg-primary);">
<button onclick="changeThemeTo('tokyo-night')" class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary); background-color: var(--bg-primary);"> <span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #7aa2f7;"></span>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #7aa2f7;"></span> Tokyo Night
Tokyo Night </button>
</button> <button @click=\"changeThemeTo('dracula')\" class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary); background-color: var(--bg-primary);">
<button onclick="changeThemeTo('dracula')" class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary); background-color: var(--bg-primary);"> <span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #bd93f9;"></span>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #bd93f9;"></span> Dracula
Dracula </button>
</button> <button @click=\"changeThemeTo('nord')\" class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary); background-color: var(--bg-primary);">
<button onclick="changeThemeTo('nord')" class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary); background-color: var(--bg-primary);"> <span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #88c0d0;"></span>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #88c0d0;"></span> Nord
Nord </button>
</button> <button @click=\"changeThemeTo('solarized-dark')\" class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary); background-color: var(--bg-primary);">
<button onclick="changeThemeTo('solarized-dark')" class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary); background-color: var(--bg-primary);"> <span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #2aa198;"></span>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #2aa198;"></span> Solarized Dark
Solarized Dark </button>
</button> <button @click=\"changeThemeTo('monokai')\" class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary); background-color: var(--bg-primary);">
<button onclick="changeThemeTo('monokai')" class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary); background-color: var(--bg-primary);"> <span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #a6e22e;"></span>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #a6e22e;"></span> Monokai
Monokai </button>
</button> <button @click=\"changeThemeTo('one-dark-pro')\" class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary); background-color: var(--bg-primary);">
<button onclick="changeThemeTo('one-dark-pro')" class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary); background-color: var(--bg-primary);"> <span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #61dafb;"></span>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #61dafb;"></span> One Dark Pro
One Dark Pro </button>
</button> <button @click=\"changeThemeTo('material-dark')\" class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary); background-color: var(--bg-primary);">
<button onclick="changeThemeTo('material-dark')" class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary); background-color: var(--bg-primary);"> <span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #80cbc4;"></span>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #80cbc4;"></span> Material Dark
Material Dark </button>
</button> <div class="border-t pt-2 mt-2" style="border-color: var(--border);">
<div class="border-t pt-2 mt-2" style="border-color: var(--border);"> <p class="text-xs mb-2" style="color: var(--text-secondary)">Bookshelf Background</p>
<p class="text-xs mb-2" style="color: var(--text-secondary)">Bookshelf Background</p> <button
<button onclick="changeWoodPaneling('none')" @click="changeWoodPaneling('none')"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);" style="color: var(--text-primary);"
data-wood="none"> data-wood="none"
None >
</button> None
<button onclick="changeWoodPaneling('wood-light')" </button>
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" <button
style="color: var(--text-primary);" @click="changeWoodPaneling('wood-light')"
data-wood="wood-light"> class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
<span class="inline-block w-4 h-4 rounded mr-2" style="color: var(--text-primary);"
style="background: url('/static/textures/wood-light.png'); background-size: cover;"></span> data-wood="wood-light"
Wood Light >
</button> <span
<button onclick="changeWoodPaneling('wood-dark')" class="inline-block w-4 h-4 rounded mr-2"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="background: url('/static/textures/wood-light.png'); background-size: cover;"
style="color: var(--text-primary);" ></span>
data-wood="wood-dark"> Wood Light
<span class="inline-block w-4 h-4 rounded mr-2" </button>
style="background: url('/static/textures/wood-dark.png'); background-size: cover;"></span> <button
Wood Dark @click="changeWoodPaneling('wood-dark')"
</button> class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
<button onclick="changeWoodPaneling('wood-mahogany')" style="color: var(--text-primary);"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" data-wood="wood-dark"
style="color: var(--text-primary);" >
data-wood="wood-mahogany"> <span
<span class="inline-block w-4 h-4 rounded mr-2" class="inline-block w-4 h-4 rounded mr-2"
style="background: url('/static/textures/wood-mahogany.png'); background-size: cover;"></span> style="background: url('/static/textures/wood-dark.png'); background-size: cover;"
Wood Mahogany ></span>
</button> Wood Dark
</div> </button>
</div> <button
</div> @click="changeWoodPaneling('wood-mahogany')"
</div> class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
</div> style="color: var(--text-primary);"
data-wood="wood-mahogany"
<!-- User Icon with Dropdown --> >
<div class="relative"> <span
if user.ID != "" { class="inline-block w-4 h-4 rounded mr-2"
<!-- LOGGED IN: Show user menu with logout --> style="background: url('/static/textures/wood-mahogany.png'); background-size: cover;"
<button onclick="toggleUserMenu()" class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors" style="background-color: var(--bg-primary);"> ></span>
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24"> Wood Mahogany
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/> </button>
</svg> </div>
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">{ user.Username }</span> </div>
</button> </div>
<div id="user-menu" class="hidden absolute right-0 mt-2 w-48 rounded-lg shadow-lg z-50" style="background-color: var(--bg-secondary); border: 1px solid var(--border);"> </div>
<div class="py-1"> </div>
<a href="/profile" class="block px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary); text-decoration: none;"> <!-- User Icon with Dropdown -->
<div class="relative">
if user.ID != "" {
<!-- LOGGED IN: Show user menu with logout -->
<button @click="toggleUserMenu()" class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors" style="background-color: var(--bg-primary);">
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">{ user.Username }</span>
</button>
<div id="user-menu" class="hidden absolute right-0 mt-2 w-48 rounded-lg shadow-lg z-50" style="background-color: var(--bg-secondary); border: 1px solid var(--border);">
<div class="py-1">
<a href="/profile" class="block px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary); text-decoration: none;">
Profile Profile
</a> </a>
if user.Role == "admin" { if user.Role == "admin" {
<a href="/admin" class="block px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary); text-decoration: none;"> <a href="/admin" class="block px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary); text-decoration: none;">
Admin Panel Admin Panel
</a> </a>
} }
<div class="border-t my-1" style="border-color: var(--border);"></div> <div class="border-t my-1" style="border-color: var(--border);"></div>
<button onclick="logout()" class="block w-full text-left px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary);"> <button @click="logout()" class="block w-full text-left px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary);">
Logout Logout
</button> </button>
</div> </div>
</div> </div>
} else { } else {
<!-- LOGGED OUT: Show login form --> <!-- LOGGED OUT: Show login form -->
<button onclick="toggleUserMenu()" class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors" style="background-color: var(--bg-primary);"> <button @click="toggleUserMenu()" class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors" style="background-color: var(--bg-primary);">
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg> </svg>
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">Login</span> <span class="text-sm hidden sm:block" style="color: var(--text-primary)">Login</span>
</button> </button>
<div id="user-menu" class="hidden absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50 p-4" style="background-color: var(--bg-secondary); border: 1px solid var(--border);"> <div id="user-menu" class="hidden absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50 p-4" style="background-color: var(--bg-secondary); border: 1px solid var(--border);">
<form hx-post="/api/auth/login" hx-target="#login-result" hx-swap="innerHTML" class="space-y-3"> <form hx-post="/api/auth/login" hx-target="#login-result" hx-swap="innerHTML" class="space-y-3">
<input type="hidden" name="redirect" value="{ currentPath }"> <input type="hidden" name="redirect" value="{ currentPath }"/>
<div> <div>
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Email or Username</label> <label class="block text-sm mb-1" style="color: var(--text-secondary)">Email or Username</label>
<input type="text" name="login" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required> <input type="text" name="login" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
</div> </div>
<div> <div>
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Password</label> <label class="block text-sm mb-1" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required> <input type="password" name="password" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
</div> </div>
<button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;"> <button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;">
Sign In Sign In
</button> </button>
</form> </form>
<div id="login-result"></div> <div id="login-result"></div>
<div class="border-t my-2" style="border-color: var(--border);"></div> <div class="border-t my-2" style="border-color: var(--border);"></div>
<a href="/register" class="block text-center text-sm hover:opacity-80" style="color: var(--text-secondary); text-decoration: none;"> <a href="/register" class="block text-center text-sm hover:opacity-80" style="color: var(--text-secondary); text-decoration: none;">
Don't have an account? Sign Up Don't have an account? Sign Up
</a> </a>
</div> </div>
} }
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</nav> </nav>
<script src="/static/theme.js"></script> <script src="/static/theme.js"></script>
<script src="/static/woodPaneling.js"></script> <script src="/static/woodPaneling.js"></script>
<script src="/static/themeDropdown.js"></script> <script src="/static/themeDropdown.js"></script>
<script src="/static/header.js"></script> <script src="/static/header.js"></script>
} }
+119 -161
View File
@@ -1,165 +1,123 @@
package templates package templates
templ Index(loggedIn bool) { templ Index(loggedIn bool) {
<!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>Bookhoard - Home</title> <title>Bookhoard - Home</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script> <script src="/static/main.js"></script>
<link href="/static/style.css" rel="stylesheet"> <link href="/static/style.css" rel="stylesheet"/>
</head> </head>
<body class="theme-tokyo-night"> <body class="theme-tokyo-night" x-data="index" x-init="initIndexTheme(); checkAuthRedirect()">
<script> <!-- Hero Section -->
// Progressive enhancement: check localStorage immediately <div class="relative overflow-hidden">
(function() { <div class="absolute inset-0 bg-gradient-to-r from-blue-600 to-purple-700 opacity-10"></div>
const savedTheme = localStorage.getItem('theme'); <div class="relative max-w-7xl mx-auto px-4 py-16 sm:px-6 sm:py-24 lg:py-32 lg:px-8">
if (savedTheme && savedTheme !== 'tokyo-night') { <div class="flex justify-between items-start mb-8">
document.body.className = 'theme-' + savedTheme; <div></div>
} <select id="theme-select" @change="changeTheme()" class="px-3 py-2 border rounded text-sm" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)">
})(); <option value="tokyo-night">Tokyo Night</option>
</script> <option value="dracula">Dracula</option>
<!-- Hero Section --> <option value="nord">Nord</option>
<div class="relative overflow-hidden"> <option value="solarized-dark">Solarized Dark</option>
<div class="absolute inset-0 bg-gradient-to-r from-blue-600 to-purple-700 opacity-10"></div> <option value="monokai">Monokai</option>
<div class="relative max-w-7xl mx-auto px-4 py-16 sm:px-6 sm:py-24 lg:py-32 lg:px-8"> <option value="one-dark-pro">One Dark Pro</option>
<div class="flex justify-between items-start mb-8"> <option value="material-dark">Material Dark</option>
<div></div> <option value="catppuccin-mocha">Catppuccin Mocha</option>
<select id="theme-select" class="px-3 py-2 border rounded text-sm" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)" onchange="changeTheme()"> <option value="catppuccin-macchiato">Catppuccin Macchiato</option>
<option value="tokyo-night">Tokyo Night</option> <option value="catppuccin-frappe">Catppuccin Frappé</option>
<option value="dracula">Dracula</option> <option value="catppuccin-latte">Catppuccin Latte</option>
<option value="nord">Nord</option> </select>
<option value="solarized-dark">Solarized Dark</option> </div>
<option value="monokai">Monokai</option> <div class="text-center">
<option value="one-dark-pro">One Dark Pro</option> <h1 class="text-4xl sm:text-6xl lg:text-7xl font-extrabold" style="color: var(--text-primary)">
<option value="material-dark">Material Dark</option> 📚 Bookhoard
<option value="catppuccin-mocha">Catppuccin Mocha</option> </h1>
<option value="catppuccin-macchiato">Catppuccin Macchiato</option> <p class="mt-6 max-w-2xl mx-auto text-xl" style="color: var(--text-secondary)">
<option value="catppuccin-frappe">Catppuccin Frappé</option> Your personal ebook management system. Track reading progress, organize your library, and enjoy beautiful themes.
<option value="catppuccin-latte">Catppuccin Latte</option> </p>
</select> <div class="mt-10">
</div> <a href="#auth" class="btn-primary px-8 py-3 rounded-lg font-medium text-lg">
Get Started
<div class="text-center"> </a>
<h1 class="text-4xl sm:text-6xl lg:text-7xl font-extrabold" style="color: var(--text-primary)"> </div>
📚 Bookhoard </div>
</h1> </div>
<p class="mt-6 max-w-2xl mx-auto text-xl" style="color: var(--text-secondary)"> </div>
Your personal ebook management system. Track reading progress, organize your library, and enjoy beautiful themes. <!-- Features Section -->
</p> <div class="py-16" style="background-color: var(--bg-secondary)">
<div class="mt-10"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<a href="#auth" class="btn-primary px-8 py-3 rounded-lg font-medium text-lg"> <div class="text-center mb-12">
Get Started <h2 class="text-3xl font-extrabold" style="color: var(--text-primary)">Why Bookhoard?</h2>
</a> <p class="mt-4 text-lg" style="color: var(--text-secondary)">Discover the features that make ebook management effortless</p>
</div> </div>
</div> <div class="grid grid-cols-1 md:grid-cols-3 gap-8">
</div> <div class="card p-6 rounded-xl border">
</div> <div class="text-center">
<div class="mx-auto w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center mb-4">
<!-- Features Section --> <span class="text-white text-2xl">📖</span>
<div class="py-16" style="background-color: var(--bg-secondary)"> </div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Reading Progress</h3>
<div class="text-center mb-12"> <p style="color: var(--text-secondary)">Track your reading progress across all your ebooks with detailed statistics.</p>
<h2 class="text-3xl font-extrabold" style="color: var(--text-primary)">Why Bookhoard?</h2> </div>
<p class="mt-4 text-lg" style="color: var(--text-secondary)">Discover the features that make ebook management effortless</p> </div>
</div> <div class="card p-6 rounded-xl border">
<div class="text-center">
<div class="grid grid-cols-1 md:grid-cols-3 gap-8"> <div class="mx-auto w-12 h-12 bg-purple-500 rounded-lg flex items-center justify-center mb-4">
<div class="card p-6 rounded-xl border"> <span class="text-white text-2xl">🎨</span>
<div class="text-center"> </div>
<div class="mx-auto w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center mb-4"> <h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Beautiful Themes</h3>
<span class="text-white text-2xl">📖</span> <p style="color: var(--text-secondary)">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin variants.</p>
</div> </div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Reading Progress</h3> </div>
<p style="color: var(--text-secondary)">Track your reading progress across all your ebooks with detailed statistics.</p> <div class="card p-6 rounded-xl border">
</div> <div class="text-center">
</div> <div class="mx-auto w-12 h-12 bg-green-500 rounded-lg flex items-center justify-center mb-4">
<span class="text-white text-2xl"></span>
<div class="card p-6 rounded-xl border"> </div>
<div class="text-center"> <h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Fast & Modern</h3>
<div class="mx-auto w-12 h-12 bg-purple-500 rounded-lg flex items-center justify-center mb-4"> <p style="color: var(--text-secondary)">Built with Go and HTMX for lightning-fast performance and smooth interactions.</p>
<span class="text-white text-2xl">🎨</span> </div>
</div> </div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Beautiful Themes</h3> </div>
<p style="color: var(--text-secondary)">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin variants.</p> </div>
</div> </div>
</div> <!-- Auth Section -->
if !loggedIn {
<div class="card p-6 rounded-xl border"> <div id="auth" class="py-16">
<div class="text-center"> <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mx-auto w-12 h-12 bg-green-500 rounded-lg flex items-center justify-center mb-4"> <div class="text-center mb-12">
<span class="text-white text-2xl"></span> <h2 class="text-3xl font-extrabold" style="color: var(--text-primary)">Join Bookhoard Today</h2>
</div> <p class="mt-4 text-lg" style="color: var(--text-secondary)">Sign in to start managing your ebook library</p>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Fast & Modern</h3> </div>
<p style="color: var(--text-secondary)">Built with Go and HTMX for lightning-fast performance and smooth interactions.</p> <div class="grid grid-cols-1 gap-8">
</div> <div class="card p-8 rounded-xl border">
</div> <h3 class="text-2xl font-semibold mb-6 text-center" style="color: var(--text-primary)">Login</h3>
</div> <form hx-post="/api/auth/login" hx-target="#auth-result" hx-swap="innerHTML">
</div> <div class="space-y-4">
</div> <div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email or Username</label>
<!-- Auth Section --> <input type="text" name="login" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
if !loggedIn { </div>
<div id="auth" class="py-16"> <div>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8"> <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label>
<div class="text-center mb-12"> <input type="password" name="password" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
<h2 class="text-3xl font-extrabold" style="color: var(--text-primary)">Join Bookhoard Today</h2> </div>
<p class="mt-4 text-lg" style="color: var(--text-secondary)">Sign in to start managing your ebook library</p> <button type="submit" class="w-full btn-primary py-2 rounded-lg font-medium">
</div> Sign In
</button>
<div class="grid grid-cols-1 gap-8"> </div>
<div class="card p-8 rounded-xl border"> </form>
<h3 class="text-2xl font-semibold mb-6 text-center" style="color: var(--text-primary)">Login</h3> </div>
<form hx-post="/api/auth/login" hx-target="#auth-result" hx-swap="innerHTML"> </div>
<div class="space-y-4"> <div id="auth-result" class="mt-8 text-center"></div>
<div> </div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email or Username</label> </div>
<input type="text" name="login" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required> }
</div> </body>
<div> </html>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
</div>
<button type="submit" class="w-full btn-primary py-2 rounded-lg font-medium">
Sign In
</button>
</div>
</form>
</div>
</div>
<div id="auth-result" class="mt-8 text-center"></div>
</div>
</div>
}
<script src="/static/theme.js"></script>
<script>
// Auto-redirect to dashboard if user is logged in
document.addEventListener('DOMContentLoaded', function() {
const token = localStorage.getItem('token');
if (token) {
// Validate token by checking profile
fetch('/api/auth/profile', {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(response => {
if (response.ok) {
// Token is valid, redirect to dashboard
window.location.href = '/dashboard';
}
})
.catch(error => {
// Silently fail - user stays on homepage
console.log('Not logged in');
});
}
});
</script>
</body>
</html>
} }
+63 -77
View File
@@ -1,81 +1,67 @@
package templates package templates
templ Login(sessionExpired bool, deleted bool) { templ Login(sessionExpired bool, deleted bool) {
<!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>Login</title> <title>Login</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script> <script src="/static/main.js"></script>
<script src="/static/theme.js"></script> <link href="/static/style.css" rel="stylesheet"/>
<link href="/static/style.css" rel="stylesheet"> </head>
</head> <body class="theme-tokyo-night min-h-screen" x-data="login" x-init="initLoginTheme()">
<body class="theme-tokyo-night min-h-screen"> <div class="container mx-auto px-4 py-8 max-w-md">
<script> <div class="flex justify-between items-center mb-8">
// Progressive enhancement: check localStorage immediately <h1 class="text-3xl font-bold">Login to Bookhoard</h1>
(function() { <select id="theme-select" @change="changeTheme()" class="px-3 py-2 border rounded" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)">
const savedTheme = localStorage.getItem('theme'); <option value="tokyo-night">Tokyo Night</option>
if (savedTheme && savedTheme !== 'tokyo-night') { <option value="dracula">Dracula</option>
document.body.className = 'theme-' + savedTheme + ' min-h-screen'; <option value="nord">Nord</option>
} <option value="solarized-dark">Solarized Dark</option>
})(); <option value="monokai">Monokai</option>
</script> <option value="one-dark-pro">One Dark Pro</option>
<div class="container mx-auto px-4 py-8 max-w-md"> <option value="material-dark">Material Dark</option>
<div class="flex justify-between items-center mb-8"> <option value="catppuccin-mocha">Catppuccin Mocha</option>
<h1 class="text-3xl font-bold">Login to Bookhoard</h1> <option value="catppuccin-macchiato">Catppuccin Macchiato</option>
<select id="theme-select" class="px-3 py-2 border rounded" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)" onchange="changeTheme()"> <option value="catppuccin-frappe">Catppuccin Frappé</option>
<option value="tokyo-night">Tokyo Night</option> <option value="catppuccin-latte">Catppuccin Latte</option>
<option value="dracula">Dracula</option> </select>
<option value="nord">Nord</option> </div>
<option value="solarized-dark">Solarized Dark</option> if sessionExpired {
<option value="monokai">Monokai</option> <div class="mb-4 p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--accent);">
<option value="one-dark-pro">One Dark Pro</option> <p style="color: var(--text-primary);">
<option value="material-dark">Material Dark</option> Your session has expired. Please log in again to continue.
<option value="catppuccin-mocha">Catppuccin Mocha</option> </p>
<option value="catppuccin-macchiato">Catppuccin Macchiato</option> </div>
<option value="catppuccin-frappe">Catppuccin Frappé</option> }
<option value="catppuccin-latte">Catppuccin Latte</option> if deleted {
</select> <div class="mb-4 p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--accent);">
</div> <p style="color: var(--text-primary);">
Your account has been deleted successfully.
if sessionExpired { </p>
<div class="mb-4 p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--accent);"> </div>
<p style="color: var(--text-primary);"> }
Your session has expired. Please log in again to continue. <form hx-post="/api/auth/login" hx-target="#result" hx-swap="innerHTML" class="card p-6 rounded-lg shadow-md border">
</p> <div class="mb-4">
</div> <label class="block mb-2" style="color: var(--text-secondary)">Email or Username</label>
} <input type="text" name="login" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
</div>
if deleted { <div class="mb-4">
<div class="mb-4 p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--accent);"> <label class="block mb-2" style="color: var(--text-secondary)">Password</label>
<p style="color: var(--text-primary);"> <input type="password" name="password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
Your account has been deleted successfully. </div>
</p> <button type="submit" class="btn-primary w-full py-2 rounded">
</div> Sign In
} </button>
</form>
<form hx-post="/api/auth/login" hx-target="#result" hx-swap="innerHTML" class="card p-6 rounded-lg shadow-md border"> <div id="result" class="mt-4 text-center"></div>
<div class="mb-4"> <p class="text-center mt-4">
<label class="block mb-2" style="color: var(--text-secondary)">Email or Username</label> <a href="/register" class="text-blue-500 hover:underline">Don't have an account? Sign Up</a>
<input type="text" name="login" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required> </p>
</div> </div>
<div class="mb-4"> </body>
<label class="block mb-2" style="color: var(--text-secondary)">Password</label> </html>
<input type="password" name="password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
</div>
<button type="submit" class="btn-primary w-full py-2 rounded">
Sign In
</button>
</form>
<div id="result" class="mt-4 text-center"></div>
<p class="text-center mt-4">
<a href="/register" class="text-blue-500 hover:underline">Don't have an account? Sign Up</a>
</p>
</div>
</body>
</html>
} }
+28 -59
View File
@@ -3,66 +3,35 @@ package templates
templ Profile(user User) { templ Profile(user User) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"/>
<title>Profile Settings - Bookhoard</title> <title>Profile Settings - 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/main.js"></script>
<script src="/static/header.js"></script> <link href="/static/style.css" rel="stylesheet"/>
<link href="/static/style.css" rel="stylesheet"> </head>
</head> <body class="theme-{ user.Theme }" x-data="profile">
<body class="theme-{ user.Theme }"> @Header(user, "/profile")
@Header(user, "/profile") <main class="max-w-4xl mx-auto px-4 py-8">
<div class="mb-8">
<main class="max-w-4xl mx-auto px-4 py-8"> <h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Profile Settings</h1>
<div class="mb-8"> <p style="color: var(--text-secondary)">Manage your account information and preferences</p>
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Profile Settings</h1>
<p style="color: var(--text-secondary)">Manage your account information and preferences</p>
</div>
<div class="space-y-8">
<!-- Account Information & Password - uses reusable ProfileForm -->
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
@ProfileForm(user, "/api/auth/profile", true, false, false)
</div> </div>
<div class="space-y-8">
<!-- Danger Zone --> <!-- Account Information & Password - uses reusable ProfileForm -->
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)"> <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)">Danger Zone</h3> @ProfileForm(user, "/api/auth/profile", true, false, false)
<p style="color: var(--text-secondary)" class="mb-4">Once you delete your account, there is no going back. Please be certain.</p> </div>
<!-- Danger Zone -->
<button onclick="confirmDeleteAccount()" class="px-4 py-2 rounded text-sm" style="background-color: #dc2626; color: white;"> <div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
Remove My Account <h3 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Danger Zone</h3>
</button> <p style="color: var(--text-secondary)" class="mb-4">Once you delete your account, there is no going back. Please be certain.</p>
<button @click="confirmDeleteAccount()" class="px-4 py-2 rounded text-sm" style="background-color: #dc2626; color: white;">
Remove My Account
</button>
</div>
</div> </div>
</div> </main>
</main> </body>
<script>
function confirmDeleteAccount() {
if (confirm("Are you sure? All preferences and devices will be deleted. This action cannot be undone.")) {
fetch('/api/auth/profile', {
method: 'DELETE',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
})
.then(response => response.json())
.then(data => {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login?deleted=true';
})
.catch(error => {
alert('Failed to delete account: ' + error.message);
});
}
}
document.addEventListener('DOMContentLoaded', function() {
loadTheme();
});
</script>
</body>
</html> </html>
} }
+1 -1
View File
@@ -154,7 +154,7 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
<div class="flex justify-end gap-3 pt-6 border-t" style="border-color: var(--border);"> <div class="flex justify-end gap-3 pt-6 border-t" style="border-color: var(--border);">
if showCancelButton { if showCancelButton {
<button type="button" <button type="button"
onclick="closeProfileModal()" @click="closeProfileModal()"
class="px-4 py-2 rounded" class="px-4 py-2 rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"> style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);">
Cancel Cancel
+2 -18
View File
@@ -1,7 +1,7 @@
package templates package templates
templ ProfileModal(user User) { templ ProfileModal(user User) {
<div id="profile-modal" class="fixed inset-0 flex items-center justify-center z-50" style="background-color: rgba(0,0,0,0.5);"> <div id="profile-modal" class="fixed inset-0 flex items-center justify-center z-50" x-data="profileModal" x-init="setupProfileModal()" style="background-color: rgba(0,0,0,0.5);">
<div class="rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto" style="background-color: var(--bg-secondary);"> <div class="rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto" style="background-color: var(--bg-secondary);">
<!-- Modal Header --> <!-- Modal Header -->
<div class="flex justify-between items-center p-6 border-b" style="border-color: var(--border);"> <div class="flex justify-between items-center p-6 border-b" style="border-color: var(--border);">
@@ -12,7 +12,7 @@ templ ProfileModal(user User) {
</p> </p>
</div> </div>
<button <button
onclick="closeProfileModal()" @click="closeProfileModal()"
class="text-2xl" class="text-2xl"
style="color: var(--text-secondary);" style="color: var(--text-secondary);"
> >
@@ -26,20 +26,4 @@ templ ProfileModal(user User) {
</div> </div>
</div> </div>
</div> </div>
<script>
function closeProfileModal() {
const modal = document.getElementById('profile-modal');
if (modal) {
modal.remove();
}
}
// Close modal on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeProfileModal();
}
});
</script>
} }
+96 -108
View File
@@ -3,112 +3,100 @@ package templates
import "bookhoard/internal/handlers" import "bookhoard/internal/handlers"
templ Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage string) { templ Progress(user User, progressData []handlers.ProgressWithMedia, 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>Reading Progress - Bookhoard</title> <title>Reading Progress - 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/main.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 }" x-data="progress">
@Header(user, "/progress") @Header(user, "/progress")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="w-full px-4 sm:px-6 lg:px-8 py-8"> <div class="mb-8">
<div class="mb-8"> <h1 class="text-3xl font-bold" style="color: var(--text-primary)">Reading Progress</h1>
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Reading Progress</h1> <p style="color: var(--text-secondary)">Track your reading progress across all devices</p>
<p style="color: var(--text-secondary)">Track your reading progress across all devices</p> </div>
</div> <div id="progress-container" class="space-y-6">
if len(progressData) == 0 {
<div id="progress-container" class="space-y-6"> <div class="text-center py-16" style="color: var(--text-secondary)">
if len(progressData) == 0 { <div class="text-6xl mb-4">📖</div>
<div class="text-center py-16" style="color: var(--text-secondary)"> <h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Progress Yet</h3>
<div class="text-6xl mb-4">📖</div> <p>Start reading a book to track your progress</p>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Progress Yet</h3> </div>
<p>Start reading a book to track your progress</p> }
</div> for _, item := range progressData {
} <div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex gap-6">
for _, item := range progressData { <div class="flex-shrink-0">
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);"> <img
<div class="flex gap-6"> src="{ item.CoverImagePath }"
<div class="flex-shrink-0"> alt="Cover"
<img src="{ item.CoverImagePath }" alt="Cover" class="w-24 h-32 object-cover rounded"
class="w-24 h-32 object-cover rounded" onerror="this.src='/static/placeholder-book.svg'"
onerror="this.src='/static/placeholder-book.svg'"> />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<div class="flex justify-between items-start mb-2"> <div class="flex justify-between items-start mb-2">
<div> <div>
<h3 class="font-semibold text-lg" style="color: var(--text-primary)">{ item.Title }</h3> <h3 class="font-semibold text-lg" style="color: var(--text-primary)">{ item.Title }</h3>
if item.Author != "" { if item.Author != "" {
<p class="text-sm" style="color: var(--text-secondary)">by { item.Author }</p> <p class="text-sm" style="color: var(--text-secondary)">by { item.Author }</p>
} }
</div> </div>
<div class="text-right"> <div class="text-right">
<span class="text-2xl font-bold" style="color: var(--accent)">{ item.ProgressPercentage }%</span> <span class="text-2xl font-bold" style="color: var(--accent)">{ item.ProgressPercentage }%</span>
</div> </div>
</div> </div>
<div class="mb-4">
<div class="mb-4"> <div class="w-full bg-gray-700 rounded-full h-3">
<div class="w-full bg-gray-700 rounded-full h-3"> <div class="h-3 rounded-full transition-all" style="width: { item.ProgressPercentage }%; background-color: var(--accent);"></div>
<div class="h-3 rounded-full transition-all" style="width: { item.ProgressPercentage }%; background-color: var(--accent);"></div> </div>
</div> </div>
</div> <div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm"> <p style="color: var(--text-secondary)">Current Position</p>
<div> <p style="color: var(--text-primary)">
<p style="color: var(--text-secondary)">Current Position</p> { item.CurrentPage } / { item.TotalPages }
<p style="color: var(--text-primary)"> </p>
{ item.CurrentPage } / { item.TotalPages } </div>
</p> <div>
</div> <p style="color: var(--text-secondary)">Last Updated</p>
<div> <p style="color: var(--text-primary)">{ item.LastUpdated }</p>
<p style="color: var(--text-secondary)">Last Updated</p> </div>
<p style="color: var(--text-primary)">{ item.LastUpdated }</p> <div>
</div> <p style="color: var(--text-secondary)">Synced From</p>
<div> <div class="flex items-center gap-2">
<p style="color: var(--text-secondary)">Synced From</p> <span class="text-xl">{ item.DeviceIcon }</span>
<div class="flex items-center gap-2"> <span style="color: var(--text-primary)">{ item.DeviceName }</span>
<span class="text-xl">{ item.DeviceIcon }</span> </div>
<span style="color: var(--text-primary)">{ item.DeviceName }</span> </div>
</div> </div>
</div> if item.DeviceIcon != "" {
</div> <div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<div class="flex items-center gap-2 text-xs" style="color: var(--text-secondary);">
if item.DeviceIcon != "" { <span>🔄</span>
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);"> <span>Last sync from <strong>{ item.DeviceName }</strong></span>
<div class="flex items-center gap-2 text-xs" style="color: var(--text-secondary);"> <span>({ item.DeviceType })</span>
<span>🔄</span> </div>
<span>Last sync from <strong>{ item.DeviceName }</strong></span> </div>
<span>({ item.DeviceType })</span> }
</div> if item.EpubCFI != "" {
</div> <div class="mt-2 text-xs" style="color: var(--text-secondary);">
} <span>📍</span>
<span>CFI: { item.EpubCFI }</span>
if item.EpubCFI != "" { </div>
<div class="mt-2 text-xs" style="color: var(--text-secondary);"> }
<span>📍</span> </div>
<span>CFI: { item.EpubCFI }</span> </div>
</div> </div>
} }
</div> </div>
</div> </div>
</div> @ErrorToast(errorMessage)
} </body>
</div> </html>
</div>
<script>
function logout() {
localStorage.removeItem('token');
window.location.href = '/login';
}
</script>
@ErrorToast(errorMessage)
</body>
</html>
} }
+18 -19
View File
@@ -10,11 +10,10 @@ templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Q
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Sync Queue - Bookhoard</title> <title>Sync Queue - 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/main.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<script src="/static/theme.js"></script>
</head> </head>
<body class="theme-{ user.Theme }"> <body x-data="queue" class="theme-{ user.Theme }">
@Header(user, "/queue") @Header(user, "/queue")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8"> <div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8"> <div class="mb-8">
@@ -24,13 +23,13 @@ templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Q
<p style="color: var(--text-secondary)">Monitor and manage offline sync queue</p> <p style="color: var(--text-secondary)">Monitor and manage offline sync queue</p>
</div> </div>
<div class="flex space-x-3"> <div class="flex space-x-3">
<button onclick="processPendingItems()" class="btn-primary px-4 py-2 rounded-lg"> <button @click="processPendingItems()" class="btn-primary px-4 py-2 rounded-lg">
Process Queue Process Queue
</button> </button>
<button onclick="clearFailedItems()" class="btn-secondary px-4 py-2 rounded-lg"> <button @click="clearFailedItems()" class="btn-secondary px-4 py-2 rounded-lg">
Clear Failed Clear Failed
</button> </button>
<button onclick="clearAllItems()" class="btn-danger px-4 py-2 rounded-lg"> <button @click="clearAllItems()" class="btn-danger px-4 py-2 rounded-lg">
Clear All Clear All
</button> </button>
</div> </div>
@@ -98,7 +97,7 @@ templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Q
</select> </select>
</div> </div>
<div class="flex-1"></div> <div class="flex-1"></div>
<button onclick="refreshQueue()" class="btn-secondary px-4 py-2 rounded-lg"> <button @click="refreshQueue()" class="btn-secondary px-4 py-2 rounded-lg">
Refresh Refresh
</button> </button>
</div> </div>
@@ -120,15 +119,15 @@ templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Q
<p>No items in the sync queue</p> <p>No items in the sync queue</p>
</div> </div>
} }
for _, item := range queueItems { for _, item := range queueItems {
<div class="card p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div class="card p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-start mb-2"> <div class="flex justify-between items-start mb-2">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<span class="inline-block px-2 py-0.5 rounded text-[11px] font-semibold uppercase status-{ item.Status }">{ item.Status }</span> <span class="inline-block px-2 py-0.5 rounded text-[11px] font-semibold uppercase status-{ item.Status }">{ item.Status }</span>
<span class="text-sm" style="color: var(--text-secondary)">{ item.SyncType }</span> <span class="text-sm" style="color: var(--text-secondary)">{ item.SyncType }</span>
</div> </div>
<span class="inline-block px-2 py-0.5 rounded text-[10px] font-semibold priority-{ item.Priority }">P{ item.Priority }</span> <span class="inline-block px-2 py-0.5 rounded text-[10px] font-semibold priority-{ item.Priority }">P{ item.Priority }</span>
</div> </div>
<div class="text-sm mb-2" style="color: var(--text-secondary)"> <div class="text-sm mb-2" style="color: var(--text-secondary)">
Device ID: { item.DeviceID } Device ID: { item.DeviceID }
if item.MediaTitle != nil { if item.MediaTitle != nil {
@@ -151,14 +150,14 @@ templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Q
<div class="card rounded-lg p-6 w-full max-w-2xl mx-4 my-8" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div class="card rounded-lg p-6 w-full max-w-2xl mx-4 my-8" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Queue Item Details</h2> <h2 class="text-2xl font-bold" style="color: var(--text-primary)">Queue Item Details</h2>
<button onclick="hideQueueItemModal()" class="p-2 hover:opacity-80 rounded-lg" style="color: var(--text-primary); background-color: var(--bg-primary);"> <button @click="hideQueueItemModal()" class="p-2 hover:opacity-80 rounded-lg" style="color: var(--text-primary); background-color: var(--bg-primary);">
X X
</button> </button>
</div> </div>
<div id="queue-item-details"></div> <div id="queue-item-details"></div>
<div class="flex justify-end space-x-3 mt-6"> <div class="flex justify-end space-x-3 mt-6">
<button onclick="hideQueueItemModal()" class="btn-secondary px-4 py-2 rounded-lg">Close</button> <button @click="hideQueueItemModal()" class="btn-secondary px-4 py-2 rounded-lg">Close</button>
<button onclick="retryQueueItem()" class="btn-primary px-4 py-2 rounded-lg">Retry</button> <button @click="retryQueueItem()" class="btn-primary px-4 py-2 rounded-lg">Retry</button>
</div> </div>
</div> </div>
</div> </div>
+73 -93
View File
@@ -1,97 +1,77 @@
package templates package templates
templ Register() { templ Register() {
<!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>Register</title> <title>Register</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script> <script src="/static/main.js"></script>
<script src="/static/theme.js"></script> <link href="/static/style.css" rel="stylesheet"/>
<link href="/static/style.css" rel="stylesheet"> </head>
</head> <body class="theme-tokyo-night min-h-screen" x-data="register" x-init="initRegisterTheme()">
<body class="theme-tokyo-night min-h-screen"> <div class="container mx-auto px-4 py-8 max-w-md">
<script> <h1 class="text-3xl font-bold text-center mb-8">Register for Bookhoard</h1>
// Progressive enhancement: check localStorage immediately <form hx-post="/api/auth/register" hx-target="#result" hx-swap="innerHTML" class="card p-6 rounded-lg shadow-md border">
(function() { <div class="mb-4">
const savedTheme = localStorage.getItem('theme'); <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email</label>
if (savedTheme && savedTheme !== 'tokyo-night') { <input type="email" id="email" name="email" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
document.body.className = 'theme-' + savedTheme + ' min-h-screen'; </div>
} <div class="mb-4">
})(); <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label>
</script> <input type="text" id="username" name="username" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
<div class="container mx-auto px-4 py-8 max-w-md"> </div>
<h1 class="text-3xl font-bold text-center mb-8">Register for Bookhoard</h1> <div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label>
<form hx-post="/api/auth/register" hx-target="#result" hx-swap="innerHTML" class="card p-6 rounded-lg shadow-md border"> <input type="text" name="first_name" placeholder="Optional" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"/>
<div class="mb-4"> </div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email</label> <div class="mb-4">
<input type="email" id="email" name="email" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required> <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label>
</div> <input type="text" name="last_name" placeholder="Optional" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"/>
<div class="mb-4"> </div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label> <div id="password-requirements" class="mb-4 p-4 rounded-lg border text-sm" style="background-color: var(--bg-secondary); border-color: var(--border)">
<input type="text" id="username" name="username" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required> <p class="font-semibold mb-2" style="color: var(--text-primary)">Password Requirements:</p>
</div> <ul class="space-y-1" style="color: var(--text-secondary)">
<div class="mb-4"> <li id="req-length" class="flex items-center gap-2">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label> <span class="requirement-icon"></span> At least 8 characters
<input type="text" name="first_name" placeholder="Optional" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"> </li>
</div> <li id="req-upper" class="flex items-center gap-2">
<div class="mb-4"> <span class="requirement-icon"></span> One uppercase letter
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label> </li>
<input type="text" name="last_name" placeholder="Optional" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"> <li id="req-lower" class="flex items-center gap-2">
</div> <span class="requirement-icon"></span> One lowercase letter
<div id="password-requirements" class="mb-4 p-4 rounded-lg border text-sm" style="background-color: var(--bg-secondary); border-color: var(--border)"> </li>
<p class="font-semibold mb-2" style="color: var(--text-primary)">Password Requirements:</p> <li id="req-number" class="flex items-center gap-2">
<ul class="space-y-1" style="color: var(--text-secondary)"> <span class="requirement-icon"></span> One number
<li id="req-length" class="flex items-center gap-2"> </li>
<span class="requirement-icon"></span> At least 8 characters <li id="req-special" class="flex items-center gap-2">
</li> <span class="requirement-icon"></span> One special character
<li id="req-upper" class="flex items-center gap-2"> </li>
<span class="requirement-icon"></span> One uppercase letter <li id="req-match" class="flex items-center gap-2">
</li> <span class="requirement-icon"></span> Passwords match
<li id="req-lower" class="flex items-center gap-2"> </li>
<span class="requirement-icon"></span> One lowercase letter </ul>
</li> </div>
<li id="req-number" class="flex items-center gap-2"> <div class="mb-4">
<span class="requirement-icon"></span> One number <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label>
</li> <input type="password" id="password" name="password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
<li id="req-special" class="flex items-center gap-2"> </div>
<span class="requirement-icon"></span> One special character <div class="mb-4">
</li> <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm Password</label>
<li id="req-match" class="flex items-center gap-2"> <input type="password" id="confirm-password" name="confirm_password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
<span class="requirement-icon"></span> Passwords match </div>
</li> <button type="submit" id="register-btn" class="btn-primary w-full py-2 rounded opacity-50 cursor-not-allowed" disabled>
</ul> Register
</div> </button>
<div class="mb-4"> </form>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label> <div id="result" class="mt-4 text-center"></div>
<input type="password" id="password" name="password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required> <p class="text-center mt-4">
</div> <a href="/login" class="text-blue-500 hover:underline">Already have an account? Login</a>
<div class="mb-4"> </p>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm Password</label> </div>
<input type="password" id="confirm-password" name="confirm_password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required> </body>
</div> </html>
<button type="submit" id="register-btn" class="btn-primary w-full py-2 rounded opacity-50 cursor-not-allowed" disabled>
Register
</button>
</form>
<div id="result" class="mt-4 text-center"></div>
<p class="text-center mt-4">
<a href="/login" class="text-blue-500 hover:underline">Already have an account? Login</a>
</p>
</div>
<script src="/static/password_validation.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
if (window.initPasswordValidation) {
window.initPasswordValidation();
}
});
</script>
</body>
</html>
} }
@@ -1,11 +1,11 @@
package templates package templates
templ RestoreSystemCollectionModal() { templ RestoreSystemCollectionModal() {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70"> <div x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<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)">Restore System Collection</h2> <h2 class="text-xl font-bold" style="color: var(--text-primary)">Restore System Collection</h2>
<button type="button" onclick="closeCollectionModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button> <button type="button" @click="closeCollectionModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<p class="mb-4" style="color: var(--text-secondary)">Select a system collection to restore:</p> <p class="mb-4" style="color: var(--text-secondary)">Select a system collection to restore:</p>
@@ -21,7 +21,7 @@ templ RestoreSystemCollectionModal() {
<option value="not-started">Not Started</option> <option value="not-started">Not Started</option>
</select> </select>
<div class="flex justify-end space-x-3 mt-6"> <div class="flex justify-end space-x-3 mt-6">
<button type="button" onclick="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button> <button type="button" @click="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">Restore</button> <button type="submit" class="btn-primary px-4 py-2 rounded-lg">Restore</button>
</div> </div>
</form> </form>
+1 -28
View File
@@ -2,33 +2,6 @@ package templates
templ ErrorToast(message string) { templ ErrorToast(message string) {
if message != "" { if message != "" {
<script> <div x-data="toastError" x-init="showErrorToast('{ message }')"></div>
document.addEventListener('DOMContentLoaded', function() {
// Show error toast with 8 second auto-dismiss
window.showToast.error('{ message }', 8000);
// Add retry button to the toast
setTimeout(function() {
const toastContainer = document.getElementById('toast-container');
if (toastContainer && toastContainer.lastElementChild) {
const toast = toastContainer.lastElementChild;
const retryBtn = document.createElement('button');
retryBtn.className = 'ml-4 px-3 py-1 bg-white/20 hover:bg-white/30 rounded text-sm font-medium transition-colors';
retryBtn.textContent = 'Retry';
retryBtn.onclick = function() {
window.location.reload();
};
// Insert before the close button
const closeBtn = toast.querySelector('.toast-close');
if (closeBtn) {
closeBtn.parentElement.insertBefore(retryBtn, closeBtn);
} else {
toast.appendChild(retryBtn);
}
}
}, 100);
});
</script>
} }
} }
+192 -497
View File
@@ -1,501 +1,196 @@
package templates package templates
templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) { templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
<!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>Unlinked Books - Bookhoard</title> <title>Unlinked Books - 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/main.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 }" x-data="unlinkedBooks" x-init="setupEventDelegation()">
@Header(user, "/unlinked") @Header(user, "/unlinked")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="w-full px-4 sm:px-6 lg:px-8 py-8"> <div class="mb-8">
<div class="mb-8"> <h1 class="text-3xl font-bold" style="color: var(--text-primary)">Unlinked Books</h1>
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Unlinked Books</h1> <p style="color: var(--text-secondary)">Resolve books that couldn't be automatically matched between devices</p>
<p style="color: var(--text-secondary)">Resolve books that couldn't be automatically matched between devices</p> </div>
</div> if len(unlinkedBooks) > 0 {
<div class="flex justify-between items-center mb-6 p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
if len(unlinkedBooks) > 0 { <div class="flex items-center gap-4">
<div class="flex justify-between items-center mb-6 p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);"> <input type="checkbox" id="select-all-unlinked" @change="toggleAllUnlinked()" class="w-5 h-5"/>
<div class="flex items-center gap-4"> <label for="select-all-unlinked" class="cursor-pointer" style="color: var(--text-primary)">Select All</label>
<input type="checkbox" id="select-all-unlinked" onchange="toggleAllUnlinked()" class="w-5 h-5"> <span id="selected-count" class="text-sm" style="color: var(--text-secondary)">0 selected</span>
<label for="select-all-unlinked" class="cursor-pointer" style="color: var(--text-primary)">Select All</label> </div>
<span id="selected-count" class="text-sm" style="color: var(--text-secondary)">0 selected</span> <div class="flex gap-2">
</div> <button @click="bulkAutoLink()" class="btn-secondary px-4 py-2 rounded-lg text-sm">
<div class="flex gap-2"> 🤖 Auto-Link Selected (High Confidence)
<button onclick="bulkAutoLink()" class="btn-secondary px-4 py-2 rounded-lg text-sm"> </button>
🤖 Auto-Link Selected (High Confidence) <button @click="bulkGetSuggestions()" class="btn-secondary px-4 py-2 rounded-lg text-sm">
</button> 💡 Get Suggestions for Selected
<button onclick="bulkGetSuggestions()" class="btn-secondary px-4 py-2 rounded-lg text-sm"> </button>
💡 Get Suggestions for Selected <button @click="showBulkManualLink()" class="btn-primary px-4 py-2 rounded-lg text-sm">
</button> 🔗 Bulk Manual Link
<button onclick="showBulkManualLink()" class="btn-primary px-4 py-2 rounded-lg text-sm"> </button>
🔗 Bulk Manual Link </div>
</button> </div>
</div> }
</div> <div id="unlinked-container" class="space-y-6">
} if len(unlinkedBooks) == 0 {
<div class="text-center py-16" style="color: var(--text-secondary)">
<div id="unlinked-container" class="space-y-6"> <div class="text-6xl mb-4"></div>
if len(unlinkedBooks) == 0 { <h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">All Books Linked!</h3>
<div class="text-center py-16" style="color: var(--text-secondary)"> <p>There are no unlinked books that need manual resolution.</p>
<div class="text-6xl mb-4"></div> </div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">All Books Linked!</h3> }
<p>There are no unlinked books that need manual resolution.</p> for _, book := range unlinkedBooks {
</div> <div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
} <div class="flex gap-6">
<div class="flex items-start pt-2">
for _, book := range unlinkedBooks { <input type="checkbox" class="unlinked-checkbox w-5 h-5" data-progress-id="{ book.ProgressID }" data-title="{ book.TitleFromDevice }" onchange="updateSelectedCount()"/>
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);"> </div>
<div class="flex gap-6"> <div class="flex-1">
<div class="flex items-start pt-2"> <div class="flex items-center gap-3 mb-4">
<input type="checkbox" class="unlinked-checkbox w-5 h-5" data-progress-id="{ book.ProgressID }" data-title="{ book.TitleFromDevice }" onchange="updateSelectedCount()"> <div class="text-4xl">
</div> if book.DeviceType == "koreader" {
<div class="flex-1"> 📖
<div class="flex items-center gap-3 mb-4"> } else if book.DeviceType == "kobo" {
<div class="text-4xl"> 📚
if book.DeviceType == "koreader" { } else {
📖 📱
} else if book.DeviceType == "kobo" { }
📚 </div>
} else { <div>
📱 <h3 class="font-semibold text-lg" style="color: var(--text-primary)">{ book.TitleFromDevice }</h3>
} <p class="text-sm" style="color: var(--text-secondary)">
</div> Device: { book.DeviceName } ({ book.DeviceType })
<div> </p>
<h3 class="font-semibold text-lg" style="color: var(--text-primary)">{ book.TitleFromDevice }</h3> </div>
<p class="text-sm" style="color: var(--text-secondary)"> </div>
Device: { book.DeviceName } ({ book.DeviceType }) <div class="space-y-2 text-sm mb-4">
</p> <div>
</div> <span style="color: var(--text-secondary)">File Path:</span>
</div> <code style="color: var(--text-primary); background-color: var(--bg-primary); padding: 2px 6px; border-radius: 4px;">
{ book.FilePath }
<div class="space-y-2 text-sm mb-4"> </code>
<div> </div>
<span style="color: var(--text-secondary)">File Path:</span> if book.SHA256 != "" {
<code style="color: var(--text-primary); background-color: var(--bg-primary); padding: 2px 6px; border-radius: 4px;"> <div>
{ book.FilePath } <span style="color: var(--text-secondary)">SHA-256:</span>
</code> <code style="color: var(--text-primary); background-color: var(--bg-primary); padding: 2px 6px; border-radius: 4px; font-size: 11px;">
</div> { book.SHA256 }
if book.SHA256 != "" { </code>
<div> </div>
<span style="color: var(--text-secondary)">SHA-256:</span> }
<code style="color: var(--text-primary); background-color: var(--bg-primary); padding: 2px 6px; border-radius: 4px; font-size: 11px;"> <div>
{ book.SHA256 } <span style="color: var(--text-secondary)">Last Synced:</span>
</code> <span style="color: var(--text-primary)">{ book.LastSyncTime }</span>
</div> </div>
} if book.ConfidenceScore > 0 {
<div> <div>
<span style="color: var(--text-secondary)">Last Synced:</span> <span style="color: var(--text-secondary)">Auto-Link Confidence:</span>
<span style="color: var(--text-primary)">{ book.LastSyncTime }</span> <span style="color: var(--text-primary)">
</div> { book.ConfidenceScore }% confidence
if book.ConfidenceScore > 0 { </span>
<div> </div>
<span style="color: var(--text-secondary)">Auto-Link Confidence:</span> }
<span style="color: var(--text-primary)"> </div>
{ book.ConfidenceScore }% confidence <div class="mb-4">
</span> <button
</div> @click="searchMatches('{ book.ProgressID }', '{ book.SHA256 }', '{ book.TitleFromDevice }')"
} class="btn-secondary px-4 py-2 rounded-lg text-sm"
</div> >
🔍 Find Potential Matches
<div class="mb-4"> </button>
<button onclick="searchMatches('{ book.ProgressID }', '{ book.SHA256 }', '{ book.TitleFromDevice }')" <button
class="btn-secondary px-4 py-2 rounded-lg text-sm"> @click="showManualLinkModal('{ book.ProgressID }', '{ book.TitleFromDevice }')"
🔍 Find Potential Matches class="btn-primary px-4 py-2 rounded-lg text-sm"
</button> >
<button onclick="showManualLinkModal('{ book.ProgressID }', '{ book.TitleFromDevice }')" 🔗 Manually Link
class="btn-primary px-4 py-2 rounded-lg text-sm"> </button>
🔗 Manually Link </div>
</button> <div id="matches-{ book.ProgressID }" class="hidden">
</div> <div class="border-t pt-4" style="border-color: var(--border);">
<h4 class="font-semibold mb-3" style="color: var(--text-primary)">Potential Matches</h4>
<div id="matches-{ book.ProgressID }" class="hidden"> <div id="matches-list-{ book.ProgressID }" class="space-y-3">
<div class="border-t pt-4" style="border-color: var(--border);"> <p style="color: var(--text-secondary)">Click "Find Potential Matches" to search</p>
<h4 class="font-semibold mb-3" style="color: var(--text-primary)">Potential Matches</h4> </div>
<div id="matches-list-{ book.ProgressID }" class="space-y-3"> </div>
<p style="color: var(--text-secondary)">Click "Find Potential Matches" to search</p> </div>
</div> </div>
</div> </div>
</div> </div>
</div> }
</div> </div>
</div> </div>
} <!-- Manual Link Modal -->
</div> <div id="manual-link-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
</div> <div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-6">
<!-- Manual Link Modal --> <h2 class="text-xl font-bold" style="color: var(--text-primary)">Manually Link Book</h2>
<div id="manual-link-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);"> <button @click="hideManualLinkModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);"> </div>
<div class="flex justify-between items-center mb-6"> <input type="hidden" id="link-progress-id"/>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Manually Link Book</h2> <input type="hidden" id="link-book-sha256"/>
<button onclick="hideManualLinkModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button> <div class="mb-4">
</div> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Book Title from Device</label>
<input
<input type="hidden" id="link-progress-id"> type="text"
<input type="hidden" id="link-book-sha256"> id="link-book-title"
readonly
<div class="mb-4"> class="w-full px-4 py-2 border rounded-lg"
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Book Title from Device</label> style="background-color: var(--bg-primary); color: var(--text-secondary); border-color: var(--border);"
<input type="text" id="link-book-title" readonly />
class="w-full px-4 py-2 border rounded-lg" </div>
style="background-color: var(--bg-primary); color: var(--text-secondary); border-color: var(--border);"> <div class="mb-4">
</div> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Search for Book to Link</label>
<div class="flex gap-2">
<div class="mb-4"> <input
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Search for Book to Link</label> type="text"
<div class="flex gap-2"> id="link-search-input"
<input type="text" id="link-search-input" placeholder="Search by title, author..." placeholder="Search by title, author..."
class="flex-1 px-4 py-2 border rounded-lg" class="flex-1 px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
<button type="button" onclick="searchBooksForLink()" />
class="btn-primary px-4 py-2 rounded-lg"> <button
Search type="button"
</button> @click="searchBooksForLink()"
</div> class="btn-primary px-4 py-2 rounded-lg"
</div> >
Search
<div id="link-search-results" class="mb-4 max-h-48 overflow-y-auto space-y-2"> </button>
<p style="color: var(--text-secondary)">Search for books to link</p> </div>
</div> </div>
<div id="link-search-results" class="mb-4 max-h-48 overflow-y-auto space-y-2">
<div class="mb-4"> <p style="color: var(--text-secondary)">Search for books to link</p>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Confidence Score</label> </div>
<input type="number" id="link-confidence" min="0" max="1" step="0.1" value="1.0" <div class="mb-4">
class="w-full px-4 py-2 border rounded-lg" <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Confidence Score</label>
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> <input
<p class="text-xs mt-1" style="color: var(--text-secondary)">1.0 = manual override (highest confidence)</p> type="number"
</div> id="link-confidence"
min="0"
<div class="flex justify-end space-x-3"> max="1"
<button type="button" onclick="hideManualLinkModal()" class="btn-secondary px-4 py-2 rounded-lg"> step="0.1"
Cancel value="1.0"
</button> class="w-full px-4 py-2 border rounded-lg"
<button type="button" onclick="confirmManualLink()" class="btn-primary px-4 py-2 rounded-lg"> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
Link Book />
</button> <p class="text-xs mt-1" style="color: var(--text-secondary)">1.0 = manual override (highest confidence)</p>
</div> </div>
</div> <div class="flex justify-end space-x-3">
</div> <button type="button" @click="hideManualLinkModal()" class="btn-secondary px-4 py-2 rounded-lg">
Cancel
<script> </button>
let selectedMediaItem = null; <button type="button" @click="confirmManualLink()" class="btn-primary px-4 py-2 rounded-lg">
Link Book
function searchMatches(progressId, sha256, title) { </button>
const container = document.getElementById(`matches-${progressId}`); </div>
const matchesList = document.getElementById(`matches-list-${progressId}`); </div>
</div>
container.classList.remove('hidden'); </body>
matchesList.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>'; </html>
const url = sha256 ? `/api/books/match?sha256=${sha256}` : `/api/books/match?title=${encodeURIComponent(title)}`;
fetch(url, {
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
})
.then(response => response.json())
.then(result => {
if (result.matches && result.matches.length > 0) {
matchesList.innerHTML = result.matches.map(match => `
<div class="card p-4 rounded-lg border cursor-pointer hover:shadow-lg transition-shadow"
style="background-color: var(--bg-primary); border-color: var(--border);"
onclick="autoLinkBook('${progressId}', '${match.media_item_id}', ${match.confidence})">
<div class="flex gap-4">
<img src="${match.cover_image_path || '/static/placeholder-book.svg'}"
alt="Cover" class="w-16 h-24 object-cover rounded">
<div>
<h5 class="font-semibold" style="color: var(--text-primary)">${match.title}</h5>
<p class="text-sm" style="color: var(--text-secondary)">by ${match.author || 'Unknown'}</p>
<div class="mt-2 flex items-center gap-2">
<span class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: var(--bg-primary);">
${Math.round(match.confidence * 100)}% confidence
</span>
<span class="text-xs" style="color: var(--text-secondary)">${match.match_method}</span>
</div>
</div>
</div>
</div>
`).join('');
} else {
matchesList.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">No matches found. Try manual linking.</p>';
}
})
.catch(error => {
matchesList.innerHTML = '<p class="text-sm" style="color: var(--error)">Failed to search</p>';
});
}
function autoLinkBook(progressId, mediaItemId, confidence) {
if (!confirm('Link this book? The confidence score is ' + Math.round(confidence * 100) + '%')) return;
const data = {
device_file: {
file_path: document.getElementById(`matches-${progressId}`).querySelector('code').textContent,
sha256: document.getElementById(`matches-${progressId}`).querySelector('[title="SHA-256"]')?.textContent || ''
},
media_item_id: mediaItemId,
confidence_score: confidence
};
fetch(`/api/devices/${deviceId}/sync/link-book`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
showToast('Book linked successfully', 'success');
location.reload();
})
.catch(error => {
showToast('Failed to link book', 'error');
});
}
function showManualLinkModal(progressId, bookTitle) {
document.getElementById('link-progress-id').value = progressId;
document.getElementById('link-book-title').value = bookTitle;
document.getElementById('link-search-results').innerHTML = '<p style="color: var(--text-secondary)">Search for books to link</p>';
selectedMediaItem = null;
document.getElementById('manual-link-modal').classList.remove('hidden');
}
function hideManualLinkModal() {
document.getElementById('manual-link-modal').classList.add('hidden');
document.getElementById('link-search-input').value = '';
selectedMediaItem = null;
}
function searchBooksForLink() {
const searchTerm = document.getElementById('link-search-input').value;
const resultsContainer = document.getElementById('link-search-results');
if (searchTerm.length < 2) {
resultsContainer.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">Enter at least 2 characters</p>';
return;
}
resultsContainer.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>';
fetch(`/api/books/match?title=${encodeURIComponent(searchTerm)}`, {
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
})
.then(response => response.json())
.then(result => {
if (result.matches && result.matches.length > 0) {
resultsContainer.innerHTML = result.matches.map(match => `
<div class="card p-3 rounded-lg border cursor-pointer ${selectedMediaItem === match.media_item_id ? 'border-2 border-blue-500' : ''}"
style="background-color: var(--bg-primary); border-color: var(--border);"
onclick="selectBookForLink('${match.media_item_id}', '${match.title}', '${match.cover_image_path || ''}')">
<div class="flex gap-3">
<img src="${match.cover_image_path || '/static/placeholder-book.svg'}"
alt="Cover" class="w-12 h-16 object-cover rounded">
<div>
<h5 class="font-semibold text-sm" style="color: var(--text-primary)">${match.title}</h5>
<p class="text-xs" style="color: var(--text-secondary)">by ${match.author || 'Unknown'}</p>
<p class="text-xs" style="color: var(--accent)">${Math.round(match.confidence * 100)}% confidence</p>
</div>
</div>
</div>
`).join('');
} else {
resultsContainer.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">No matches found</p>';
}
})
.catch(error => {
resultsContainer.innerHTML = '<p class="text-sm" style="color: var(--error)">Failed to search</p>';
});
}
function selectBookForLink(mediaItemId, title, coverPath) {
selectedMediaItem = mediaItemId;
const resultsContainer = document.getElementById('link-search-results');
const cards = resultsContainer.querySelectorAll('.card');
cards.forEach(card => {
card.classList.remove('border-2', 'border-blue-500');
if (card.getAttribute('onclick').includes(mediaItemId)) {
card.classList.add('border-2', 'border-blue-500');
}
});
}
function confirmManualLink() {
if (!selectedMediaItem) {
showToast('Please select a book to link', 'error');
return;
}
const progressId = document.getElementById('link-progress-id').value;
const confidence = parseFloat(document.getElementById('link-confidence').value);
const data = {
device_file: {
file_path: document.getElementById('link-book-title').value,
sha256: document.getElementById('link-book-sha256').value
},
media_item_id: selectedMediaItem,
confidence_score: confidence
};
fetch(`/api/devices/${deviceId}/sync/link-book`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
showToast('Book linked successfully', 'success');
hideManualLinkModal();
location.reload();
})
.catch(error => {
showToast('Failed to link book', 'error');
});
}
function logout() {
localStorage.removeItem('token');
window.location.href = '/login';
}
// Bulk Operations Functions
function toggleAllUnlinked() {
const selectAll = document.getElementById('select-all-unlinked');
document.querySelectorAll('.unlinked-checkbox').forEach(cb => {
cb.checked = selectAll.checked;
});
updateSelectedCount();
}
function getSelectedUnlinked() {
return Array.from(document.querySelectorAll('.unlinked-checkbox:checked'))
.map(cb => ({
progressId: cb.getAttribute('data-progress-id'),
title: cb.getAttribute('data-title')
}));
}
function updateSelectedCount() {
const count = document.querySelectorAll('.unlinked-checkbox:checked').length;
document.getElementById('selected-count').textContent = `${count} selected`;
}
async function bulkAutoLink() {
const selected = getSelectedUnlinked();
if (selected.length === 0) {
showToast('Please select at least one book', 'error');
return;
}
if (!confirm(`Auto-link ${selected.length} books with high confidence matches (≥80%)?`)) {
return;
}
try {
const response = await fetch('/sync/auto-link-books', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: JSON.stringify({
confidence_threshold: 0.8,
limit: selected.length
})
});
const result = await response.json();
showToast(`Auto-linked ${result.auto_linked} books successfully`, 'success');
setTimeout(() => location.reload(), 1500);
} catch (error) {
showToast('Auto-link failed: ' + error.message, 'error');
}
}
async function bulkGetSuggestions() {
const selected = getSelectedUnlinked();
if (selected.length === 0) {
showToast('Please select at least one book', 'error');
return;
}
for (const book of selected) {
try {
const response = await fetch(`/sync/unlinked-books/${book.progressId}/suggestions`, {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
});
const result = await response.json();
displaySuggestions(book.progressId, result.suggestions, result.action);
} catch (error) {
console.error('Failed to get suggestions:', error);
}
}
}
function displaySuggestions(progressId, suggestions, action) {
const container = document.getElementById(`matches-${progressId}`);
if (!container) return;
container.classList.remove('hidden');
const listContainer = container.querySelector('.matches-list');
listContainer.innerHTML = '';
if (suggestions.length === 0) {
listContainer.innerHTML = '<p style="color: var(--text-secondary)">No matches found</p>';
return;
}
suggestions.forEach(match => {
const div = document.createElement('div');
div.className = 'p-3 border rounded cursor-pointer hover:bg-opacity-80 transition-colors';
div.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`;
div.innerHTML = `
<div class="flex justify-between items-center">
<div>
<h4 class="font-semibold" style="color: var(--text-primary)">${match.title}</h4>
<p class="text-sm" style="color: var(--text-secondary)">Author: ${match.author || 'Unknown'}</p>
</div>
<div class="text-right">
<div class="text-sm font-semibold" style="color: var(--text-primary)">
${(match.confidence * 100).toFixed(0)}% confidence
</div>
<div class="text-xs" style="color: var(--text-secondary)">${match.match_method}</div>
</div>
</div>
`;
div.onclick = () => selectMatchForLink(progressId, match);
listContainer.appendChild(div);
});
}
function showBulkManualLink() {
const selected = getSelectedUnlinked();
if (selected.length === 0) {
showToast('Please select at least one book', 'error');
return;
}
showToast(`Bulk manual link for ${selected.length} books - select target book in library`, 'info');
// For now, redirect to manual linking. In the future, this could open a modal
window.location.href = '/library?mode=link&unlinked=' + selected.map(s => s.progressId).join(',');
}
</script>
</body>
</html>
} }