Add clickable links to book detail page (/media/:uuid) from: - Dashboard: BookCard components now link to detail page - Changed from data-action pattern to direct <a> tags - Removes unused viewBook() function and switch case - Follows progressive enhancement (works without JS) - Collections: Book titles link to detail page - Books displayed in collection detail view - Progress: Book titles link to detail page - Progress cards now have clickable title links All links use direct navigation for better UX and progressive enhancement. Book detail page can display comprehensive metadata, reading progress, sync status, and external service links.
376 lines
13 KiB
Templ
376 lines
13 KiB
Templ
package templates
|
||
|
||
import "bookhoard/internal/handlers"
|
||
|
||
templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||
<title>Collections - Bookhoard</title>
|
||
<script src="/static/htmx.min.js"></script>
|
||
<link href="/static/style.css" rel="stylesheet"/>
|
||
</head>
|
||
<body x-data="collections" x-init="initializeCollectionWebSocket(); setupHTMXModalInit()" class="theme-{ user.Theme }">
|
||
@Header(user, "/collections")
|
||
<!-- Modal Container -->
|
||
<div id="modal-container"></div>
|
||
<!-- Actual container page -->
|
||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||
<div class="mb-8 flex justify-between items-center">
|
||
<div>
|
||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">My Collections</h1>
|
||
<p style="color: var(--text-secondary)">Organize your books into custom collections</p>
|
||
</div>
|
||
<div class="flex gap-3">
|
||
<button
|
||
hx-get="/collections/restore-modal"
|
||
hx-target="#modal-container"
|
||
hx-swap="innerHTML"
|
||
class="btn-secondary px-4 py-2 rounded-lg"
|
||
>
|
||
🔄 Restore System
|
||
</button>
|
||
<button
|
||
hx-get="/collections/create-modal"
|
||
hx-target="#modal-container"
|
||
hx-swap="innerHTML"
|
||
class="btn-primary px-4 py-2 rounded-lg"
|
||
>
|
||
➕ New Collection
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div id="collections-list" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
if len(collections) == 0 {
|
||
<div class="text-center py-16 col-span-full" style="color: var(--text-secondary)">
|
||
<div class="text-6xl mb-4">📚</div>
|
||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Collections Yet</h3>
|
||
<p class="mb-4">Create collections to organize your books</p>
|
||
<button
|
||
hx-get="/collections/create-modal"
|
||
hx-target="#modal-container"
|
||
hx-swap="innerHTML"
|
||
class="btn-primary px-4 py-2 rounded-lg"
|
||
>
|
||
Create Your First Collection
|
||
</button>
|
||
</div>
|
||
}
|
||
for _, col := range collections {
|
||
<div @click="navigateToCollection($el)" data-href={ "/collections/" + col.ID } class="block">
|
||
<div
|
||
class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow"
|
||
style="background-color: var(--bg-secondary);"
|
||
data-color={ col.Color }
|
||
>
|
||
<div class="flex justify-between items-start mb-4">
|
||
<div class="text-3xl">{ col.Icon }</div>
|
||
<div class="flex space-x-2">
|
||
<button
|
||
hx-get={ "/collections/" + col.ID + "/edit-modal" }
|
||
hx-target="#modal-container"
|
||
hx-swap="innerHTML"
|
||
class="p-2 hover:opacity-80 rounded"
|
||
style="color: var(--text-secondary); background-color: var(--bg-primary);"
|
||
>
|
||
✏️
|
||
</button>
|
||
<button
|
||
hx-delete={ "/api/collections/" + col.ID + "" }
|
||
hx-redirect="/collections"
|
||
hx-confirm="Are you sure you want to delete this collection?"
|
||
class="p-2 hover:opacity-80 rounded"
|
||
style="color: var(--text-secondary); background-color: var(--bg-primary);"
|
||
>
|
||
🗑️
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ col.Name }</h3>
|
||
<p class="text-sm mb-4" style="color: var(--text-secondary)">{ col.Description }</p>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
@ErrorToast(errorMessage)
|
||
</body>
|
||
</html>
|
||
}
|
||
|
||
templ CollectionDetail(user User, collection CollectionData, books []handlers.BookInfo, libraryID string) {
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||
<title>{ collection.Name } - Bookhoard</title>
|
||
<script src="/static/htmx.min.js"></script>
|
||
<link href="/static/style.css" rel="stylesheet"/>
|
||
</head>
|
||
<body x-data="collections" x-init="initializeCollectionWebSocket()" class="theme-{ user.Theme }">
|
||
@Header(user, "/collections")
|
||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||
<div class="mb-6">
|
||
<button @click="backToCollections" class="btn-secondary px-4 py-2 rounded-lg mb-4">
|
||
← Back to Collections
|
||
</button>
|
||
<div class="flex items-center gap-4">
|
||
<div class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div>
|
||
<div>
|
||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
|
||
<p style="color: var(--text-secondary)">{ collection.Description }</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mb-6 flex justify-between items-center">
|
||
<div class="flex items-center gap-4">
|
||
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Books in this Collection</h2>
|
||
<span id="selected-count" class="hidden px-3 py-1 text-sm rounded" style="background-color: var(--accent); color: var(--bg-primary);">
|
||
0 selected
|
||
</span>
|
||
</div>
|
||
<div class="flex gap-3">
|
||
<div class="flex-1 max-w-md">
|
||
<input
|
||
type="text"
|
||
id="collection-search"
|
||
placeholder="Search within collection..."
|
||
onkeyup="filterCollectionBooks()"
|
||
class="w-full px-4 py-2 border rounded-lg"
|
||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||
/>
|
||
</div>
|
||
<button
|
||
id="bulk-remove-btn"
|
||
disabled
|
||
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
🗑️ Remove Selected
|
||
</button>
|
||
<button
|
||
@click="$store.bookPicker.open()"
|
||
class="btn-primary px-4 py-2 rounded-lg"
|
||
>
|
||
➕ Add Books
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||
if len(books) == 0 {
|
||
<div id="empty-state" class="col-span-full text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
|
||
}
|
||
for _, book := range books {
|
||
<a href={ "/media/" + book.MediaItemID }>
|
||
<div
|
||
class="card p-4 rounded-lg border hover:shadow-lg transition-shadow"
|
||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||
>
|
||
<div class="flex gap-4">
|
||
<!-- Checkbox and Book Info -->
|
||
<div class="flex-shrink-0 pt-1">
|
||
<input
|
||
type="checkbox"
|
||
onchange="toggleBookForRemoval('{ book.MediaItemID }')"
|
||
class="w-5 h-5"
|
||
/>
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<h3
|
||
class="font-semibold text-lg mb-1 line-clamp-2"
|
||
style="color: var(--text-primary)"
|
||
>
|
||
{ book.Title }
|
||
</h3>
|
||
if book.Author != "" {
|
||
<p
|
||
class="text-sm line-clamp-1"
|
||
style="color: var(--text-secondary)"
|
||
>
|
||
by { book.Author }
|
||
</p>
|
||
}
|
||
</div>
|
||
<!-- Book Cover -->
|
||
<div class="flex-shrink-0 w-16 sm:w-20">
|
||
if book.CoverImagePath != "" {
|
||
<img
|
||
src={ book.CoverImagePath }
|
||
alt="Cover"
|
||
class="w-full aspect-[3/4] object-cover rounded shadow-md"
|
||
onerror="this.src='/static/placeholder-book.svg'"
|
||
/>
|
||
} else {
|
||
<img
|
||
src="/static/placeholder-book.svg"
|
||
alt="Cover"
|
||
class="w-full aspect-[3/4] object-cover rounded shadow-md"
|
||
/>
|
||
}
|
||
</div>
|
||
</div>
|
||
<!-- Remove Button -->
|
||
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
||
<button
|
||
@click="removeBook('{ book.MediaItemID }')"
|
||
class="px-3 py-1 text-sm border rounded hover:opacity-80"
|
||
style="border-color: var(--border); color: var(--text-secondary);"
|
||
>
|
||
🗑️ Remove from Collection
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
}
|
||
</div>
|
||
</div>
|
||
<!-- Book Picker Modal -->
|
||
<!-- CRITICAL: Selection state stored in Alpine.store to persist across HTMX swaps -->
|
||
<div
|
||
x-data="bookPicker"
|
||
@keyup.escape.window="$store.bookPicker.close()"
|
||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||
style="display: none;"
|
||
>
|
||
<!-- Modal content with same filtering UI as bookshelf + multi-select -->
|
||
<!-- CRITICAL: x-show manages visibility, style="display: none;" prevents FOUC -->
|
||
<!-- Following ALPINE_COMPLETION_GUIDE.md principles: Alpine state for UI, not class="hidden" -->
|
||
<div
|
||
@click.stop
|
||
x-show="$store.bookPicker.isOpen"
|
||
x-transition:enter="transition ease-out duration-200"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-150"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
class="card rounded-lg w-full max-w-6xl mx-4 my-8"
|
||
style="background-color: var(--bg-secondary); border-color: var(--border); display: none;"
|
||
>
|
||
<div
|
||
class="flex justify-between items-center p-6 border-b"
|
||
style="border-color: var(--border);"
|
||
>
|
||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">
|
||
Add Books to Collection
|
||
</h2>
|
||
<button
|
||
@click="$store.bookPicker.close()"
|
||
class="p-2 hover:opacity-80 rounded"
|
||
style="color: var(--text-primary)"
|
||
>✕</button>
|
||
</div>
|
||
<!-- Filter Bar (same as bookshelf) -->
|
||
<div
|
||
class="p-4 border-b"
|
||
style="border-color: var(--border);"
|
||
>
|
||
<div class="flex flex-wrap gap-4 items-center">
|
||
<!-- Search -->
|
||
<div class="flex-1 min-w-[200px]">
|
||
<input
|
||
type="text"
|
||
name="search"
|
||
placeholder="Search books..."
|
||
class="w-full px-3 py-2 border rounded-lg"
|
||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
|
||
hx-target="#book-picker-grid"
|
||
hx-trigger="keyup changed delay:300ms"
|
||
hx-include="#book-picker-filters"
|
||
/>
|
||
</div>
|
||
<!-- Author -->
|
||
<div class="flex-1 min-w-[150px]">
|
||
<input
|
||
type="text"
|
||
name="author_filter"
|
||
placeholder="Author"
|
||
class="w-full px-3 py-2 border rounded-lg"
|
||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
|
||
hx-target="#book-picker-grid"
|
||
hx-trigger="change"
|
||
hx-include="#book-picker-filters"
|
||
/>
|
||
</div>
|
||
<!-- Genre -->
|
||
<div class="flex-1 min-w-[150px]">
|
||
<input
|
||
type="text"
|
||
name="genre_filter"
|
||
placeholder="Genre"
|
||
class="w-full px-3 py-2 border rounded-lg"
|
||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
|
||
hx-target="#book-picker-grid"
|
||
hx-trigger="change"
|
||
hx-include="#book-picker-filters"
|
||
/>
|
||
</div>
|
||
<!-- Clear -->
|
||
<div>
|
||
<button
|
||
@click="$store.bookPicker.clearFilters()"
|
||
class="px-4 py-2 rounded-lg border"
|
||
style="border-color: var(--border); color: var(--text-primary);"
|
||
>
|
||
✕ Clear
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<!-- Hidden form for HTMX -->
|
||
<!-- NOTE: class="hidden" is acceptable here because form is never shown to user -->
|
||
<!-- HTMX uses it for parameter inclusion only -->
|
||
<form id="filter-form" class="hidden">
|
||
<input type="hidden" name="limit" value="50"/>
|
||
<input type="hidden" name="offset" value="0"/>
|
||
</form>
|
||
</div>
|
||
<!-- Books Grid with Multi-select -->
|
||
<!-- CRITICAL: HTMX swaps this div's content, but Alpine.store preserves selection state -->
|
||
<div
|
||
id="book-picker-grid"
|
||
class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4 max-h-96 overflow-y-auto"
|
||
>
|
||
<!-- Books loaded via HTMX -->
|
||
</div>
|
||
<!-- Selected Count & Submit -->
|
||
<div
|
||
class="p-4 border-t flex justify-between items-center"
|
||
style="border-color: var(--border);"
|
||
>
|
||
<div class="text-sm" style="color: var(--text-secondary);">
|
||
<span x-text="$store.bookPicker.selectedCount"></span> books selected
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<button
|
||
@click="$store.bookPicker.close()"
|
||
class="px-4 py-2 rounded-lg border"
|
||
style="border-color: var(--border); color: var(--text-primary);"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
@click="$store.bookPicker.submit()"
|
||
class="px-4 py-2 rounded-lg font-medium"
|
||
style="background-color: var(--accent); color: var(--bg-primary);"
|
||
>
|
||
Add Selected Books
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
<div
|
||
id="collection-data"
|
||
data-id="{collection.ID}"
|
||
data-library-id="{ libraryID }"
|
||
style="display: none;"
|
||
></div>
|
||
</html>
|
||
}
|