refactor: Convert template event handlers to Alpine.js

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

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

Dynamic content in devices.templ uses event delegation via data attributes.
This commit is contained in:
2026-03-08 21:36:02 -04:00
parent b78aacd320
commit 08d45617a7
20 changed files with 1523 additions and 2511 deletions
+19 -24
View File
@@ -4,16 +4,14 @@ templ Admin(user User) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"/>
<title>Admin Dashboard - Bookhoard</title> <title>Admin Dashboard - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script> <script src="/static/main.js"></script>
<script src="/static/admin.js"></script> <link href="/static/style.css" rel="stylesheet"/>
<link href="/static/style.css" rel="stylesheet">
</head> </head>
<body class="theme-tokyo-night"> <body class="theme-tokyo-night" x-data="admin">
@Header(user, "/admin") @Header(user, "/admin")
<div class="flex min-h-screen" style="background-color: var(--bg-primary)"> <div class="flex min-h-screen" style="background-color: var(--bg-primary)">
@AdminSidebar(user, "/admin") @AdminSidebar(user, "/admin")
<main class="flex-1 p-8"> <main class="flex-1 p-8">
@@ -22,7 +20,6 @@ templ Admin(user User) {
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Dashboard</h1> <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> <p style="color: var(--text-secondary)">Overview of your Bookhoard library and settings</p>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8"> <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="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="flex items-center space-x-3">
@@ -34,7 +31,6 @@ templ Admin(user User) {
</div> </div>
<a href="/" class="mt-4 inline-block text-sm btn-secondary px-3 py-1 rounded">View Library</a> <a href="/" class="mt-4 inline-block text-sm btn-secondary px-3 py-1 rounded">View Library</a>
</div> </div>
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)"> <div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<div class="text-3xl">👁️</div> <div class="text-3xl">👁️</div>
@@ -48,14 +44,11 @@ templ Admin(user User) {
Watching <span id="watch-count">0</span> libraries Watching <span id="watch-count">0</span> libraries
</div> </div>
</div> </div>
</div> </div>
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)"> <div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<h3 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Quick Actions</h3> <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"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<button onclick="scanAllLibraries()" class="btn-primary p-4 rounded-lg text-left"> <button @click="scanAllLibraries()" class="btn-primary p-4 rounded-lg text-left">
<div class="font-medium">Rescan Library</div> <div class="font-medium">Rescan Library</div>
<div style="color: var(--text-secondary)" class="text-sm">Re-scan existing files and fix metadata</div> <div style="color: var(--text-secondary)" class="text-sm">Re-scan existing files and fix metadata</div>
</button> </button>
@@ -65,18 +58,16 @@ templ Admin(user User) {
</a> </a>
</div> </div>
</div> </div>
<!-- Scan Progress Section --> <!-- 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 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"> <div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold" style="color: var(--text-primary)"> <h3 class="text-lg font-semibold" style="color: var(--text-primary)">
📚 Scanning Libraries 📚 Scanning Libraries
</h3> </h3>
<button onclick="hideScanProgress()" class="p-2 hover:bg-gray-700 rounded"> <button @click="hideScanProgress()" class="p-2 hover:bg-gray-700 rounded">
</button> </button>
</div> </div>
<!-- Overall Progress --> <!-- Overall Progress -->
<div class="mb-4"> <div class="mb-4">
<div class="flex justify-between text-sm mb-2"> <div class="flex justify-between text-sm mb-2">
@@ -84,21 +75,20 @@ templ Admin(user User) {
<span id="scan-progress-text" style="color: var(--text-primary)">0%</span> <span id="scan-progress-text" style="color: var(--text-primary)">0%</span>
</div> </div>
<div class="w-full bg-gray-700 rounded-full h-3"> <div class="w-full bg-gray-700 rounded-full h-3">
<div id="scan-progress-bar" <div
id="scan-progress-bar"
class="h-3 rounded-full transition-all duration-500" class="h-3 rounded-full transition-all duration-500"
style="width: 0%; background-color: var(--accent);"> style="width: 0%; background-color: var(--accent);"
</div> ></div>
</div> </div>
<div id="scan-status" class="text-sm mt-2" style="color: var(--text-secondary)"> <div id="scan-status" class="text-sm mt-2" style="color: var(--text-secondary)">
Starting scan... Starting scan...
</div> </div>
</div> </div>
<!-- Per-Library Progress --> <!-- Per-Library Progress -->
<div id="library-progress-list" class="space-y-3"> <div id="library-progress-list" class="space-y-3">
<!-- Dynamically populated --> <!-- Dynamically populated -->
</div> </div>
<!-- Results Summary --> <!-- 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);"> <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> <h4 class="font-semibold mb-2" style="color: var(--text-primary)"> Scan Complete!</h4>
@@ -106,12 +96,16 @@ templ Admin(user User) {
<!-- Results populated by JS --> <!-- Results populated by JS -->
</div> </div>
<div class="mt-4 flex gap-2"> <div class="mt-4 flex gap-2">
<button onclick="window.location.reload()" <button
class="btn-primary px-4 py-2 rounded-lg"> @click="window.location.reload()"
class="btn-primary px-4 py-2 rounded-lg"
>
Refresh to View Books Refresh to View Books
</button> </button>
<button onclick="hideScanProgress()" <button
class="btn-secondary px-4 py-2 rounded-lg"> @click="hideScanProgress()"
class="btn-secondary px-4 py-2 rounded-lg"
>
Dismiss Dismiss
</button> </button>
</div> </div>
@@ -123,3 +117,4 @@ templ Admin(user User) {
</body> </body>
</html> </html>
} }
+18 -19
View File
@@ -4,45 +4,48 @@ templ Analytics(user User) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Reading Analytics - Bookhoard</title> <title>Reading Analytics - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script> <script src="/static/main.js"></script>
<link href="/static/style.css" rel="stylesheet"> <link href="/static/style.css" rel="stylesheet"/>
<script src="/static/theme.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head> </head>
<body class="theme-{ user.Theme }"> <body x-data="analytics" class="theme-{ user.Theme }">
@Header(user, "/analytics") @Header(user, "/analytics")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8"> <div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">📊 Reading Analytics</h1> <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> <p style="color: var(--text-secondary)">Track your reading habits and device usage</p>
</div> </div>
<!-- Date Range Picker --> <!-- 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="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 flex-wrap gap-4 items-center">
<div class="flex-1 min-w-[200px]"> <div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Start Date</label> <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()" <input
type="date"
id="start-date"
onchange="loadAnalytics()"
class="w-full px-4 py-2 border rounded-lg" class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
/>
</div> </div>
<div class="flex-1 min-w-[200px]"> <div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">End Date</label> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">End Date</label>
<input type="date" id="end-date" onchange="loadAnalytics()" <input
type="date"
id="end-date"
onchange="loadAnalytics()"
class="w-full px-4 py-2 border rounded-lg" class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
/>
</div> </div>
<div class="flex items-end"> <div class="flex items-end">
<button onclick="loadAnalytics()" class="btn-primary px-6 py-2 rounded-lg">Update</button> <button @click="loadAnalytics()" class="btn-primary px-6 py-2 rounded-lg">Update</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Stats Cards --> <!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8" id="stats-container"> <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);"> <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);">
@@ -62,7 +65,6 @@ templ Analytics(user User) {
<p id="completion-rate" class="text-3xl font-bold" style="color: var(--text-primary);">-</p> <p id="completion-rate" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
</div> </div>
</div> </div>
<!-- Charts Row --> <!-- Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Daily Reading Chart --> <!-- Daily Reading Chart -->
@@ -72,7 +74,6 @@ templ Analytics(user User) {
<canvas id="daily-reading-chart"></canvas> <canvas id="daily-reading-chart"></canvas>
</div> </div>
</div> </div>
<!-- Device Usage Chart --> <!-- Device Usage Chart -->
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Device Usage</h3> <h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Device Usage</h3>
@@ -81,7 +82,6 @@ templ Analytics(user User) {
</div> </div>
</div> </div>
</div> </div>
<!-- Popular Books --> <!-- Popular Books -->
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Most Read Books</h3> <h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Most Read Books</h3>
@@ -93,7 +93,6 @@ templ Analytics(user User) {
</div> </div>
</div> </div>
</div> </div>
<script src="/static/analytics.js"></script> <script src="/static/analytics.js"></script>
</body> </body>
</html> </html>
+9 -113
View File
@@ -25,16 +25,16 @@ templ APIExplorer(explorer APIExplorerData) {
<pre class="bg-background-primary p-4 rounded-lg overflow-x-auto"><code class="text-text-primary text-sm">{ explorer.Endpoint.Response }</code></pre> <pre class="bg-background-primary p-4 rounded-lg overflow-x-auto"><code class="text-text-primary text-sm">{ explorer.Endpoint.Response }</code></pre>
</div> </div>
<div class="flex gap-2 mt-4"> <div class="flex gap-2 mt-4">
<button onclick="copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Request</button> @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Request</button>
<button onclick="copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Response</button> @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Response</button>
</div> </div>
</div> </div>
} else { } else {
// Show interactive explorer with real execution // Show interactive explorer with real execution
<div class="border border-border rounded-lg p-6 mt-8 bg-background-secondary"> <div class="border border-border rounded-lg p-6 mt-8 bg-background-secondary" x-data="apiExplorerDoc" x-init="initAPIExplorerDoc('{ explorer.Endpoint.Path }', '{ explorer.Endpoint.RequestBody }', '{ explorer.Endpoint.Response }')">
<div class="flex gap-2 mb-4"> <div class="flex gap-2 mb-4">
<button class="mode-btn px-4 py-2 border border-border rounded bg-background-primary text-text-primary cursor-pointer hover:bg-background-secondary text-sm" id="mock-btn">Mock Data</button> <button class="mode-btn px-4 py-2 border border-border rounded bg-background-primary text-text-primary cursor-pointer hover:bg-background-secondary text-sm" id="mock-btn" @click="showDocMode('mock')">Mock Data</button>
<button class="mode-btn px-4 py-2 bg-accent text-white rounded cursor-pointer text-sm" id="real-btn">Try It Out</button> <button class="mode-btn px-4 py-2 bg-accent text-white rounded cursor-pointer text-sm" id="real-btn" @click="showDocMode('real')">Try It Out</button>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<div class="mb-4"> <div class="mb-4">
@@ -48,7 +48,7 @@ templ APIExplorer(explorer APIExplorerData) {
<h4 class="text-text-secondary mb-2 text-sm font-medium">Request Body</h4> <h4 class="text-text-secondary mb-2 text-sm font-medium">Request Body</h4>
<textarea id="request-body" placeholder="Edit request body..." class="w-full min-h-[150px] px-3 py-2 font-mono text-sm bg-background-primary text-text-primary border border-border rounded focus:outline-none focus:ring-2 focus:ring-accent">{ explorer.Endpoint.RequestBody }</textarea> <textarea id="request-body" placeholder="Edit request body..." class="w-full min-h-[150px] px-3 py-2 font-mono text-sm bg-background-primary text-text-primary border border-border rounded focus:outline-none focus:ring-2 focus:ring-accent">{ explorer.Endpoint.RequestBody }</textarea>
</div> </div>
<button id="try-it-out" class="bg-accent text-white px-6 py-3 rounded cursor-pointer mt-4 hover:opacity-90">Try It Out</button> <button id="try-it-out" @click="tryDocEndpoint()" class="bg-accent text-white px-6 py-3 rounded cursor-pointer mt-4 hover:opacity-90">Try It Out</button>
<div class="api-response mt-4 hidden"> <div class="api-response mt-4 hidden">
<div class="flex justify-between mb-2 text-sm"> <div class="flex justify-between mb-2 text-sm">
<span id="response-status" class="font-semibold"></span> <span id="response-status" class="font-semibold"></span>
@@ -57,116 +57,12 @@ templ APIExplorer(explorer APIExplorerData) {
<pre class="bg-background-primary p-4 rounded-lg overflow-x-auto"><code id="response-body" class="text-text-primary text-sm"></code></pre> <pre class="bg-background-primary p-4 rounded-lg overflow-x-auto"><code id="response-body" class="text-text-primary text-sm"></code></pre>
</div> </div>
<div class="flex gap-2 mt-4 flex-wrap"> <div class="flex gap-2 mt-4 flex-wrap">
<button onclick="copyRequest()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Request</button> <button @click="copyDocRequest()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Request</button>
<button onclick="copyResponse()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Response</button> <button @click="copyDocResponse()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Response</button>
<button onclick="generateCURL()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Generate cURL</button> <button @click="generateDocCURL()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Generate cURL</button>
</div> </div>
</div> </div>
} }
<script>
const endpointPath = '{ explorer.Endpoint.Path }';
const exampleRequest = { explorer.Endpoint.RequestBody };
const exampleResponse = { explorer.Endpoint.Response };
// Mode toggle logic
document.getElementById('mock-btn')?.addEventListener('click', () => showMode('mock'));
document.getElementById('real-btn')?.addEventListener('click', () => showMode('real'));
function showMode(mode) {
const mockBtn = document.getElementById('mock-btn');
const realBtn = document.getElementById('real-btn');
const responseBody = document.getElementById('response-body');
const responseStatus = document.getElementById('response-status');
const responseTime = document.getElementById('response-time');
const apiResponse = document.querySelector('.api-response');
const requestBody = document.getElementById('request-body');
const tryItOut = document.getElementById('try-it-out');
if (mode === 'mock') {
mockBtn.classList.add('bg-accent', 'text-white');
mockBtn.classList.remove('bg-background-primary', 'text-text-primary');
realBtn.classList.remove('bg-accent', 'text-white');
realBtn.classList.add('bg-background-primary', 'text-text-primary');
apiResponse.classList.remove('hidden');
requestBody.readOnly = true;
tryItOut.classList.add('hidden');
responseBody.textContent = JSON.stringify(exampleResponse, null, 2);
responseStatus.textContent = '200 OK';
responseTime.textContent = 'Mock';
} else {
realBtn.classList.add('bg-accent', 'text-white');
realBtn.classList.remove('bg-background-primary', 'text-text-primary');
mockBtn.classList.remove('bg-accent', 'text-white');
mockBtn.classList.add('bg-background-primary', 'text-text-primary');
apiResponse.classList.add('hidden');
requestBody.readOnly = false;
tryItOut.classList.remove('hidden');
}
}
// Try it out logic
document.getElementById('try-it-out')?.addEventListener('click', async () => {
const method = document.getElementById('http-method').value;
const body = document.getElementById('request-body').value;
const startTime = Date.now();
try {
const response = await fetch(endpointPath, {
method: method,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: ['GET', 'DELETE'].includes(method) ? undefined : body
});
const duration = Date.now() - startTime;
const data = await response.json();
document.getElementById('response-status').textContent = `${response.status} (${response.statusText})`;
document.getElementById('response-time').textContent = `${duration}ms`;
document.getElementById('response-body').textContent = JSON.stringify(data, null, 2);
document.querySelector('.api-response').classList.remove('hidden');
} catch (error) {
document.getElementById('response-status').textContent = 'Error';
document.getElementById('response-body').textContent = error.message;
document.querySelector('.api-response').classList.remove('hidden');
}
});
function copyToClipboard(text) {
navigator.clipboard.writeText(text);
}
function copyRequest() {
const body = document.getElementById('request-body').value;
navigator.clipboard.writeText(body);
}
function copyResponse() {
const body = document.getElementById('response-body').textContent;
navigator.clipboard.writeText(body);
}
function generateCURL() {
const method = document.getElementById('http-method').value;
const body = document.getElementById('request-body').value;
const token = localStorage.getItem('token');
let curl = `curl -X ${method} \\n -H "Content-Type: application/json" \\n -H "Authorization: Bearer ${token}"`;
if (!['GET', 'DELETE'].includes(method) && body.trim()) {
curl += ` \\n -d '${body}'`;
}
curl += ` \\n ${endpointPath}`;
navigator.clipboard.writeText(curl);
}
</script>
} }
// APIExplorerData holds data for the API explorer component // APIExplorerData holds data for the API explorer component
+7 -213
View File
@@ -4,15 +4,14 @@ templ BookShelf(user User, libraries []LibraryData) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Library - Bookhoard</title> <title>Library - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script> <script src="/static/main.js"></script>
<script src="/static/search.js"></script> <link href="/static/style.css" rel="stylesheet"/>
<link href="/static/style.css" rel="stylesheet">
</head> </head>
<body class="theme-{ user.Theme }"> <body class="theme-{ user.Theme }" x-data="bookshelf" x-init="initBookshelf(); setupEventDelegation()">
@Header(user, "/bookshelf") @Header(user, "/bookshelf")
<div class="w-full px-4 sm:px-6 lg:8 py-8"> <div class="w-full px-4 sm:px-6 lg:8 py-8">
<!-- Library Selector --> <!-- Library Selector -->
@@ -20,7 +19,7 @@ templ BookShelf(user User, libraries []LibraryData) {
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between"> <div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
<div class="flex-1 w-full"> <div class="flex-1 w-full">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Select Library</label> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Select Library</label>
<select id="library-select" 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);"> <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 { if len(libraries) == 0 {
<option value="">No libraries available</option> <option value="">No libraries available</option>
} else { } else {
@@ -31,13 +30,12 @@ templ BookShelf(user User, libraries []LibraryData) {
</select> </select>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button onclick="loadBookshelf()" class="btn-primary px-6 py-3 rounded-lg font-medium"> <button @click="loadBookshelf()" class="btn-primary px-6 py-3 rounded-lg font-medium">
Refresh Refresh
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<!-- Bookshelf Container --> <!-- Bookshelf Container -->
<div id="bookshelf-container"> <div id="bookshelf-container">
<!-- Loading state --> <!-- Loading state -->
@@ -45,222 +43,18 @@ templ BookShelf(user User, libraries []LibraryData) {
<div class="loading-spinner mx-auto mb-4"></div> <div class="loading-spinner mx-auto mb-4"></div>
<p>Loading your library...</p> <p>Loading your library...</p>
</div> </div>
<!-- Empty state --> <!-- Empty state -->
<div id="empty-state" class="hidden text-center py-16" style="color: var(--text-secondary)"> <div id="empty-state" class="hidden text-center py-16" style="color: var(--text-secondary)">
<div class="text-6xl mb-4">📚</div> <div class="text-6xl mb-4">📚</div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Books Yet</h3> <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> <p>Select a library to view your collection</p>
</div> </div>
<!-- Books Grid --> <!-- Books Grid -->
<div id="books-grid" class="hidden"> <div id="books-grid" class="hidden">
<!-- Books will be loaded here on shelves --> <!-- Books will be loaded here on shelves -->
</div> </div>
</div> </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> </body>
</html> </html>
} }
+14 -14
View File
@@ -1,7 +1,7 @@
package templates package templates
templ CollectionModal(collection CollectionData) { templ CollectionModal(collection CollectionData) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70"> <div x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
if collection.ID != "" { if collection.ID != "" {
@@ -9,7 +9,7 @@ templ CollectionModal(collection CollectionData) {
} else { } else {
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Collection</h2> <h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Collection</h2>
} }
<button type="button" onclick="closeCollectionModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button> <button type="button" @click="closeCollectionModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
</div> </div>
if collection.ID != "" { if collection.ID != "" {
<form <form
@@ -66,16 +66,16 @@ templ CollectionModal(collection CollectionData) {
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label>
<div class="flex gap-2"> <div class="flex gap-2">
<button type="button" onclick="selectColor('blue')" class="w-8 h-8 rounded-full color-option bg-blue-500"></button> <button type="button" @click=\"selectColor('blue')\" class="w-8 h-8 rounded-full color-option bg-blue-500"></button>
<button type="button" onclick="selectColor('red')" class="w-8 h-8 rounded-full color-option bg-red-500"></button> <button type="button" @click=\"selectColor('red')\" class="w-8 h-8 rounded-full color-option bg-red-500"></button>
<button type="button" onclick="selectColor('yellow')" class="w-8 h-8 rounded-full color-option bg-yellow-500"></button> <button type="button" @click=\"selectColor('yellow')\" class="w-8 h-8 rounded-full color-option bg-yellow-500"></button>
<button type="button" onclick="selectColor('green')" class="w-8 h-8 rounded-full color-option bg-green-500"></button> <button type="button" @click=\"selectColor('green')\" class="w-8 h-8 rounded-full color-option bg-green-500"></button>
<button type="button" onclick="selectColor('purple')" class="w-8 h-8 rounded-full color-option bg-purple-500"></button> <button type="button" @click=\"selectColor('purple')\" class="w-8 h-8 rounded-full color-option bg-purple-500"></button>
</div> </div>
<input type="hidden" name="color" id="collection-color" value={ collection.Color }/> <input type="hidden" name="color" id="collection-color" value={ collection.Color }/>
</div> </div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" onclick="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button> <button type="button" @click="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">Update Collection</button> <button type="submit" class="btn-primary px-4 py-2 rounded-lg">Update Collection</button>
</div> </div>
</form> </form>
@@ -132,16 +132,16 @@ templ CollectionModal(collection CollectionData) {
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label>
<div class="flex gap-2"> <div class="flex gap-2">
<button type="button" onclick="selectColor('blue')" class="w-8 h-8 rounded-full color-option bg-blue-500"></button> <button type="button" @click=\"selectColor('blue')\" class="w-8 h-8 rounded-full color-option bg-blue-500"></button>
<button type="button" onclick="selectColor('red')" class="w-8 h-8 rounded-full color-option bg-red-500"></button> <button type="button" @click=\"selectColor('red')\" class="w-8 h-8 rounded-full color-option bg-red-500"></button>
<button type="button" onclick="selectColor('yellow')" class="w-8 h-8 rounded-full color-option bg-yellow-500"></button> <button type="button" @click=\"selectColor('yellow')\" class="w-8 h-8 rounded-full color-option bg-yellow-500"></button>
<button type="button" onclick="selectColor('green')" class="w-8 h-8 rounded-full color-option bg-green-500"></button> <button type="button" @click=\"selectColor('green')\" class="w-8 h-8 rounded-full color-option bg-green-500"></button>
<button type="button" onclick="selectColor('purple')" class="w-8 h-8 rounded-full color-option bg-purple-500"></button> <button type="button" @click=\"selectColor('purple')\" class="w-8 h-8 rounded-full color-option bg-purple-500"></button>
</div> </div>
<input type="hidden" name="color" id="collection-color" value="blue"/> <input type="hidden" name="color" id="collection-color" value="blue"/>
</div> </div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" onclick="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button> <button type="button" @click="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">Create Collection</button> <button type="submit" class="btn-primary px-4 py-2 rounded-lg">Create Collection</button>
</div> </div>
</form> </form>
+58 -280
View File
@@ -4,19 +4,18 @@ templ CollectionRules(user User, collection CollectionData) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Collection Rules - { collection.Name } - Bookhoard</title> <title>Collection Rules - { collection.Name } - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script> <script src="/static/main.js"></script>
<link href="/static/style.css" rel="stylesheet"> <link href="/static/style.css" rel="stylesheet"/>
</head> </head>
<body class="theme-{ user.Theme }"> <body class="theme-{ user.Theme }" x-data="collectionRules" x-init="initCollectionRules('{ collection.ID }')">
@Header(user, "/collections") @Header(user, "/collections")
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6"> <div class="mb-6">
<button onclick="backToCollection()" class="btn-secondary px-4 py-2 rounded-lg mb-4"> <button @click="backToCollection()" class="btn-secondary px-4 py-2 rounded-lg mb-4">
Back to Collection Back to Collection
</button> </button>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
@@ -27,7 +26,6 @@ templ CollectionRules(user User, collection CollectionData) {
</div> </div>
</div> </div>
</div> </div>
<div class="mb-8"> <div class="mb-8">
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Existing Rules</h2> <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="rules-container" class="space-y-4">
@@ -38,18 +36,19 @@ templ CollectionRules(user User, collection CollectionData) {
</div> </div>
</div> </div>
</div> </div>
<div class="mb-8"> <div class="mb-8">
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Create New Rule</h2> <h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Create New Rule</h2>
<form id="rule-form" onsubmit="handleCreateRule(event)"> <form id="rule-form" @submit="handleCreateRule($event)">
<input type="hidden" id="collection-id" value="{ collection.ID }"> <input type="hidden" id="collection-id" value="{ collection.ID }"/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div> <div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Field</label> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Field</label>
<select id="rule-field" required <select
id="rule-field"
required
class="w-full px-4 py-2 border rounded-lg" class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
>
<option value="">Select field...</option> <option value="">Select field...</option>
<option value="genre">Genre</option> <option value="genre">Genre</option>
<option value="series">Series</option> <option value="series">Series</option>
@@ -60,12 +59,14 @@ templ CollectionRules(user User, collection CollectionData) {
<option value="tags">Tags</option> <option value="tags">Tags</option>
</select> </select>
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Operator</label> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Operator</label>
<select id="rule-operator" required <select
id="rule-operator"
required
class="w-full px-4 py-2 border rounded-lg" class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
>
<option value="">Select operator...</option> <option value="">Select operator...</option>
<option value="equals">equals</option> <option value="equals">equals</option>
<option value="not_equals">does not equal</option> <option value="not_equals">does not equal</option>
@@ -78,49 +79,62 @@ templ CollectionRules(user User, collection CollectionData) {
</select> </select>
</div> </div>
</div> </div>
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Value</label> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Value</label>
<input type="text" id="rule-value" required <input
type="text"
id="rule-value"
required
class="w-full px-4 py-2 border rounded-lg" class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="e.g., Science Fiction"> placeholder="e.g., Science Fiction"
/>
</div> </div>
<div class="mb-6"> <div class="mb-6">
<label class="flex items-center space-x-3 cursor-pointer"> <label class="flex items-center space-x-3 cursor-pointer">
<input type="checkbox" id="rule-enabled" checked <input
class="w-5 h-5 rounded"> type="checkbox"
id="rule-enabled"
checked
class="w-5 h-5 rounded"
/>
<span style="color: var(--text-primary)">Enable Rule</span> <span style="color: var(--text-primary)">Enable Rule</span>
</label> </label>
<p class="text-xs mt-1" style="color: var(--text-secondary)">Uncheck to disable without deleting</p> <p class="text-xs mt-1" style="color: var(--text-secondary)">Uncheck to disable without deleting</p>
</div> </div>
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Priority</label> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Priority</label>
<div class="flex gap-4"> <div class="flex gap-4">
<label class="flex items-center space-x-2 cursor-pointer"> <label class="flex items-center space-x-2 cursor-pointer">
<input type="radio" name="priority" value="1" class="w-4 h-4"> <input type="radio" name="priority" value="1" class="w-4 h-4"/>
<span style="color: var(--text-primary)">High</span> <span style="color: var(--text-primary)">High</span>
</label> </label>
<label class="flex items-center space-x-2 cursor-pointer"> <label class="flex items-center space-x-2 cursor-pointer">
<input type="radio" name="priority" value="2" checked <input
class="w-4 h-4"> type="radio"
name="priority"
value="2"
checked
class="w-4 h-4"
/>
<span style="color: var(--text-primary)">Medium</span> <span style="color: var(--text-primary)">Medium</span>
</label> </label>
<label class="flex items-center space-x-2 cursor-pointer"> <label class="flex items-center space-x-2 cursor-pointer">
<input type="radio" name="priority" value="3" <input
class="w-4 h-4"> type="radio"
name="priority"
value="3"
class="w-4 h-4"
/>
<span style="color: var(--text-primary)">Low</span> <span style="color: var(--text-primary)">Low</span>
</label> </label>
</div> </div>
</div> </div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" onclick="testRule()" class="btn-secondary px-4 py-2 rounded-lg"> <button type="button" @click="testRule()" class="btn-secondary px-4 py-2 rounded-lg">
🧪 Test Rule 🧪 Test Rule
</button> </button>
<button type="button" onclick="clearForm()" class="btn-secondary px-4 py-2 rounded-lg"> <button type="button" @click="clearForm()" class="btn-secondary px-4 py-2 rounded-lg">
Clear Clear
</button> </button>
<button type="submit" class="btn-primary px-4 py-2 rounded-lg"> <button type="submit" class="btn-primary px-4 py-2 rounded-lg">
@@ -128,14 +142,12 @@ templ CollectionRules(user User, collection CollectionData) {
</button> </button>
</div> </div>
</form> </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);"> <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> <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> <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 id="test-results-list" class="max-h-64 overflow-y-auto space-y-2"></div>
</div> </div>
</div> </div>
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border);"> <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> <h2 class="text-xl font-bold mb-4" style="color: var(--text-primary)">Rule Examples</h2>
<div class="space-y-4"> <div class="space-y-4">
@@ -144,8 +156,10 @@ templ CollectionRules(user User, collection CollectionData) {
<div> <div>
<p class="font-medium" style="color: var(--text-primary)">Add all Science Fiction books</p> <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;"> <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> Field: genre
Operator: equals<br> <br/>
Operator: equals
<br/>
Value: Science Fiction Value: Science Fiction
</code> </code>
</div> </div>
@@ -155,8 +169,10 @@ templ CollectionRules(user User, collection CollectionData) {
<div> <div>
<p class="font-medium" style="color: var(--text-primary)">Add books from a specific series</p> <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;"> <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> Field: series
Operator: starts with<br> <br/>
Operator: starts with
<br/>
Value: Harry Potter Value: Harry Potter
</code> </code>
</div> </div>
@@ -166,8 +182,10 @@ templ CollectionRules(user User, collection CollectionData) {
<div> <div>
<p class="font-medium" style="color: var(--text-primary)">Add books published in a year range</p> <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;"> <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> Field: copyright_year
Operator: greater than<br> <br/>
Operator: greater than
<br/>
Value: 2020 Value: 2020
</code> </code>
</div> </div>
@@ -175,246 +193,6 @@ templ CollectionRules(user User, collection CollectionData) {
</div> </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> </body>
</html> </html>
} }
+10 -10
View File
@@ -13,7 +13,7 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
<script src="/static/toast.js"></script> <script src="/static/toast.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
</head> </head>
<body class="theme-{ user.Theme }"> <body x-data="collections" class="theme-{ user.Theme }">
@Header(user, "/collections") @Header(user, "/collections")
<!-- Modal Container --> <!-- Modal Container -->
<div id="modal-container"></div> <div id="modal-container"></div>
@@ -60,7 +60,7 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
</div> </div>
} }
for _, col := range collections { for _, col := range collections {
<div onclick="navigateToCollection(this)" data-href={ "/collections/" + col.ID } class="block"> <div @click="navigateToCollection($el)" data-href={ "/collections/" + col.ID } class="block">
<div <div
class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow" class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow"
style="background-color: var(--bg-secondary);" style="background-color: var(--bg-secondary);"
@@ -112,11 +112,11 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
<script src="/static/toast.js"></script> <script src="/static/toast.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
</head> </head>
<body class="theme-{ user.Theme }"> <body x-data="collections" class="theme-{ user.Theme }">
@Header(user, "/collections") @Header(user, "/collections")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8"> <div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6"> <div class="mb-6">
<button onclick="backToCollections()" class="btn-secondary px-4 py-2 rounded-lg mb-4"> <button @click="backToCollections()" class="btn-secondary px-4 py-2 rounded-lg mb-4">
Back to Collections Back to Collections
</button> </button>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
@@ -147,13 +147,13 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
</div> </div>
<button <button
id="bulk-remove-btn" id="bulk-remove-btn"
onclick="removebooksToAdd()" @click="removebooksToAdd()"
disabled disabled
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed" class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
> >
🗑️ Remove Selected 🗑️ Remove Selected
</button> </button>
<button onclick="showAddBooksModal()" class="btn-primary px-4 py-2 rounded-lg"> <button @click="showAddBooksModal()" class="btn-primary px-4 py-2 rounded-lg">
Add Books Add Books
</button> </button>
</div> </div>
@@ -213,7 +213,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
<!-- Remove Button --> <!-- Remove Button -->
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);"> <div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button <button
onclick="removeBook('{ book.MediaItemID }')" @click=\"removeBook('{ book.MediaItemID }')\"
class="px-3 py-1 text-sm border rounded hover:opacity-80" class="px-3 py-1 text-sm border rounded hover:opacity-80"
style="border-color: var(--border); color: var(--text-secondary);" style="border-color: var(--border); color: var(--text-secondary);"
> >
@@ -228,7 +228,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
<div class="card rounded-lg p-6 w-full max-w-2xl mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div class="card rounded-lg p-6 w-full max-w-2xl mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Books to Collection</h2> <h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Books to Collection</h2>
<button onclick="hideAddBooksModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button> <button @click="hideAddBooksModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
</div> </div>
<p class="mb-4" style="color: var(--text-secondary)">Search and select books to add to this collection.</p> <p class="mb-4" style="color: var(--text-secondary)">Search and select books to add to this collection.</p>
<!-- Library filter toggle - hidden by default, shown by JS when library_id present --> <!-- Library filter toggle - hidden by default, shown by JS when library_id present -->
@@ -249,10 +249,10 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
</div> </div>
<div id="book-results" class="max-h-64 overflow-y-auto mb-4"></div> <div id="book-results" class="max-h-64 overflow-y-auto mb-4"></div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" onclick="hideAddBooksModal()" class="btn-secondary px-4 py-2 rounded-lg"> <button type="button" @click="hideAddBooksModal()" class="btn-secondary px-4 py-2 rounded-lg">
Cancel Cancel
</button> </button>
<button type="button" onclick="addSelectedBooks()" class="btn-primary px-4 py-2 rounded-lg"> <button type="button" @click="addSelectedBooks()" class="btn-primary px-4 py-2 rounded-lg">
Add Selected Books Add Selected Books
</button> </button>
</div> </div>
+65 -59
View File
@@ -6,17 +6,15 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Sync Conflicts - Bookhoard</title> <title>Sync Conflicts - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script> <script src="/static/main.js"></script>
<link href="/static/style.css" rel="stylesheet"> <link href="/static/style.css" rel="stylesheet"/>
<script src="/static/theme.js"></script>
</head> </head>
<body class="theme-{ user.Theme }"> <body x-data="conflicts" class="theme-{ user.Theme }">
@Header(user, "/conflicts") @Header(user, "/conflicts")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8"> <div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8"> <div class="mb-8">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
@@ -25,68 +23,68 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
<p style="color: var(--text-secondary)">Resolve conflicts when reading progress differs across devices</p> <p style="color: var(--text-secondary)">Resolve conflicts when reading progress differs across devices</p>
</div> </div>
<div class="flex space-x-3"> <div class="flex space-x-3">
<select id="status-filter" onchange="filterConflicts()" <select
id="status-filter"
onchange="filterConflicts()"
class="px-4 py-2 border rounded-lg" class="px-4 py-2 border rounded-lg"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"> style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
>
<option value="unresolved">Unresolved Only</option> <option value="unresolved">Unresolved Only</option>
<option value="all">All Conflicts</option> <option value="all">All Conflicts</option>
<option value="resolved">Resolved Only</option> <option value="resolved">Resolved Only</option>
</select> </select>
<button onclick="dismissAllResolved()" class="btn-secondary px-4 py-2 rounded-lg"> <button @click="dismissAllResolved()" class="btn-secondary px-4 py-2 rounded-lg">
Dismiss All Resolved Dismiss All Resolved
</button> </button>
</div> </div>
</div> </div>
<div id="stats-bar" class="mt-4 flex space-x-6 text-sm"> <div id="stats-bar" class="mt-4 flex space-x-6 text-sm">
<span style="color: var(--text-secondary)">Total: <strong style="color: var(--text-primary)">{ total }</strong></span> <span style="color: var(--text-secondary)">Total: <strong style="color: var(--text-primary)">{ total }</strong></span>
<span style="color: var(--text-secondary)">Unresolved: <strong style="color: #f59e0b;">{ unresolved }</strong></span> <span style="color: var(--text-secondary)">Unresolved: <strong style="color: #f59e0b;">{ unresolved }</strong></span>
<span style="color: var(--text-secondary)">Resolved: <strong style="color: #10b981;">{ total - unresolved }</strong></span> <span style="color: var(--text-secondary)">Resolved: <strong style="color: #10b981;">{ total - unresolved }</strong></span>
</div> </div>
<div id="bulk-actions" class="mt-4 hidden"> <div id="bulk-actions" class="mt-4 hidden">
<div class="flex items-center justify-between p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div class="flex items-center justify-between p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<span class="text-sm font-semibold" style="color: var(--text-primary);"> <span class="text-sm font-semibold" style="color: var(--text-primary);">
<span id="selected-count">0</span> conflicts selected <span id="selected-count">0</span> conflicts selected
</span> </span>
<select id="bulk-strategy" class="px-3 py-2 border rounded-lg text-sm" <select
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> 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="most_recent">Most Recent</option>
<option value="highest_progress">Highest Progress</option> <option value="highest_progress">Highest Progress</option>
</select> </select>
</div> </div>
<div class="flex space-x-2"> <div class="flex space-x-2">
<button onclick="bulkResolve()" class="btn-primary px-4 py-2 rounded-lg text-sm"> <button @click="bulkResolve()" class="btn-primary px-4 py-2 rounded-lg text-sm">
Resolve Selected Resolve Selected
</button> </button>
<button onclick="bulkDismiss()" class="btn-danger px-4 py-2 rounded-lg text-sm"> <button @click="bulkDismiss()" class="btn-danger px-4 py-2 rounded-lg text-sm">
Dismiss Selected Dismiss Selected
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div id="loading" class="hidden text-center py-16" style="color: var(--text-secondary)"> <div id="loading" class="hidden text-center py-16" style="color: var(--text-secondary)">
<div class="loading-spinner mx-auto mb-4"></div> <div class="loading-spinner mx-auto mb-4"></div>
<p>Loading conflicts...</p> <p>Loading conflicts...</p>
</div> </div>
<div id="empty-state" class="hidden text-center py-16" style="color: var(--text-secondary)"> <div id="empty-state" class="hidden text-center py-16" style="color: var(--text-secondary)">
<div class="text-6xl mb-4"></div> <div class="text-6xl mb-4"></div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3> <h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
<p>Your devices are in sync! No conflicts to resolve.</p> <p>Your devices are in sync! No conflicts to resolve.</p>
</div> </div>
<div id="conflicts-list" class="space-y-6"> <div id="conflicts-list" class="space-y-6">
<div id="select-all-container" class="mb-4 hidden"> <div id="select-all-container" class="mb-4 hidden">
<label class="flex items-center space-x-2 cursor-pointer"> <label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" onchange="toggleAllConflicts()"> <input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" onchange="toggleAllConflicts()"/>
<span class="text-sm font-semibold" style="color: var(--text-primary);">Select All</span> <span class="text-sm font-semibold" style="color: var(--text-primary);">Select All</span>
</label> </label>
</div> </div>
if len(conflicts) == 0 { if len(conflicts) == 0 {
<div class="text-center py-16" style="color: var(--text-secondary)"> <div class="text-center py-16" style="color: var(--text-secondary)">
<div class="text-6xl mb-4"></div> <div class="text-6xl mb-4"></div>
@@ -94,20 +92,22 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
<p>Your devices are in sync! No conflicts to resolve.</p> <p>Your devices are in sync! No conflicts to resolve.</p>
</div> </div>
} }
for _, conflict := range conflicts { 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="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 justify-between items-start mb-4">
<div class="flex items-start space-x-4"> <div class="flex items-start space-x-4">
<input type="checkbox" class="conflict-checkbox w-5 h-5 rounded mt-1" <input
type="checkbox"
class="conflict-checkbox w-5 h-5 rounded mt-1"
data-conflict-id="{ conflict.ID }" data-conflict-id="{ conflict.ID }"
onchange="updateBulkActions()"> onchange="updateBulkActions()"
/>
<div> <div>
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">{ conflict.MediaItemTitle }</h3> <h3 class="text-lg font-semibold" style="color: var(--text-primary)">{ conflict.MediaItemTitle }</h3>
<p class="text-sm" style="color: var(--text-secondary)">Type: { conflict.ConflictType }</p> <p class="text-sm" style="color: var(--text-secondary)">Type: { conflict.ConflictType }</p>
</div> </div>
</div> </div>
<button onclick="showConflictModal('{ conflict.ID }')" class="btn-primary px-4 py-2 rounded-lg"> <button @click=\"showConflictModal('{ conflict.ID }')\" class="btn-primary px-4 py-2 rounded-lg">
Resolve Resolve
</button> </button>
</div> </div>
@@ -120,7 +120,6 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
</div> </div>
} }
</div> </div>
<div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto" style="background-color: rgba(0, 0, 0, 0.7);"> <div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto" style="background-color: rgba(0, 0, 0, 0.7);">
<div class="card rounded-lg p-6 w-full max-w-4xl mx-4 my-8" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div class="card rounded-lg p-6 w-full max-w-4xl mx-4 my-8" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
@@ -128,111 +127,118 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Resolve Conflict</h2> <h2 class="text-2xl font-bold" style="color: var(--text-primary)">Resolve Conflict</h2>
<p class="text-sm" id="conflict-book-title" style="color: var(--text-secondary);">Book Title</p> <p class="text-sm" id="conflict-book-title" style="color: var(--text-secondary);">Book Title</p>
</div> </div>
<button onclick="hideConflictModal()" class="p-2 hover:opacity-80 rounded-lg" style="color: var(--text-primary); background-color: var(--bg-primary);"> <button @click="hideConflictModal()" class="p-2 hover:opacity-80 rounded-lg" style="color: var(--text-primary); background-color: var(--bg-primary);">
X X
</button> </button>
</div> </div>
<form id="conflict-resolution-form" @submit="handleResolveConflict($event)">
<form id="conflict-resolution-form" onsubmit="handleResolveConflict(event)"> <input type="hidden" id="conflict-id"/>
<input type="hidden" id="conflict-id">
<div id="conflict-details" class="mb-6"></div> <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);"> <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> <h3 class="font-semibold mb-4" style="color: var(--text-primary)">Resolution Options</h3>
<div class="space-y-3 mb-6"> <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);"> <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> <input type="radio" name="winner" value="koreader" class="mr-3" required/>
<div class="flex-1"> <div class="flex-1">
<span class="font-semibold" style="color: var(--text-primary);">Keep KOReader Progress</span> <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> <p class="text-xs" style="color: var(--text-secondary);">Use progress from your KOReader device</p>
</div> </div>
</label> </label>
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);"> <label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
<input type="radio" name="winner" value="kobo" class="mr-3"> <input type="radio" name="winner" value="kobo" class="mr-3"/>
<div class="flex-1"> <div class="flex-1">
<span class="font-semibold" style="color: var(--text-primary);">Keep Kobo Progress</span> <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> <p class="text-xs" style="color: var(--text-secondary);">Use progress from your Kobo device</p>
</div> </div>
</label> </label>
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);"> <label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
<input type="radio" name="winner" value="web" class="mr-3"> <input type="radio" name="winner" value="web" class="mr-3"/>
<div class="flex-1"> <div class="flex-1">
<span class="font-semibold" style="color: var(--text-primary);">Keep Web Progress</span> <span class="font-semibold" style="color: var(--text-primary);">Keep Web Progress</span>
<p class="text-xs" style="color: var(--text-secondary);">Use progress from the web interface</p> <p class="text-xs" style="color: var(--text-secondary);">Use progress from the web interface</p>
</div> </div>
</label> </label>
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);"> <label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
<input type="radio" name="winner" value="manual" class="mr-3"> <input type="radio" name="winner" value="manual" class="mr-3"/>
<div class="flex-1"> <div class="flex-1">
<span class="font-semibold" style="color: var(--text-primary);">Manual Override</span> <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> <p class="text-xs" style="color: var(--text-secondary);">Specify a custom progress value</p>
</div> </div>
</label> </label>
</div> </div>
<div id="manual-override" class="hidden mb-4 p-4 border rounded-lg" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div id="manual-override" class="hidden mb-4 p-4 border rounded-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h4 class="font-semibold mb-3" style="color: var(--text-primary);">Manual Progress</h4> <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 class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Percentage (0-100)</label> <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" <input
type="number"
id="manual-percentage"
min="0"
max="100"
step="0.01"
class="w-full px-4 py-2 border rounded-lg" class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
/>
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Page Number</label> <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" <input
type="number"
id="manual-page"
min="1"
class="w-full px-4 py-2 border rounded-lg" class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
/>
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">EPUB CFI</label> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">EPUB CFI</label>
<input type="text" id="manual-epubcfi" <input
type="text"
id="manual-epubcfi"
class="w-full px-4 py-2 border rounded-lg" class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
/>
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Chapter</label> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Chapter</label>
<input type="number" id="manual-chapter" min="1" <input
type="number"
id="manual-chapter"
min="1"
class="w-full px-4 py-2 border rounded-lg" class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
/>
</div> </div>
</div> </div>
</div> </div>
<div class="mb-6"> <div class="mb-6">
<label class="flex items-center space-x-3 cursor-pointer"> <label class="flex items-center space-x-3 cursor-pointer">
<input type="checkbox" id="apply-to-all" class="w-5 h-5 rounded"> <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> <span style="color: var(--text-primary);">Apply this resolution to all future conflicts</span>
</label> </label>
</div> </div>
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Resolution Reason (optional)</label> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Resolution Reason (optional)</label>
<input type="text" id="resolution-reason" <input
type="text"
id="resolution-reason"
class="w-full px-4 py-2 border rounded-lg" class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="e.g., Device was offline, using most recent progress"> placeholder="e.g., Device was offline, using most recent progress"
/>
</div> </div>
</div> </div>
<div class="flex justify-end space-x-3"> <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" @click="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="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> <button type="submit" class="btn-primary px-6 py-2 rounded-lg">Resolve Conflict</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<script src="/static/conflicts.js"></script> <script src="/static/conflicts.js"></script>
@ErrorToast(errorMessage) @ErrorToast(errorMessage)
</body> </body>
</html> </html>
+50 -38
View File
@@ -24,94 +24,107 @@ templ Header(user User, currentPath string) {
</a> </a>
</div> </div>
</div> </div>
<!-- Center: Search Box --> <!-- Center: Search Box -->
<div class="flex-1 max-w-2xl mx-8"> <div class="flex-1 max-w-2xl mx-8">
<div class="relative"> <div class="relative">
<input type="text" <input
type="text"
id="header-search" id="header-search"
placeholder="Search your library..." placeholder="Search your library..."
class="w-full px-4 py-2 pl-10 border rounded-lg" class="w-full px-4 py-2 pl-10 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
autocomplete="off" autocomplete="off"
> />
<svg class="absolute left-3 top-2.5 h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="absolute left-3 top-2.5 h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg> </svg>
</div> </div>
</div> </div>
<!-- Right: Theme Switcher & User Menu --> <!-- Right: Theme Switcher & User Menu -->
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<!-- Theme Switcher --> <!-- Theme Switcher -->
<div class="relative"> <div class="relative">
<button onclick="toggleThemeDropdown()" class="p-2 rounded-lg hover:bg-gray-700 transition-colors" style="background-color: var(--bg-primary);"> <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"> <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 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> </svg>
</button> </button>
<div id="theme-dropdown" class="hidden absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50" style="background-color: var(--bg-secondary); border: 1px solid var(--border);"> <div id="theme-dropdown" class="hidden absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50" style="background-color: var(--bg-secondary); border: 1px solid var(--border);">
<div class="p-4"> <div class="p-4">
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">Select Theme</h3> <h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">Select Theme</h3>
<div class="space-y-2"> <div class="space-y-2">
<button 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);"> <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> <span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #7aa2f7;"></span>
Tokyo Night Tokyo Night
</button> </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);"> <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> <span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #bd93f9;"></span>
Dracula Dracula
</button> </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);"> <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> <span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #88c0d0;"></span>
Nord Nord
</button> </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);"> <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> <span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #2aa198;"></span>
Solarized Dark Solarized Dark
</button> </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);"> <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> <span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #a6e22e;"></span>
Monokai Monokai
</button> </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);"> <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> <span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #61dafb;"></span>
One Dark Pro One Dark Pro
</button> </button>
<button 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);"> <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> <span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #80cbc4;"></span>
Material Dark Material Dark
</button> </button>
<div class="border-t pt-2 mt-2" style="border-color: var(--border);"> <div class="border-t pt-2 mt-2" style="border-color: var(--border);">
<p class="text-xs mb-2" style="color: var(--text-secondary)">Bookshelf Background</p> <p class="text-xs mb-2" style="color: var(--text-secondary)">Bookshelf Background</p>
<button onclick="changeWoodPaneling('none')" <button
@click="changeWoodPaneling('none')"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);" style="color: var(--text-primary);"
data-wood="none"> data-wood="none"
>
None None
</button> </button>
<button onclick="changeWoodPaneling('wood-light')" <button
@click="changeWoodPaneling('wood-light')"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);" style="color: var(--text-primary);"
data-wood="wood-light"> 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> <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 Wood Light
</button> </button>
<button onclick="changeWoodPaneling('wood-dark')" <button
@click="changeWoodPaneling('wood-dark')"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);" style="color: var(--text-primary);"
data-wood="wood-dark"> 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> <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 Wood Dark
</button> </button>
<button onclick="changeWoodPaneling('wood-mahogany')" <button
@click="changeWoodPaneling('wood-mahogany')"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);" style="color: var(--text-primary);"
data-wood="wood-mahogany"> 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> <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 Wood Mahogany
</button> </button>
</div> </div>
@@ -119,20 +132,19 @@ templ Header(user User, currentPath string) {
</div> </div>
</div> </div>
</div> </div>
<!-- User Icon with Dropdown --> <!-- User Icon with Dropdown -->
<div class="relative"> <div class="relative">
if user.ID != "" { if user.ID != "" {
<!-- LOGGED IN: Show user menu with logout --> <!-- 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);"> <button @click="toggleUserMenu()" class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors" style="background-color: var(--bg-primary);">
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg> </svg>
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">{ user.Username }</span> <span class="text-sm hidden sm:block" style="color: var(--text-primary)">{ user.Username }</span>
</button> </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 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"> <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;"> <a href="/profile" class="block px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary); text-decoration: none;">
Profile Profile
</a> </a>
if user.Role == "admin" { if user.Role == "admin" {
@@ -141,29 +153,29 @@ templ Header(user User, currentPath string) {
</a> </a>
} }
<div class="border-t my-1" style="border-color: var(--border);"></div> <div class="border-t my-1" style="border-color: var(--border);"></div>
<button onclick="logout()" class="block w-full text-left px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary);"> <button @click="logout()" class="block w-full text-left px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary);">
Logout Logout
</button> </button>
</div> </div>
</div> </div>
} else { } else {
<!-- LOGGED OUT: Show login form --> <!-- LOGGED OUT: Show login form -->
<button onclick="toggleUserMenu()" class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors" style="background-color: var(--bg-primary);"> <button @click="toggleUserMenu()" class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors" style="background-color: var(--bg-primary);">
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg> </svg>
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">Login</span> <span class="text-sm hidden sm:block" style="color: var(--text-primary)">Login</span>
</button> </button>
<div id="user-menu" class="hidden absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50 p-4" style="background-color: var(--bg-secondary); border: 1px solid var(--border);"> <div id="user-menu" class="hidden absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50 p-4" style="background-color: var(--bg-secondary); border: 1px solid var(--border);">
<form hx-post="/api/auth/login" hx-target="#login-result" hx-swap="innerHTML" class="space-y-3"> <form hx-post="/api/auth/login" hx-target="#login-result" hx-swap="innerHTML" class="space-y-3">
<input type="hidden" name="redirect" value="{ currentPath }"> <input type="hidden" name="redirect" value="{ currentPath }"/>
<div> <div>
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Email or Username</label> <label class="block text-sm mb-1" style="color: var(--text-secondary)">Email or Username</label>
<input type="text" name="login" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required> <input type="text" name="login" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
</div> </div>
<div> <div>
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Password</label> <label class="block text-sm mb-1" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required> <input type="password" name="password" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
</div> </div>
<button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;"> <button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;">
Sign In Sign In
+9 -51
View File
@@ -4,30 +4,21 @@ templ Index(loggedIn bool) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Bookhoard - Home</title> <title>Bookhoard - Home</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script> <script src="/static/main.js"></script>
<link href="/static/style.css" rel="stylesheet"> <link href="/static/style.css" rel="stylesheet"/>
</head> </head>
<body class="theme-tokyo-night"> <body class="theme-tokyo-night" x-data="index" x-init="initIndexTheme(); checkAuthRedirect()">
<script>
// Progressive enhancement: check localStorage immediately
(function() {
const savedTheme = localStorage.getItem('theme');
if (savedTheme && savedTheme !== 'tokyo-night') {
document.body.className = 'theme-' + savedTheme;
}
})();
</script>
<!-- Hero Section --> <!-- Hero Section -->
<div class="relative overflow-hidden"> <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="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="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 class="flex justify-between items-start mb-8">
<div></div> <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()"> <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="tokyo-night">Tokyo Night</option>
<option value="dracula">Dracula</option> <option value="dracula">Dracula</option>
<option value="nord">Nord</option> <option value="nord">Nord</option>
@@ -41,7 +32,6 @@ templ Index(loggedIn bool) {
<option value="catppuccin-latte">Catppuccin Latte</option> <option value="catppuccin-latte">Catppuccin Latte</option>
</select> </select>
</div> </div>
<div class="text-center"> <div class="text-center">
<h1 class="text-4xl sm:text-6xl lg:text-7xl font-extrabold" style="color: var(--text-primary)"> <h1 class="text-4xl sm:text-6xl lg:text-7xl font-extrabold" style="color: var(--text-primary)">
📚 Bookhoard 📚 Bookhoard
@@ -57,7 +47,6 @@ templ Index(loggedIn bool) {
</div> </div>
</div> </div>
</div> </div>
<!-- Features Section --> <!-- Features Section -->
<div class="py-16" style="background-color: var(--bg-secondary)"> <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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@@ -65,7 +54,6 @@ templ Index(loggedIn bool) {
<h2 class="text-3xl font-extrabold" style="color: var(--text-primary)">Why Bookhoard?</h2> <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> <p class="mt-4 text-lg" style="color: var(--text-secondary)">Discover the features that make ebook management effortless</p>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8"> <div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="card p-6 rounded-xl border"> <div class="card p-6 rounded-xl border">
<div class="text-center"> <div class="text-center">
@@ -76,7 +64,6 @@ templ Index(loggedIn bool) {
<p style="color: var(--text-secondary)">Track your reading progress across all your ebooks with detailed statistics.</p> <p style="color: var(--text-secondary)">Track your reading progress across all your ebooks with detailed statistics.</p>
</div> </div>
</div> </div>
<div class="card p-6 rounded-xl border"> <div class="card p-6 rounded-xl border">
<div class="text-center"> <div class="text-center">
<div class="mx-auto w-12 h-12 bg-purple-500 rounded-lg flex items-center justify-center mb-4"> <div class="mx-auto w-12 h-12 bg-purple-500 rounded-lg flex items-center justify-center mb-4">
@@ -86,7 +73,6 @@ templ Index(loggedIn bool) {
<p style="color: var(--text-secondary)">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin variants.</p> <p style="color: var(--text-secondary)">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin variants.</p>
</div> </div>
</div> </div>
<div class="card p-6 rounded-xl border"> <div class="card p-6 rounded-xl border">
<div class="text-center"> <div class="text-center">
<div class="mx-auto w-12 h-12 bg-green-500 rounded-lg flex items-center justify-center mb-4"> <div class="mx-auto w-12 h-12 bg-green-500 rounded-lg flex items-center justify-center mb-4">
@@ -99,7 +85,6 @@ templ Index(loggedIn bool) {
</div> </div>
</div> </div>
</div> </div>
<!-- Auth Section --> <!-- Auth Section -->
if !loggedIn { if !loggedIn {
<div id="auth" class="py-16"> <div id="auth" class="py-16">
@@ -108,7 +93,6 @@ templ Index(loggedIn bool) {
<h2 class="text-3xl font-extrabold" style="color: var(--text-primary)">Join Bookhoard Today</h2> <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> <p class="mt-4 text-lg" style="color: var(--text-secondary)">Sign in to start managing your ebook library</p>
</div> </div>
<div class="grid grid-cols-1 gap-8"> <div class="grid grid-cols-1 gap-8">
<div class="card p-8 rounded-xl border"> <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> <h3 class="text-2xl font-semibold mb-6 text-center" style="color: var(--text-primary)">Login</h3>
@@ -116,11 +100,11 @@ templ Index(loggedIn bool) {
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email or Username</label> <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> <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>
<div> <div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label> <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> <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> </div>
<button type="submit" class="w-full btn-primary py-2 rounded-lg font-medium"> <button type="submit" class="w-full btn-primary py-2 rounded-lg font-medium">
Sign In Sign In
@@ -129,37 +113,11 @@ templ Index(loggedIn bool) {
</form> </form>
</div> </div>
</div> </div>
<div id="auth-result" class="mt-8 text-center"></div> <div id="auth-result" class="mt-8 text-center"></div>
</div> </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> </body>
</html> </html>
} }
+9 -23
View File
@@ -4,28 +4,18 @@ templ Login(sessionExpired bool, deleted bool) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Login</title> <title>Login</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script> <script src="/static/main.js"></script>
<script src="/static/theme.js"></script> <link href="/static/style.css" rel="stylesheet"/>
<link href="/static/style.css" rel="stylesheet">
</head> </head>
<body class="theme-tokyo-night min-h-screen"> <body class="theme-tokyo-night min-h-screen" x-data="login" x-init="initLoginTheme()">
<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="container mx-auto px-4 py-8 max-w-md">
<div class="flex justify-between items-center mb-8"> <div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold">Login to Bookhoard</h1> <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()"> <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="tokyo-night">Tokyo Night</option>
<option value="dracula">Dracula</option> <option value="dracula">Dracula</option>
<option value="nord">Nord</option> <option value="nord">Nord</option>
@@ -39,7 +29,6 @@ templ Login(sessionExpired bool, deleted bool) {
<option value="catppuccin-latte">Catppuccin Latte</option> <option value="catppuccin-latte">Catppuccin Latte</option>
</select> </select>
</div> </div>
if sessionExpired { if sessionExpired {
<div class="mb-4 p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--accent);"> <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);"> <p style="color: var(--text-primary);">
@@ -47,7 +36,6 @@ templ Login(sessionExpired bool, deleted bool) {
</p> </p>
</div> </div>
} }
if deleted { if deleted {
<div class="mb-4 p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--accent);"> <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);"> <p style="color: var(--text-primary);">
@@ -55,23 +43,20 @@ templ Login(sessionExpired bool, deleted bool) {
</p> </p>
</div> </div>
} }
<form hx-post="/api/auth/login" hx-target="#result" hx-swap="innerHTML" class="card p-6 rounded-lg shadow-md border"> <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"> <div class="mb-4">
<label class="block mb-2" style="color: var(--text-secondary)">Email or Username</label> <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> <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>
<div class="mb-4"> <div class="mb-4">
<label class="block mb-2" style="color: var(--text-secondary)">Password</label> <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> <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> </div>
<button type="submit" class="btn-primary w-full py-2 rounded"> <button type="submit" class="btn-primary w-full py-2 rounded">
Sign In Sign In
</button> </button>
</form> </form>
<div id="result" class="mt-4 text-center"></div> <div id="result" class="mt-4 text-center"></div>
<p class="text-center mt-4"> <p class="text-center mt-4">
<a href="/register" class="text-blue-500 hover:underline">Don't have an account? Sign Up</a> <a href="/register" class="text-blue-500 hover:underline">Don't have an account? Sign Up</a>
</p> </p>
@@ -79,3 +64,4 @@ templ Login(sessionExpired bool, deleted bool) {
</body> </body>
</html> </html>
} }
+5 -36
View File
@@ -4,65 +4,34 @@ templ Profile(user User) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"/>
<title>Profile Settings - Bookhoard</title> <title>Profile Settings - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script> <script src="/static/main.js"></script>
<script src="/static/header.js"></script> <link href="/static/style.css" rel="stylesheet"/>
<link href="/static/style.css" rel="stylesheet">
</head> </head>
<body class="theme-{ user.Theme }"> <body class="theme-{ user.Theme }" x-data="profile">
@Header(user, "/profile") @Header(user, "/profile")
<main class="max-w-4xl mx-auto px-4 py-8"> <main class="max-w-4xl mx-auto px-4 py-8">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Profile Settings</h1> <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> <p style="color: var(--text-secondary)">Manage your account information and preferences</p>
</div> </div>
<div class="space-y-8"> <div class="space-y-8">
<!-- Account Information & Password - uses reusable ProfileForm --> <!-- Account Information & Password - uses reusable ProfileForm -->
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)"> <div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
@ProfileForm(user, "/api/auth/profile", true, false, false) @ProfileForm(user, "/api/auth/profile", true, false, false)
</div> </div>
<!-- Danger Zone --> <!-- Danger Zone -->
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)"> <div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<h3 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Danger Zone</h3> <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> <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;">
<button onclick="confirmDeleteAccount()" class="px-4 py-2 rounded text-sm" style="background-color: #dc2626; color: white;">
Remove My Account Remove My Account
</button> </button>
</div> </div>
</div> </div>
</main> </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> </body>
</html> </html>
} }
+1 -1
View File
@@ -154,7 +154,7 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
<div class="flex justify-end gap-3 pt-6 border-t" style="border-color: var(--border);"> <div class="flex justify-end gap-3 pt-6 border-t" style="border-color: var(--border);">
if showCancelButton { if showCancelButton {
<button type="button" <button type="button"
onclick="closeProfileModal()" @click="closeProfileModal()"
class="px-4 py-2 rounded" class="px-4 py-2 rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"> style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);">
Cancel Cancel
+2 -18
View File
@@ -1,7 +1,7 @@
package templates package templates
templ ProfileModal(user User) { templ ProfileModal(user User) {
<div id="profile-modal" class="fixed inset-0 flex items-center justify-center z-50" style="background-color: rgba(0,0,0,0.5);"> <div id="profile-modal" class="fixed inset-0 flex items-center justify-center z-50" x-data="profileModal" x-init="setupProfileModal()" style="background-color: rgba(0,0,0,0.5);">
<div class="rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto" style="background-color: var(--bg-secondary);"> <div class="rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto" style="background-color: var(--bg-secondary);">
<!-- Modal Header --> <!-- Modal Header -->
<div class="flex justify-between items-center p-6 border-b" style="border-color: var(--border);"> <div class="flex justify-between items-center p-6 border-b" style="border-color: var(--border);">
@@ -12,7 +12,7 @@ templ ProfileModal(user User) {
</p> </p>
</div> </div>
<button <button
onclick="closeProfileModal()" @click="closeProfileModal()"
class="text-2xl" class="text-2xl"
style="color: var(--text-secondary);" style="color: var(--text-secondary);"
> >
@@ -26,20 +26,4 @@ templ ProfileModal(user User) {
</div> </div>
</div> </div>
</div> </div>
<script>
function closeProfileModal() {
const modal = document.getElementById('profile-modal');
if (modal) {
modal.remove();
}
}
// Close modal on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeProfileModal();
}
});
</script>
} }
+10 -22
View File
@@ -6,22 +6,20 @@ templ Progress(user User, progressData []handlers.ProgressWithMedia, errorMessag
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Reading Progress - Bookhoard</title> <title>Reading Progress - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script> <script src="/static/main.js"></script>
<link href="/static/style.css" rel="stylesheet"> <link href="/static/style.css" rel="stylesheet"/>
</head> </head>
<body class="theme-{ user.Theme }"> <body class="theme-{ user.Theme }" x-data="progress">
@Header(user, "/progress") @Header(user, "/progress")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8"> <div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Reading Progress</h1> <h1 class="text-3xl font-bold" style="color: var(--text-primary)">Reading Progress</h1>
<p style="color: var(--text-secondary)">Track your reading progress across all devices</p> <p style="color: var(--text-secondary)">Track your reading progress across all devices</p>
</div> </div>
<div id="progress-container" class="space-y-6"> <div id="progress-container" class="space-y-6">
if len(progressData) == 0 { if len(progressData) == 0 {
<div class="text-center py-16" style="color: var(--text-secondary)"> <div class="text-center py-16" style="color: var(--text-secondary)">
@@ -30,14 +28,16 @@ templ Progress(user User, progressData []handlers.ProgressWithMedia, errorMessag
<p>Start reading a book to track your progress</p> <p>Start reading a book to track your progress</p>
</div> </div>
} }
for _, item := range progressData { for _, item := range progressData {
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex gap-6"> <div class="flex gap-6">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<img src="{ item.CoverImagePath }" alt="Cover" <img
src="{ item.CoverImagePath }"
alt="Cover"
class="w-24 h-32 object-cover rounded" class="w-24 h-32 object-cover rounded"
onerror="this.src='/static/placeholder-book.svg'"> onerror="this.src='/static/placeholder-book.svg'"
/>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<div class="flex justify-between items-start mb-2"> <div class="flex justify-between items-start mb-2">
@@ -51,13 +51,11 @@ templ Progress(user User, progressData []handlers.ProgressWithMedia, errorMessag
<span class="text-2xl font-bold" style="color: var(--accent)">{ item.ProgressPercentage }%</span> <span class="text-2xl font-bold" style="color: var(--accent)">{ item.ProgressPercentage }%</span>
</div> </div>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<div class="w-full bg-gray-700 rounded-full h-3"> <div class="w-full bg-gray-700 rounded-full h-3">
<div class="h-3 rounded-full transition-all" style="width: { item.ProgressPercentage }%; background-color: var(--accent);"></div> <div class="h-3 rounded-full transition-all" style="width: { item.ProgressPercentage }%; background-color: var(--accent);"></div>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div> <div>
<p style="color: var(--text-secondary)">Current Position</p> <p style="color: var(--text-secondary)">Current Position</p>
@@ -77,7 +75,6 @@ templ Progress(user User, progressData []handlers.ProgressWithMedia, errorMessag
</div> </div>
</div> </div>
</div> </div>
if item.DeviceIcon != "" { if item.DeviceIcon != "" {
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);"> <div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<div class="flex items-center gap-2 text-xs" style="color: var(--text-secondary);"> <div class="flex items-center gap-2 text-xs" style="color: var(--text-secondary);">
@@ -87,7 +84,6 @@ templ Progress(user User, progressData []handlers.ProgressWithMedia, errorMessag
</div> </div>
</div> </div>
} }
if item.EpubCFI != "" { if item.EpubCFI != "" {
<div class="mt-2 text-xs" style="color: var(--text-secondary);"> <div class="mt-2 text-xs" style="color: var(--text-secondary);">
<span>📍</span> <span>📍</span>
@@ -100,14 +96,6 @@ templ Progress(user User, progressData []handlers.ProgressWithMedia, errorMessag
} }
</div> </div>
</div> </div>
<script>
function logout() {
localStorage.removeItem('token');
window.location.href = '/login';
}
</script>
@ErrorToast(errorMessage) @ErrorToast(errorMessage)
</body> </body>
</html> </html>
+9 -10
View File
@@ -10,11 +10,10 @@ templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Q
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Sync Queue - Bookhoard</title> <title>Sync Queue - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script> <script src="/static/main.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<script src="/static/theme.js"></script>
</head> </head>
<body class="theme-{ user.Theme }"> <body x-data="queue" class="theme-{ user.Theme }">
@Header(user, "/queue") @Header(user, "/queue")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8"> <div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8"> <div class="mb-8">
@@ -24,13 +23,13 @@ templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Q
<p style="color: var(--text-secondary)">Monitor and manage offline sync queue</p> <p style="color: var(--text-secondary)">Monitor and manage offline sync queue</p>
</div> </div>
<div class="flex space-x-3"> <div class="flex space-x-3">
<button onclick="processPendingItems()" class="btn-primary px-4 py-2 rounded-lg"> <button @click="processPendingItems()" class="btn-primary px-4 py-2 rounded-lg">
Process Queue Process Queue
</button> </button>
<button onclick="clearFailedItems()" class="btn-secondary px-4 py-2 rounded-lg"> <button @click="clearFailedItems()" class="btn-secondary px-4 py-2 rounded-lg">
Clear Failed Clear Failed
</button> </button>
<button onclick="clearAllItems()" class="btn-danger px-4 py-2 rounded-lg"> <button @click="clearAllItems()" class="btn-danger px-4 py-2 rounded-lg">
Clear All Clear All
</button> </button>
</div> </div>
@@ -98,7 +97,7 @@ templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Q
</select> </select>
</div> </div>
<div class="flex-1"></div> <div class="flex-1"></div>
<button onclick="refreshQueue()" class="btn-secondary px-4 py-2 rounded-lg"> <button @click="refreshQueue()" class="btn-secondary px-4 py-2 rounded-lg">
Refresh Refresh
</button> </button>
</div> </div>
@@ -151,14 +150,14 @@ templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Q
<div class="card rounded-lg p-6 w-full max-w-2xl mx-4 my-8" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div class="card rounded-lg p-6 w-full max-w-2xl mx-4 my-8" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Queue Item Details</h2> <h2 class="text-2xl font-bold" style="color: var(--text-primary)">Queue Item Details</h2>
<button onclick="hideQueueItemModal()" class="p-2 hover:opacity-80 rounded-lg" style="color: var(--text-primary); background-color: var(--bg-primary);"> <button @click="hideQueueItemModal()" class="p-2 hover:opacity-80 rounded-lg" style="color: var(--text-primary); background-color: var(--bg-primary);">
X X
</button> </button>
</div> </div>
<div id="queue-item-details"></div> <div id="queue-item-details"></div>
<div class="flex justify-end space-x-3 mt-6"> <div class="flex justify-end space-x-3 mt-6">
<button onclick="hideQueueItemModal()" class="btn-secondary px-4 py-2 rounded-lg">Close</button> <button @click="hideQueueItemModal()" class="btn-secondary px-4 py-2 rounded-lg">Close</button>
<button onclick="retryQueueItem()" class="btn-primary px-4 py-2 rounded-lg">Retry</button> <button @click="retryQueueItem()" class="btn-primary px-4 py-2 rounded-lg">Retry</button>
</div> </div>
</div> </div>
</div> </div>
+12 -32
View File
@@ -4,43 +4,32 @@ templ Register() {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Register</title> <title>Register</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script> <script src="/static/main.js"></script>
<script src="/static/theme.js"></script> <link href="/static/style.css" rel="stylesheet"/>
<link href="/static/style.css" rel="stylesheet">
</head> </head>
<body class="theme-tokyo-night min-h-screen"> <body class="theme-tokyo-night min-h-screen" x-data="register" x-init="initRegisterTheme()">
<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="container mx-auto px-4 py-8 max-w-md">
<h1 class="text-3xl font-bold text-center mb-8">Register for Bookhoard</h1> <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"> <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"> <div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email</label> <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> <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>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label> <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> <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>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label> <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)"> <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>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label> <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)"> <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>
<div id="password-requirements" class="mb-4 p-4 rounded-lg border text-sm" style="background-color: var(--bg-secondary); border-color: var(--border)"> <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> <p class="font-semibold mb-2" style="color: var(--text-primary)">Password Requirements:</p>
@@ -67,31 +56,22 @@ templ Register() {
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label> <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> <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>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm Password</label> <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> <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> </div>
<button type="submit" id="register-btn" class="btn-primary w-full py-2 rounded opacity-50 cursor-not-allowed" disabled> <button type="submit" id="register-btn" class="btn-primary w-full py-2 rounded opacity-50 cursor-not-allowed" disabled>
Register Register
</button> </button>
</form> </form>
<div id="result" class="mt-4 text-center"></div> <div id="result" class="mt-4 text-center"></div>
<p class="text-center mt-4"> <p class="text-center mt-4">
<a href="/login" class="text-blue-500 hover:underline">Already have an account? Login</a> <a href="/login" class="text-blue-500 hover:underline">Already have an account? Login</a>
</p> </p>
</div> </div>
<script src="/static/password_validation.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
if (window.initPasswordValidation) {
window.initPasswordValidation();
}
});
</script>
</body> </body>
</html> </html>
} }
@@ -1,11 +1,11 @@
package templates package templates
templ RestoreSystemCollectionModal() { templ RestoreSystemCollectionModal() {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70"> <div x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Restore System Collection</h2> <h2 class="text-xl font-bold" style="color: var(--text-primary)">Restore System Collection</h2>
<button type="button" onclick="closeCollectionModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button> <button type="button" @click="closeCollectionModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<p class="mb-4" style="color: var(--text-secondary)">Select a system collection to restore:</p> <p class="mb-4" style="color: var(--text-secondary)">Select a system collection to restore:</p>
@@ -21,7 +21,7 @@ templ RestoreSystemCollectionModal() {
<option value="not-started">Not Started</option> <option value="not-started">Not Started</option>
</select> </select>
<div class="flex justify-end space-x-3 mt-6"> <div class="flex justify-end space-x-3 mt-6">
<button type="button" onclick="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button> <button type="button" @click="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">Restore</button> <button type="submit" class="btn-primary px-4 py-2 rounded-lg">Restore</button>
</div> </div>
</form> </form>
+1 -28
View File
@@ -2,33 +2,6 @@ package templates
templ ErrorToast(message string) { templ ErrorToast(message string) {
if message != "" { if message != "" {
<script> <div x-data="toastError" x-init="showErrorToast('{ message }')"></div>
document.addEventListener('DOMContentLoaded', function() {
// Show error toast with 8 second auto-dismiss
window.showToast.error('{ message }', 8000);
// Add retry button to the toast
setTimeout(function() {
const toastContainer = document.getElementById('toast-container');
if (toastContainer && toastContainer.lastElementChild) {
const toast = toastContainer.lastElementChild;
const retryBtn = document.createElement('button');
retryBtn.className = 'ml-4 px-3 py-1 bg-white/20 hover:bg-white/30 rounded text-sm font-medium transition-colors';
retryBtn.textContent = 'Retry';
retryBtn.onclick = function() {
window.location.reload();
};
// Insert before the close button
const closeBtn = toast.querySelector('.toast-close');
if (closeBtn) {
closeBtn.parentElement.insertBefore(retryBtn, closeBtn);
} else {
toast.appendChild(retryBtn);
}
}
}, 100);
});
</script>
} }
} }
+49 -354
View File
@@ -4,43 +4,40 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Unlinked Books - Bookhoard</title> <title>Unlinked Books - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script> <script src="/static/main.js"></script>
<link href="/static/style.css" rel="stylesheet"> <link href="/static/style.css" rel="stylesheet"/>
</head> </head>
<body class="theme-{ user.Theme }"> <body class="theme-{ user.Theme }" x-data="unlinkedBooks" x-init="setupEventDelegation()">
@Header(user, "/unlinked") @Header(user, "/unlinked")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8"> <div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Unlinked Books</h1> <h1 class="text-3xl font-bold" style="color: var(--text-primary)">Unlinked Books</h1>
<p style="color: var(--text-secondary)">Resolve books that couldn't be automatically matched between devices</p> <p style="color: var(--text-secondary)">Resolve books that couldn't be automatically matched between devices</p>
</div> </div>
if len(unlinkedBooks) > 0 { 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 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"> <div class="flex items-center gap-4">
<input type="checkbox" id="select-all-unlinked" onchange="toggleAllUnlinked()" class="w-5 h-5"> <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> <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> <span id="selected-count" class="text-sm" style="color: var(--text-secondary)">0 selected</span>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button onclick="bulkAutoLink()" class="btn-secondary px-4 py-2 rounded-lg text-sm"> <button @click="bulkAutoLink()" class="btn-secondary px-4 py-2 rounded-lg text-sm">
🤖 Auto-Link Selected (High Confidence) 🤖 Auto-Link Selected (High Confidence)
</button> </button>
<button onclick="bulkGetSuggestions()" class="btn-secondary px-4 py-2 rounded-lg text-sm"> <button @click="bulkGetSuggestions()" class="btn-secondary px-4 py-2 rounded-lg text-sm">
💡 Get Suggestions for Selected 💡 Get Suggestions for Selected
</button> </button>
<button onclick="showBulkManualLink()" class="btn-primary px-4 py-2 rounded-lg text-sm"> <button @click="showBulkManualLink()" class="btn-primary px-4 py-2 rounded-lg text-sm">
🔗 Bulk Manual Link 🔗 Bulk Manual Link
</button> </button>
</div> </div>
</div> </div>
} }
<div id="unlinked-container" class="space-y-6"> <div id="unlinked-container" class="space-y-6">
if len(unlinkedBooks) == 0 { if len(unlinkedBooks) == 0 {
<div class="text-center py-16" style="color: var(--text-secondary)"> <div class="text-center py-16" style="color: var(--text-secondary)">
@@ -49,12 +46,11 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
<p>There are no unlinked books that need manual resolution.</p> <p>There are no unlinked books that need manual resolution.</p>
</div> </div>
} }
for _, book := range unlinkedBooks { for _, book := range unlinkedBooks {
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex gap-6"> <div class="flex gap-6">
<div class="flex items-start pt-2"> <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()"> <input type="checkbox" class="unlinked-checkbox w-5 h-5" data-progress-id="{ book.ProgressID }" data-title="{ book.TitleFromDevice }" onchange="updateSelectedCount()"/>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
@@ -74,7 +70,6 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
</p> </p>
</div> </div>
</div> </div>
<div class="space-y-2 text-sm mb-4"> <div class="space-y-2 text-sm mb-4">
<div> <div>
<span style="color: var(--text-secondary)">File Path:</span> <span style="color: var(--text-secondary)">File Path:</span>
@@ -103,18 +98,20 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
</div> </div>
} }
</div> </div>
<div class="mb-4"> <div class="mb-4">
<button onclick="searchMatches('{ book.ProgressID }', '{ book.SHA256 }', '{ book.TitleFromDevice }')" <button
class="btn-secondary px-4 py-2 rounded-lg text-sm"> @click="searchMatches('{ book.ProgressID }', '{ book.SHA256 }', '{ book.TitleFromDevice }')"
class="btn-secondary px-4 py-2 rounded-lg text-sm"
>
🔍 Find Potential Matches 🔍 Find Potential Matches
</button> </button>
<button onclick="showManualLinkModal('{ book.ProgressID }', '{ book.TitleFromDevice }')" <button
class="btn-primary px-4 py-2 rounded-lg text-sm"> @click="showManualLinkModal('{ book.ProgressID }', '{ book.TitleFromDevice }')"
class="btn-primary px-4 py-2 rounded-lg text-sm"
>
🔗 Manually Link 🔗 Manually Link
</button> </button>
</div> </div>
<div id="matches-{ book.ProgressID }" class="hidden"> <div id="matches-{ book.ProgressID }" class="hidden">
<div class="border-t pt-4" style="border-color: var(--border);"> <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> <h4 class="font-semibold mb-3" style="color: var(--text-primary)">Potential Matches</h4>
@@ -129,373 +126,71 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
} }
</div> </div>
</div> </div>
<!-- Manual Link Modal --> <!-- 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 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="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Manually Link Book</h2> <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> <button @click="hideManualLinkModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
</div> </div>
<input type="hidden" id="link-progress-id"/>
<input type="hidden" id="link-progress-id"> <input type="hidden" id="link-book-sha256"/>
<input type="hidden" id="link-book-sha256">
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Book Title from Device</label> <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 <input
type="text"
id="link-book-title"
readonly
class="w-full px-4 py-2 border rounded-lg" class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-secondary); border-color: var(--border);"> style="background-color: var(--bg-primary); color: var(--text-secondary); border-color: var(--border);"
/>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Search for Book to Link</label> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Search for Book to Link</label>
<div class="flex gap-2"> <div class="flex gap-2">
<input type="text" id="link-search-input" placeholder="Search by title, author..." <input
type="text"
id="link-search-input"
placeholder="Search by title, author..."
class="flex-1 px-4 py-2 border rounded-lg" class="flex-1 px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
<button type="button" onclick="searchBooksForLink()" />
class="btn-primary px-4 py-2 rounded-lg"> <button
type="button"
@click="searchBooksForLink()"
class="btn-primary px-4 py-2 rounded-lg"
>
Search Search
</button> </button>
</div> </div>
</div> </div>
<div id="link-search-results" class="mb-4 max-h-48 overflow-y-auto space-y-2"> <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> <p style="color: var(--text-secondary)">Search for books to link</p>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Confidence Score</label> <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" <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" class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
/>
<p class="text-xs mt-1" style="color: var(--text-secondary)">1.0 = manual override (highest confidence)</p> <p class="text-xs mt-1" style="color: var(--text-secondary)">1.0 = manual override (highest confidence)</p>
</div> </div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" onclick="hideManualLinkModal()" class="btn-secondary px-4 py-2 rounded-lg"> <button type="button" @click="hideManualLinkModal()" class="btn-secondary px-4 py-2 rounded-lg">
Cancel Cancel
</button> </button>
<button type="button" onclick="confirmManualLink()" class="btn-primary px-4 py-2 rounded-lg"> <button type="button" @click="confirmManualLink()" class="btn-primary px-4 py-2 rounded-lg">
Link Book Link Book
</button> </button>
</div> </div>
</div> </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> </body>
</html> </html>
} }