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:
+116
-121
@@ -1,125 +1,120 @@
|
||||
package templates
|
||||
|
||||
templ Admin(user User) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Admin Dashboard - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/toast.js"></script>
|
||||
<script src="/static/admin.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="theme-tokyo-night">
|
||||
@Header(user, "/admin")
|
||||
|
||||
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
|
||||
@AdminSidebar(user, "/admin")
|
||||
<main class="flex-1 p-8">
|
||||
<div class="max-w-4xl">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Dashboard</h1>
|
||||
<p style="color: var(--text-secondary)">Overview of your Bookhoard library and settings</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="text-3xl">📖</div>
|
||||
<div>
|
||||
<h3 class="font-semibold" style="color: var(--text-primary)">Library</h3>
|
||||
<p style="color: var(--text-secondary)" class="text-sm">Manage your ebook collection</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/" class="mt-4 inline-block text-sm btn-secondary px-3 py-1 rounded">View Library</a>
|
||||
</div>
|
||||
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="text-3xl">👁️</div>
|
||||
<div>
|
||||
<h3 class="font-semibold" style="color: var(--text-primary)">Scan Watch Status</h3>
|
||||
<p style="color: var(--text-secondary)" class="text-sm">Auto-detecting new files</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="watch-status" class="mt-4 text-sm" style="color: var(--text-secondary)">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-2"></span>
|
||||
Watching <span id="watch-count">0</span> libraries
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<h3 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Quick Actions</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<button onclick="scanAllLibraries()" class="btn-primary p-4 rounded-lg text-left">
|
||||
<div class="font-medium">Rescan Library</div>
|
||||
<div style="color: var(--text-secondary)" class="text-sm">Re-scan existing files and fix metadata</div>
|
||||
</button>
|
||||
<a href="/admin/library" class="btn-secondary p-4 rounded-lg text-left block">
|
||||
<div class="font-medium">Manage Folders</div>
|
||||
<div style="color: var(--text-secondary)" class="text-sm">Add or remove scan directories</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scan Progress Section -->
|
||||
<div id="scan-progress-container" class="hidden mt-6 p-6 rounded-lg border opacity-0 -translate-y-2.5 transition-all duration-300 ease-out" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">
|
||||
📚 Scanning Libraries
|
||||
</h3>
|
||||
<button onclick="hideScanProgress()" class="p-2 hover:bg-gray-700 rounded">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Overall Progress -->
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-2">
|
||||
<span style="color: var(--text-secondary)">Overall Progress</span>
|
||||
<span id="scan-progress-text" style="color: var(--text-primary)">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-700 rounded-full h-3">
|
||||
<div id="scan-progress-bar"
|
||||
class="h-3 rounded-full transition-all duration-500"
|
||||
style="width: 0%; background-color: var(--accent);">
|
||||
</div>
|
||||
</div>
|
||||
<div id="scan-status" class="text-sm mt-2" style="color: var(--text-secondary)">
|
||||
Starting scan...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-Library Progress -->
|
||||
<div id="library-progress-list" class="space-y-3">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
|
||||
<!-- Results Summary -->
|
||||
<div id="scan-results" class="hidden mt-6 p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||
<h4 class="font-semibold mb-2" style="color: var(--text-primary)">✅ Scan Complete!</h4>
|
||||
<div id="scan-results-content" style="color: var(--text-secondary)">
|
||||
<!-- Results populated by JS -->
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button onclick="window.location.reload()"
|
||||
class="btn-primary px-4 py-2 rounded-lg">
|
||||
Refresh to View Books
|
||||
</button>
|
||||
<button onclick="hideScanProgress()"
|
||||
class="btn-secondary px-4 py-2 rounded-lg">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>Admin Dashboard - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/main.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body class="theme-tokyo-night" x-data="admin">
|
||||
@Header(user, "/admin")
|
||||
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
|
||||
@AdminSidebar(user, "/admin")
|
||||
<main class="flex-1 p-8">
|
||||
<div class="max-w-4xl">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Dashboard</h1>
|
||||
<p style="color: var(--text-secondary)">Overview of your Bookhoard library and settings</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="text-3xl">📖</div>
|
||||
<div>
|
||||
<h3 class="font-semibold" style="color: var(--text-primary)">Library</h3>
|
||||
<p style="color: var(--text-secondary)" class="text-sm">Manage your ebook collection</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/" class="mt-4 inline-block text-sm btn-secondary px-3 py-1 rounded">View Library</a>
|
||||
</div>
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="text-3xl">👁️</div>
|
||||
<div>
|
||||
<h3 class="font-semibold" style="color: var(--text-primary)">Scan Watch Status</h3>
|
||||
<p style="color: var(--text-secondary)" class="text-sm">Auto-detecting new files</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="watch-status" class="mt-4 text-sm" style="color: var(--text-secondary)">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-2"></span>
|
||||
Watching <span id="watch-count">0</span> libraries
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<h3 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Quick Actions</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<button @click="scanAllLibraries()" class="btn-primary p-4 rounded-lg text-left">
|
||||
<div class="font-medium">Rescan Library</div>
|
||||
<div style="color: var(--text-secondary)" class="text-sm">Re-scan existing files and fix metadata</div>
|
||||
</button>
|
||||
<a href="/admin/library" class="btn-secondary p-4 rounded-lg text-left block">
|
||||
<div class="font-medium">Manage Folders</div>
|
||||
<div style="color: var(--text-secondary)" class="text-sm">Add or remove scan directories</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Scan Progress Section -->
|
||||
<div id="scan-progress-container" class="hidden mt-6 p-6 rounded-lg border opacity-0 -translate-y-2.5 transition-all duration-300 ease-out" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">
|
||||
📚 Scanning Libraries
|
||||
</h3>
|
||||
<button @click="hideScanProgress()" class="p-2 hover:bg-gray-700 rounded">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<!-- Overall Progress -->
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-2">
|
||||
<span style="color: var(--text-secondary)">Overall Progress</span>
|
||||
<span id="scan-progress-text" style="color: var(--text-primary)">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-700 rounded-full h-3">
|
||||
<div
|
||||
id="scan-progress-bar"
|
||||
class="h-3 rounded-full transition-all duration-500"
|
||||
style="width: 0%; background-color: var(--accent);"
|
||||
></div>
|
||||
</div>
|
||||
<div id="scan-status" class="text-sm mt-2" style="color: var(--text-secondary)">
|
||||
Starting scan...
|
||||
</div>
|
||||
</div>
|
||||
<!-- Per-Library Progress -->
|
||||
<div id="library-progress-list" class="space-y-3">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
<!-- Results Summary -->
|
||||
<div id="scan-results" class="hidden mt-6 p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||
<h4 class="font-semibold mb-2" style="color: var(--text-primary)">✅ Scan Complete!</h4>
|
||||
<div id="scan-results-content" style="color: var(--text-secondary)">
|
||||
<!-- Results populated by JS -->
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button
|
||||
@click="window.location.reload()"
|
||||
class="btn-primary px-4 py-2 rounded-lg"
|
||||
>
|
||||
Refresh to View Books
|
||||
</button>
|
||||
<button
|
||||
@click="hideScanProgress()"
|
||||
class="btn-secondary px-4 py-2 rounded-lg"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
|
||||
+95
-96
@@ -1,100 +1,99 @@
|
||||
package templates
|
||||
|
||||
templ Analytics(user User) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Reading Analytics - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/toast.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet">
|
||||
<script src="/static/theme.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }">
|
||||
@Header(user, "/analytics")
|
||||
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">📊 Reading Analytics</h1>
|
||||
<p style="color: var(--text-secondary)">Track your reading habits and device usage</p>
|
||||
</div>
|
||||
|
||||
<!-- Date Range Picker -->
|
||||
<div class="card p-4 rounded-lg border mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Start Date</label>
|
||||
<input type="date" id="start-date" onchange="loadAnalytics()"
|
||||
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>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<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()"
|
||||
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>
|
||||
<div class="flex items-end">
|
||||
<button onclick="loadAnalytics()" class="btn-primary px-6 py-2 rounded-lg">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8" id="stats-container">
|
||||
<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>
|
||||
<p id="total-books" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
||||
</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);">
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Pages Read</h3>
|
||||
<p id="total-pages" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
||||
</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);">
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Reading Time</h3>
|
||||
<p id="reading-time" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
||||
</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);">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Daily Reading Chart -->
|
||||
<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);">Daily Reading Minutes</h3>
|
||||
<div class="h-80">
|
||||
<canvas id="daily-reading-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Usage Chart -->
|
||||
<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);">Device Usage</h3>
|
||||
<div class="h-80">
|
||||
<canvas id="device-usage-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Popular Books -->
|
||||
<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>
|
||||
<div id="popular-books" class="space-y-3">
|
||||
<div class="text-center py-8" style="color: var(--text-secondary);">
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p>Loading analytics...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/analytics.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Reading Analytics - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/main.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body x-data="analytics" class="theme-{ user.Theme }">
|
||||
@Header(user, "/analytics")
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">📊 Reading Analytics</h1>
|
||||
<p style="color: var(--text-secondary)">Track your reading habits and device usage</p>
|
||||
</div>
|
||||
<!-- Date Range Picker -->
|
||||
<div class="card p-4 rounded-lg border mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="start-date"
|
||||
onchange="loadAnalytics()"
|
||||
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>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<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()"
|
||||
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>
|
||||
<div class="flex items-end">
|
||||
<button @click="loadAnalytics()" class="btn-primary px-6 py-2 rounded-lg">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8" id="stats-container">
|
||||
<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>
|
||||
<p id="total-books" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
||||
</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);">
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Pages Read</h3>
|
||||
<p id="total-pages" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
||||
</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);">
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Reading Time</h3>
|
||||
<p id="reading-time" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
||||
</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);">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Charts Row -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Daily Reading Chart -->
|
||||
<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);">Daily Reading Minutes</h3>
|
||||
<div class="h-80">
|
||||
<canvas id="daily-reading-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Device Usage Chart -->
|
||||
<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);">Device Usage</h3>
|
||||
<div class="h-80">
|
||||
<canvas id="device-usage-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Popular Books -->
|
||||
<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>
|
||||
<div id="popular-books" class="space-y-3">
|
||||
<div class="text-center py-8" style="color: var(--text-secondary);">
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p>Loading analytics...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/analytics.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
<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>
|
||||
<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.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.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>
|
||||
} else {
|
||||
// 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">
|
||||
<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 bg-accent text-white rounded cursor-pointer text-sm" id="real-btn">Try It Out</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" @click="showDocMode('real')">Try It Out</button>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<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="flex justify-between mb-2 text-sm">
|
||||
<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>
|
||||
</div>
|
||||
<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 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 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="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 @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 @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>
|
||||
}
|
||||
|
||||
<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
|
||||
|
||||
+56
-262
@@ -1,266 +1,60 @@
|
||||
package templates
|
||||
|
||||
templ BookShelf(user User, libraries []LibraryData) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Library - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/toast.js"></script>
|
||||
<script src="/static/search.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }">
|
||||
@Header(user, "/bookshelf")
|
||||
<div class="w-full px-4 sm:px-6 lg:8 py-8">
|
||||
<!-- Library Selector -->
|
||||
<div class="mb-8">
|
||||
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
|
||||
<div class="flex-1 w-full">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Select Library</label>
|
||||
<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 {
|
||||
<option value="">No libraries available</option>
|
||||
} else {
|
||||
for _, lib := range libraries {
|
||||
<option value={ lib.ID }>{ lib.Name }</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="loadBookshelf()" class="btn-primary px-6 py-3 rounded-lg font-medium">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bookshelf Container -->
|
||||
<div id="bookshelf-container">
|
||||
<!-- Loading state -->
|
||||
<div id="loading" class="text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p>Loading your library...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div id="empty-state" class="hidden text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="text-6xl mb-4">📚</div>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Books Yet</h3>
|
||||
<p>Select a library to view your collection</p>
|
||||
</div>
|
||||
|
||||
<!-- Books Grid -->
|
||||
<div id="books-grid" class="hidden">
|
||||
<!-- Books will be loaded here on shelves -->
|
||||
</div>
|
||||
</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>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Library - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/main.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }" x-data="bookshelf" x-init="initBookshelf(); setupEventDelegation()">
|
||||
@Header(user, "/bookshelf")
|
||||
<div class="w-full px-4 sm:px-6 lg:8 py-8">
|
||||
<!-- Library Selector -->
|
||||
<div class="mb-8">
|
||||
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
|
||||
<div class="flex-1 w-full">
|
||||
<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);">
|
||||
if len(libraries) == 0 {
|
||||
<option value="">No libraries available</option>
|
||||
} else {
|
||||
for _, lib := range libraries {
|
||||
<option value={ lib.ID }>{ lib.Name }</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="loadBookshelf()" class="btn-primary px-6 py-3 rounded-lg font-medium">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bookshelf Container -->
|
||||
<div id="bookshelf-container">
|
||||
<!-- Loading state -->
|
||||
<div id="loading" class="text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p>Loading your library...</p>
|
||||
</div>
|
||||
<!-- Empty state -->
|
||||
<div id="empty-state" class="hidden text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="text-6xl mb-4">📚</div>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Books Yet</h3>
|
||||
<p>Select a library to view your collection</p>
|
||||
</div>
|
||||
<!-- Books Grid -->
|
||||
<div id="books-grid" class="hidden">
|
||||
<!-- Books will be loaded here on shelves -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package templates
|
||||
|
||||
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="flex justify-between items-center mb-6">
|
||||
if collection.ID != "" {
|
||||
@@ -9,7 +9,7 @@ templ CollectionModal(collection CollectionData) {
|
||||
} else {
|
||||
<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>
|
||||
if collection.ID != "" {
|
||||
<form
|
||||
@@ -66,16 +66,16 @@ templ CollectionModal(collection CollectionData) {
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label>
|
||||
<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" onclick="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" onclick="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('blue')\" class="w-8 h-8 rounded-full color-option bg-blue-500"></button>
|
||||
<button type="button" @click=\"selectColor('red')\" class="w-8 h-8 rounded-full color-option bg-red-500"></button>
|
||||
<button type="button" @click=\"selectColor('yellow')\" class="w-8 h-8 rounded-full color-option bg-yellow-500"></button>
|
||||
<button type="button" @click=\"selectColor('green')\" class="w-8 h-8 rounded-full color-option bg-green-500"></button>
|
||||
<button type="button" @click=\"selectColor('purple')\" class="w-8 h-8 rounded-full color-option bg-purple-500"></button>
|
||||
</div>
|
||||
<input type="hidden" name="color" id="collection-color" value={ collection.Color }/>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -132,16 +132,16 @@ templ CollectionModal(collection CollectionData) {
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label>
|
||||
<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" onclick="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" onclick="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('blue')\" class="w-8 h-8 rounded-full color-option bg-blue-500"></button>
|
||||
<button type="button" @click=\"selectColor('red')\" class="w-8 h-8 rounded-full color-option bg-red-500"></button>
|
||||
<button type="button" @click=\"selectColor('yellow')\" class="w-8 h-8 rounded-full color-option bg-yellow-500"></button>
|
||||
<button type="button" @click=\"selectColor('green')\" class="w-8 h-8 rounded-full color-option bg-green-500"></button>
|
||||
<button type="button" @click=\"selectColor('purple')\" class="w-8 h-8 rounded-full color-option bg-purple-500"></button>
|
||||
</div>
|
||||
<input type="hidden" name="color" id="collection-color" value="blue"/>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
+194
-416
@@ -1,420 +1,198 @@
|
||||
package templates
|
||||
|
||||
templ CollectionRules(user User, collection CollectionData) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Collection Rules - { collection.Name } - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/toast.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }">
|
||||
@Header(user, "/collections")
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-6">
|
||||
<button onclick="backToCollection()" class="btn-secondary px-4 py-2 rounded-lg mb-4">
|
||||
← Back to Collection
|
||||
</button>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
|
||||
<p style="color: var(--text-secondary)">Auto-Assign Rules</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Existing Rules</h2>
|
||||
<div id="rules-container" class="space-y-4">
|
||||
<div id="no-rules" class="card p-6 rounded-lg border text-center" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div class="text-4xl mb-2">📋</div>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">No Rules Yet</h3>
|
||||
<p style="color: var(--text-secondary)">Create auto-assign rules to automatically add books to this collection</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Create New Rule</h2>
|
||||
<form id="rule-form" onsubmit="handleCreateRule(event)">
|
||||
<input type="hidden" id="collection-id" value="{ collection.ID }">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Field</label>
|
||||
<select id="rule-field" required
|
||||
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="genre">Genre</option>
|
||||
<option value="series">Series</option>
|
||||
<option value="author">Author</option>
|
||||
<option value="language">Language</option>
|
||||
<option value="publisher">Publisher</option>
|
||||
<option value="copyright_year">Copyright Year</option>
|
||||
<option value="tags">Tags</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Operator</label>
|
||||
<select id="rule-operator" required
|
||||
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 operator...</option>
|
||||
<option value="equals">equals</option>
|
||||
<option value="not_equals">does not equal</option>
|
||||
<option value="contains">contains</option>
|
||||
<option value="not_contains">does not contain</option>
|
||||
<option value="starts_with">starts with</option>
|
||||
<option value="ends_with">ends with</option>
|
||||
<option value="greater_than">greater than</option>
|
||||
<option value="less_than">less than</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Value</label>
|
||||
<input type="text" id="rule-value" required
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
placeholder="e.g., Science Fiction">
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="flex items-center space-x-3 cursor-pointer">
|
||||
<input type="checkbox" id="rule-enabled" checked
|
||||
class="w-5 h-5 rounded">
|
||||
<span style="color: var(--text-primary)">Enable Rule</span>
|
||||
</label>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Uncheck to disable without deleting</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Priority</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="radio" name="priority" value="1" class="w-4 h-4">
|
||||
<span style="color: var(--text-primary)">High</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="radio" name="priority" value="2" checked
|
||||
class="w-4 h-4">
|
||||
<span style="color: var(--text-primary)">Medium</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="radio" name="priority" value="3"
|
||||
class="w-4 h-4">
|
||||
<span style="color: var(--text-primary)">Low</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="testRule()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
🧪 Test Rule
|
||||
</button>
|
||||
<button type="button" onclick="clearForm()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
Clear
|
||||
</button>
|
||||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
|
||||
➕ Add Rule
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="test-results" class="hidden card p-6 rounded-lg border mb-6" style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||
<h3 class="font-semibold mb-3" style="color: var(--text-primary)">Rule Test Results</h3>
|
||||
<p class="text-sm mb-2" style="color: var(--text-secondary)">Books that would be added by this rule:</p>
|
||||
<div id="test-results-list" class="max-h-64 overflow-y-auto space-y-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||
<h2 class="text-xl font-bold mb-4" style="color: var(--text-primary)">Rule Examples</h2>
|
||||
<div class="space-y-4">
|
||||
<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);">1</div>
|
||||
<div>
|
||||
<p class="font-medium" style="color: var(--text-primary)">Add all Science Fiction books</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: genre<br>
|
||||
Operator: equals<br>
|
||||
Value: Science Fiction
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<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);">2</div>
|
||||
<div>
|
||||
<p class="font-medium" style="color: var(--text-primary)">Add books from a specific series</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: series<br>
|
||||
Operator: starts with<br>
|
||||
Value: Harry Potter
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div>
|
||||
<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<br>
|
||||
Operator: greater than<br>
|
||||
Value: 2020
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let collectionId = '{ collection.ID }';
|
||||
|
||||
function backToCollection() {
|
||||
window.location.href = `/collections/${collectionId}`;
|
||||
}
|
||||
|
||||
function renderRules(rules) {
|
||||
const container = document.getElementById('rules-container');
|
||||
const noRulesDiv = document.getElementById('no-rules');
|
||||
|
||||
if (rules && rules.length > 0) {
|
||||
noRulesDiv.classList.add('hidden');
|
||||
|
||||
container.innerHTML = rules.map((rule, index) => `
|
||||
<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">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<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>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Collection Rules - { collection.Name } - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/main.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }" x-data="collectionRules" x-init="initCollectionRules('{ collection.ID }')">
|
||||
@Header(user, "/collections")
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-6">
|
||||
<button @click="backToCollection()" class="btn-secondary px-4 py-2 rounded-lg mb-4">
|
||||
← Back to Collection
|
||||
</button>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
|
||||
<p style="color: var(--text-secondary)">Auto-Assign Rules</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Existing Rules</h2>
|
||||
<div id="rules-container" class="space-y-4">
|
||||
<div id="no-rules" class="card p-6 rounded-lg border text-center" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div class="text-4xl mb-2">📋</div>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">No Rules Yet</h3>
|
||||
<p style="color: var(--text-secondary)">Create auto-assign rules to automatically add books to this collection</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Create New Rule</h2>
|
||||
<form id="rule-form" @submit="handleCreateRule($event)">
|
||||
<input type="hidden" id="collection-id" value="{ collection.ID }"/>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Field</label>
|
||||
<select
|
||||
id="rule-field"
|
||||
required
|
||||
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="genre">Genre</option>
|
||||
<option value="series">Series</option>
|
||||
<option value="author">Author</option>
|
||||
<option value="language">Language</option>
|
||||
<option value="publisher">Publisher</option>
|
||||
<option value="copyright_year">Copyright Year</option>
|
||||
<option value="tags">Tags</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Operator</label>
|
||||
<select
|
||||
id="rule-operator"
|
||||
required
|
||||
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 operator...</option>
|
||||
<option value="equals">equals</option>
|
||||
<option value="not_equals">does not equal</option>
|
||||
<option value="contains">contains</option>
|
||||
<option value="not_contains">does not contain</option>
|
||||
<option value="starts_with">starts with</option>
|
||||
<option value="ends_with">ends with</option>
|
||||
<option value="greater_than">greater than</option>
|
||||
<option value="less_than">less than</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Value</label>
|
||||
<input
|
||||
type="text"
|
||||
id="rule-value"
|
||||
required
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
placeholder="e.g., Science Fiction"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="flex items-center space-x-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="rule-enabled"
|
||||
checked
|
||||
class="w-5 h-5 rounded"
|
||||
/>
|
||||
<span style="color: var(--text-primary)">Enable Rule</span>
|
||||
</label>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Uncheck to disable without deleting</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Priority</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="radio" name="priority" value="1" class="w-4 h-4"/>
|
||||
<span style="color: var(--text-primary)">High</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="priority"
|
||||
value="2"
|
||||
checked
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span style="color: var(--text-primary)">Medium</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="priority"
|
||||
value="3"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span style="color: var(--text-primary)">Low</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="testRule()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
🧪 Test Rule
|
||||
</button>
|
||||
<button type="button" @click="clearForm()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
Clear
|
||||
</button>
|
||||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
|
||||
➕ Add Rule
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="test-results" class="hidden card p-6 rounded-lg border mb-6" style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||
<h3 class="font-semibold mb-3" style="color: var(--text-primary)">Rule Test Results</h3>
|
||||
<p class="text-sm mb-2" style="color: var(--text-secondary)">Books that would be added by this rule:</p>
|
||||
<div id="test-results-list" class="max-h-64 overflow-y-auto space-y-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||
<h2 class="text-xl font-bold mb-4" style="color: var(--text-primary)">Rule Examples</h2>
|
||||
<div class="space-y-4">
|
||||
<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);">1</div>
|
||||
<div>
|
||||
<p class="font-medium" style="color: var(--text-primary)">Add all Science Fiction books</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: genre
|
||||
<br/>
|
||||
Operator: equals
|
||||
<br/>
|
||||
Value: Science Fiction
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<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);">2</div>
|
||||
<div>
|
||||
<p class="font-medium" style="color: var(--text-primary)">Add books from a specific series</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: series
|
||||
<br/>
|
||||
Operator: starts with
|
||||
<br/>
|
||||
Value: Harry Potter
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div>
|
||||
<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
|
||||
<br/>
|
||||
Operator: greater than
|
||||
<br/>
|
||||
Value: 2020
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
+10
-10
@@ -13,7 +13,7 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||
<script src="/static/toast.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }">
|
||||
<body x-data="collections" class="theme-{ user.Theme }">
|
||||
@Header(user, "/collections")
|
||||
<!-- Modal Container -->
|
||||
<div id="modal-container"></div>
|
||||
@@ -60,7 +60,7 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||
</div>
|
||||
}
|
||||
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
|
||||
class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow"
|
||||
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>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }">
|
||||
<body x-data="collections" class="theme-{ user.Theme }">
|
||||
@Header(user, "/collections")
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<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
|
||||
</button>
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -147,13 +147,13 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
</div>
|
||||
<button
|
||||
id="bulk-remove-btn"
|
||||
onclick="removebooksToAdd()"
|
||||
@click="removebooksToAdd()"
|
||||
disabled
|
||||
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
🗑️ Remove Selected
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
@@ -213,7 +213,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
<!-- Remove Button -->
|
||||
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
||||
<button
|
||||
onclick="removeBook('{ book.MediaItemID }')"
|
||||
@click=\"removeBook('{ book.MediaItemID }')\"
|
||||
class="px-3 py-1 text-sm border rounded hover:opacity-80"
|
||||
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="flex justify-between items-center mb-6">
|
||||
<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>
|
||||
<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 -->
|
||||
@@ -249,10 +249,10 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
</div>
|
||||
<div id="book-results" class="max-h-64 overflow-y-auto mb-4"></div>
|
||||
<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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+239
-233
@@ -3,237 +3,243 @@ package templates
|
||||
import "bookhoard/internal/handlers"
|
||||
|
||||
templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, errorMessage string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sync Conflicts - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/toast.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet">
|
||||
<script src="/static/theme.js"></script>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }">
|
||||
@Header(user, "/conflicts")
|
||||
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Sync Conflicts</h1>
|
||||
<p style="color: var(--text-secondary)">Resolve conflicts when reading progress differs across devices</p>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<select id="status-filter" onchange="filterConflicts()"
|
||||
class="px-4 py-2 border rounded-lg"
|
||||
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="resolved">Resolved Only</option>
|
||||
</select>
|
||||
<button onclick="dismissAllResolved()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
Dismiss All Resolved
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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)">Unresolved: <strong style="color: #f59e0b;">{ unresolved }</strong></span>
|
||||
<span style="color: var(--text-secondary)">Resolved: <strong style="color: #10b981;">{ total - unresolved }</strong></span>
|
||||
</div>
|
||||
|
||||
<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 space-x-4">
|
||||
<span class="text-sm font-semibold" style="color: var(--text-primary);">
|
||||
<span id="selected-count">0</span> conflicts selected
|
||||
</span>
|
||||
<select id="bulk-strategy" class="px-3 py-2 border rounded-lg text-sm"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
|
||||
<option value="most_recent">Most Recent</option>
|
||||
<option value="highest_progress">Highest Progress</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button onclick="bulkResolve()" class="btn-primary px-4 py-2 rounded-lg text-sm">
|
||||
✅ Resolve Selected
|
||||
</button>
|
||||
<button onclick="bulkDismiss()" class="btn-danger px-4 py-2 rounded-lg text-sm">
|
||||
❌ Dismiss Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="hidden text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p>Loading conflicts...</p>
|
||||
</div>
|
||||
|
||||
<div id="empty-state" class="hidden text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="text-6xl mb-4">✅</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div id="conflicts-list" class="space-y-6">
|
||||
<div id="select-all-container" class="mb-4 hidden">
|
||||
<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()">
|
||||
<span class="text-sm font-semibold" style="color: var(--text-primary);">Select All</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
if len(conflicts) == 0 {
|
||||
<div class="text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="text-6xl mb-4">✅</div>
|
||||
<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>
|
||||
</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">
|
||||
<div class="flex items-start space-x-4">
|
||||
<input type="checkbox" class="conflict-checkbox w-5 h-5 rounded mt-1"
|
||||
data-conflict-id="{ conflict.ID }"
|
||||
onchange="updateBulkActions()">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="showConflictModal('{ conflict.ID }')" class="btn-primary px-4 py-2 rounded-lg">
|
||||
Resolve
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm" style="color: var(--text-secondary)">
|
||||
Created: { conflict.CreatedAt.Format("2006-01-02 15:04:05") }
|
||||
if conflict.ResolvedBy != "" {
|
||||
| Resolved by: { conflict.ResolvedBy }
|
||||
}
|
||||
</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 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>
|
||||
<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>
|
||||
</div>
|
||||
<button onclick="hideConflictModal()" class="p-2 hover:opacity-80 rounded-lg" style="color: var(--text-primary); background-color: var(--bg-primary);">
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="conflict-resolution-form" onsubmit="handleResolveConflict(event)">
|
||||
<input type="hidden" id="conflict-id">
|
||||
|
||||
<div id="conflict-details" class="mb-6"></div>
|
||||
|
||||
<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 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);">
|
||||
<input type="radio" name="winner" value="koreader" class="mr-3" required>
|
||||
<div class="flex-1">
|
||||
<span class="font-semibold" style="color: var(--text-primary);">Keep KOReader Progress</span>
|
||||
<p class="text-xs" style="color: var(--text-secondary);">Use progress from your KOReader device</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<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="kobo" class="mr-3">
|
||||
<div class="flex-1">
|
||||
<span class="font-semibold" style="color: var(--text-primary);">Keep Kobo Progress</span>
|
||||
<p class="text-xs" style="color: var(--text-secondary);">Use progress from your Kobo device</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<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">
|
||||
<div class="flex-1">
|
||||
<span class="font-semibold" style="color: var(--text-primary);">Keep Web Progress</span>
|
||||
<p class="text-xs" style="color: var(--text-secondary);">Use progress from the web interface</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<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="manual" class="mr-3">
|
||||
<div class="flex-1">
|
||||
<span class="font-semibold" style="color: var(--text-primary);">Manual Override</span>
|
||||
<p class="text-xs" style="color: var(--text-secondary);">Specify a custom progress value</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="manual-override" class="hidden mb-4 p-4 border rounded-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<h4 class="font-semibold mb-3" style="color: var(--text-primary);">Manual Progress</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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"
|
||||
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>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Page Number</label>
|
||||
<input type="number" id="manual-page" min="1"
|
||||
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>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">EPUB CFI</label>
|
||||
<input type="text" id="manual-epubcfi"
|
||||
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>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Chapter</label>
|
||||
<input type="number" id="manual-chapter" min="1"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="flex items-center space-x-3 cursor-pointer">
|
||||
<input type="checkbox" id="apply-to-all" class="w-5 h-5 rounded">
|
||||
<span style="color: var(--text-primary);">Apply this resolution to all future conflicts</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Resolution Reason (optional)</label>
|
||||
<input type="text" id="resolution-reason"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
placeholder="e.g., Device was offline, using most recent progress">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="hideConflictModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
|
||||
<button type="button" onclick="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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/conflicts.js"></script>
|
||||
|
||||
@ErrorToast(errorMessage)
|
||||
</body>
|
||||
</html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Sync Conflicts - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/main.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body x-data="conflicts" class="theme-{ user.Theme }">
|
||||
@Header(user, "/conflicts")
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Sync Conflicts</h1>
|
||||
<p style="color: var(--text-secondary)">Resolve conflicts when reading progress differs across devices</p>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<select
|
||||
id="status-filter"
|
||||
onchange="filterConflicts()"
|
||||
class="px-4 py-2 border rounded-lg"
|
||||
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="resolved">Resolved Only</option>
|
||||
</select>
|
||||
<button @click="dismissAllResolved()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
Dismiss All Resolved
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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)">Unresolved: <strong style="color: #f59e0b;">{ unresolved }</strong></span>
|
||||
<span style="color: var(--text-secondary)">Resolved: <strong style="color: #10b981;">{ total - unresolved }</strong></span>
|
||||
</div>
|
||||
<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 space-x-4">
|
||||
<span class="text-sm font-semibold" style="color: var(--text-primary);">
|
||||
<span id="selected-count">0</span> conflicts selected
|
||||
</span>
|
||||
<select
|
||||
id="bulk-strategy"
|
||||
class="px-3 py-2 border rounded-lg text-sm"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
>
|
||||
<option value="most_recent">Most Recent</option>
|
||||
<option value="highest_progress">Highest Progress</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button @click="bulkResolve()" class="btn-primary px-4 py-2 rounded-lg text-sm">
|
||||
✅ Resolve Selected
|
||||
</button>
|
||||
<button @click="bulkDismiss()" class="btn-danger px-4 py-2 rounded-lg text-sm">
|
||||
❌ Dismiss Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="loading" class="hidden text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p>Loading conflicts...</p>
|
||||
</div>
|
||||
<div id="empty-state" class="hidden text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="text-6xl mb-4">✅</div>
|
||||
<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>
|
||||
</div>
|
||||
<div id="conflicts-list" class="space-y-6">
|
||||
<div id="select-all-container" class="mb-4 hidden">
|
||||
<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()"/>
|
||||
<span class="text-sm font-semibold" style="color: var(--text-primary);">Select All</span>
|
||||
</label>
|
||||
</div>
|
||||
if len(conflicts) == 0 {
|
||||
<div class="text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="text-6xl mb-4">✅</div>
|
||||
<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>
|
||||
</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">
|
||||
<div class="flex items-start space-x-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="conflict-checkbox w-5 h-5 rounded mt-1"
|
||||
data-conflict-id="{ conflict.ID }"
|
||||
onchange="updateBulkActions()"
|
||||
/>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<button @click=\"showConflictModal('{ conflict.ID }')\" class="btn-primary px-4 py-2 rounded-lg">
|
||||
Resolve
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm" style="color: var(--text-secondary)">
|
||||
Created: { conflict.CreatedAt.Format("2006-01-02 15:04:05") }
|
||||
if conflict.ResolvedBy != "" {
|
||||
| Resolved by: { conflict.ResolvedBy }
|
||||
}
|
||||
</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 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>
|
||||
<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>
|
||||
</div>
|
||||
<button @click="hideConflictModal()" class="p-2 hover:opacity-80 rounded-lg" style="color: var(--text-primary); background-color: var(--bg-primary);">
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<form id="conflict-resolution-form" @submit="handleResolveConflict($event)">
|
||||
<input type="hidden" id="conflict-id"/>
|
||||
<div id="conflict-details" class="mb-6"></div>
|
||||
<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 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);">
|
||||
<input type="radio" name="winner" value="koreader" class="mr-3" required/>
|
||||
<div class="flex-1">
|
||||
<span class="font-semibold" style="color: var(--text-primary);">Keep KOReader Progress</span>
|
||||
<p class="text-xs" style="color: var(--text-secondary);">Use progress from your KOReader device</p>
|
||||
</div>
|
||||
</label>
|
||||
<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="kobo" class="mr-3"/>
|
||||
<div class="flex-1">
|
||||
<span class="font-semibold" style="color: var(--text-primary);">Keep Kobo Progress</span>
|
||||
<p class="text-xs" style="color: var(--text-secondary);">Use progress from your Kobo device</p>
|
||||
</div>
|
||||
</label>
|
||||
<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"/>
|
||||
<div class="flex-1">
|
||||
<span class="font-semibold" style="color: var(--text-primary);">Keep Web Progress</span>
|
||||
<p class="text-xs" style="color: var(--text-secondary);">Use progress from the web interface</p>
|
||||
</div>
|
||||
</label>
|
||||
<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="manual" class="mr-3"/>
|
||||
<div class="flex-1">
|
||||
<span class="font-semibold" style="color: var(--text-primary);">Manual Override</span>
|
||||
<p class="text-xs" style="color: var(--text-secondary);">Specify a custom progress value</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div id="manual-override" class="hidden mb-4 p-4 border rounded-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<h4 class="font-semibold mb-3" style="color: var(--text-primary);">Manual Progress</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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"
|
||||
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>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Page Number</label>
|
||||
<input
|
||||
type="number"
|
||||
id="manual-page"
|
||||
min="1"
|
||||
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>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">EPUB CFI</label>
|
||||
<input
|
||||
type="text"
|
||||
id="manual-epubcfi"
|
||||
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>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Chapter</label>
|
||||
<input
|
||||
type="number"
|
||||
id="manual-chapter"
|
||||
min="1"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="flex items-center space-x-3 cursor-pointer">
|
||||
<input type="checkbox" id="apply-to-all" class="w-5 h-5 rounded"/>
|
||||
<span style="color: var(--text-primary);">Apply this resolution to all future conflicts</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Resolution Reason (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="resolution-reason"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
placeholder="e.g., Device was offline, using most recent progress"
|
||||
/>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/conflicts.js"></script>
|
||||
@ErrorToast(errorMessage)
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
+193
-181
@@ -2,187 +2,199 @@ package templates
|
||||
|
||||
templ Header(user User, currentPath string) {
|
||||
<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="flex justify-between items-center h-16">
|
||||
<!-- Left: App Title & Navigation -->
|
||||
<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;">
|
||||
📚 Bookhoard
|
||||
</a>
|
||||
<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;">
|
||||
Library
|
||||
</a>
|
||||
<a href="/collections" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Collections
|
||||
</a>
|
||||
<a href="/progress" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Progress
|
||||
</a>
|
||||
<a href="/devices" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Devices
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: Search Box -->
|
||||
<div class="flex-1 max-w-2xl mx-8">
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
id="header-search"
|
||||
placeholder="Search your library..."
|
||||
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);"
|
||||
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">
|
||||
<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"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Theme Switcher & User Menu -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Theme Switcher -->
|
||||
<div class="relative">
|
||||
<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">
|
||||
<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>
|
||||
</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 class="p-4">
|
||||
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">Select Theme</h3>
|
||||
<div class="space-y-2">
|
||||
<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>
|
||||
Tokyo Night
|
||||
</button>
|
||||
<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>
|
||||
Dracula
|
||||
</button>
|
||||
<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>
|
||||
Nord
|
||||
</button>
|
||||
<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>
|
||||
Solarized Dark
|
||||
</button>
|
||||
<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>
|
||||
Monokai
|
||||
</button>
|
||||
<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>
|
||||
One Dark Pro
|
||||
</button>
|
||||
<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>
|
||||
Material Dark
|
||||
</button>
|
||||
<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>
|
||||
<button onclick="changeWoodPaneling('none')"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="none">
|
||||
None
|
||||
</button>
|
||||
<button onclick="changeWoodPaneling('wood-light')"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-light">
|
||||
<span class="inline-block w-4 h-4 rounded mr-2"
|
||||
style="background: url('/static/textures/wood-light.png'); background-size: cover;"></span>
|
||||
Wood Light
|
||||
</button>
|
||||
<button onclick="changeWoodPaneling('wood-dark')"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-dark">
|
||||
<span class="inline-block w-4 h-4 rounded mr-2"
|
||||
style="background: url('/static/textures/wood-dark.png'); background-size: cover;"></span>
|
||||
Wood Dark
|
||||
</button>
|
||||
<button onclick="changeWoodPaneling('wood-mahogany')"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-mahogany">
|
||||
<span class="inline-block w-4 h-4 rounded mr-2"
|
||||
style="background: url('/static/textures/wood-mahogany.png'); background-size: cover;"></span>
|
||||
Wood Mahogany
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Icon with Dropdown -->
|
||||
<div class="relative">
|
||||
if user.ID != "" {
|
||||
<!-- LOGGED IN: Show user menu with logout -->
|
||||
<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);">
|
||||
<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"/>
|
||||
</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;">
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<!-- Left: App Title & Navigation -->
|
||||
<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;">
|
||||
📚 Bookhoard
|
||||
</a>
|
||||
<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;">
|
||||
Library
|
||||
</a>
|
||||
<a href="/collections" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Collections
|
||||
</a>
|
||||
<a href="/progress" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Progress
|
||||
</a>
|
||||
<a href="/devices" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Devices
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Center: Search Box -->
|
||||
<div class="flex-1 max-w-2xl mx-8">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="header-search"
|
||||
placeholder="Search your library..."
|
||||
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);"
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: Theme Switcher & User Menu -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Theme Switcher -->
|
||||
<div class="relative">
|
||||
<button @click="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">
|
||||
<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>
|
||||
</svg>
|
||||
</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 class="p-4">
|
||||
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">Select Theme</h3>
|
||||
<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);">
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #7aa2f7;"></span>
|
||||
Tokyo Night
|
||||
</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);">
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #bd93f9;"></span>
|
||||
Dracula
|
||||
</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);">
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #88c0d0;"></span>
|
||||
Nord
|
||||
</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);">
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #2aa198;"></span>
|
||||
Solarized Dark
|
||||
</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);">
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #a6e22e;"></span>
|
||||
Monokai
|
||||
</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);">
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #61dafb;"></span>
|
||||
One Dark Pro
|
||||
</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);">
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #80cbc4;"></span>
|
||||
Material Dark
|
||||
</button>
|
||||
<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>
|
||||
<button
|
||||
@click="changeWoodPaneling('none')"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="none"
|
||||
>
|
||||
None
|
||||
</button>
|
||||
<button
|
||||
@click="changeWoodPaneling('wood-light')"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-light"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-4 h-4 rounded mr-2"
|
||||
style="background: url('/static/textures/wood-light.png'); background-size: cover;"
|
||||
></span>
|
||||
Wood Light
|
||||
</button>
|
||||
<button
|
||||
@click="changeWoodPaneling('wood-dark')"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-dark"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-4 h-4 rounded mr-2"
|
||||
style="background: url('/static/textures/wood-dark.png'); background-size: cover;"
|
||||
></span>
|
||||
Wood Dark
|
||||
</button>
|
||||
<button
|
||||
@click="changeWoodPaneling('wood-mahogany')"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-mahogany"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-4 h-4 rounded mr-2"
|
||||
style="background: url('/static/textures/wood-mahogany.png'); background-size: cover;"
|
||||
></span>
|
||||
Wood Mahogany
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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
|
||||
</a>
|
||||
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;">
|
||||
Admin Panel
|
||||
</a>
|
||||
}
|
||||
<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);">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<!-- 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);">
|
||||
<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"/>
|
||||
</svg>
|
||||
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">Login</span>
|
||||
</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);">
|
||||
<form hx-post="/api/auth/login" hx-target="#login-result" hx-swap="innerHTML" class="space-y-3">
|
||||
<input type="hidden" name="redirect" value="{ currentPath }">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
<div id="login-result"></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;">
|
||||
Don't have an account? Sign Up
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<script src="/static/theme.js"></script>
|
||||
<script src="/static/woodPaneling.js"></script>
|
||||
<script src="/static/themeDropdown.js"></script>
|
||||
<script src="/static/header.js"></script>
|
||||
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;">
|
||||
Admin Panel
|
||||
</a>
|
||||
}
|
||||
<div class="border-t my-1" style="border-color: var(--border);"></div>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<!-- LOGGED OUT: Show login form -->
|
||||
<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)">Login</span>
|
||||
</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);">
|
||||
<form hx-post="/api/auth/login" hx-target="#login-result" hx-swap="innerHTML" class="space-y-3">
|
||||
<input type="hidden" name="redirect" value="{ currentPath }"/>
|
||||
<div>
|
||||
<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/>
|
||||
</div>
|
||||
<div>
|
||||
<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/>
|
||||
</div>
|
||||
<button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
<div id="login-result"></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;">
|
||||
Don't have an account? Sign Up
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<script src="/static/theme.js"></script>
|
||||
<script src="/static/woodPaneling.js"></script>
|
||||
<script src="/static/themeDropdown.js"></script>
|
||||
<script src="/static/header.js"></script>
|
||||
}
|
||||
|
||||
+119
-161
@@ -1,165 +1,123 @@
|
||||
package templates
|
||||
|
||||
templ Index(loggedIn bool) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bookhoard - Home</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/toast.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="theme-tokyo-night">
|
||||
<script>
|
||||
// Progressive enhancement: check localStorage immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme && savedTheme !== 'tokyo-night') {
|
||||
document.body.className = 'theme-' + savedTheme;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<!-- Hero Section -->
|
||||
<div class="relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue-600 to-purple-700 opacity-10"></div>
|
||||
<div class="relative max-w-7xl mx-auto px-4 py-16 sm:px-6 sm:py-24 lg:py-32 lg:px-8">
|
||||
<div class="flex justify-between items-start mb-8">
|
||||
<div></div>
|
||||
<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="tokyo-night">Tokyo Night</option>
|
||||
<option value="dracula">Dracula</option>
|
||||
<option value="nord">Nord</option>
|
||||
<option value="solarized-dark">Solarized Dark</option>
|
||||
<option value="monokai">Monokai</option>
|
||||
<option value="one-dark-pro">One Dark Pro</option>
|
||||
<option value="material-dark">Material Dark</option>
|
||||
<option value="catppuccin-mocha">Catppuccin Mocha</option>
|
||||
<option value="catppuccin-macchiato">Catppuccin Macchiato</option>
|
||||
<option value="catppuccin-frappe">Catppuccin Frappé</option>
|
||||
<option value="catppuccin-latte">Catppuccin Latte</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl sm:text-6xl lg:text-7xl font-extrabold" style="color: var(--text-primary)">
|
||||
📚 Bookhoard
|
||||
</h1>
|
||||
<p class="mt-6 max-w-2xl mx-auto text-xl" style="color: var(--text-secondary)">
|
||||
Your personal ebook management system. Track reading progress, organize your library, and enjoy beautiful themes.
|
||||
</p>
|
||||
<div class="mt-10">
|
||||
<a href="#auth" class="btn-primary px-8 py-3 rounded-lg font-medium text-lg">
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features Section -->
|
||||
<div class="py-16" style="background-color: var(--bg-secondary)">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-extrabold" style="color: var(--text-primary)">Why Bookhoard?</h2>
|
||||
<p class="mt-4 text-lg" style="color: var(--text-secondary)">Discover the features that make ebook management effortless</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div class="card p-6 rounded-xl border">
|
||||
<div class="text-center">
|
||||
<div class="mx-auto w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center mb-4">
|
||||
<span class="text-white text-2xl">📖</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Reading Progress</h3>
|
||||
<p style="color: var(--text-secondary)">Track your reading progress across all your ebooks with detailed statistics.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-6 rounded-xl border">
|
||||
<div class="text-center">
|
||||
<div class="mx-auto w-12 h-12 bg-purple-500 rounded-lg flex items-center justify-center mb-4">
|
||||
<span class="text-white text-2xl">🎨</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Beautiful Themes</h3>
|
||||
<p style="color: var(--text-secondary)">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin variants.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-6 rounded-xl border">
|
||||
<div class="text-center">
|
||||
<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>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Fast & Modern</h3>
|
||||
<p style="color: var(--text-secondary)">Built with Go and HTMX for lightning-fast performance and smooth interactions.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auth Section -->
|
||||
if !loggedIn {
|
||||
<div id="auth" class="py-16">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-extrabold" style="color: var(--text-primary)">Join Bookhoard Today</h2>
|
||||
<p class="mt-4 text-lg" style="color: var(--text-secondary)">Sign in to start managing your ebook library</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-8">
|
||||
<div class="card p-8 rounded-xl border">
|
||||
<h3 class="text-2xl font-semibold mb-6 text-center" style="color: var(--text-primary)">Login</h3>
|
||||
<form hx-post="/api/auth/login" hx-target="#auth-result" hx-swap="innerHTML">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium 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-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Bookhoard - Home</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/main.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body class="theme-tokyo-night" x-data="index" x-init="initIndexTheme(); checkAuthRedirect()">
|
||||
<!-- Hero Section -->
|
||||
<div class="relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue-600 to-purple-700 opacity-10"></div>
|
||||
<div class="relative max-w-7xl mx-auto px-4 py-16 sm:px-6 sm:py-24 lg:py-32 lg:px-8">
|
||||
<div class="flex justify-between items-start mb-8">
|
||||
<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>
|
||||
<option value="dracula">Dracula</option>
|
||||
<option value="nord">Nord</option>
|
||||
<option value="solarized-dark">Solarized Dark</option>
|
||||
<option value="monokai">Monokai</option>
|
||||
<option value="one-dark-pro">One Dark Pro</option>
|
||||
<option value="material-dark">Material Dark</option>
|
||||
<option value="catppuccin-mocha">Catppuccin Mocha</option>
|
||||
<option value="catppuccin-macchiato">Catppuccin Macchiato</option>
|
||||
<option value="catppuccin-frappe">Catppuccin Frappé</option>
|
||||
<option value="catppuccin-latte">Catppuccin Latte</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl sm:text-6xl lg:text-7xl font-extrabold" style="color: var(--text-primary)">
|
||||
📚 Bookhoard
|
||||
</h1>
|
||||
<p class="mt-6 max-w-2xl mx-auto text-xl" style="color: var(--text-secondary)">
|
||||
Your personal ebook management system. Track reading progress, organize your library, and enjoy beautiful themes.
|
||||
</p>
|
||||
<div class="mt-10">
|
||||
<a href="#auth" class="btn-primary px-8 py-3 rounded-lg font-medium text-lg">
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Features Section -->
|
||||
<div class="py-16" style="background-color: var(--bg-secondary)">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-extrabold" style="color: var(--text-primary)">Why Bookhoard?</h2>
|
||||
<p class="mt-4 text-lg" style="color: var(--text-secondary)">Discover the features that make ebook management effortless</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div class="card p-6 rounded-xl border">
|
||||
<div class="text-center">
|
||||
<div class="mx-auto w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center mb-4">
|
||||
<span class="text-white text-2xl">📖</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Reading Progress</h3>
|
||||
<p style="color: var(--text-secondary)">Track your reading progress across all your ebooks with detailed statistics.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-6 rounded-xl border">
|
||||
<div class="text-center">
|
||||
<div class="mx-auto w-12 h-12 bg-purple-500 rounded-lg flex items-center justify-center mb-4">
|
||||
<span class="text-white text-2xl">🎨</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Beautiful Themes</h3>
|
||||
<p style="color: var(--text-secondary)">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin variants.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-6 rounded-xl border">
|
||||
<div class="text-center">
|
||||
<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>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Fast & Modern</h3>
|
||||
<p style="color: var(--text-secondary)">Built with Go and HTMX for lightning-fast performance and smooth interactions.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Auth Section -->
|
||||
if !loggedIn {
|
||||
<div id="auth" class="py-16">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-extrabold" style="color: var(--text-primary)">Join Bookhoard Today</h2>
|
||||
<p class="mt-4 text-lg" style="color: var(--text-secondary)">Sign in to start managing your ebook library</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-8">
|
||||
<div class="card p-8 rounded-xl border">
|
||||
<h3 class="text-2xl font-semibold mb-6 text-center" style="color: var(--text-primary)">Login</h3>
|
||||
<form hx-post="/api/auth/login" hx-target="#auth-result" hx-swap="innerHTML">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium 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-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
}
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
|
||||
+63
-77
@@ -1,81 +1,67 @@
|
||||
package templates
|
||||
|
||||
templ Login(sessionExpired bool, deleted bool) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/toast.js"></script>
|
||||
<script src="/static/theme.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="theme-tokyo-night min-h-screen">
|
||||
<script>
|
||||
// Progressive enhancement: check localStorage immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme && savedTheme !== 'tokyo-night') {
|
||||
document.body.className = 'theme-' + savedTheme + ' min-h-screen';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<div class="container mx-auto px-4 py-8 max-w-md">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-3xl font-bold">Login to Bookhoard</h1>
|
||||
<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="tokyo-night">Tokyo Night</option>
|
||||
<option value="dracula">Dracula</option>
|
||||
<option value="nord">Nord</option>
|
||||
<option value="solarized-dark">Solarized Dark</option>
|
||||
<option value="monokai">Monokai</option>
|
||||
<option value="one-dark-pro">One Dark Pro</option>
|
||||
<option value="material-dark">Material Dark</option>
|
||||
<option value="catppuccin-mocha">Catppuccin Mocha</option>
|
||||
<option value="catppuccin-macchiato">Catppuccin Macchiato</option>
|
||||
<option value="catppuccin-frappe">Catppuccin Frappé</option>
|
||||
<option value="catppuccin-latte">Catppuccin Latte</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
if sessionExpired {
|
||||
<div class="mb-4 p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--accent);">
|
||||
<p style="color: var(--text-primary);">
|
||||
Your session has expired. Please log in again to continue.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
if deleted {
|
||||
<div class="mb-4 p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--accent);">
|
||||
<p style="color: var(--text-primary);">
|
||||
Your account has been deleted successfully.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form hx-post="/api/auth/login" hx-target="#result" hx-swap="innerHTML" class="card p-6 rounded-lg shadow-md border">
|
||||
<div class="mb-4">
|
||||
<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>
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2" style="color: var(--text-secondary)">Password</label>
|
||||
<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>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Login</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/main.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body class="theme-tokyo-night min-h-screen" x-data="login" x-init="initLoginTheme()">
|
||||
<div class="container mx-auto px-4 py-8 max-w-md">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-3xl font-bold">Login to Bookhoard</h1>
|
||||
<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)">
|
||||
<option value="tokyo-night">Tokyo Night</option>
|
||||
<option value="dracula">Dracula</option>
|
||||
<option value="nord">Nord</option>
|
||||
<option value="solarized-dark">Solarized Dark</option>
|
||||
<option value="monokai">Monokai</option>
|
||||
<option value="one-dark-pro">One Dark Pro</option>
|
||||
<option value="material-dark">Material Dark</option>
|
||||
<option value="catppuccin-mocha">Catppuccin Mocha</option>
|
||||
<option value="catppuccin-macchiato">Catppuccin Macchiato</option>
|
||||
<option value="catppuccin-frappe">Catppuccin Frappé</option>
|
||||
<option value="catppuccin-latte">Catppuccin Latte</option>
|
||||
</select>
|
||||
</div>
|
||||
if sessionExpired {
|
||||
<div class="mb-4 p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--accent);">
|
||||
<p style="color: var(--text-primary);">
|
||||
Your session has expired. Please log in again to continue.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
if deleted {
|
||||
<div class="mb-4 p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--accent);">
|
||||
<p style="color: var(--text-primary);">
|
||||
Your account has been deleted successfully.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
<form hx-post="/api/auth/login" hx-target="#result" hx-swap="innerHTML" class="card p-6 rounded-lg shadow-md border">
|
||||
<div class="mb-4">
|
||||
<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>
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2" style="color: var(--text-secondary)">Password</label>
|
||||
<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
@@ -3,66 +3,35 @@ package templates
|
||||
templ Profile(user User) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Profile Settings - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/toast.js"></script>
|
||||
<script src="/static/header.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }">
|
||||
@Header(user, "/profile")
|
||||
|
||||
<main class="max-w-4xl mx-auto px-4 py-8">
|
||||
<div class="mb-8">
|
||||
<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)
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>Profile Settings - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/main.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }" x-data="profile">
|
||||
@Header(user, "/profile")
|
||||
<main class="max-w-4xl mx-auto px-4 py-8">
|
||||
<div class="mb-8">
|
||||
<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>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<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>
|
||||
<p style="color: var(--text-secondary)" class="mb-4">Once you delete your account, there is no going back. Please be certain.</p>
|
||||
|
||||
<button onclick="confirmDeleteAccount()" class="px-4 py-2 rounded text-sm" style="background-color: #dc2626; color: white;">
|
||||
Remove My Account
|
||||
</button>
|
||||
<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>
|
||||
<!-- Danger Zone -->
|
||||
<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>
|
||||
<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>
|
||||
</main>
|
||||
|
||||
<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>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
@@ -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);">
|
||||
if showCancelButton {
|
||||
<button type="button"
|
||||
onclick="closeProfileModal()"
|
||||
@click="closeProfileModal()"
|
||||
class="px-4 py-2 rounded"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);">
|
||||
Cancel
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package templates
|
||||
|
||||
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);">
|
||||
<!-- Modal Header -->
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
onclick="closeProfileModal()"
|
||||
@click="closeProfileModal()"
|
||||
class="text-2xl"
|
||||
style="color: var(--text-secondary);"
|
||||
>
|
||||
@@ -26,20 +26,4 @@ templ ProfileModal(user User) {
|
||||
</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
@@ -3,112 +3,100 @@ package templates
|
||||
import "bookhoard/internal/handlers"
|
||||
|
||||
templ Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Reading Progress - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/toast.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }">
|
||||
@Header(user, "/progress")
|
||||
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div id="progress-container" class="space-y-6">
|
||||
if len(progressData) == 0 {
|
||||
<div class="text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="text-6xl mb-4">📖</div>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Progress Yet</h3>
|
||||
<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">
|
||||
<div class="flex-shrink-0">
|
||||
<img src="{ item.CoverImagePath }" alt="Cover"
|
||||
class="w-24 h-32 object-cover rounded"
|
||||
onerror="this.src='/static/placeholder-book.svg'">
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<h3 class="font-semibold text-lg" style="color: var(--text-primary)">{ item.Title }</h3>
|
||||
if item.Author != "" {
|
||||
<p class="text-sm" style="color: var(--text-secondary)">by { item.Author }</p>
|
||||
}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-2xl font-bold" style="color: var(--accent)">{ item.ProgressPercentage }%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Current Position</p>
|
||||
<p style="color: var(--text-primary)">
|
||||
{ item.CurrentPage } / { item.TotalPages }
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Last Updated</p>
|
||||
<p style="color: var(--text-primary)">{ item.LastUpdated }</p>
|
||||
</div>
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Synced From</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl">{ item.DeviceIcon }</span>
|
||||
<span style="color: var(--text-primary)">{ item.DeviceName }</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
if item.DeviceIcon != "" {
|
||||
<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);">
|
||||
<span>🔄</span>
|
||||
<span>Last sync from <strong>{ item.DeviceName }</strong></span>
|
||||
<span>({ item.DeviceType })</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if item.EpubCFI != "" {
|
||||
<div class="mt-2 text-xs" style="color: var(--text-secondary);">
|
||||
<span>📍</span>
|
||||
<span>CFI: { item.EpubCFI }</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function logout() {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
</script>
|
||||
|
||||
@ErrorToast(errorMessage)
|
||||
</body>
|
||||
</html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Reading Progress - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/main.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }" x-data="progress">
|
||||
@Header(user, "/progress")
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<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>
|
||||
</div>
|
||||
<div id="progress-container" class="space-y-6">
|
||||
if len(progressData) == 0 {
|
||||
<div class="text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="text-6xl mb-4">📖</div>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Progress Yet</h3>
|
||||
<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">
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
src="{ item.CoverImagePath }"
|
||||
alt="Cover"
|
||||
class="w-24 h-32 object-cover rounded"
|
||||
onerror="this.src='/static/placeholder-book.svg'"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<h3 class="font-semibold text-lg" style="color: var(--text-primary)">{ item.Title }</h3>
|
||||
if item.Author != "" {
|
||||
<p class="text-sm" style="color: var(--text-secondary)">by { item.Author }</p>
|
||||
}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-2xl font-bold" style="color: var(--accent)">{ item.ProgressPercentage }%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<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>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Current Position</p>
|
||||
<p style="color: var(--text-primary)">
|
||||
{ item.CurrentPage } / { item.TotalPages }
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Last Updated</p>
|
||||
<p style="color: var(--text-primary)">{ item.LastUpdated }</p>
|
||||
</div>
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Synced From</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl">{ item.DeviceIcon }</span>
|
||||
<span style="color: var(--text-primary)">{ item.DeviceName }</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
if item.DeviceIcon != "" {
|
||||
<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);">
|
||||
<span>🔄</span>
|
||||
<span>Last sync from <strong>{ item.DeviceName }</strong></span>
|
||||
<span>({ item.DeviceType })</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
if item.EpubCFI != "" {
|
||||
<div class="mt-2 text-xs" style="color: var(--text-secondary);">
|
||||
<span>📍</span>
|
||||
<span>CFI: { item.EpubCFI }</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@ErrorToast(errorMessage)
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
+18
-19
@@ -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"/>
|
||||
<title>Sync Queue - Bookhoard</title>
|
||||
<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"/>
|
||||
<script src="/static/theme.js"></script>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }">
|
||||
<body x-data="queue" class="theme-{ user.Theme }">
|
||||
@Header(user, "/queue")
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-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>
|
||||
</div>
|
||||
<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
|
||||
</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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
@@ -98,7 +97,7 @@ templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Q
|
||||
</select>
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
@@ -120,15 +119,15 @@ templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Q
|
||||
<p>No items in the sync queue</p>
|
||||
</div>
|
||||
}
|
||||
for _, item := range queueItems {
|
||||
<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 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="text-sm" style="color: var(--text-secondary)">{ item.SyncType }</span>
|
||||
</div>
|
||||
<span class="inline-block px-2 py-0.5 rounded text-[10px] font-semibold priority-{ item.Priority }">P{ item.Priority }</span>
|
||||
</div>
|
||||
for _, item := range queueItems {
|
||||
<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 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="text-sm" style="color: var(--text-secondary)">{ item.SyncType }</span>
|
||||
</div>
|
||||
<span class="inline-block px-2 py-0.5 rounded text-[10px] font-semibold priority-{ item.Priority }">P{ item.Priority }</span>
|
||||
</div>
|
||||
<div class="text-sm mb-2" style="color: var(--text-secondary)">
|
||||
Device ID: { item.DeviceID }
|
||||
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="flex justify-between items-center mb-6">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
<div id="queue-item-details"></div>
|
||||
<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 onclick="retryQueueItem()" class="btn-primary px-4 py-2 rounded-lg">Retry</button>
|
||||
<button @click="hideQueueItemModal()" class="btn-secondary px-4 py-2 rounded-lg">Close</button>
|
||||
<button @click="retryQueueItem()" class="btn-primary px-4 py-2 rounded-lg">Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+73
-93
@@ -1,97 +1,77 @@
|
||||
package templates
|
||||
|
||||
templ Register() {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Register</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/toast.js"></script>
|
||||
<script src="/static/theme.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="theme-tokyo-night min-h-screen">
|
||||
<script>
|
||||
// Progressive enhancement: check localStorage immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme && savedTheme !== 'tokyo-night') {
|
||||
document.body.className = 'theme-' + savedTheme + ' min-h-screen';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<div class="container mx-auto px-4 py-8 max-w-md">
|
||||
<h1 class="text-3xl font-bold text-center mb-8">Register for Bookhoard</h1>
|
||||
|
||||
<form hx-post="/api/auth/register" hx-target="#result" hx-swap="innerHTML" class="card p-6 rounded-lg shadow-md border">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email</label>
|
||||
<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>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label>
|
||||
<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>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label>
|
||||
<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>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label>
|
||||
<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>
|
||||
<div id="password-requirements" class="mb-4 p-4 rounded-lg border text-sm" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<p class="font-semibold mb-2" style="color: var(--text-primary)">Password Requirements:</p>
|
||||
<ul class="space-y-1" style="color: var(--text-secondary)">
|
||||
<li id="req-length" class="flex items-center gap-2">
|
||||
<span class="requirement-icon">○</span> At least 8 characters
|
||||
</li>
|
||||
<li id="req-upper" class="flex items-center gap-2">
|
||||
<span class="requirement-icon">○</span> One uppercase letter
|
||||
</li>
|
||||
<li id="req-lower" class="flex items-center gap-2">
|
||||
<span class="requirement-icon">○</span> One lowercase letter
|
||||
</li>
|
||||
<li id="req-number" class="flex items-center gap-2">
|
||||
<span class="requirement-icon">○</span> One number
|
||||
</li>
|
||||
<li id="req-special" class="flex items-center gap-2">
|
||||
<span class="requirement-icon">○</span> One special character
|
||||
</li>
|
||||
<li id="req-match" class="flex items-center gap-2">
|
||||
<span class="requirement-icon">○</span> Passwords match
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label>
|
||||
<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>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm Password</label>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Register</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/main.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body class="theme-tokyo-night min-h-screen" x-data="register" x-init="initRegisterTheme()">
|
||||
<div class="container mx-auto px-4 py-8 max-w-md">
|
||||
<h1 class="text-3xl font-bold text-center mb-8">Register for Bookhoard</h1>
|
||||
<form hx-post="/api/auth/register" hx-target="#result" hx-swap="innerHTML" class="card p-6 rounded-lg shadow-md border">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email</label>
|
||||
<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/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label>
|
||||
<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>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label>
|
||||
<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>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label>
|
||||
<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>
|
||||
<div id="password-requirements" class="mb-4 p-4 rounded-lg border text-sm" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<p class="font-semibold mb-2" style="color: var(--text-primary)">Password Requirements:</p>
|
||||
<ul class="space-y-1" style="color: var(--text-secondary)">
|
||||
<li id="req-length" class="flex items-center gap-2">
|
||||
<span class="requirement-icon">○</span> At least 8 characters
|
||||
</li>
|
||||
<li id="req-upper" class="flex items-center gap-2">
|
||||
<span class="requirement-icon">○</span> One uppercase letter
|
||||
</li>
|
||||
<li id="req-lower" class="flex items-center gap-2">
|
||||
<span class="requirement-icon">○</span> One lowercase letter
|
||||
</li>
|
||||
<li id="req-number" class="flex items-center gap-2">
|
||||
<span class="requirement-icon">○</span> One number
|
||||
</li>
|
||||
<li id="req-special" class="flex items-center gap-2">
|
||||
<span class="requirement-icon">○</span> One special character
|
||||
</li>
|
||||
<li id="req-match" class="flex items-center gap-2">
|
||||
<span class="requirement-icon">○</span> Passwords match
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label>
|
||||
<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/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm Password</label>
|
||||
<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/>
|
||||
</div>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package templates
|
||||
|
||||
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="flex justify-between items-center mb-6">
|
||||
<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 class="mb-4">
|
||||
<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>
|
||||
</select>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
+1
-28
@@ -2,33 +2,6 @@ package templates
|
||||
|
||||
templ ErrorToast(message string) {
|
||||
if message != "" {
|
||||
<script>
|
||||
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>
|
||||
<div x-data="toastError" x-init="showErrorToast('{ message }')"></div>
|
||||
}
|
||||
}
|
||||
|
||||
+192
-497
@@ -1,501 +1,196 @@
|
||||
package templates
|
||||
|
||||
templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Unlinked Books - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/toast.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }">
|
||||
@Header(user, "/unlinked")
|
||||
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<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>
|
||||
</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);">
|
||||
<div class="flex items-center gap-4">
|
||||
<input type="checkbox" id="select-all-unlinked" onchange="toggleAllUnlinked()" class="w-5 h-5">
|
||||
<label for="select-all-unlinked" class="cursor-pointer" style="color: var(--text-primary)">Select All</label>
|
||||
<span id="selected-count" class="text-sm" style="color: var(--text-secondary)">0 selected</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="bulkAutoLink()" class="btn-secondary px-4 py-2 rounded-lg text-sm">
|
||||
🤖 Auto-Link Selected (High Confidence)
|
||||
</button>
|
||||
<button onclick="bulkGetSuggestions()" class="btn-secondary px-4 py-2 rounded-lg text-sm">
|
||||
💡 Get Suggestions for Selected
|
||||
</button>
|
||||
<button onclick="showBulkManualLink()" class="btn-primary px-4 py-2 rounded-lg text-sm">
|
||||
🔗 Bulk Manual Link
|
||||
</button>
|
||||
</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 class="text-6xl mb-4">✓</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>
|
||||
</div>
|
||||
}
|
||||
|
||||
for _, book := range unlinkedBooks {
|
||||
<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">
|
||||
<input type="checkbox" class="unlinked-checkbox w-5 h-5" data-progress-id="{ book.ProgressID }" data-title="{ book.TitleFromDevice }" onchange="updateSelectedCount()">
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="text-4xl">
|
||||
if book.DeviceType == "koreader" {
|
||||
📖
|
||||
} else if book.DeviceType == "kobo" {
|
||||
📚
|
||||
} else {
|
||||
📱
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-lg" style="color: var(--text-primary)">{ book.TitleFromDevice }</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">
|
||||
Device: { book.DeviceName } ({ book.DeviceType })
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm mb-4">
|
||||
<div>
|
||||
<span style="color: var(--text-secondary)">File Path:</span>
|
||||
<code style="color: var(--text-primary); background-color: var(--bg-primary); padding: 2px 6px; border-radius: 4px;">
|
||||
{ book.FilePath }
|
||||
</code>
|
||||
</div>
|
||||
if book.SHA256 != "" {
|
||||
<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;">
|
||||
{ book.SHA256 }
|
||||
</code>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<span style="color: var(--text-secondary)">Last Synced:</span>
|
||||
<span style="color: var(--text-primary)">{ book.LastSyncTime }</span>
|
||||
</div>
|
||||
if book.ConfidenceScore > 0 {
|
||||
<div>
|
||||
<span style="color: var(--text-secondary)">Auto-Link Confidence:</span>
|
||||
<span style="color: var(--text-primary)">
|
||||
{ book.ConfidenceScore }% confidence
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<button onclick="searchMatches('{ book.ProgressID }', '{ book.SHA256 }', '{ book.TitleFromDevice }')"
|
||||
class="btn-secondary px-4 py-2 rounded-lg text-sm">
|
||||
🔍 Find Potential Matches
|
||||
</button>
|
||||
<button onclick="showManualLinkModal('{ book.ProgressID }', '{ book.TitleFromDevice }')"
|
||||
class="btn-primary px-4 py-2 rounded-lg text-sm">
|
||||
🔗 Manually Link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="matches-{ book.ProgressID }" class="hidden">
|
||||
<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-list-{ book.ProgressID }" class="space-y-3">
|
||||
<p style="color: var(--text-secondary)">Click "Find Potential Matches" to search</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual Link Modal -->
|
||||
<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 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">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Manually Link Book</h2>
|
||||
<button onclick="hideManualLinkModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="link-progress-id">
|
||||
<input type="hidden" id="link-book-sha256">
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Book Title from Device</label>
|
||||
<input type="text" id="link-book-title" readonly
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-secondary); border-color: var(--border);">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<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">
|
||||
<input type="text" id="link-search-input" placeholder="Search by title, author..."
|
||||
class="flex-1 px-4 py-2 border rounded-lg"
|
||||
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">
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="link-search-results" class="mb-4 max-h-48 overflow-y-auto space-y-2">
|
||||
<p style="color: var(--text-secondary)">Search for books to link</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Confidence Score</label>
|
||||
<input type="number" id="link-confidence" min="0" max="1" step="0.1" value="1.0"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">1.0 = manual override (highest confidence)</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="hideManualLinkModal()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" onclick="confirmManualLink()" class="btn-primary px-4 py-2 rounded-lg">
|
||||
Link Book
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedMediaItem = null;
|
||||
|
||||
function searchMatches(progressId, sha256, title) {
|
||||
const container = document.getElementById(`matches-${progressId}`);
|
||||
const matchesList = document.getElementById(`matches-list-${progressId}`);
|
||||
|
||||
container.classList.remove('hidden');
|
||||
matchesList.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>';
|
||||
|
||||
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>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Unlinked Books - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/main.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }" x-data="unlinkedBooks" x-init="setupEventDelegation()">
|
||||
@Header(user, "/unlinked")
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<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>
|
||||
</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);">
|
||||
<div class="flex items-center gap-4">
|
||||
<input type="checkbox" id="select-all-unlinked" @change="toggleAllUnlinked()" class="w-5 h-5"/>
|
||||
<label for="select-all-unlinked" class="cursor-pointer" style="color: var(--text-primary)">Select All</label>
|
||||
<span id="selected-count" class="text-sm" style="color: var(--text-secondary)">0 selected</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="bulkAutoLink()" class="btn-secondary px-4 py-2 rounded-lg text-sm">
|
||||
🤖 Auto-Link Selected (High Confidence)
|
||||
</button>
|
||||
<button @click="bulkGetSuggestions()" class="btn-secondary px-4 py-2 rounded-lg text-sm">
|
||||
💡 Get Suggestions for Selected
|
||||
</button>
|
||||
<button @click="showBulkManualLink()" class="btn-primary px-4 py-2 rounded-lg text-sm">
|
||||
🔗 Bulk Manual Link
|
||||
</button>
|
||||
</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 class="text-6xl mb-4">✓</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>
|
||||
</div>
|
||||
}
|
||||
for _, book := range unlinkedBooks {
|
||||
<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">
|
||||
<input type="checkbox" class="unlinked-checkbox w-5 h-5" data-progress-id="{ book.ProgressID }" data-title="{ book.TitleFromDevice }" onchange="updateSelectedCount()"/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="text-4xl">
|
||||
if book.DeviceType == "koreader" {
|
||||
📖
|
||||
} else if book.DeviceType == "kobo" {
|
||||
📚
|
||||
} else {
|
||||
📱
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-lg" style="color: var(--text-primary)">{ book.TitleFromDevice }</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">
|
||||
Device: { book.DeviceName } ({ book.DeviceType })
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2 text-sm mb-4">
|
||||
<div>
|
||||
<span style="color: var(--text-secondary)">File Path:</span>
|
||||
<code style="color: var(--text-primary); background-color: var(--bg-primary); padding: 2px 6px; border-radius: 4px;">
|
||||
{ book.FilePath }
|
||||
</code>
|
||||
</div>
|
||||
if book.SHA256 != "" {
|
||||
<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;">
|
||||
{ book.SHA256 }
|
||||
</code>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<span style="color: var(--text-secondary)">Last Synced:</span>
|
||||
<span style="color: var(--text-primary)">{ book.LastSyncTime }</span>
|
||||
</div>
|
||||
if book.ConfidenceScore > 0 {
|
||||
<div>
|
||||
<span style="color: var(--text-secondary)">Auto-Link Confidence:</span>
|
||||
<span style="color: var(--text-primary)">
|
||||
{ book.ConfidenceScore }% confidence
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<button
|
||||
@click="searchMatches('{ book.ProgressID }', '{ book.SHA256 }', '{ book.TitleFromDevice }')"
|
||||
class="btn-secondary px-4 py-2 rounded-lg text-sm"
|
||||
>
|
||||
🔍 Find Potential Matches
|
||||
</button>
|
||||
<button
|
||||
@click="showManualLinkModal('{ book.ProgressID }', '{ book.TitleFromDevice }')"
|
||||
class="btn-primary px-4 py-2 rounded-lg text-sm"
|
||||
>
|
||||
🔗 Manually Link
|
||||
</button>
|
||||
</div>
|
||||
<div id="matches-{ book.ProgressID }" class="hidden">
|
||||
<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-list-{ book.ProgressID }" class="space-y-3">
|
||||
<p style="color: var(--text-secondary)">Click "Find Potential Matches" to search</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Manual Link Modal -->
|
||||
<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 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">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Manually Link Book</h2>
|
||||
<button @click="hideManualLinkModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||
</div>
|
||||
<input type="hidden" id="link-progress-id"/>
|
||||
<input type="hidden" id="link-book-sha256"/>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Book Title from Device</label>
|
||||
<input
|
||||
type="text"
|
||||
id="link-book-title"
|
||||
readonly
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-secondary); border-color: var(--border);"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<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">
|
||||
<input
|
||||
type="text"
|
||||
id="link-search-input"
|
||||
placeholder="Search by title, author..."
|
||||
class="flex-1 px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="searchBooksForLink()"
|
||||
class="btn-primary px-4 py-2 rounded-lg"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="link-search-results" class="mb-4 max-h-48 overflow-y-auto space-y-2">
|
||||
<p style="color: var(--text-secondary)">Search for books to link</p>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Confidence Score</label>
|
||||
<input
|
||||
type="number"
|
||||
id="link-confidence"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value="1.0"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
/>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">1.0 = manual override (highest confidence)</p>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="hideManualLinkModal()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" @click="confirmManualLink()" class="btn-primary px-4 py-2 rounded-lg">
|
||||
Link Book
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user