feat: Add collection detail page with /collections/:id route

Add comprehensive collection detail page that works for both system collections
(continue-reading, recently-added, not-started) and user collections.

Backend changes:
- Add new /collections/:id route in internal/router/frontend.go
  - Fetches collection using GetCollection with UUID parameter
  - Determines collection type from QueryType field
  - Resolves library_id for system collections
  - Converts database.MediaItems to handlers.BookInfo for display
  - Renders CollectionDetail template with collection and books data

- Update SectionData struct in internal/handlers/collections.go
  - Add CollectionID string field for view all links

- Update BuildSections() in internal/handlers/dashboard.go
  - Pass CollectionID to SectionData for proper link generation

- Simplify getViewAllURL() in internal/handlers/dashboard.go
  - Return /collections/{collectionID} instead of /section/{type}
  - Works uniformly for both system and user collections

Frontend changes:
- Fix CollectionDetail template in templates/collections.templ
  - Fix broken div nesting causing compilation error
  - Add null check for CoverImagePath to prevent broken images
  - Update aspect ratio to modern aspect-[3/4] syntax
  - Use responsive widths (w-16 sm:w-20) for mobile/desktop
  - Improve card layout with horizontal flex structure
  - Add placeholder image fallback for books without covers
  - Remove erroneous renderBooks() function call

This change aligns with the backend update where system collections are
now pre-made user collections in the database with query_type fields.
All collections can now use the same CollectionDetail template for a
consistent viewing experience.
This commit is contained in:
2026-03-01 00:28:54 -05:00
parent fd608f3e3f
commit 0b666f3fdd
6 changed files with 481 additions and 274 deletions
+271 -229
View File
@@ -3,117 +3,122 @@ 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>
<script src="/static/toast.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/collections")
<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>
<button onclick="showCreateModal()" class="btn-primary px-4 py-2 rounded-lg">
New Collection
</button>
</div>
<div id="collections-container" 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 onclick="showCreateModal()" class="btn-primary px-4 py-2 rounded-lg">
Create Your First Collection
</button>
</div>
}
for _, col := range collections {
<div class="card p-6 rounded-lg border cursor-pointer hover:shadow-lg transition-shadow"
style="background-color: var(--bg-secondary); border-color: { col.Color }; border-left-width: 4px; border-left-style: solid;"
onclick="viewCollection('{ col.ID }')">
<div class="flex justify-between items-start mb-4">
<div class="text-3xl">{ col.Icon }</div>
<div class="flex space-x-2">
<button onclick="event.stopPropagation(); editCollection('{ col.ID }')"
class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
✏️
</button>
<button onclick="event.stopPropagation(); deleteCollection('{ col.ID }')"
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 id="create-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Collection</h2>
<button onclick="hideCreateModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
</div>
<form id="create-form" onsubmit="handleCreate(event)">
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name</label>
<input type="text" id="collection-name" required
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="My Reading List">
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
<textarea id="collection-description"
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="Optional description"
rows="3"></textarea>
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label>
<div class="flex gap-2">
<button type="button" onclick="selectColor('#7aa2f7')" class="w-8 h-8 rounded-full color-option" style="background-color: #7aa2f7;"></button>
<button type="button" onclick="selectColor('#f7768e')" class="w-8 h-8 rounded-full color-option" style="background-color: #f7768e;"></button>
<button type="button" onclick="selectColor('#e0af68')" class="w-8 h-8 rounded-full color-option" style="background-color: #e0af68;"></button>
<button type="button" onclick="selectColor('#9ece6a')" class="w-8 h-8 rounded-full color-option" style="background-color: #9ece6a;"></button>
<button type="button" onclick="selectColor('#7dcfff')" class="w-8 h-8 rounded-full color-option" style="background-color: #7dcfff;"></button>
<button type="button" onclick="selectColor('#bb9af7')" class="w-8 h-8 rounded-full color-option" style="background-color: #bb9af7;"></button>
</div>
<input type="hidden" id="collection-color" value="#7aa2f7">
</div>
<div class="flex justify-end space-x-3">
<button type="button" onclick="hideCreateModal()" class="btn-secondary px-4 py-2 rounded-lg">
Cancel
</button>
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
Create Collection
</button>
</div>
</form>
</div>
</div>
<script>
<!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>
<script src="/static/toast.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/collections")
<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>
<button onclick="showCreateModal()" class="btn-primary px-4 py-2 rounded-lg">
New Collection
</button>
</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 onclick="showCreateModal()" class="btn-primary px-4 py-2 rounded-lg">
Create Your First Collection
</button>
</div>
}
for _, col := range collections {
<div
class="card p-6 rounded-lg border cursor-pointer hover:shadow-lg transition-shadow"
style="background-color: var(--bg-secondary); border-color: { col.Color }; border-left-width: 4px; border-left-style: solid;"
onclick="viewCollection('{ col.ID }')"
>
<div class="flex justify-between items-start mb-4">
<div class="text-3xl">{ col.Icon }</div>
<div class="flex space-x-2">
<button
onclick="event.stopPropagation(); editCollection('{ col.ID }')"
class="p-2 hover:opacity-80 rounded"
style="color: var(--text-secondary); background-color: var(--bg-primary);"
>
✏️
</button>
<button
onclick="event.stopPropagation(); deleteCollection('{ col.ID }')"
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 id="create-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Collection</h2>
<button onclick="hideCreateModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
</div>
<form id="create-form" onsubmit="handleCreate(event)">
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name</label>
<input
type="text"
id="collection-name"
required
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="My Reading List"
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
<textarea
id="collection-description"
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="Optional description"
rows="3"
></textarea>
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label>
<div class="flex gap-2">
<button type="button" onclick="selectColor('#7aa2f7')" class="w-8 h-8 rounded-full color-option" style="background-color: #7aa2f7;"></button>
<button type="button" onclick="selectColor('#f7768e')" class="w-8 h-8 rounded-full color-option" style="background-color: #f7768e;"></button>
<button type="button" onclick="selectColor('#e0af68')" class="w-8 h-8 rounded-full color-option" style="background-color: #e0af68;"></button>
<button type="button" onclick="selectColor('#9ece6a')" class="w-8 h-8 rounded-full color-option" style="background-color: #9ece6a;"></button>
<button type="button" onclick="selectColor('#7dcfff')" class="w-8 h-8 rounded-full color-option" style="background-color: #7dcfff;"></button>
<button type="button" onclick="selectColor('#bb9af7')" class="w-8 h-8 rounded-full color-option" style="background-color: #bb9af7;"></button>
</div>
<input type="hidden" id="collection-color" value="#7aa2f7"/>
</div>
<div class="flex justify-end space-x-3">
<button type="button" onclick="hideCreateModal()" class="btn-secondary px-4 py-2 rounded-lg">
Cancel
</button>
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
Create Collection
</button>
</div>
</form>
</div>
</div>
<script>
let selectedColor = '#7aa2f7';
function showCreateModal() {
@@ -207,123 +212,162 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
window.location.href = '/login';
}
</script>
@ErrorToast(errorMessage)
</body>
</html>
@ErrorToast(errorMessage)
</body>
</html>
}
templ CollectionDetail(user User, collection CollectionData, books []handlers.BookInfo) {
<!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>
<script src="/static/toast.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/collections")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6">
<button onclick="backToCollections()" class="btn-secondary px-4 py-2 rounded-lg mb-4">
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" onclick="removeSelectedBooks()" disabled
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed">
🗑️ Remove Selected
</button>
<button onclick="showAddBooksModal()" class="btn-primary px-4 py-2 rounded-lg">
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 {
<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 items-start gap-4">
<input type="checkbox"
onchange="toggleBookForRemoval('{ book.MediaItemID }')"
class="w-5 h-5 mt-2">
<div class="aspect-w-3 aspect-h-4 flex-shrink-0 w-24 mb-3 overflow-hidden rounded">
<img src="{ book.CoverImagePath }" alt="Cover"
class="w-full h-32 object-cover rounded"
onerror="this.src='/static/placeholder-book.svg'">
</div>
<div class="flex-1">
<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" style="color: var(--text-secondary)">by { book.Author }</p>
}
<button onclick="removeBook('{ book.MediaItemID }')"
class="mt-2 px-3 py-1 text-sm border rounded hover:opacity-80"
style="border-color: var(--border); color: var(--text-secondary);">
Remove
</button>
</div>
</div>
</div>
}
</div>
</div>
<div id="add-books-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-2xl mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Books to Collection</h2>
<button onclick="hideAddBooksModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
</div>
<p class="mb-4" style="color: var(--text-secondary)">Search and select books to add to this collection.</p>
<div class="mb-4">
<input type="text" id="book-search" placeholder="Search books..."
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
<div id="book-results" class="max-h-64 overflow-y-auto mb-4"></div>
<div class="flex justify-end space-x-3">
<button type="button" onclick="hideAddBooksModal()" class="btn-secondary px-4 py-2 rounded-lg">
Cancel
</button>
<button type="button" onclick="addSelectedBooks()" class="btn-primary px-4 py-2 rounded-lg">
Add Selected Books
</button>
</div>
</div>
</div>
<script>
<!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>
<script src="/static/toast.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/collections")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6">
<button onclick="backToCollections()" class="btn-secondary px-4 py-2 rounded-lg mb-4">
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"
onclick="removeSelectedBooks()"
disabled
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
🗑️ Remove Selected
</button>
<button onclick="showAddBooksModal()" class="btn-primary px-4 py-2 rounded-lg">
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 {
<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
onclick="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>
}
</div>
</div>
<div id="add-books-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-2xl mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Books to Collection</h2>
<button onclick="hideAddBooksModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
</div>
<p class="mb-4" style="color: var(--text-secondary)">Search and select books to add to this collection.</p>
<div class="mb-4">
<input
type="text"
id="book-search"
placeholder="Search books..."
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
/>
</div>
<div id="book-results" class="max-h-64 overflow-y-auto mb-4"></div>
<div class="flex justify-end space-x-3">
<button type="button" onclick="hideAddBooksModal()" class="btn-secondary px-4 py-2 rounded-lg">
Cancel
</button>
<button type="button" onclick="addSelectedBooks()" class="btn-primary px-4 py-2 rounded-lg">
Add Selected Books
</button>
</div>
</div>
</div>
<script>
let collectionId = '{ collection.ID }';
let selectedBooks = new Set();
let booksToRemove = new Set();
@@ -588,9 +632,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
localStorage.removeItem('token');
window.location.href = '/login';
}
renderBooks();
</script>
</body>
</html>
</body>
</html>
}