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
+9 -8
View File
@@ -75,14 +75,15 @@ type BookInfo struct {
} }
type SectionData struct { type SectionData struct {
ID string `json:"id"` ID string `json:"id"`
IsSystem bool `json:"is_system"` CollectionID string `json:"collection_id"`
Title string `json:"title"` IsSystem bool `json:"is_system"`
Description string `json:"description"` Title string `json:"title"`
Icon string `json:"icon"` Description string `json:"description"`
Items []BookInfo `json:"items"` Icon string `json:"icon"`
ViewAllURL string `json:"view_all_url"` Items []BookInfo `json:"items"`
Priority int `json:"priority"` ViewAllURL string `json:"view_all_url"`
Priority int `json:"priority"`
} }
func (h *CollectionHandler) CreateCollection(c echo.Context) error { func (h *CollectionHandler) CreateCollection(c echo.Context) error {
+45 -17
View File
@@ -40,6 +40,9 @@ func (h *DashboardHandler) GetSections(c echo.Context) error {
prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID) prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
limit := 20 limit := 20
if prefs.ItemsPerSection.Valid {
limit = int(prefs.ItemsPerSection.Int32)
}
if limitStr := c.QueryParam("limit"); limitStr != "" { if limitStr := c.QueryParam("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l limit = l
@@ -83,6 +86,11 @@ func (h *DashboardHandler) UpdatePreferences(c echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
} }
// Sanitize preferences (remove duplicates)
cleanHidden, cleanOrder := h.dashboardService.SanitizeDashboardPreferences(req.HiddenCollections, req.CollectionOrder)
req.HiddenCollections = cleanHidden
req.CollectionOrder = cleanOrder
prefs, err := h.dashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{ prefs, err := h.dashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
@@ -157,29 +165,49 @@ func BuildSections(sections []services.DashboardSection) []SectionData {
} }
result = append(result, SectionData{ result = append(result, SectionData{
ID: ds.CollectionName, ID: ds.CollectionName,
IsSystem: ds.IsSystem, CollectionID: ds.CollectionID.String(),
Title: ds.Title, IsSystem: ds.IsSystem,
Description: ds.Description, Title: ds.Title,
Icon: ds.Icon, Description: ds.Description,
Items: bookCards, Icon: ds.Icon,
ViewAllURL: getViewAllURL(ds.CollectionName, ds.QueryType), Items: bookCards,
Priority: ds.Priority, ViewAllURL: getViewAllURL(ds.CollectionID.String()),
Priority: ds.Priority,
}) })
} }
return result return result
} }
func getViewAllURL(key, queryType string) string { func getViewAllURL(collectionID string) string {
urls := map[string]string{ if collectionID != "" {
"continue-reading": "/section/continue-reading", return "/collections/" + collectionID
"recently-added": "/section/recently-added",
"recently-read": "/history",
"not-started": "/section/not-started",
}
if url, exists := urls[queryType]; exists {
return url
} }
return "" return ""
} }
func (h *DashboardHandler) GetPreferences(c echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
libraryID := c.QueryParam("library_id")
// ✅ Add validation
if libraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
}
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
prefs, err := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Preferences not found"})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"hidden_collections": prefs.HiddenCollections,
"collection_order": prefs.CollectionOrder,
"items_per_section": prefs.ItemsPerSection.Int32,
})
}
+120 -8
View File
@@ -10,6 +10,7 @@ import (
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/handlers" "bookhoard/internal/handlers"
"bookhoard/internal/services" "bookhoard/internal/services"
"bookhoard/internal/utils"
"bookhoard/templates" "bookhoard/templates"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@@ -137,27 +138,38 @@ func registerFrontendRoutes(cfg *Config) {
libUUID, _ := uuid.Parse(libraryID) libUUID, _ := uuid.Parse(libraryID)
userUUID, _ := uuid.Parse(user.ID) userUUID, _ := uuid.Parse(user.ID)
prefs, _ := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID) prefs, err := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
if err != nil {
log.Printf("GetDashboardPreferences failed: %v", err)
prefs = database.UserDashboardPreferences{
HiddenCollections: []string{},
CollectionOrder: []string{},
ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true},
}
}
limit := 20 limit := 20
if prefs.ItemsPerSection.Int32 > 0 { if prefs.ItemsPerSection.Int32 > 0 {
limit = int(prefs.ItemsPerSection.Int32) limit = int(prefs.ItemsPerSection.Int32)
} }
var sections []services.DashboardSection
sections, err = cfg.DashboardService.GetDashboardSections( // Get ALL sections (unfiltered) for the modal
allSections, err := cfg.DashboardService.GetDashboardSections(
c.Request().Context(), c.Request().Context(),
userUUID, userUUID,
libUUID, libUUID,
limit, limit,
prefs.CollectionOrder, prefs.CollectionOrder,
prefs.HiddenCollections, []string{}, // No filtering - get all sections
) )
if err != nil { if err != nil {
log.Printf("Dashboard sections query failed: %v", err) log.Printf("Dashboard sections query failed: %v", err)
sections = []services.DashboardSection{} allSections = []services.DashboardSection{}
errorMsg = "Error loading dashboard" errorMsg = "Error loading dashboard"
} }
// Get only visible sections for the dashboard display
visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections)
userUUID2, _ := uuid.Parse(user.ID) userUUID2, _ := uuid.Parse(user.ID)
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID2)) libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID2))
if err != nil { if err != nil {
@@ -179,10 +191,11 @@ func registerFrontendRoutes(cfg *Config) {
} }
} }
sectionData := handlers.BuildSections(sections) sectionData := handlers.BuildSections(visibleSections)
allSectionsData := handlers.BuildSections(allSections)
var buf bytes.Buffer var buf bytes.Buffer
err = templates.Dashboard(user, sectionData, libData, libraryID, errorMsg).Render(c.Request().Context(), &buf) err = templates.Dashboard(user, sectionData, allSectionsData, libData, libraryID, prefs.HiddenCollections, limit, errorMsg).Render(c.Request().Context(), &buf)
if err != nil { if err != nil {
return err return err
} }
@@ -225,6 +238,105 @@ func registerFrontendRoutes(cfg *Config) {
return c.HTML(http.StatusOK, buf.String()) return c.HTML(http.StatusOK, buf.String())
}) })
// Collection detail page (works for both system and user collections)
frontendProtected.GET("/collections/:id", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
// Parse collection ID from URL
collectionID := c.Param("id")
collUUID, err := uuid.Parse(collectionID)
if err != nil {
return renderErrorPage(c, "Invalid collection ID", "invalid_id")
}
// Fetch collection details
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if err != nil {
if err.Error() == "no rows in result set" {
return renderErrorPage(c, "Collection not found", "not_found")
}
return renderErrorPage(c, "Error loading collection", "collection_load_error")
}
// Fetch books in collection
userUUID, _ := uuid.Parse(user.ID)
var books []handlers.BookInfo
if collection.QueryType.Valid && collection.QueryType.String != "" {
// System collection - use query type
// System collection - need library_id for system collections
// Get library_id from query param or default to user's first library
libraryID := c.QueryParam("library_id")
if libraryID == "" {
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
if err == nil && len(libraries) > 0 {
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
libraryID = libUUID.String()
}
}
libUUID, _ := uuid.Parse(libraryID)
dashboardSvc := services.NewDashboardService(cfg.Queries)
sections, err := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
if err != nil {
return renderErrorPage(c, "Error loading books", "books_load_error")
}
// Find the matching section and convert items
for _, section := range sections {
if section.CollectionID.String() == collectionID {
// Convert []database.MediaItems to []handlers.BookInfo
bookCards := make([]handlers.BookInfo, len(section.Items))
for i, item := range section.Items {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards[i] = handlers.BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: getText(item.Author),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
}
}
books = bookCards
break
}
}
} else {
// User collection - fetch collection items
collItems, err := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if err != nil {
books = []handlers.BookInfo{}
}
// Convert to BookInfo format
bookCards := make([]handlers.BookInfo, len(collItems))
for i, item := range collItems {
itemUUID, _ := uuid.FromBytes(item.MediaItemID.Bytes[0:16])
bookCards[i] = handlers.BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: getText(item.Author),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
}
}
books = bookCards
}
// Build collection data
colData := templates.CollectionData{
ID: collectionID,
Name: collection.Name,
Description: collection.Description.String,
Color: collection.Color.String,
Icon: collection.Icon.String,
}
// Render the CollectionDetail template
var buf bytes.Buffer
err = templates.CollectionDetail(user, colData, books).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Custom Section Builder page // Custom Section Builder page
frontendProtected.GET("/custom-section", func(c echo.Context) error { frontendProtected.GET("/custom-section", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg) user, err := getTemplateUserWithTheme(c, cfg)
+26 -2
View File
@@ -196,7 +196,7 @@ func (s *DashboardService) GetDashboardSections(
}) })
} }
results = s.filterHiddenCollections(results, hiddenCollections) results = s.FilterHiddenCollections(results, hiddenCollections)
results = s.reorderCollections(results, collectionOrder) results = s.reorderCollections(results, collectionOrder)
if len(collectionOrder) == 0 { if len(collectionOrder) == 0 {
@@ -206,7 +206,7 @@ func (s *DashboardService) GetDashboardSections(
return results, nil return results, nil
} }
func (s *DashboardService) filterHiddenCollections(sections []DashboardSection, hidden []string) []DashboardSection { func (s *DashboardService) FilterHiddenCollections(sections []DashboardSection, hidden []string) []DashboardSection {
if len(hidden) == 0 { if len(hidden) == 0 {
return sections return sections
} }
@@ -373,6 +373,30 @@ func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, param
return s.db.UpsertDashboardPreferences(ctx, params) return s.db.UpsertDashboardPreferences(ctx, params)
} }
func (s *DashboardService) SanitizeDashboardPreferences(hiddenCollections, collectionOrder []string) ([]string, []string) {
// Deduplicate collection_order while preserving order
seen := make(map[string]bool)
var sanitizedOrder []string
for _, id := range collectionOrder {
if !seen[id] {
seen[id] = true
sanitizedOrder = append(sanitizedOrder, id)
}
}
// Deduplicate hidden_collections
seen = make(map[string]bool)
var sanitizedHidden []string
for _, id := range hiddenCollections {
if !seen[id] {
seen[id] = true
sanitizedHidden = append(sanitizedHidden, id)
}
}
return sanitizedHidden, sanitizedOrder
}
func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID uuid.UUID, collectionName string, resetType string) error { func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID uuid.UUID, collectionName string, resetType string) error {
defaultMetadata := map[string]struct { defaultMetadata := map[string]struct {
Description string Description string
+271 -229
View File
@@ -3,117 +3,122 @@ package templates
import "bookhoard/internal/handlers" import "bookhoard/internal/handlers"
templ Collection(user User, collections []CollectionData, errorMessage string) { templ Collection(user User, collections []CollectionData, errorMessage string) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Collections - Bookhoard</title> <title>Collections - 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/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 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-8 flex justify-between items-center">
<div class="mb-8 flex justify-between items-center"> <div>
<div> <h1 class="text-3xl font-bold" style="color: var(--text-primary)">My Collections</h1>
<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>
<p style="color: var(--text-secondary)">Organize your books into custom collections</p> </div>
</div> <button onclick="showCreateModal()" class="btn-primary px-4 py-2 rounded-lg">
<button onclick="showCreateModal()" class="btn-primary px-4 py-2 rounded-lg"> New Collection
New Collection </button>
</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 id="collections-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="text-center py-16 col-span-full" style="color: var(--text-secondary)">
if len(collections) == 0 { <div class="text-6xl mb-4">📚</div>
<div class="text-center py-16 col-span-full" style="color: var(--text-secondary)"> <h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Collections Yet</h3>
<div class="text-6xl mb-4">📚</div> <p class="mb-4">Create collections to organize your books</p>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Collections Yet</h3> <button onclick="showCreateModal()" class="btn-primary px-4 py-2 rounded-lg">
<p class="mb-4">Create collections to organize your books</p> Create Your First Collection
<button onclick="showCreateModal()" class="btn-primary px-4 py-2 rounded-lg"> </button>
Create Your First Collection </div>
</button> }
</div> for _, col := range collections {
} <div
class="card p-6 rounded-lg border cursor-pointer hover:shadow-lg transition-shadow"
for _, col := range collections { style="background-color: var(--bg-secondary); border-color: { col.Color }; border-left-width: 4px; border-left-style: solid;"
<div class="card p-6 rounded-lg border cursor-pointer hover:shadow-lg transition-shadow" onclick="viewCollection('{ col.ID }')"
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="flex justify-between items-start mb-4"> <div class="text-3xl">{ col.Icon }</div>
<div class="text-3xl">{ col.Icon }</div> <div class="flex space-x-2">
<div class="flex space-x-2"> <button
<button onclick="event.stopPropagation(); editCollection('{ col.ID }')" onclick="event.stopPropagation(); editCollection('{ col.ID }')"
class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);"> 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>
🗑️ <button
</button> onclick="event.stopPropagation(); deleteCollection('{ col.ID }')"
</div> class="p-2 hover:opacity-80 rounded"
</div> style="color: var(--text-secondary); background-color: var(--bg-primary);"
<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> </button>
} </div>
</div> </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 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>
<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>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Collection</h2> </div>
<button onclick="hideCreateModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button> <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> <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">
<form id="create-form" onsubmit="handleCreate(event)"> <h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Collection</h2>
<div class="mb-4"> <button onclick="hideCreateModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name</label> </div>
<input type="text" id="collection-name" required <form id="create-form" onsubmit="handleCreate(event)">
class="w-full px-4 py-2 border rounded-lg" <div class="mb-4">
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name</label>
placeholder="My Reading List"> <input
</div> type="text"
id="collection-name"
<div class="mb-4"> required
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label> class="w-full px-4 py-2 border rounded-lg"
<textarea id="collection-description" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="w-full px-4 py-2 border rounded-lg" placeholder="My Reading List"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" />
placeholder="Optional description" </div>
rows="3"></textarea> <div class="mb-4">
</div> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
<textarea
<div class="mb-6"> id="collection-description"
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label> class="w-full px-4 py-2 border rounded-lg"
<div class="flex gap-2"> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
<button type="button" onclick="selectColor('#7aa2f7')" class="w-8 h-8 rounded-full color-option" style="background-color: #7aa2f7;"></button> placeholder="Optional description"
<button type="button" onclick="selectColor('#f7768e')" class="w-8 h-8 rounded-full color-option" style="background-color: #f7768e;"></button> rows="3"
<button type="button" onclick="selectColor('#e0af68')" class="w-8 h-8 rounded-full color-option" style="background-color: #e0af68;"></button> ></textarea>
<button type="button" onclick="selectColor('#9ece6a')" class="w-8 h-8 rounded-full color-option" style="background-color: #9ece6a;"></button> </div>
<button type="button" onclick="selectColor('#7dcfff')" class="w-8 h-8 rounded-full color-option" style="background-color: #7dcfff;"></button> <div class="mb-6">
<button type="button" onclick="selectColor('#bb9af7')" class="w-8 h-8 rounded-full color-option" style="background-color: #bb9af7;"></button> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label>
</div> <div class="flex gap-2">
<input type="hidden" id="collection-color" value="#7aa2f7"> <button type="button" onclick="selectColor('#7aa2f7')" class="w-8 h-8 rounded-full color-option" style="background-color: #7aa2f7;"></button>
</div> <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>
<div class="flex justify-end space-x-3"> <button type="button" onclick="selectColor('#9ece6a')" class="w-8 h-8 rounded-full color-option" style="background-color: #9ece6a;"></button>
<button type="button" onclick="hideCreateModal()" class="btn-secondary px-4 py-2 rounded-lg"> <button type="button" onclick="selectColor('#7dcfff')" class="w-8 h-8 rounded-full color-option" style="background-color: #7dcfff;"></button>
Cancel <button type="button" onclick="selectColor('#bb9af7')" class="w-8 h-8 rounded-full color-option" style="background-color: #bb9af7;"></button>
</button> </div>
<button type="submit" class="btn-primary px-4 py-2 rounded-lg"> <input type="hidden" id="collection-color" value="#7aa2f7"/>
Create Collection </div>
</button> <div class="flex justify-end space-x-3">
</div> <button type="button" onclick="hideCreateModal()" class="btn-secondary px-4 py-2 rounded-lg">
</form> Cancel
</div> </button>
</div> <button type="submit" class="btn-primary px-4 py-2 rounded-lg">
Create Collection
<script> </button>
</div>
</form>
</div>
</div>
<script>
let selectedColor = '#7aa2f7'; let selectedColor = '#7aa2f7';
function showCreateModal() { function showCreateModal() {
@@ -207,123 +212,162 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
window.location.href = '/login'; window.location.href = '/login';
} }
</script> </script>
@ErrorToast(errorMessage)
@ErrorToast(errorMessage) </body>
</body> </html>
</html>
} }
templ CollectionDetail(user User, collection CollectionData, books []handlers.BookInfo) { templ CollectionDetail(user User, collection CollectionData, books []handlers.BookInfo) {
<!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.Name } - Bookhoard</title> <title>{ 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/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 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 onclick="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"> <div class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div>
<div class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div> <div>
<div> <h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1> <p style="color: var(--text-secondary)">{ collection.Description }</p>
<p style="color: var(--text-secondary)">{ collection.Description }</p> </div>
</div> </div>
</div> </div>
</div> <div class="mb-6 flex justify-between items-center">
<div class="flex items-center gap-4">
<div class="mb-6 flex justify-between items-center"> <h2 class="text-xl font-semibold" style="color: var(--text-primary)">Books in this Collection</h2>
<div class="flex items-center gap-4"> <span id="selected-count" class="hidden px-3 py-1 text-sm rounded" style="background-color: var(--accent); color: var(--bg-primary);">
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Books in this Collection</h2> 0 selected
<span id="selected-count" class="hidden px-3 py-1 text-sm rounded" style="background-color: var(--accent); color: var(--bg-primary);"> </span>
0 selected </div>
</span> <div class="flex gap-3">
</div> <div class="flex-1 max-w-md">
<div class="flex gap-3"> <input
<div class="flex-1 max-w-md"> type="text"
<input type="text" id="collection-search" placeholder="Search within collection..." id="collection-search"
onkeyup="filterCollectionBooks()" placeholder="Search within collection..."
class="w-full px-4 py-2 border rounded-lg" onkeyup="filterCollectionBooks()"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> class="w-full px-4 py-2 border rounded-lg"
</div> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
<button id="bulk-remove-btn" onclick="removeSelectedBooks()" disabled />
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"> </div>
🗑️ Remove Selected <button
</button> id="bulk-remove-btn"
<button onclick="showAddBooksModal()" class="btn-primary px-4 py-2 rounded-lg"> onclick="removeSelectedBooks()"
Add Books disabled
</button> class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
</div> >
</div> 🗑️ Remove Selected
</button>
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <button onclick="showAddBooksModal()" class="btn-primary px-4 py-2 rounded-lg">
if len(books) == 0 { Add Books
<div id="empty-state" class="col-span-full text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div> </button>
} </div>
</div>
for _, book := range books { <div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div class="card p-4 rounded-lg border hover:shadow-lg transition-shadow" if len(books) == 0 {
style="background-color: var(--bg-secondary); border-color: var(--border);"> <div id="empty-state" class="col-span-full text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
<div class="flex items-start gap-4"> }
<input type="checkbox" for _, book := range books {
onchange="toggleBookForRemoval('{ book.MediaItemID }')" <div
class="w-5 h-5 mt-2"> class="card p-4 rounded-lg border hover:shadow-lg transition-shadow"
<div class="aspect-w-3 aspect-h-4 flex-shrink-0 w-24 mb-3 overflow-hidden rounded"> style="background-color: var(--bg-secondary); border-color: var(--border);"
<img src="{ book.CoverImagePath }" alt="Cover" >
class="w-full h-32 object-cover rounded" <div class="flex gap-4">
onerror="this.src='/static/placeholder-book.svg'"> <!-- Checkbox and Book Info -->
</div> <div class="flex-shrink-0 pt-1">
<div class="flex-1"> <input
<h3 class="font-semibold text-lg mb-1 line-clamp-2" style="color: var(--text-primary)">{ book.Title }</h3> type="checkbox"
if book.Author != "" { onchange="toggleBookForRemoval('{ book.MediaItemID }')"
<p class="text-sm" style="color: var(--text-secondary)">by { book.Author }</p> class="w-5 h-5"
} />
<button onclick="removeBook('{ book.MediaItemID }')" </div>
class="mt-2 px-3 py-1 text-sm border rounded hover:opacity-80" <div class="flex-1 min-w-0">
style="border-color: var(--border); color: var(--text-secondary);"> <h3
Remove class="font-semibold text-lg mb-1 line-clamp-2"
</button> style="color: var(--text-primary)"
</div> >
</div> { book.Title }
</div> </h3>
} if book.Author != "" {
</div> <p
</div> class="text-sm line-clamp-1"
style="color: var(--text-secondary)"
<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);"> by { book.Author }
<div class="flex justify-between items-center mb-6"> </p>
<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>
</div> <!-- Book Cover -->
<p class="mb-4" style="color: var(--text-secondary)">Search and select books to add to this collection.</p> <div class="flex-shrink-0 w-16 sm:w-20">
<div class="mb-4"> if book.CoverImagePath != "" {
<input type="text" id="book-search" placeholder="Search books..." <img
class="w-full px-4 py-2 border rounded-lg" src={ book.CoverImagePath }
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> alt="Cover"
</div> class="w-full aspect-[3/4] object-cover rounded shadow-md"
<div id="book-results" class="max-h-64 overflow-y-auto mb-4"></div> onerror="this.src='/static/placeholder-book.svg'"
<div class="flex justify-end space-x-3"> />
<button type="button" onclick="hideAddBooksModal()" class="btn-secondary px-4 py-2 rounded-lg"> } else {
Cancel <img
</button> src="/static/placeholder-book.svg"
<button type="button" onclick="addSelectedBooks()" class="btn-primary px-4 py-2 rounded-lg"> alt="Cover"
Add Selected Books class="w-full aspect-[3/4] object-cover rounded shadow-md"
</button> />
</div> }
</div> </div>
</div> </div>
<!-- Remove Button -->
<script> <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 collectionId = '{ collection.ID }';
let selectedBooks = new Set(); let selectedBooks = new Set();
let booksToRemove = new Set(); let booksToRemove = new Set();
@@ -588,9 +632,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
localStorage.removeItem('token'); localStorage.removeItem('token');
window.location.href = '/login'; window.location.href = '/login';
} }
renderBooks();
</script> </script>
</body> </body>
</html> </html>
} }
+10 -10
View File
@@ -39,7 +39,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<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 templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -57,7 +57,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 47, Col: 60} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 46, Col: 40}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -70,7 +70,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 59, Col: 109} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 64, Col: 91}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -83,7 +83,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 60, Col: 103} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 65, Col: 85}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -138,7 +138,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 222, Col: 32} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 226, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -159,7 +159,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 236, Col: 95} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 239, Col: 81}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -172,7 +172,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 238, Col: 107} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 241, Col: 90}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -185,7 +185,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 239, Col: 88} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 242, Col: 71}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -209,7 +209,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 286, Col: 131} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 301, Col: 108}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -227,7 +227,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 288, Col: 108} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 303, Col: 82}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {