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:
@@ -76,6 +76,7 @@ type BookInfo struct {
|
||||
|
||||
type SectionData struct {
|
||||
ID string `json:"id"`
|
||||
CollectionID string `json:"collection_id"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
|
||||
@@ -40,6 +40,9 @@ func (h *DashboardHandler) GetSections(c echo.Context) error {
|
||||
prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
||||
|
||||
limit := 20
|
||||
if prefs.ItemsPerSection.Valid {
|
||||
limit = int(prefs.ItemsPerSection.Int32)
|
||||
}
|
||||
if limitStr := c.QueryParam("limit"); limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||
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"})
|
||||
}
|
||||
|
||||
// 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{
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
@@ -158,12 +166,13 @@ func BuildSections(sections []services.DashboardSection) []SectionData {
|
||||
|
||||
result = append(result, SectionData{
|
||||
ID: ds.CollectionName,
|
||||
CollectionID: ds.CollectionID.String(),
|
||||
IsSystem: ds.IsSystem,
|
||||
Title: ds.Title,
|
||||
Description: ds.Description,
|
||||
Icon: ds.Icon,
|
||||
Items: bookCards,
|
||||
ViewAllURL: getViewAllURL(ds.CollectionName, ds.QueryType),
|
||||
ViewAllURL: getViewAllURL(ds.CollectionID.String()),
|
||||
Priority: ds.Priority,
|
||||
})
|
||||
}
|
||||
@@ -171,15 +180,34 @@ func BuildSections(sections []services.DashboardSection) []SectionData {
|
||||
return result
|
||||
}
|
||||
|
||||
func getViewAllURL(key, queryType string) string {
|
||||
urls := map[string]string{
|
||||
"continue-reading": "/section/continue-reading",
|
||||
"recently-added": "/section/recently-added",
|
||||
"recently-read": "/history",
|
||||
"not-started": "/section/not-started",
|
||||
}
|
||||
if url, exists := urls[queryType]; exists {
|
||||
return url
|
||||
func getViewAllURL(collectionID string) string {
|
||||
if collectionID != "" {
|
||||
return "/collections/" + collectionID
|
||||
}
|
||||
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
@@ -10,6 +10,7 @@ import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/utils"
|
||||
"bookhoard/templates"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -137,27 +138,38 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
libUUID, _ := uuid.Parse(libraryID)
|
||||
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
|
||||
if prefs.ItemsPerSection.Int32 > 0 {
|
||||
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(),
|
||||
userUUID,
|
||||
libUUID,
|
||||
limit,
|
||||
prefs.CollectionOrder,
|
||||
prefs.HiddenCollections,
|
||||
[]string{}, // No filtering - get all sections
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Dashboard sections query failed: %v", err)
|
||||
sections = []services.DashboardSection{}
|
||||
allSections = []services.DashboardSection{}
|
||||
errorMsg = "Error loading dashboard"
|
||||
}
|
||||
|
||||
// Get only visible sections for the dashboard display
|
||||
visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections)
|
||||
|
||||
userUUID2, _ := uuid.Parse(user.ID)
|
||||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID2))
|
||||
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
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -225,6 +238,105 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
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
|
||||
frontendProtected.GET("/custom-section", func(c echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
|
||||
@@ -196,7 +196,7 @@ func (s *DashboardService) GetDashboardSections(
|
||||
})
|
||||
}
|
||||
|
||||
results = s.filterHiddenCollections(results, hiddenCollections)
|
||||
results = s.FilterHiddenCollections(results, hiddenCollections)
|
||||
results = s.reorderCollections(results, collectionOrder)
|
||||
|
||||
if len(collectionOrder) == 0 {
|
||||
@@ -206,7 +206,7 @@ func (s *DashboardService) GetDashboardSections(
|
||||
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 {
|
||||
return sections
|
||||
}
|
||||
@@ -373,6 +373,30 @@ func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, param
|
||||
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 {
|
||||
defaultMetadata := map[string]struct {
|
||||
Description string
|
||||
|
||||
+101
-59
@@ -6,16 +6,15 @@ 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">
|
||||
<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">
|
||||
<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>
|
||||
@@ -26,8 +25,7 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||
➕ New Collection
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="collections-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<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>
|
||||
@@ -38,20 +36,27 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
for _, col := range collections {
|
||||
<div class="card p-6 rounded-lg border cursor-pointer hover:shadow-lg transition-shadow"
|
||||
<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 }')">
|
||||
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
|
||||
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
|
||||
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>
|
||||
@@ -62,32 +67,34 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||
}
|
||||
</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
|
||||
<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">
|
||||
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"
|
||||
<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>
|
||||
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">
|
||||
@@ -98,9 +105,8 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||
<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">
|
||||
<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
|
||||
@@ -112,7 +118,6 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedColor = '#7aa2f7';
|
||||
|
||||
@@ -207,7 +212,6 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
</script>
|
||||
|
||||
@ErrorToast(errorMessage)
|
||||
</body>
|
||||
</html>
|
||||
@@ -217,16 +221,15 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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">
|
||||
<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">
|
||||
@@ -240,7 +243,6 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
</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>
|
||||
@@ -250,13 +252,21 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1 max-w-md">
|
||||
<input type="text" id="collection-search" placeholder="Search within collection..."
|
||||
<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);">
|
||||
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">
|
||||
<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">
|
||||
@@ -264,41 +274,72 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
</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"
|
||||
<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 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'">
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-lg mb-1 line-clamp-2" style="color: var(--text-primary)">{ book.Title }</h3>
|
||||
<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" style="color: var(--text-secondary)">by { book.Author }</p>
|
||||
<p
|
||||
class="text-sm line-clamp-1"
|
||||
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
|
||||
</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>
|
||||
|
||||
<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">
|
||||
@@ -307,9 +348,13 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
</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..."
|
||||
<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);">
|
||||
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">
|
||||
@@ -322,7 +367,6 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let collectionId = '{ collection.ID }';
|
||||
let selectedBooks = new Set();
|
||||
@@ -588,8 +632,6 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
renderBooks();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -39,7 +39,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -70,7 +70,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -83,7 +83,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -138,7 +138,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -159,7 +159,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -172,7 +172,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -185,7 +185,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -209,7 +209,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -227,7 +227,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
Reference in New Issue
Block a user