feat(ui): rebuild shared chrome and core browse/read pages

- Header: sticky app-wide, active nav highlighting, data-driven theme
  picker with active swatch, unified SVG icons, deduped login fragment
  (was duplicated verbatim for desktop + mobile), removed stray console.log
- LibrarySwitcher: cleaner sticky bar, SVG action icons, theme-aware
- BookCard: the atomic unit now has a real surface (was floating text
  when wood-paneling was off due to an undefined --wood-border), with
  cohesive shadow + hover lift; reused across dashboard/bookshelf/series
- Dashboard: calmer section headers, snap carousels with SVG chevrons
- Bookshelf: recompose the filter wall into a search toolbar + collapsible
  Filters panel (all 9 filters + save/load/clear preserved), redesigned
  empty state and pagination
- Book detail, series, collections, browse: cohesive cards, SVG action
  icons, uppercase muted labels, theme-aware badges/links
This commit is contained in:
2026-08-05 16:01:09 -04:00
parent 6f271d624f
commit 28e40e302c
19 changed files with 3127 additions and 2593 deletions
+240 -486
View File
@@ -4,9 +4,9 @@ import (
"bookhoard/internal/handlers"
"fmt"
"net/url"
"strings"
"github.com/microcosm-cc/bluemonday"
"strings"
)
templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
@@ -19,177 +19,128 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body x-data="bookDetail" class="theme-{ user.Theme }" data-format-group={ book.FormatGroup } data-library-id={ uuidToString(book.LibraryID) } data-rating={ fmt.Sprintf("%d", getBookRating(book.Rating)) } data-conflict-id={ conflictID(book.ActiveConflict) } data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }>
<body
x-data="bookDetail"
class="theme-{ user.Theme }"
data-format-group={ book.FormatGroup }
data-library-id={ uuidToString(book.LibraryID) }
data-rating={ fmt.Sprintf("%d", getBookRating(book.Rating)) }
data-conflict-id={ conflictID(book.ActiveConflict) }
data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }
>
@Header(user, "/media/{ uuidToString(book.ID) }")
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
<div class="w-full px-4 py-3 flex items-center gap-4">
<a id="back-link" href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Back
<!-- Back bar -->
<div class="sticky top-16 z-30 border-b border-line bg-surface/95 backdrop-blur">
<div class="w-full px-4 sm:px-6 lg:px-8 py-2.5">
<a id="back-link" href="/dashboard" class="inline-flex items-center gap-1.5 text-sm font-medium text-content-muted transition-colors hover:text-content">
@Icon("arrow-left", "h-4 w-4")
Back
</a>
</div>
</div>
<div class="xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 py-8">
<!-- Top Section: Cover + Basic Info + Actions -->
<div class="flex flex-col md:flex-row gap-8 mb-8">
<!-- Left: Cover Image (256x384px) -->
<div class="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8 py-8">
<!-- Cover + header -->
<div class="flex flex-col gap-8 md:flex-row">
<div class="flex-shrink-0">
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
<img
src={ book.CoverImagePath.String }
alt={ book.Title }
class="w-64 h-96 object-cover rounded-lg shadow-xl"
onerror="this.src='/static/placeholder-book.svg'"
/>
} else {
<img
src="/static/placeholder-book.svg"
alt="{ book.Title }"
class="w-64 h-96 object-cover rounded-lg shadow-xl"
/>
}
<div class="overflow-hidden rounded-xl shadow-card-hover">
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
<img
src={ book.CoverImagePath.String }
alt={ book.Title }
class="w-48 object-cover md:w-56 lg:w-60"
onerror="this.src='/static/placeholder-book.svg'"
/>
} else {
<img src="/static/placeholder-book.svg" alt={ book.Title } class="w-48 object-cover md:w-56 lg:w-60"/>
}
</div>
</div>
<!-- Right: Details -->
<div class="flex-1">
<!-- Title & Author -->
<h1 class="text-4xl font-bold mb-2" style="color: var(--text-primary)">{ book.Title }</h1>
<div class="min-w-0 flex-1">
<h1 class="text-3xl font-bold tracking-tight text-content">{ book.Title }</h1>
if book.Author.Valid && book.Author.String != "" {
<h2 class="text-2xl mb-6" style="color: var(--text-secondary)">
by { book.Author.String }
</h2>
<p class="mt-1 text-lg text-content-muted">by { book.Author.String }</p>
}
<!-- Action Buttons -->
<div class="flex flex-wrap gap-3 mb-6">
<!-- Read Now (placeholder) -->
<button
@click={ "window.location.href = '/readers/" + uuidToString(book.ID) + "'" }
class="px-6 py-3 rounded-lg font-semibold"
style="background-color: var(--accent); color: white;"
>
📖 Read Now
<!-- Actions -->
<div class="mt-5 flex flex-wrap gap-2">
<button @click={ "window.location.href = '/readers/" + uuidToString(book.ID) + "'" } class="btn btn-primary">
@Icon("book-open", "h-4 w-4")
Read Now
</button>
<!-- Mark as Read / Unread toggle -->
if book.ReadingProgress != nil && book.ReadingProgress.Percentage.Valid && book.ReadingProgress.Percentage.Float64 >= 1.0 {
<button
@click="toggleRead(false)"
:disabled="readSaving"
class="px-6 py-3 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
>
<span x-show="!readSaving"> Mark as Unread</span>
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
<button @click="toggleRead(false)" :disabled="readSaving" class="btn btn-secondary">
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("check-circle", "h-4 w-4") Mark as Unread</span>
<svg x-show="readSaving" x-cloak class="inline-block h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</button>
} else {
<button
@click="toggleRead(true)"
:disabled="readSaving"
class="px-6 py-3 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
>
<span x-show="!readSaving">📖 Mark as Read</span>
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
<button @click="toggleRead(true)" :disabled="readSaving" class="btn btn-secondary">
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("book", "h-4 w-4") Mark as Read</span>
<svg x-show="readSaving" x-cloak class="inline-block h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</button>
}
<!-- Sync Progress -->
if book.ActiveConflict != nil || book.ReadingProgress != nil {
<button
@click="showProgressSyncModal()"
class="px-6 py-3 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
>
🔄 Sync Progress
<button @click="showProgressSyncModal()" class="btn btn-secondary">
@Icon("sync", "h-4 w-4")
Sync Progress
</button>
}
<!-- View Notes/Highlights -->
<button
@click="showNotesModal()"
class="px-6 py-3 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
>
📝 Notes & Highlights
<button @click="showNotesModal()" class="btn btn-secondary">
@Icon("edit", "h-4 w-4")
Notes
if book.NotesCount + book.HighlightsCount > 0 {
<span
class="ml-2 px-2 py-0.5 rounded text-xs font-semibold"
style="background-color: var(--accent); color: white;"
>
{ book.NotesCount + book.HighlightsCount }
</span>
<span class="badge bg-brand/20 text-brand">{ book.NotesCount + book.HighlightsCount }</span>
}
</button>
<!-- Edit Metadata (placeholder) -->
<button
@click="showMetadataEditor()"
class="px-6 py-3 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
>
✏️ Edit Metadata
<button @click="showMetadataEditor()" class="btn btn-ghost">
@Icon("settings", "h-4 w-4")
Edit
</button>
</div>
<!-- Rating Display -->
<div class="mb-6" @mouseleave="ratingHover = 0">
<span class="text-2xl">
<!-- Rating -->
<div class="mt-5 flex flex-wrap items-center gap-2" @mouseleave="ratingHover = 0">
<span class="text-2xl tracking-tight">
<template x-for="i in 5" :key="i">
<span style="position: relative; display: inline-block;">
<span :style="starFill(i)"></span>
<button
type="button"
@click="setRating(i*2-1)"
@mouseenter="ratingHover = i*2-1"
:disabled="ratingSaving"
:aria-label="'Rate ' + ((i*2-1)/2) + ' of 5 stars'"
style="position: absolute; left: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
></button>
<button
type="button"
@click="setRating(i*2)"
@mouseenter="ratingHover = i*2"
:disabled="ratingSaving"
:aria-label="'Rate ' + ((i*2)/2) + ' of 5 stars'"
style="position: absolute; right: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
></button>
<span :style="starFill(i)" class="text-content-muted/40"></span>
<button type="button" @click="setRating(i*2-1)" @mouseenter="ratingHover = i*2-1" :disabled="ratingSaving" :aria-label="'Rate ' + ((i*2-1)/2) + ' of 5 stars'" style="position: absolute; left: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"></button>
<button type="button" @click="setRating(i*2)" @mouseenter="ratingHover = i*2" :disabled="ratingSaving" :aria-label="'Rate ' + ((i*2)/2) + ' of 5 stars'" style="position: absolute; right: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"></button>
</span>
</template>
</span>
<span class="ml-2 text-sm align-middle" style="color: var(--text-secondary);" x-text="ratingText()"></span>
<button
type="button"
x-show="userRating > 0 && !ratingSaving"
@click="clearRating()"
class="ml-2 text-xs underline hover:opacity-70"
style="color: var(--text-secondary);"
>Clear</button>
<svg x-show="ratingSaving" class="animate-spin inline-block h-4 w-4 ml-1 align-middle" viewBox="0 0 24 24" fill="none" style="color: var(--text-secondary);">
<span class="text-sm text-content-muted" x-text="ratingText()"></span>
<button type="button" x-show="userRating > 0 && !ratingSaving" x-cloak @click="clearRating()" class="text-xs text-content-muted underline hover:text-content">Clear</button>
<svg x-show="ratingSaving" x-cloak class="inline-block h-4 w-4 animate-spin text-content-muted" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
<!-- Community Rating Display (from metadata) -->
<!-- Community rating -->
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
<div class="mb-2">
<span class="text-lg" style="color: var(--text-secondary);">
Community Rating:
<span class="font-bold" style="color: var(--text-primary);">
@templ.Raw(renderStars(int32(book.CommunityRating.Float64)))
</span>
<span class="ml-2 text-sm" style="color: var(--text-secondary);">
{ fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) }
</span>
</span>
<div class="mt-2 flex items-center gap-2 text-sm text-content-muted">
<span>Community:</span>
<span class="text-base">@templ.Raw(renderStars(int32(book.CommunityRating.Float64)))</span>
<span>{ fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) }</span>
</div>
}
<!-- Series Badge -->
<!-- Series badge -->
if book.Series.Valid && book.Series.String != "" {
<div class="mb-4">
<div class="mt-4">
<a
href={ "/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID) }
class="px-3 py-1 rounded-full text-sm font-semibold inline-block hover:opacity-80 transition-opacity"
style="background-color: var(--accent); color: white; text-decoration: none;"
class="chip hover:opacity-90"
>
@Icon("layers", "h-3.5 w-3.5")
{ book.Series.String }
if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 {
#{ book.SeriesNumber.Int32 }
@@ -197,397 +148,200 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
</a>
</div>
}
<!-- Reading Direction Badge (for manga/comics) -->
if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" {
<div class="mb-4">
<span
class="px-3 py-1 rounded-full text-sm font-semibold"
style="background-color: var(--accent); color: white;"
>
📖 { strings.ToUpper(book.ReadingDirection.String) }
</span>
</div>
}
<!-- Comic-Specific Badges -->
<div class="flex flex-wrap gap-2 mb-4">
<!-- Age Rating Badge -->
<!-- Reading direction + comic badges -->
<div class="mt-4 flex flex-wrap items-center gap-2">
if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" {
<span class="chip">@Icon("book-open", "h-3.5 w-3.5"){ strings.ToUpper(book.ReadingDirection.String) }</span>
}
if book.AgeRating.Valid && book.AgeRating.String != "" {
<span
class="px-2 py-1 rounded-full text-xs font-semibold"
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
>
{ book.AgeRating.String }
</span>
<span class="badge bg-surface-hover text-content-muted">{ book.AgeRating.String }</span>
}
<!-- Black and White Badge -->
if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool {
<span
class="px-2 py-1 rounded-full text-xs font-semibold"
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
>
B&W
</span>
<span class="badge bg-surface-hover text-content-muted">B&amp;W</span>
}
<!-- Story Arc Badge -->
if book.StoryArc.Valid && book.StoryArc.String != "" {
<span
class="px-2 py-1 rounded-full text-xs font-semibold"
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
>
📚 { book.StoryArc.String }
</span>
<span class="badge bg-surface-hover text-content-muted">{ book.StoryArc.String }</span>
}
</div>
<!-- Tags -->
if len(book.Tags) > 0 {
<div class="flex flex-wrap gap-2 mb-4">
<div class="mt-4 flex flex-wrap gap-1.5">
for _, tag := range book.Tags {
<a
href={ "/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID) }
class="px-2 py-1 rounded-full text-xs font-semibold hover:opacity-80 transition-opacity"
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary); text-decoration: none;"
>
<a href={ "/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID) } class="badge bg-surface-hover text-content-muted transition-colors hover:bg-surface-hover hover:text-content">
{ tag }
</a>
}
</div>
}
<!-- Description/Synopsis -->
<!-- Synopsis -->
if book.Description.Valid && book.Description.String != "" {
<div class="mb-6">
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Synopsis</h3>
<div class="max-h-[20rem] overflow-y-auto pr-2" style="color: var(--text-secondary)">
@UnsafeHTML(
bluemonday.UGCPolicy().Sanitize(book.Description.String),
).ToComponent()
<div class="mt-5">
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-content-muted">Synopsis</h3>
<div class="max-h-80 overflow-y-auto pr-2 text-sm leading-relaxed text-content-muted">
@UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Description.String)).ToComponent()
</div>
</div>
}
</div>
</div>
<!-- Summary (from ComicInfo.xml, if different from description) -->
if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String {
<div class="mb-6">
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Comic Summary</h3>
<div class="max-h-[20rem] overflow-y-auto pr-2" style="color: var(--text-secondary)">
@UnsafeHTML(
bluemonday.UGCPolicy().Sanitize(book.Summary.String),
).ToComponent()
</div>
</div>
}
<!-- Metadata Notes (technical notes from metadata files) -->
if book.MetadataNotes.Valid && book.MetadataNotes.String != "" {
<div
class="card p-6 rounded-lg border mb-6"
style="background-color: var(--bg-secondary); border-color: var(--border);"
>
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Metadata Notes</h3>
<div class="text-sm" style="color: var(--text-secondary);">
{ book.MetadataNotes.String }
</div>
</div>
}
<!-- Progress Section -->
if book.ReadingProgress != nil {
<div
class="card p-6 rounded-lg border mb-6"
style="background-color: var(--bg-secondary); border-color: var(--border);"
>
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Reading Progress</h3>
if book.ActiveConflict != nil {
<div
class="mb-4 p-3 rounded-lg border"
style="background-color: #f59e0b20; border-color: #f59e0b;"
>
<p style="color: var(--text-primary);">
⚠️ Progress conflict detected - Click "Sync Progress" to review and resolve
</p>
</div>
}
<!-- Progress Bar -->
<div class="mb-4">
<div class="w-full rounded-full h-3" style="background-color: var(--bg-primary);">
<div
class="h-3 rounded-full transition-all"
style={ fmt.Sprintf("width: %.1f%%; background-color: var(--accent);", book.ReadingProgress.Percentage.Float64*100) }
></div>
</div>
</div>
<!-- Progress Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<p style="color: var(--text-secondary)">Progress</p>
<p class="text-2xl font-bold" style="color: var(--accent);">
{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64 * 100) }%
</p>
</div>
if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {
<div>
<p style="color: var(--text-secondary)">Page</p>
<p>{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }</p>
</div>
}
if book.ReadingProgress.LastReadAt.Valid {
<div>
<p style="color: var(--text-secondary)">Last Read</p>
<p>{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</p>
</div>
}
if book.ReadingProgress.LastSyncSource.Valid {
<div>
<p style="color: var(--text-secondary)">Source</p>
<p class="capitalize">{ book.ReadingProgress.LastSyncSource.String }</p>
</div>
}
</div>
</div>
}
<!-- Metadata Grid -->
<div
class="card p-6 rounded-lg border mb-6"
style="background-color: var(--bg-secondary); border-color: var(--border);"
>
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Metadata</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Publication Info -->
if book.Publisher.Valid && book.Publisher.String != "" {
<div>
<p style="color: var(--text-secondary)">Publisher</p>
<p>{ book.Publisher.String }</p>
</div>
}
if book.DatePublished.Valid {
<div>
<p style="color: var(--text-secondary)">Published</p>
<p>{ book.DatePublished.Time.Format("01-02-2006") }</p>
</div>
}
if book.Isbn.Valid && book.Isbn.String != "" {
<div>
<p style="color: var(--text-secondary)">Isbn</p>
<p>{ book.Isbn.String }</p>
</div>
}
if book.Language.Valid && book.Language.String != "" {
<div>
<p style="color: var(--text-secondary)">Language</p>
<p class="capitalize">{ book.Language.String }</p>
</div>
}
if book.Edition.Valid && book.Edition.String != "" {
<div>
<p style="color: var(--text-secondary)">Edition</p>
<p>{ book.Edition.String }</p>
</div>
}
if book.PageCount.Valid && book.PageCount.Int32 > 0 {
<div>
<p style="color: var(--text-secondary)">Pages</p>
<p>{ book.PageCount.Int32 }</p>
</div>
}
if book.Genre.Valid && book.Genre.String != "" {
<div>
<p style="color: var(--text-secondary)">Genre</p>
<p>{ book.Genre.String }</p>
</div>
}
<!-- Series Information (if not shown in badge) -->
if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 {
<div>
<p style="color: var(--text-secondary)">Series Count</p>
<p>{ book.SeriesCount.Int32 } items</p>
</div>
}
if book.Volume.Valid && book.Volume.Int32 > 0 {
<div>
<p style="color: var(--text-secondary)">Volume</p>
<p>Vol. { book.Volume.Int32 }</p>
</div>
}
if book.Imprint.Valid && book.Imprint.String != "" {
<div>
<p style="color: var(--text-secondary)">Imprint</p>
<p>{ book.Imprint.String }</p>
</div>
}
if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {
<div>
<p style="color: var(--text-secondary)">Copyright Year</p>
<p>{ book.CopyrightYear.Int32 }</p>
</div>
}
<!-- Comic-Specific Fields -->
if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" {
<div>
<p style="color: var(--text-secondary)">Manga Type</p>
<p class="capitalize">{ strings.ReplaceAll(book.MangaType.String, "_", " ") }</p>
</div>
}
if book.ScanInformation.Valid && book.ScanInformation.String != "" {
<div>
<p style="color: var(--text-secondary)">Scan Info</p>
<p class="text-sm" style="color: var(--text-secondary);">{ book.ScanInformation.String }</p>
</div>
}
if len(book.AlternateInfo) > 0 {
<div>
<p style="color: var(--text-secondary)">Alternate Series</p>
<p class="text-sm">{ string(book.AlternateInfo) }</p>
</div>
}
if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" {
<div>
<p style="color: var(--text-secondary)">Alternate Series</p>
<p>{ altSeries }</p>
</div>
}
if len(book.Contributors) > 0 {
<div>
<p style="color: var(--text-secondary)">Contributors</p>
<p>{ strings.Join(book.Contributors, ", ") }</p>
</div>
}
<!-- Technical Info -->
<div class="mt-8 space-y-6">
<!-- Summary -->
if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String {
<div>
<p style="color: var(--text-secondary)">Format</p>
<p>{ book.MimeType.String }</p>
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-content-muted">Comic Summary</h3>
<div class="max-h-80 overflow-y-auto pr-2 text-sm leading-relaxed text-content-muted">
@UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Summary.String)).ToComponent()
</div>
</div>
if book.FileSize.Valid && book.FileSize.Int64 > 0 {
<div>
<p style="color: var(--text-secondary)">File Size</p>
<p>{ formatFileSize(book.FileSize.Int64) }</p>
}
<!-- Metadata notes -->
if book.MetadataNotes.Valid && book.MetadataNotes.String != "" {
<div class="card p-5">
<h3 class="mb-2 text-sm font-semibold text-content">Metadata Notes</h3>
<p class="text-sm text-content-muted">{ book.MetadataNotes.String }</p>
</div>
}
<!-- Progress -->
if book.ReadingProgress != nil {
<div class="card p-5">
<h3 class="mb-4 text-sm font-semibold text-content">Reading Progress</h3>
if book.ActiveConflict != nil {
<div class="mb-4 flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 p-3 text-sm text-content">
@Icon("alert", "h-4 w-4 mt-0.5 shrink-0 text-warning")
<span>Progress conflict detected click "Sync Progress" to review and resolve.</span>
</div>
}
<div class="mb-4">
<div class="h-2 w-full overflow-hidden rounded-full bg-surface-hover">
<div class="h-2 rounded-full bg-brand transition-all" style={ fmt.Sprintf("width: %.1f%%", book.ReadingProgress.Percentage.Float64*100) }></div>
</div>
</div>
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<div>
<p class="text-xs uppercase tracking-wide text-content-muted">Progress</p>
<p class="text-xl font-bold text-brand">{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100) }%</p>
</div>
if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {
<div>
<p class="text-xs uppercase tracking-wide text-content-muted">Page</p>
<p class="text-content">{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }</p>
</div>
}
if book.ReadingProgress.LastReadAt.Valid {
<div>
<p class="text-xs uppercase tracking-wide text-content-muted">Last Read</p>
<p class="text-content">{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</p>
</div>
}
if book.ReadingProgress.LastSyncSource.Valid {
<div>
<p class="text-xs uppercase tracking-wide text-content-muted">Source</p>
<p class="capitalize text-content">{ book.ReadingProgress.LastSyncSource.String }</p>
</div>
}
</div>
</div>
}
<!-- Metadata grid -->
<div class="card p-5">
<h3 class="mb-4 text-sm font-semibold text-content">Metadata</h3>
<dl class="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2 lg:grid-cols-3">
if book.Publisher.Valid && book.Publisher.String != "" {
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Publisher</dt><dd class="text-content">{ book.Publisher.String }</dd></div>
}
if book.DatePublished.Valid {
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Published</dt><dd class="text-content">{ book.DatePublished.Time.Format("01-02-2006") }</dd></div>
}
if book.Isbn.Valid && book.Isbn.String != "" {
<div><dt class="text-xs uppercase tracking-wide text-content-muted">ISBN</dt><dd class="text-content">{ book.Isbn.String }</dd></div>
}
if book.Language.Valid && book.Language.String != "" {
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Language</dt><dd class="capitalize text-content">{ book.Language.String }</dd></div>
}
if book.Edition.Valid && book.Edition.String != "" {
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Edition</dt><dd class="text-content">{ book.Edition.String }</dd></div>
}
if book.PageCount.Valid && book.PageCount.Int32 > 0 {
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Pages</dt><dd class="text-content">{ book.PageCount.Int32 }</dd></div>
}
if book.Genre.Valid && book.Genre.String != "" {
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Genre</dt><dd class="text-content">{ book.Genre.String }</dd></div>
}
if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 {
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Series Count</dt><dd class="text-content">{ book.SeriesCount.Int32 } items</dd></div>
}
if book.Volume.Valid && book.Volume.Int32 > 0 {
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Volume</dt><dd class="text-content">Vol. { book.Volume.Int32 }</dd></div>
}
if book.Imprint.Valid && book.Imprint.String != "" {
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Imprint</dt><dd class="text-content">{ book.Imprint.String }</dd></div>
}
if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Copyright Year</dt><dd class="text-content">{ book.CopyrightYear.Int32 }</dd></div>
}
if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" {
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Manga Type</dt><dd class="capitalize text-content">{ strings.ReplaceAll(book.MangaType.String, "_", " ") }</dd></div>
}
if book.ScanInformation.Valid && book.ScanInformation.String != "" {
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Scan Info</dt><dd class="text-sm text-content-muted">{ book.ScanInformation.String }</dd></div>
}
if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" {
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Alternate Series</dt><dd class="text-content">{ altSeries }</dd></div>
}
if len(book.Contributors) > 0 {
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Contributors</dt><dd class="text-content">{ strings.Join(book.Contributors, ", ") }</dd></div>
}
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Format</dt><dd class="text-content">{ book.MimeType.String }</dd></div>
if book.FileSize.Valid && book.FileSize.Int64 > 0 {
<div><dt class="text-xs uppercase tracking-wide text-content-muted">File Size</dt><dd class="text-content">{ formatFileSize(book.FileSize.Int64) }</dd></div>
}
</dl>
<!-- External links -->
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
<div class="mt-5 border-t border-line pt-4">
<h4 class="mb-2 text-xs font-semibold uppercase tracking-wide text-content-muted">External Links</h4>
<div class="flex flex-wrap gap-2">
<a href={ getExternalURL("goodreads", book.GoodreadsID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("book", "h-3 w-3") Goodreads</a>
<a href={ getExternalURL("openlibrary", book.OpenlibraryID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("book-open", "h-3 w-3") Open Library</a>
<a href={ getExternalURL("googlebooks", book.GoogleBooksID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("search", "h-3 w-3") Google Books</a>
if book.Asin.Valid && book.Asin.String != "" {
<a href={ getExternalURL("amazon", book.Asin.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("external", "h-3 w-3") Amazon</a>
} else if book.Isbn.Valid && book.Isbn.String != "" {
<a href={ getExternalURL("amazon", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("external", "h-3 w-3") Amazon</a>
}
if book.WebUrl.Valid && book.WebUrl.String != "" {
<a href={ book.WebUrl.String } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("external", "h-3 w-3") { getDomainName(book.WebUrl.String) }</a>
}
</div>
</div>
}
</div>
<!-- External IDs Section -->
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
<div class="mt-4 pt-4 border-t" style="border-color: var(--border);">
<h4 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">External Links</h4>
<div class="flex flex-wrap gap-3">
if book.GoodreadsID.Valid && book.GoodreadsID.String != "" {
<a
href={ getExternalURL("goodreads", book.GoodreadsID.String, book.Isbn, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);"
>
📚 Goodreads
</a>
} else {
<a
href={ getExternalURL("goodreads", "", book.Isbn, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);"
>
📚 Goodreads
</a>
}
if book.OpenlibraryID.Valid && book.OpenlibraryID.String != "" {
<a
href={ getExternalURL("openlibrary", book.OpenlibraryID.String, book.Isbn, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);"
>
📖 Open Library
</a>
} else {
<a
href={ getExternalURL("openlibrary", "", book.Isbn, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);"
>
📖 Open Library
</a>
}
if book.GoogleBooksID.Valid && book.GoogleBooksID.String != "" {
<a
href={ getExternalURL("googlebooks", book.GoogleBooksID.String, book.Isbn, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);"
>
🔍 Google Books
</a>
} else {
<a
href={ getExternalURL("googlebooks", "", book.Isbn, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);"
>
🔍 Google Books
</a>
}
if book.Asin.Valid && book.Asin.String != "" {
<a
href={ getExternalURL("amazon", book.Asin.String, book.Isbn, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);"
>
🛒 Amazon
</a>
} else if book.Isbn.Valid && book.Isbn.String != "" {
<a
href={ getExternalURL("amazon", "", book.Isbn, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);"
>
🛒 Amazon
</a>
}
if book.WebUrl.Valid && book.WebUrl.String != "" {
<a
href={ book.WebUrl.String }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);"
>
🔗 { getDomainName(book.WebUrl.String) }
<!-- Collections -->
if len(book.Collections) > 0 {
<div class="card p-5">
<h3 class="mb-4 text-sm font-semibold text-content">Collections</h3>
<div class="flex flex-wrap gap-2">
for _, col := range book.Collections {
<a href={ "/collections/" + uuidToString(col.ID) } class="flex items-center gap-2 rounded-lg border border-line bg-surface px-3 py-1.5 text-sm transition-colors hover:bg-surface-hover">
<span style={ "color: " + col.Color.String }>{ col.Icon.String }</span>
<span class="text-content">{ col.Name }</span>
</a>
}
</div>
</div>
}
</div>
<!-- Collections Section -->
if len(book.Collections) > 0 {
<div
class="card p-6 rounded-lg border mb-6"
style="background-color: var(--bg-secondary); border-color: var(--border);"
>
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Collections</h3>
<div class="flex flex-wrap gap-3">
for _, col := range book.Collections {
<a
href={ "/collections/" + uuidToString(col.ID) }
class="px-3 py-2 rounded-lg border flex items-center gap-2 hover:opacity-80 transition-opacity"
style="border-color: { col.Color.String }; background-color: var(--bg-primary); text-decoration: none;"
>
<span style="color: { col.Color.String };">{ col.Icon.String }</span>
<span style="color: var(--text-primary);">{ col.Name }</span>
</a>
}
</div>
</div>
}
</div>
<!-- Modals -->
@ProgressSyncModal(user, book)
@NotesHighlightsModal(book)
@MetadataEditorModal(book)
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -8,10 +8,12 @@ templ BooksGrid(books []handlers.BookInfo, limit int, offset int, count int, cur
@BookCard(book)
}
} else {
<!-- Empty state -->
<div class="col-span-full text-center py-12" style="color: var(--text-secondary);">
<p class="text-lg mb-2">📚 No books found</p>
<p class="text-sm">Try adjusting your filters or add some books to your library.</p>
<div class="col-span-full flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-16 text-center">
<div class="mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted">
@Icon("book", "h-7 w-7")
</div>
<p class="text-base font-semibold text-content">No books found</p>
<p class="mt-1 text-sm text-content-muted">Try adjusting your filters or add some books to your library.</p>
</div>
}
}
+9 -1
View File
@@ -39,7 +39,15 @@ func BooksGrid(books []handlers.BookInfo, limit int, offset int, count int, curr
}
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!-- Empty state --> <div class=\"col-span-full text-center py-12\" style=\"color: var(--text-secondary);\"><p class=\"text-lg mb-2\">📚 No books found</p><p class=\"text-sm\">Try adjusting your filters or add some books to your library.</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"col-span-full flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-16 text-center\"><div class=\"mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("book", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div><p class=\"text-base font-semibold text-content\">No books found</p><p class=\"mt-1 text-sm text-content-muted\">Try adjusting your filters or add some books to your library.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+169 -287
View File
@@ -3,6 +3,7 @@ package templates
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"fmt"
)
templ BookShelf(
@@ -11,10 +12,10 @@ templ BookShelf(
currentLibraryID string,
errorMessage string,
savedFilters []database.SavedFilters,
books []handlers.BookInfo, // NEW
limit int, // NEW
offset int, // NEW
count int, // NEW
books []handlers.BookInfo,
limit int,
offset int,
count int,
) {
<!DOCTYPE html>
<html lang="en">
@@ -25,31 +26,28 @@ templ BookShelf(
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body
class="theme-{ user.Theme }"
x-data="bookshelf"
x-init="initBookshelf()"
>
<body class="theme-{ user.Theme }" x-data="bookshelf" x-init="initBookshelf()">
@Header(user, "/bookshelf")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<!-- Filter Bar -->
<div
class="mb-6 card p-4 rounded-lg border"
style="background-color: var(--bg-secondary); border-color: var(--border);"
>
<form id="filter-form" hx-get="/api/media-items/search" hx-target="#books-grid">
<div class="flex flex-wrap gap-4 items-center">
<!-- Library Selector -->
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Library
</label>
<select
id="library-select"
name="library_id"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
>
<div class="w-full px-4 sm:px-6 lg:px-8 py-6">
<form id="filter-form" hx-get="/api/media-items/search" hx-target="#books-grid" class="space-y-3">
<!-- Toolbar -->
<div class="card p-3">
<div class="flex flex-wrap items-center gap-2">
<!-- Search (prominent) -->
<div class="relative min-w-[14rem] flex-1">
<div class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-content-muted">
@Icon("search", "h-4 w-4")
</div>
<input
name="q"
type="text"
class="input pl-9"
placeholder="Search all fields…"
/>
</div>
<!-- Library -->
<select name="library_id" class="input w-auto">
if len(libraries) == 0 {
<option value="">No libraries available</option>
} else {
@@ -66,328 +64,235 @@ templ BookShelf(
}
}
}
</select>
</div>
<!-- Search Input -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
</select>
<!-- Sort -->
<select
name="sort"
class="input w-auto"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-trigger="change"
hx-include="#filter-form"
>
<option value="title ASC">Title (AZ)</option>
<option value="title DESC">Title (ZA)</option>
<option value="author ASC">Author (AZ)</option>
<option value="created_at DESC">Date Added</option>
<option value="page_count DESC">Page Count</option>
</select>
<!-- Filters toggle -->
<button type="button" @click="filtersOpen = !filtersOpen" class="btn btn-secondary">
@Icon("filter", "h-4 w-4")
Filters
</button>
<div class="ml-auto flex items-center gap-1">
<button type="button" @click="showSaveFilterModal()" class="btn btn-ghost" title="Save filter">
@Icon("save", "h-4 w-4")
</button>
<div class="relative">
<button type="button" @click="toggleFiltersDropdown()" data-load-filter-btn class="btn btn-ghost" title="Load filter">
@Icon("folder", "h-4 w-4")
</button>
<!-- Saved filters dropdown -->
<div
x-show="showFiltersDropdown"
x-cloak
@click.outside="showFiltersDropdown = false"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 top-full mt-2 w-80 rounded-xl border border-line bg-surface-raised p-3 shadow-pop"
style="display: none;"
>
<p class="pb-2 text-xs font-semibold uppercase tracking-wide text-content-muted">Saved Filters</p>
<div class="space-y-1" id="saved-filters-list">
for _, filter := range savedFilters {
<div class="flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover" data-filter-id={ uuidToString(filter.ID) }>
<button data-action="load-filter" @click="loadFilter($event)" class="flex-1 truncate text-left text-sm text-content">{ filter.Name }</button>
<button data-action="delete-filter" @click="deleteFilter($event)" class="icon-btn h-7 w-7" title="Delete filter">
@Icon("trash", "h-4 w-4")
</button>
</div>
}
if len(savedFilters) == 0 {
<p class="py-3 text-center text-sm text-content-muted">No saved filters yet</p>
}
</div>
</div>
</div>
<button type="button" @click="clearFilters()" class="btn btn-ghost" title="Clear filters">
@Icon("close", "h-4 w-4")
</button>
<button type="submit" class="btn btn-primary">
@Icon("search", "h-4 w-4")
Search
</label>
<input
name="q"
type="text"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="Search all fields..."
/>
</button>
</div>
<!-- Author Filter with Autocomplete -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Author
</label>
</div>
</div>
<!-- Collapsible filters panel -->
<div x-show="filtersOpen" x-cloak x-transition class="card p-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- Author -->
<div>
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Author</label>
<input
type="text"
name="author_filter"
class="input"
placeholder="Filter by author"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
list="author-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
/>
<datalist id="author-datalist"></datalist>
</div>
<!-- Tags Filter with Autocomplete -->
<div class="flex-1 min-w-[150px] relative">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Tags</label>
<!-- Tags -->
<div class="relative">
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Tags</label>
<input
type="text"
name="tags_filter"
class="input"
placeholder="Filter by tags"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
@keydown="onTagFilterKeydown($event)"
@blur="hideTagDropdown()"
/>
<div
x-show="showTagDropdown"
x-cloak
x-transition
class="absolute z-50 mt-1 w-full rounded-lg shadow-lg border max-h-48 overflow-y-auto"
style="background-color: var(--bg-primary); border-color: var(--border);"
class="absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-line bg-surface-raised shadow-pop"
style="display: none;"
>
<template x-for="sug in tagSuggestions" :key="sug.value">
<button
type="button"
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center"
:class="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''"
:style="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'"
class="flex w-full items-center justify-between px-3 py-2 text-sm transition-colors hover:bg-surface-hover"
:class="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'bg-surface-hover' : ''"
@click="selectTagSuggestion(sug.value)"
>
<span x-text="sug.value"></span>
<span class="text-xs" style="color: var(--text-secondary);" x-text="sug.count + ' books'"></span>
<span class="text-content" x-text="sug.value"></span>
<span class="text-xs text-content-muted" x-text="sug.count + ' books'"></span>
</button>
</template>
</div>
</div>
// <!-- Genre Filter with Autocomplete -->
// <div class="flex-1 min-w-[150px]">
// <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
// Genre
// </label>
// <input
// type="text"
// name="genre_filter"
// placeholder="Filter by genre"
// class="w-full px-3 py-2 border rounded-lg"
// style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
// list="genre-datalist"
// @input.debounce.300ms="if($el.value.length >= 2) fetchGenreValues($el)"
// />
// <datalist id="genre-datalist"></datalist>
// </div>
<!-- Series Filter with Autocomplete (NEW) -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Series
</label>
<!-- Series -->
<div>
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Series</label>
<input
type="text"
name="series_filter"
class="input"
placeholder="Filter by series"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
list="series-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
/>
<datalist id="series-datalist"></datalist>
</div>
<!-- Language Filter with Autocomplete (NEW) -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Language
</label>
<!-- Language -->
<div>
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Language</label>
<input
type="text"
name="language_filter"
class="input"
placeholder="Filter by language"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
list="language-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
/>
<datalist id="language-datalist"></datalist>
</div>
<!-- Year Range -->
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Year Range
</label>
<!-- Year range -->
<div>
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Year Range</label>
<div class="flex gap-2">
<input
type="number"
name="year_min"
placeholder="From"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
/>
<input
type="number"
name="year_max"
placeholder="To"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
/>
<input type="number" name="year_min" class="input" placeholder="From"/>
<input type="number" name="year_max" class="input" placeholder="To"/>
</div>
</div>
<!-- Has Cover Filter - TriState Button -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Cover Filter
</label>
<!-- Cover filter -->
<div>
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Cover</label>
<input
type="button"
id="has-cover-tristate"
class="tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm"
:class="{
'state-null': hasCoverState === null,
'state-true': hasCoverState === true,
'state-false': hasCoverState === false
}"
:value="hasCoverState === null ? '○ Cover: Any' : hasCoverState === true ? '✓ Has Cover' : '✗ No Cover'"
class="tristate-btn input cursor-pointer flex items-center justify-center gap-2 font-medium"
:class="{ 'state-null': hasCoverState === null, 'state-true': hasCoverState === true, 'state-false': hasCoverState === false }"
:value="hasCoverState === null ? 'Cover: Any' : hasCoverState === true ? 'Has Cover' : 'No Cover'"
@click="cycleHasCover()"
/>
<!-- Hidden input for HTMX form submission - conditionally rendered -->
<template x-if="hasCoverState !== null">
<input
type="hidden"
name="has_cover"
:value="hasCoverState ? 'true' : 'false'"
/>
<input type="hidden" name="has_cover" :value="hasCoverState ? 'true' : 'false'"/>
</template>
</div>
<!-- Sort By Dropdown (PRESERVED) -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Sort By
</label>
<select
name="sort"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-trigger="change"
hx-include="#filter-form"
>
<option value="title ASC">Title (A-Z)</option>
<option value="title DESC">Title (Z-A)</option>
<option value="author ASC">Author (A-Z)</option>
<option value="created_at DESC">Date Added</option>
<option value="page_count DESC">Page Count</option>
</select>
</div>
<!-- Submit Button -->
<div class="flex items-end">
<button
type="submit"
class="px-6 py-2 rounded-lg font-medium"
style="background-color: var(--accent); color: var(--bg-primary);"
>
🔍 Search
</button>
</div>
<!-- Save Filter Button (PRESERVED) -->
<div class="flex items-end">
<button
@click="showSaveFilterModal()"
class="px-4 py-2 rounded-lg font-medium"
style="background-color: var(--accent); color: var(--bg-primary);"
>
💾 Save Filter
</button>
</div>
<!-- Load Filter Button (PRESERVED) -->
<div class="flex items-end relative">
<button
@click="toggleFiltersDropdown()"
data-load-filter-btn
class="px-4 py-2 rounded-lg font-medium border"
style="border-color: var(--border); color: var(--text-primary);"
>
📂 Load Filter
</button>
<!-- Saved Filters Dropdown (PRESERVED) -->
<div
x-show="showFiltersDropdown"
@click.outside="showFiltersDropdown = false"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute top-full mt-2 w-80 rounded-lg shadow-lg z-50"
:class="dropdownAlign === 'left' ? 'left-0' : 'right-0'"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;"
>
<div class="p-4">
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">
Saved Filters
</h3>
<!-- Filter List -->
<div class="space-y-2" id="saved-filters-list">
for _, filter := range savedFilters {
<div class="flex items-center justify-between p-2 rounded hover:opacity-80" style="background-color: var(--bg-primary);" data-filter-id={ uuidToString(filter.ID) }>
<button data-action="load-filter" @click="loadFilter($event)" class="flex-1 text-left px-2 py-1 rounded" style="color: var(--text-primary);">
{ filter.Name }
</button>
<button data-action="delete-filter" @click="deleteFilter($event)" class="p-1 hover:opacity-70 rounded" style="color: var(--text-secondary);" title="Delete filter">🗑️</button>
</div>
}
</div>
<!-- Empty State -->
if len(savedFilters) == 0 {
<div class="text-sm py-4 text-center" style="color: var(--text-secondary);">
No saved filters yet
</div>
}
</div>
</div>
</div>
<!-- Clear Filters Button (PRESERVED) -->
<div class="flex items-end">
<button
@click="clearFilters()"
class="px-4 py-2 rounded-lg font-medium border"
style="border-color: var(--border); color: var(--text-primary);"
>
Clear
</button>
</div>
</div>
</form>
</div>
<!-- Books Grid -->
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
</div>
</form>
<!-- Books grid -->
<div id="books-grid" class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
@BooksGrid(books, limit, offset, count, currentLibraryID)
<!-- Error Message -->
if errorMessage != "" {
<div class="mt-6 p-4 rounded-lg border bg-red-500/10 border-red-500">
<p style="color: var(--text-primary)">{ errorMessage }</p>
<div class="col-span-full mt-2 rounded-xl border border-danger/40 bg-danger/10 p-4">
<p class="text-sm text-danger">{ errorMessage }</p>
</div>
}
</div>
<!-- Pagination -->
<div id="pagination" class="mt-6 flex justify-center gap-2">
<div id="pagination" class="mt-8 flex items-center justify-center gap-3">
if count > 0 {
<button
type="button"
class="px-4 py-2 rounded-lg border disabled:opacity-50"
style="border-color: var(--border); color: var(--text-primary);"
hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset - limit }"
class="btn btn-secondary disabled:opacity-40"
hx-get={ "/api/media-items/search?library_id=" + currentLibraryID + "&limit=" + fmt.Sprintf("%d", limit) + "&offset=" + fmt.Sprintf("%d", offset-limit) }
hx-target="#books-grid"
hx-include="#filter-form"
disabled?={ offset <= 0 }
>
Previous
@Icon("chevron-left", "h-4 w-4")
Previous
</button>
<span class="px-4 py-2" style="color: var(--text-secondary);">
Page { offset / limit + 1 }
</span>
<span class="text-sm text-content-muted">Page { offset/limit + 1 }</span>
<button
class="px-4 py-2 rounded-lg border disabled:opacity-50"
style="border-color: var(--border); color: var(--text-primary);"
hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset + limit }"
type="button"
class="btn btn-secondary disabled:opacity-40"
hx-get={ "/api/media-items/search?library_id=" + currentLibraryID + "&limit=" + fmt.Sprintf("%d", limit) + "&offset=" + fmt.Sprintf("%d", offset+limit) }
hx-target="#books-grid"
hx-include="#filter-form"
disabled?={ offset+limit >= count }
>
Next
Next
@Icon("chevron-right", "h-4 w-4")
</button>
}
</div>
</div>
<!-- Save Filter Modal -->
<div
x-show="showSaveModal"
x-cloak
@click.self="showSaveModal = false"
x-transition
class="fixed inset-0 z-50 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.7); display: none;"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
style="background-color: var(--surface-overlay); display: none;"
>
<div
@click.stop
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-4">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Save Filter</h2>
<button
@click="showSaveModal = false"
class="p-2 hover:opacity-80 rounded"
style="color: var(--text-primary)"
></button>
<div class="card w-full max-w-md p-6" @click.stop>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-bold text-content">Save Filter</h2>
<button @click="showSaveModal = false" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
<form
hx-post="/api/saved-filters"
@@ -397,38 +302,15 @@ templ BookShelf(
@htmx:afterRequest="if(event.detail.xhr.status < 400) { afterFilterSave() }"
>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Filter Name
</label>
<input
type="text"
name="filter_name"
placeholder="My Custom Filter"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
required
/>
<div id="filter-save-error" style="display: none; color: #ef4444; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;"></div>
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Filter Name</label>
<input type="text" name="filter_name" class="input" placeholder="My Custom Filter" required/>
<div id="filter-save-error" class="mt-2 hidden rounded-lg bg-danger/10 p-3 text-sm text-danger"></div>
<input type="hidden" name="resource_type" value="media-items"/>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
@click="showSaveModal = false"
class="px-4 py-2 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary);"
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 rounded-lg font-medium"
style="background-color: var(--accent); color: var(--bg-primary);"
>
Save
</button>
<button type="button" @click="showSaveModal = false" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
<!-- Hidden pagination state -->
<input type="hidden" name="limit" value="50"/>
<input type="hidden" name="offset" value="0"/>
</form>
File diff suppressed because one or more lines are too long
+17 -16
View File
@@ -16,32 +16,33 @@ templ BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
</head>
<body class="theme-{ user.Theme }">
@Header(user, backUrl)
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
<div class="w-full px-4 py-3 flex items-center gap-4">
<a href={ backUrl } class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
{ backLabel }
<div class="sticky top-16 z-30 border-b border-line bg-surface/95 backdrop-blur">
<div class="w-full px-4 sm:px-6 lg:px-8 py-2.5">
<a href={ backUrl } class="inline-flex items-center gap-1.5 text-sm font-medium text-content-muted transition-colors hover:text-content">
@Icon("arrow-left", "h-4 w-4")
{ backLabel }
</a>
</div>
</div>
<div class="w-full px-4 py-8">
<div class="flex items-center gap-4 mb-6">
<span class="px-3 py-1 rounded-full text-sm font-semibold" style="background-color: var(--accent); color: white;">
{ badgeIcon } { badgeLabel }
</span>
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ title }</h1>
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d", len(books)) } books</span>
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6 flex flex-wrap items-center gap-3">
<span class="chip">{ badgeIcon } { badgeLabel }</span>
<h1 class="text-2xl font-bold tracking-tight text-content">{ title }</h1>
<span class="text-sm text-content-muted">{ fmt.Sprintf("%d", len(books)) } books</span>
</div>
if len(books) > 0 {
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
for _, book := range books {
@BookCard(book)
}
</div>
} else {
<div class="text-center py-16" style="color: var(--text-secondary)">
<div class="text-6xl mb-4">{ emptyIcon }</div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Books Found</h3>
<p>{ emptyMessage }</p>
<div class="flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-20 text-center">
<div class="mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted">
@Icon("book", "h-7 w-7")
</div>
<h3 class="text-lg font-semibold text-content">No Books Found</h3>
<p class="mt-1 text-sm text-content-muted">{ emptyMessage }</p>
</div>
}
</div>
+26 -27
View File
@@ -55,7 +55,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b\" style=\"background-color: var(--bg-primary);\"><div class=\"w-full px-4 py-3 flex items-center gap-4\"><a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"sticky top-16 z-30 border-b border-line bg-surface/95 backdrop-blur\"><div class=\"w-full px-4 sm:px-6 lg:px-8 py-2.5\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -68,27 +68,31 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"text-sm hover:opacity-80 transition-opacity\" style=\"color: var(--text-secondary); text-decoration: none;\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"inline-flex items-center gap-1.5 text-sm font-medium text-content-muted transition-colors hover:text-content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("arrow-left", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 22, Col: 21}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 23, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</a></div></div><div class=\"w-full px-4 py-8\"><div class=\"flex items-center gap-4 mb-6\"><span class=\"px-3 py-1 rounded-full text-sm font-semibold\" style=\"background-color: var(--accent); color: white;\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</a></div></div><div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6 flex flex-wrap items-center gap-3\"><span class=\"chip\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 29, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 29, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -101,33 +105,33 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 29, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 29, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span><h1 class=\"text-2xl font-bold tracking-tight text-content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 78}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 30, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</h1><span class=\"text-sm\" style=\"color: var(--text-secondary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</h1><span class=\"text-sm text-content-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 32, Col: 95}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -138,7 +142,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
return templ_7745c5c3_Err
}
if len(books) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -153,32 +157,27 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"text-center py-16\" style=\"color: var(--text-secondary)\"><div class=\"text-6xl mb-4\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-20 text-center\"><div class=\"mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("book", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div><h3 class=\"text-lg font-semibold text-content\">No Books Found</h3><p class=\"mt-1 text-sm text-content-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emptyIcon)
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 42, Col: 44}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 45, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Books Found</h3><p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 44, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
+106 -251
View File
@@ -14,82 +14,56 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
</head>
<body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }">
@Header(user, "/collections")
<!-- Modal Container -->
<div id="modal-container"></div>
<!-- Actual container page -->
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8 flex justify-between items-center">
<div class="mb-8 flex flex-wrap items-end justify-between gap-4">
<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 class="mb-1 flex items-center gap-2">
@Icon("folder", "h-6 w-6 text-brand")
<h1 class="text-2xl font-bold tracking-tight text-content">My Collections</h1>
</div>
<p class="text-sm text-content-muted">Organize your books into custom collections</p>
</div>
<div class="flex gap-3">
<button
hx-get="/collections/restore-modal"
hx-target="#modal-container"
hx-swap="innerHTML"
class="btn-secondary px-4 py-2 rounded-lg"
>
🔄 Restore System
<div class="flex gap-2">
<button hx-get="/collections/restore-modal" hx-target="#modal-container" hx-swap="innerHTML" class="btn btn-secondary">
@Icon("refresh", "h-4 w-4")
Restore System
</button>
<button
hx-get="/collections/create-modal"
hx-target="#modal-container"
hx-swap="innerHTML"
class="btn-primary px-4 py-2 rounded-lg"
>
New Collection
<button hx-get="/collections/create-modal" hx-target="#modal-container" hx-swap="innerHTML" class="btn btn-primary">
@Icon("plus", "h-4 w-4")
New Collection
</button>
</div>
</div>
<div id="collections-list" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div id="collections-list" class="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
if len(collections) == 0 {
<div class="text-center py-16 col-span-full" style="color: var(--text-secondary)">
<div class="text-6xl mb-4">📚</div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Collections Yet</h3>
<p class="mb-4">Create collections to organize your books</p>
<button
hx-get="/collections/create-modal"
hx-target="#modal-container"
hx-swap="innerHTML"
class="btn-primary px-4 py-2 rounded-lg"
>
<div class="col-span-full flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-20 text-center">
<div class="mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted">
@Icon("folder", "h-7 w-7")
</div>
<h3 class="text-lg font-semibold text-content">No Collections Yet</h3>
<p class="mb-4 mt-1 text-sm text-content-muted">Create collections to organize your books.</p>
<button hx-get="/collections/create-modal" hx-target="#modal-container" hx-swap="innerHTML" class="btn btn-primary">
Create Your First Collection
</button>
</div>
}
for _, col := range collections {
<div @click="navigateToCollection($el)" data-href={ "/collections/" + col.ID } class="block">
<div
class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow"
style="background-color: var(--bg-secondary);"
data-color={ col.Color }
>
<div class="flex justify-between items-start mb-4">
<div class="text-3xl">{ col.Icon }</div>
<div class="flex space-x-2">
<button
hx-get={ "/collections/" + col.ID + "/edit-modal" }
hx-target="#modal-container"
hx-swap="innerHTML"
class="p-2 hover:opacity-80 rounded"
style="color: var(--text-secondary); background-color: var(--bg-primary);"
>
✏️
<div class="card cursor-pointer p-5" style={ "border-left: 4px solid " + col.Color } data-color={ col.Color }>
<div class="mb-3 flex items-start justify-between">
<div class="text-2xl">{ col.Icon }</div>
<div class="flex gap-1">
<button hx-get={ "/collections/" + col.ID + "/edit-modal" } hx-target="#modal-container" hx-swap="innerHTML" class="icon-btn h-8 w-8" title="Edit">
@Icon("edit", "h-4 w-4")
</button>
<button
hx-delete={ "/api/collections/" + col.ID + "" }
hx-redirect="/collections"
hx-confirm="Are you sure you want to delete this collection?"
class="p-2 hover:opacity-80 rounded"
style="color: var(--text-secondary); background-color: var(--bg-primary);"
>
🗑️
<button hx-delete={ "/api/collections/" + col.ID + "" } hx-redirect="/collections" hx-confirm="Are you sure you want to delete this collection?" class="icon-btn h-8 w-8 hover:text-danger" title="Delete">
@Icon("trash", "h-4 w-4")
</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>
<h3 class="mb-1 text-base font-semibold text-content">{ col.Name }</h3>
<p class="text-sm text-content-muted">{ col.Description }</p>
</div>
</div>
}
@@ -115,243 +89,124 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
@LibrarySwitcher(libData, libraryID)
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6">
<a href="/collections" class="btn-secondary px-4 py-2 rounded-lg mb-4 inline-block">
Back to Collections
<a href="/collections" class="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-content-muted transition-colors hover:text-content">
@Icon("arrow-left", "h-4 w-4")
Back to Collections
</a>
<div class="flex items-center gap-4">
<div class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div>
<div class="flex h-12 w-12 items-center justify-center rounded-xl text-2xl" style={ "background-color: " + collection.Color + "20; 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>
<h1 class="text-2xl font-bold tracking-tight text-content">{ collection.Name }</h1>
<p class="text-sm text-content-muted">{ 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 class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<h2 class="text-sm font-semibold uppercase tracking-wide text-content-muted">Books in this Collection</h2>
<span id="selected-count" class="hidden badge bg-brand/15 text-brand">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 class="flex flex-1 justify-end gap-2">
<div class="relative w-full max-w-xs">
<div class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-content-muted">
@Icon("search", "h-4 w-4")
</div>
<input type="text" id="collection-search" placeholder="Search within collection…" onkeyup="filterCollectionBooks()" class="input pl-9"/>
</div>
<button
id="bulk-remove-btn"
disabled
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
🗑️ Remove Selected
<button id="bulk-remove-btn" disabled class="btn btn-danger disabled:opacity-40 disabled:cursor-not-allowed">
@Icon("trash", "h-4 w-4")
Remove Selected
</button>
<button
@click="$store.bookPicker.open()"
class="btn-primary px-4 py-2 rounded-lg"
>
Add Books
<button @click="$store.bookPicker.open()" class="btn btn-primary">
@Icon("plus", "h-4 w-4")
Add Books
</button>
</div>
</div>
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div id="books-container" class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
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>
<div id="empty-state" class="col-span-full rounded-2xl border border-dashed border-line py-16 text-center text-sm text-content-muted">No books in this collection yet.</div>
}
for _, book := range books {
<a href={ "/media/" + book.MediaItemID }>
<div
class="card p-4 rounded-lg border hover:shadow-lg transition-shadow"
style="background-color: var(--bg-secondary); border-color: var(--border);"
>
<div class="flex gap-4">
<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>
<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 class="card flex gap-4 p-4">
<div class="flex flex-shrink-0 items-center pt-1">
<input type="checkbox" onchange="toggleBookForRemoval('{ book.MediaItemID }')" class="h-5 w-5 rounded border-line"/>
</div>
<a href={ "/media/" + book.MediaItemID } class="flex min-w-0 flex-1 gap-3">
<div class="flex-shrink-0 w-12 sm:w-16">
if book.CoverImagePath != "" {
<img src={ book.CoverImagePath } alt="Cover" class="aspect-[3/4] w-full rounded object-cover shadow-card" onerror="this.src='/static/placeholder-book.svg'"/>
} else {
<img src="/static/placeholder-book.svg" alt="Cover" class="aspect-[3/4] w-full rounded object-cover shadow-card"/>
}
</div>
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button
@click="removeBook('{ book.MediaItemID }')"
class="px-3 py-1 text-sm border rounded hover:opacity-80"
style="border-color: var(--border); color: var(--text-secondary);"
>
🗑️ Remove from Collection
<div class="min-w-0 flex-1">
<h3 class="line-clamp-2 text-sm font-semibold text-content">{ book.Title }</h3>
if book.Author != "" {
<p class="mt-0.5 line-clamp-1 text-xs text-content-muted">by { book.Author }</p>
}
<button @click={ "removeBook('" + book.MediaItemID + "')" } class="mt-2 inline-flex items-center gap-1 text-xs text-content-muted transition-colors hover:text-danger">
@Icon("trash", "h-3 w-3")
Remove
</button>
</div>
</div>
</a>
</a>
</div>
}
</div>
</div>
<!-- Book Picker Modal -->
<div
x-data="bookPicker"
@keyup.escape.window="$store.bookPicker.close()"
class="fixed inset-0 z-50 flex items-center justify-center"
style="display: none;"
>
<!-- Book picker modal -->
<div x-data="bookPicker" @keyup.escape.window="$store.bookPicker.close()" class="fixed inset-0 z-50 flex items-center justify-center" style="display: none;">
<div
@click.stop
x-show="$store.bookPicker.isOpen"
x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="card rounded-lg w-full max-w-6xl mx-4 my-8"
style="background-color: var(--bg-secondary); border-color: var(--border); display: none;"
class="card my-8 w-full max-w-6xl overflow-hidden"
style="display: none;"
>
<div
class="flex justify-between items-center p-6 border-b"
style="border-color: var(--border);"
>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">
Add Books to Collection
</h2>
<button
@click="$store.bookPicker.close()"
class="p-2 hover:opacity-80 rounded"
style="color: var(--text-primary)"
></button>
<div class="flex items-center justify-between border-b border-line p-5">
<h2 class="text-lg font-bold text-content">Add Books to Collection</h2>
<button @click="$store.bookPicker.close()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
<div
class="p-4 border-b"
style="border-color: var(--border);"
>
<div class="flex flex-wrap gap-4 items-center">
<div class="flex-1 min-w-[200px]">
<input
type="text"
name="search"
placeholder="Search books..."
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
hx-target="#book-picker-grid"
hx-trigger="keyup changed delay:300ms"
hx-include="#book-picker-filters"
/>
</div>
<div class="flex-1 min-w-[150px]">
<input
type="text"
name="author_filter"
placeholder="Author"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
hx-target="#book-picker-grid"
hx-trigger="change"
hx-include="#book-picker-filters"
/>
</div>
<div class="flex-1 min-w-[150px]">
<input
type="text"
name="genre_filter"
placeholder="Genre"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
hx-target="#book-picker-grid"
hx-trigger="change"
hx-include="#book-picker-filters"
/>
</div>
<div>
<button
@click="$store.bookPicker.clearFilters()"
class="px-4 py-2 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary);"
>
Clear
</button>
</div>
<div class="flex flex-wrap items-center gap-3 border-b border-line p-4">
<div class="min-w-[12rem] flex-1">
<input type="text" name="search" placeholder="Search books…" class="input" hx-get={ "/api/media-items/filtered?show_checkbox=true&collection_id=" + collection.ID } hx-target="#book-picker-grid" hx-trigger="keyup changed delay:300ms" hx-include="#book-picker-filters"/>
</div>
<div class="min-w-[10rem] flex-1">
<input type="text" name="author_filter" placeholder="Author" class="input" hx-get={ "/api/media-items/filtered?show_checkbox=true&collection_id=" + collection.ID } hx-target="#book-picker-grid" hx-trigger="change" hx-include="#book-picker-filters"/>
</div>
<div class="min-w-[10rem] flex-1">
<input type="text" name="genre_filter" placeholder="Genre" class="input" hx-get={ "/api/media-items/filtered?show_checkbox=true&collection_id=" + collection.ID } hx-target="#book-picker-grid" hx-trigger="change" hx-include="#book-picker-filters"/>
</div>
<button @click="$store.bookPicker.clearFilters()" class="btn btn-ghost">
@Icon("close", "h-4 w-4")
Clear
</button>
<form id="filter-form" class="hidden">
<input type="hidden" name="limit" value="50"/>
<input type="hidden" name="offset" value="0"/>
</form>
</div>
<div
id="book-picker-grid"
class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4 max-h-96 overflow-y-auto"
>
</div>
<div
class="p-4 border-t flex justify-between items-center"
style="border-color: var(--border);"
>
<div class="text-sm" style="color: var(--text-secondary);">
<span x-text="$store.bookPicker.selectedCount"></span> books selected
</div>
<div id="book-picker-grid" class="grid max-h-96 grid-cols-2 gap-4 overflow-y-auto p-4 md:grid-cols-4 lg:grid-cols-6"></div>
<div class="flex items-center justify-between border-t border-line p-4">
<div class="text-sm text-content-muted"><span x-text="$store.bookPicker.selectedCount"></span> books selected</div>
<div class="flex gap-2">
<button
@click="$store.bookPicker.close()"
class="px-4 py-2 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary);"
>
Cancel
</button>
<button
@click="$store.bookPicker.submit()"
class="px-4 py-2 rounded-lg font-medium"
style="background-color: var(--accent); color: var(--bg-primary);"
>
Add Selected Books
</button>
<button @click="$store.bookPicker.close()" class="btn btn-secondary">Cancel</button>
<button @click="$store.bookPicker.submit()" class="btn btn-primary">Add Selected Books</button>
</div>
</div>
</div>
</div>
</body>
<div
id="collection-data"
data-id={ collection.ID }
data-library-id={ libraryID }
style="display: none;"
></div>
<div id="collection-data" data-id={ collection.ID } data-library-id={ libraryID } style="display: none;"></div>
</html>
}
+295 -113
View File
@@ -39,114 +39,175 @@ 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, "<!-- Modal Container --><div id=\"modal-container\"></div><!-- Actual container page --><div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8 flex justify-between items-center\"><div><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">My Collections</h1><p style=\"color: var(--text-secondary)\">Organize your books into custom collections</p></div><div class=\"flex gap-3\"><button hx-get=\"/collections/restore-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn-secondary px-4 py-2 rounded-lg\">🔄 Restore System</button> <button hx-get=\"/collections/create-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn-primary px-4 py-2 rounded-lg\"> New Collection</button></div></div><div id=\"collections-list\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div id=\"modal-container\"></div><div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8 flex flex-wrap items-end justify-between gap-4\"><div><div class=\"mb-1 flex items-center gap-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("folder", "h-6 w-6 text-brand").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<h1 class=\"text-2xl font-bold tracking-tight text-content\">My Collections</h1></div><p class=\"text-sm text-content-muted\">Organize your books into custom collections</p></div><div class=\"flex gap-2\"><button hx-get=\"/collections/restore-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Restore System</button> <button hx-get=\"/collections/create-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "New Collection</button></div></div><div id=\"collections-list\" class=\"grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(collections) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"text-center py-16 col-span-full\" style=\"color: var(--text-secondary)\"><div class=\"text-6xl mb-4\">📚</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Collections Yet</h3><p class=\"mb-4\">Create collections to organize your books</p><button hx-get=\"/collections/create-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn-primary px-4 py-2 rounded-lg\">Create Your First Collection</button></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"col-span-full flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-20 text-center\"><div class=\"mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("folder", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><h3 class=\"text-lg font-semibold text-content\">No Collections Yet</h3><p class=\"mb-4 mt-1 text-sm text-content-muted\">Create collections to organize your books.</p><button hx-get=\"/collections/create-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-primary\">Create Your First Collection</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, col := range collections {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div @click=\"navigateToCollection($el)\" data-href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div @click=\"navigateToCollection($el)\" data-href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 62, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 52, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"block\"><div class=\"card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow\" style=\"background-color: var(--bg-secondary);\" data-color=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" class=\"block\"><div class=\"card cursor-pointer p-5\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
templ_7745c5c3_Var3, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("border-left: 4px solid " + col.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 66, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 53, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"><div class=\"flex justify-between items-start mb-4\"><div class=\"text-3xl\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" data-color=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 69, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 53, Col: 114}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div class=\"flex space-x-2\"><button hx-get=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"><div class=\"mb-3 flex items-start justify-between\"><div class=\"text-2xl\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 72, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 55, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-secondary); background-color: var(--bg-primary);\">✏️</button> <button hx-delete=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><div class=\"flex gap-1\"><button hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 81, Col: 56}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 57, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" hx-redirect=\"/collections\" hx-confirm=\"Are you sure you want to delete this collection?\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-secondary); background-color: var(--bg-primary);\">🗑️</button></div></div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"icon-btn h-8 w-8\" title=\"Edit\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("edit", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</button> <button hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 91, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 60, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</h3><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" hx-redirect=\"/collections\" hx-confirm=\"Are you sure you want to delete this collection?\" class=\"icon-btn h-8 w-8 hover:text-danger\" title=\"Delete\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button></div></div><h3 class=\"mb-1 text-base font-semibold text-content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 92, Col: 86}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 65, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</h3><p class=\"text-sm text-content-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 66, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -154,7 +215,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, 13, "</body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -178,25 +239,25 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
if templ_7745c5c3_Var9 == nil {
templ_7745c5c3_Var9 = templ.NopComponent
templ_7745c5c3_Var10 := templ.GetChildren(ctx)
if templ_7745c5c3_Var10 == nil {
templ_7745c5c3_Var10 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 109, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 83, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"collections\" x-init=\"initCollectionsPage()\" class=\"theme-{ user.Theme }\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"collections\" x-init=\"initCollectionsPage()\" class=\"theme-{ user.Theme }\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -208,165 +269,286 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6\"><a href=\"/collections\" class=\"btn-secondary px-4 py-2 rounded-lg mb-4 inline-block\">← Back to Collections</a><div class=\"flex items-center gap-4\"><div class=\"text-4xl\" style=\"color: { collection.Color }\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6\"><a href=\"/collections\" class=\"mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-content-muted transition-colors hover:text-content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 122, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
templ_7745c5c3_Err = Icon("arrow-left", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div><div><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "Back to Collections</a><div class=\"flex items-center gap-4\"><div class=\"flex h-12 w-12 items-center justify-center rounded-xl text-2xl\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
templ_7745c5c3_Var12, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + collection.Color + "20; color: " + collection.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 124, Col: 90}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 97, Col: 164}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</h1><p style=\"color: var(--text-secondary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 125, Col: 71}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 97, Col: 184}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</p></div></div></div><div class=\"mb-6 flex justify-between items-center\"><div class=\"flex items-center gap-4\"><h2 class=\"text-xl font-semibold\" style=\"color: var(--text-primary)\">Books in this Collection</h2><span id=\"selected-count\" class=\"hidden px-3 py-1 text-sm rounded\" style=\"background-color: var(--accent); color: var(--bg-primary);\">0 selected</span></div><div class=\"flex gap-3\"><div class=\"flex-1 max-w-md\"><input type=\"text\" id=\"collection-search\" placeholder=\"Search within collection...\" onkeyup=\"filterCollectionBooks()\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><button id=\"bulk-remove-btn\" disabled class=\"btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed\">🗑️ Remove Selected</button> <button @click=\"$store.bookPicker.open()\" class=\"btn-primary px-4 py-2 rounded-lg\"> Add Books</button></div></div><div id=\"books-container\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><div><h1 class=\"text-2xl font-bold tracking-tight text-content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 99, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</h1><p class=\"text-sm text-content-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 100, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</p></div></div></div><div class=\"mb-6 flex flex-wrap items-center justify-between gap-3\"><div class=\"flex items-center gap-3\"><h2 class=\"text-sm font-semibold uppercase tracking-wide text-content-muted\">Books in this Collection</h2><span id=\"selected-count\" class=\"hidden badge bg-brand/15 text-brand\">0 selected</span></div><div class=\"flex flex-1 justify-end gap-2\"><div class=\"relative w-full max-w-xs\"><div class=\"pointer-events-none absolute inset-y-0 left-3 flex items-center text-content-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div><input type=\"text\" id=\"collection-search\" placeholder=\"Search within collection…\" onkeyup=\"filterCollectionBooks()\" class=\"input pl-9\"></div><button id=\"bulk-remove-btn\" disabled class=\"btn btn-danger disabled:opacity-40 disabled:cursor-not-allowed\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Remove Selected</button> <button @click=\"$store.bookPicker.open()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "Add Books</button></div></div><div id=\"books-container\" class=\"grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(books) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div id=\"empty-state\" class=\"col-span-full text-center py-16\" style=\"color: var(--text-secondary)\">No books in this collection yet.</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div id=\"empty-state\" class=\"col-span-full rounded-2xl border border-dashed border-line py-16 text-center text-sm text-content-muted\">No books in this collection yet.</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, book := range books {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"card flex gap-4 p-4\"><div class=\"flex flex-shrink-0 items-center pt-1\"><input type=\"checkbox\" onchange=\"toggleBookForRemoval('{ book.MediaItemID }')\" class=\"h-5 w-5 rounded border-line\"></div><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 templ.SafeURL
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
var templ_7745c5c3_Var16 templ.SafeURL
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 167, Col: 44}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 135, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"><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\"><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)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 185, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.Author != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<p class=\"text-sm line-clamp-1\" style=\"color: var(--text-secondary)\">by ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 192, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><div class=\"flex-shrink-0 w-16 sm:w-20\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" class=\"flex min-w-0 flex-1 gap-3\"><div class=\"flex-shrink-0 w-12 sm:w-16\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.CoverImagePath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<img src=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 199, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 138, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded shadow-md\" onerror=\"this.src='/static/placeholder-book.svg'\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" alt=\"Cover\" class=\"aspect-[3/4] w-full rounded object-cover shadow-card\" onerror=\"this.src='/static/placeholder-book.svg'\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<img src=\"/static/placeholder-book.svg\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded shadow-md\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<img src=\"/static/placeholder-book.svg\" alt=\"Cover\" class=\"aspect-[3/4] w-full rounded object-cover shadow-card\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div></div><div class=\"mt-3 pt-3 border-t\" style=\"border-color: var(--border);\"><button @click=\"removeBook('{ book.MediaItemID }')\" class=\"px-3 py-1 text-sm border rounded hover:opacity-80\" style=\"border-color: var(--border); color: var(--text-secondary);\">🗑️ Remove from Collection</button></div></div></a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div><div class=\"min-w-0 flex-1\"><h3 class=\"line-clamp-2 text-sm font-semibold text-content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 144, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.Author != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<p class=\"mt-0.5 line-clamp-1 text-xs text-content-muted\">by ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 146, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<button @click=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("removeBook('" + book.MediaItemID + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 148, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" class=\"mt-2 inline-flex items-center gap-1 text-xs text-content-muted transition-colors hover:text-danger\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("trash", "h-3 w-3").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "Remove</button></div></a></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div></div><!-- Book Picker Modal --><div x-data=\"bookPicker\" @keyup.escape.window=\"$store.bookPicker.close()\" class=\"fixed inset-0 z-50 flex items-center justify-center\" style=\"display: none;\"><div @click.stop x-show=\"$store.bookPicker.isOpen\" x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" class=\"card rounded-lg w-full max-w-6xl mx-4 my-8\" style=\"background-color: var(--bg-secondary); border-color: var(--border); display: none;\"><div class=\"flex justify-between items-center p-6 border-b\" style=\"border-color: var(--border);\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Add Books to Collection</h2><button @click=\"$store.bookPicker.close()\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-primary)\">✕</button></div><div class=\"p-4 border-b\" style=\"border-color: var(--border);\"><div class=\"flex flex-wrap gap-4 items-center\"><div class=\"flex-1 min-w-[200px]\"><input type=\"text\" name=\"search\" placeholder=\"Search books...\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" hx-get=\"/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"keyup changed delay:300ms\" hx-include=\"#book-picker-filters\"></div><div class=\"flex-1 min-w-[150px]\"><input type=\"text\" name=\"author_filter\" placeholder=\"Author\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" hx-get=\"/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><div class=\"flex-1 min-w-[150px]\"><input type=\"text\" name=\"genre_filter\" placeholder=\"Genre\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" hx-get=\"/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><div><button @click=\"$store.bookPicker.clearFilters()\" class=\"px-4 py-2 rounded-lg border\" style=\"border-color: var(--border); color: var(--text-primary);\">✕ Clear</button></div></div><form id=\"filter-form\" class=\"hidden\"><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div><div id=\"book-picker-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4 max-h-96 overflow-y-auto\"></div><div class=\"p-4 border-t flex justify-between items-center\" style=\"border-color: var(--border);\"><div class=\"text-sm\" style=\"color: var(--text-secondary);\"><span x-text=\"$store.bookPicker.selectedCount\"></span> books selected</div><div class=\"flex gap-2\"><button @click=\"$store.bookPicker.close()\" class=\"px-4 py-2 rounded-lg border\" style=\"border-color: var(--border); color: var(--text-primary);\">Cancel</button> <button @click=\"$store.bookPicker.submit()\" class=\"px-4 py-2 rounded-lg font-medium\" style=\"background-color: var(--accent); color: var(--bg-primary);\">Add Selected Books</button></div></div></div></div></body><div id=\"collection-data\" data-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</div></div><!-- Book picker modal --><div x-data=\"bookPicker\" @keyup.escape.window=\"$store.bookPicker.close()\" class=\"fixed inset-0 z-50 flex items-center justify-center\" style=\"display: none;\"><div @click.stop x-show=\"$store.bookPicker.isOpen\" x-cloak x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" class=\"card my-8 w-full max-w-6xl overflow-hidden\" style=\"display: none;\"><div class=\"flex items-center justify-between border-b border-line p-5\"><h2 class=\"text-lg font-bold text-content\">Add Books to Collection</h2><button @click=\"$store.bookPicker.close()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 352, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" data-library-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</button></div><div class=\"flex flex-wrap items-center gap-3 border-b border-line p-4\"><div class=\"min-w-[12rem] flex-1\"><input type=\"text\" name=\"search\" placeholder=\"Search books…\" class=\"input\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/filtered?show_checkbox=true&collection_id=" + collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 353, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 182, Col: 170}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" style=\"display: none;\"></div></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" hx-target=\"#book-picker-grid\" hx-trigger=\"keyup changed delay:300ms\" hx-include=\"#book-picker-filters\"></div><div class=\"min-w-[10rem] flex-1\"><input type=\"text\" name=\"author_filter\" placeholder=\"Author\" class=\"input\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/filtered?show_checkbox=true&collection_id=" + collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 185, Col: 168}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><div class=\"min-w-[10rem] flex-1\"><input type=\"text\" name=\"genre_filter\" placeholder=\"Genre\" class=\"input\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/filtered?show_checkbox=true&collection_id=" + collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 188, Col: 166}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><button @click=\"$store.bookPicker.clearFilters()\" class=\"btn btn-ghost\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "Clear</button><form id=\"filter-form\" class=\"hidden\"><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div><div id=\"book-picker-grid\" class=\"grid max-h-96 grid-cols-2 gap-4 overflow-y-auto p-4 md:grid-cols-4 lg:grid-cols-6\"></div><div class=\"flex items-center justify-between border-t border-line p-4\"><div class=\"text-sm text-content-muted\"><span x-text=\"$store.bookPicker.selectedCount\"></span> books selected</div><div class=\"flex gap-2\"><button @click=\"$store.bookPicker.close()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"$store.bookPicker.submit()\" class=\"btn btn-primary\">Add Selected Books</button></div></div></div></div></body><div id=\"collection-data\" data-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 210, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" data-library-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 210, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" style=\"display: none;\"></div></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+86 -123
View File
@@ -18,13 +18,23 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
<body x-data="dashboard" x-init="initDashboard()" class="theme-{ user.Theme }">
@Header(user, "/dashboard")
@LibrarySwitcher(libData, currentLibraryID, DashboardActions())
<!-- Collections Container -->
<main id="collections-container" class="w-full px-4 py-8">
for _, section := range sections {
@CollectionCarousel(section)
<main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 py-8">
if len(sections) == 0 {
<div class="card mx-auto max-w-md p-10 text-center">
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-brand/15 text-brand">
@Icon("book-open", "h-7 w-7")
</div>
<h2 class="text-lg font-semibold text-content">Your library is empty</h2>
<p class="mt-1 text-sm text-content-muted">Once books are added to your libraries, collections will appear here.</p>
</div>
} else {
<div class="space-y-10">
for _, section := range sections {
@CollectionCarousel(section)
}
</div>
}
</main>
<!-- Dashboard Settings Modal -->
@DashboardSettingsModal(allSections, hiddenCollections, itemsPerSection)
@ErrorToast(errorMessage)
</body>
@@ -32,115 +42,92 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
}
templ CollectionCarousel(section handlers.SectionData) {
<div
class="dashboard-collection mb-8"
<section
class="dashboard-collection"
data-collection-id={ section.ID }
data-is-system={ section.IsSystem }
>
<!-- Collection Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="text-2xl">{ section.Icon }</span>
<div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">{ section.Title }</h2>
if section.Description != "" {
<p class="text-sm" style="color: var(--text-secondary)">{ section.Description }</p>
<div class="mb-4 flex items-end justify-between gap-4">
<div class="min-w-0">
<h2 class="flex items-center gap-2 text-lg font-bold tracking-tight text-content">
if section.Icon != "" {
<span class="text-base">{ section.Icon }</span>
}
</div>
{ section.Title }
</h2>
if section.Description != "" {
<p class="mt-0.5 text-sm text-content-muted">{ section.Description }</p>
}
</div>
if section.ViewAllURL != "" {
<a
href={ section.ViewAllURL }
class="text-sm font-medium hover:underline transition-colors"
style="color: var(--accent);"
>
View All
<a href={ section.ViewAllURL } class="flex items-center gap-1 text-sm font-medium text-content-muted transition-colors hover:text-brand">
View All
@Icon("arrow-right", "h-4 w-4")
</a>
}
</div>
<!-- Carousel -->
<div class="carousel-container relative group">
<button
class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
flex items-center justify-start opacity-0 group-hover:opacity-100
transition-opacity duration-200"
class="carousel-nav-left absolute left-0 top-0 z-10 flex h-full w-12 items-center justify-start bg-gradient-to-r from-surface to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100 focus-visible:opacity-100"
data-action="scroll-carousel"
data-collection-id={ section.ID }
data-direction="-1"
aria-label="Scroll left"
>
<span class="text-3xl pl-2" style="color: var(--text-primary);"></span>
@Icon("chevron-left", "h-6 w-6 text-content")
</button>
<div
id={ "carousel-track-" + section.ID }
class="carousel-track flex gap-4 overflow-x-auto
scroll-smooth snap-x snap-mandatory
px-12 pb-4"
class="carousel-track flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory px-6 pb-2"
style="scrollbar-width: none; -ms-overflow-style: none;"
>
for _, item := range section.Items {
@BookCard(item)
}
if len(section.Items) == 0 {
<div class="text-center py-8 w-full" style="color: var(--text-secondary);">
<p>No items in this collection</p>
<div class="w-full py-10 text-center text-sm text-content-muted">
No items in this collection
</div>
}
</div>
<button
class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
flex items-center justify-end opacity-0 group-hover:opacity-100
transition-opacity duration-200"
class="carousel-nav-right absolute right-0 top-0 z-10 flex h-full w-12 items-center justify-end bg-gradient-to-l from-surface to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100 focus-visible:opacity-100"
data-action="scroll-carousel"
data-collection-id={ section.ID }
data-direction="1"
aria-label="Scroll right"
>
<span class="text-3xl pr-2" style="color: var(--text-primary);"></span>
@Icon("chevron-right", "h-6 w-6 text-content")
</button>
</div>
</div>
</section>
}
// BookCard is the single, canonical cover-forward card used across the
// dashboard, bookshelf, series and collections. The metadata block sits on a
// real surface so cards always read as cohesive objects (previously the text
// floated with no background when wood-paneling was off).
templ BookCard(item handlers.BookInfo) {
<a href={ "/media/" + item.MediaItemID } title={ item.Title }>
<div
class="book-card flex-shrink-0 w-36 rounded-lg overflow-hidden snap-start cursor-pointer
transition-transform duration-200 hover:scale-105"
tabindex="0"
role="button"
aria-label={ "View " + item.Title }
>
<div
class="aspect-[2/3] overflow-hidden shadow-lg
bg-gradient-to-br from-gray-700 to-gray-900"
>
<a href={ "/media/" + item.MediaItemID } title={ item.Title } class="block w-36 flex-shrink-0 snap-start">
<div class="book-card" tabindex="0" role="button" aria-label={ "View " + item.Title }>
<div class="aspect-[2/3] overflow-hidden bg-surface-hover">
if item.CoverImagePath != "" {
<img
src={ item.CoverImagePath }
alt={ item.Title }
class="w-full h-full object-cover"
class="h-full w-full object-cover"
loading="lazy"
onerror="this.src='/static/placeholder-book.svg'"
/>
} else {
<img
src="/static/placeholder-book.svg"
alt={ item.Title }
class="w-full h-full object-cover"
/>
<img src="/static/placeholder-book.svg" alt={ item.Title } class="h-full w-full object-cover"/>
}
</div>
<div class="book-card-text px-2 py-1 bg-[color-mix(in_srgb,var(--wood-border)_40%,transparent)]">
<h3 class="font-semibold text-base line-clamp-2" style="color: var(--text-primary)" title={ item.Title }>
{ item.Title }
</h3>
<div class="book-card-meta">
<h3 class="line-clamp-2 text-sm font-semibold leading-snug text-content" title={ item.Title }>{ item.Title }</h3>
if item.Author != "" {
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)" title={ item.Author }>
{ item.Author }
</p>
<p class="mt-0.5 line-clamp-1 text-xs text-content-muted" title={ item.Author }>{ item.Author }</p>
}
</div>
</div>
@@ -150,82 +137,69 @@ templ BookCard(item handlers.BookInfo) {
templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections []string, itemsPerSection int) {
<div
id="dashboard-settings-modal"
class="hidden fixed inset-0 z-50 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.7);"
class="hidden fixed inset-0 z-50 flex items-center justify-center p-4"
style="background-color: var(--surface-overlay);"
>
<div
class="rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl"
style="background-color: var(--bg-secondary);"
>
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
<button
data-action="close-dashboard-settings"
class="p-2 hover:bg-gray-700 rounded transition-colors"
>
<div class="card w-full max-w-2xl p-6">
<div class="mb-6 flex items-center justify-between">
<div>
<h2 class="text-lg font-bold text-content">Customize Dashboard</h2>
<p class="mt-0.5 text-sm text-content-muted">Drag to reorder, toggle to show or hide.</p>
</div>
<button data-action="close-dashboard-settings" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
<p class="text-sm mb-4" style="color: var(--text-secondary);">
Drag to reorder collections, toggle visibility with the switch.
</p>
<!-- Draggable Collection List -->
<div id="collection-list" class="space-y-2 mb-6">
<div id="collection-list" class="mb-6 space-y-2">
for _, section := range sections {
<div
class="collection-item flex items-center justify-between p-3 rounded border
cursor-move select-none"
class="flex items-center justify-between gap-3 rounded-xl border border-line bg-surface px-3 py-2.5 cursor-move select-none"
data-collection-id={ section.ID }
data-is-system={ fmt.Sprintf("%v", section.IsSystem) }
draggable="true"
style="background-color: var(--bg-primary); border-color: var(--border);"
>
<div class="flex items-center gap-3">
<span class="text-xl" style="color: var(--text-secondary);"></span>
<span class="text-xl">{ section.Icon }</span>
<div>
<span class="font-medium" style="color: var(--text-primary);">{ section.Title }</span>
if section.IsSystem {
<span class="text-xs ml-2 px-2 py-1 rounded" style="background-color: var(--accent);">System</span>
}
<div class="flex items-center gap-3 min-w-0">
@Icon("grip", "h-4 w-4 text-content-muted")
if section.Icon != "" {
<span class="text-base">{ section.Icon }</span>
}
<div class="min-w-0">
<span class="block truncate text-sm font-medium text-content">{ section.Title }</span>
</div>
if section.IsSystem {
<span class="badge bg-brand/15 text-brand">System</span>
}
</div>
<div class="flex items-center gap-3">
if section.IsSystem {
<button
data-action="restore-system-collection"
data-collection-name={ section.ID }
class="text-xs px-3 py-1 rounded border hover:opacity-80 transition-opacity"
style="border-color: var(--border); color: var(--text-secondary);"
title="Restore { section.Title } to defaults"
class="btn btn-ghost px-2 py-1 text-xs"
title="Restore"
>
Restore
</button>
}
<label class="relative inline-flex items-center cursor-pointer">
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
class="sr-only peer"
class="peer sr-only"
if !ContainsString(hiddenCollections, section.ID) {
checked
}
/>
<div
class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all
peer-checked:bg-blue-600"
></div>
<div class="h-6 w-11 rounded-full bg-surface-hover transition-colors after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-content-muted after:transition-all peer-checked:bg-brand peer-checked:after:translate-x-full peer-checked:after:bg-white peer-focus-visible:ring-2 peer-focus-visible:ring-brand/50"></div>
</label>
</div>
</div>
}
</div>
<!-- Items Per Section Slider -->
<div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Items per Collection: <span id="items-count-display" class="font-bold">{ itemsPerSection }</span>
<label class="mb-2 block text-sm font-medium text-content-muted">
Items per Collection: <span id="items-count-display" class="font-bold text-content">{ itemsPerSection }</span>
</label>
<input
type="range"
@@ -233,26 +207,15 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
max="50"
step="5"
value={ itemsPerSection }
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
class="h-2 w-full cursor-pointer appearance-none rounded-full bg-surface-hover"
data-input-action="update-items-count"
target="items-count-display"
/>
</div>
<div class="flex justify-end gap-3">
<button
data-action="close-dashboard-settings"
class="px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors"
style="border-color: var(--border); color: var(--text-primary);"
>
Cancel
</button>
<button
data-action="save-dashboard-settings"
class="px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);"
>
Save Changes
</button>
<button data-action="close-dashboard-settings" class="btn btn-secondary">Cancel</button>
<button data-action="save-dashboard-settings" class="btn btn-primary">Save Changes</button>
</div>
</div>
</div>
+172 -93
View File
@@ -46,17 +46,40 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<!-- Collections Container --><main id=\"collections-container\" class=\"w-full px-4 py-8\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main id=\"collections-container\" class=\"w-full px-4 sm:px-6 lg:px-8 py-8\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, section := range sections {
templ_7745c5c3_Err = CollectionCarousel(section).Render(ctx, templ_7745c5c3_Buffer)
if len(sections) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"card mx-auto max-w-md p-10 text-center\"><div class=\"mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-brand/15 text-brand\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("book-open", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div><h2 class=\"text-lg font-semibold text-content\">Your library is empty</h2><p class=\"mt-1 text-sm text-content-muted\">Once books are added to your libraries, collections will appear here.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"space-y-10\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, section := range sections {
templ_7745c5c3_Err = CollectionCarousel(section).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</main><!-- Dashboard Settings Modal -->")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</main>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -68,7 +91,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -97,131 +120,153 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
templ_7745c5c3_Var2 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"dashboard-collection mb-8\" data-collection-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<section class=\"dashboard-collection\" data-collection-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 37, Col: 33}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 47, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" data-is-system=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" data-is-system=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 38, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 48, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"><!-- Collection Header --><div class=\"flex items-center justify-between mb-4\"><div class=\"flex items-center gap-3\"><span class=\"text-2xl\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"><div class=\"mb-4 flex items-end justify-between gap-4\"><div class=\"min-w-0\"><h2 class=\"flex items-center gap-2 text-lg font-bold tracking-tight text-content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 43, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
if section.Icon != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<span class=\"text-base\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 54, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 45, Col: 85}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 56, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</h2>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if section.Description != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p class=\"mt-0.5 text-sm text-content-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 47, Col: 83}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 59, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if section.ViewAllURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 templ.SafeURL
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 53, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 63, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"text-sm font-medium hover:underline transition-colors\" style=\"color: var(--accent);\">View All →</a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"flex items-center gap-1 text-sm font-medium text-content-muted transition-colors hover:text-brand\">View All")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("arrow-right", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div><!-- Carousel --><div class=\"carousel-container relative group\"><button class=\"carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10 w-12 h-full bg-gradient-to-r from-gray-900 to-transparent flex items-center justify-start opacity-0 group-hover:opacity-100 transition-opacity duration-200\" data-action=\"scroll-carousel\" data-collection-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div><div class=\"carousel-container relative group\"><button class=\"carousel-nav-left absolute left-0 top-0 z-10 flex h-full w-12 items-center justify-start bg-gradient-to-r from-surface to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100 focus-visible:opacity-100\" data-action=\"scroll-carousel\" data-collection-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 69, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 74, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" data-direction=\"-1\" aria-label=\"Scroll left\"><span class=\"text-3xl pl-2\" style=\"color: var(--text-primary);\"></span></button><div id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" data-direction=\"-1\" aria-label=\"Scroll left\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("chevron-left", "h-6 w-6 text-content").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</button><div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("carousel-track-" + section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 76, Col: 39}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 81, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"carousel-track flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory px-12 pb-4\" style=\"scrollbar-width: none; -ms-overflow-style: none;\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" class=\"carousel-track flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory px-6 pb-2\" style=\"scrollbar-width: none; -ms-overflow-style: none;\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -232,12 +277,12 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
}
}
if len(section.Items) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"text-center py-8 w-full\" style=\"color: var(--text-secondary);\"><p>No items in this collection</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"w-full py-10 text-center text-sm text-content-muted\">No items in this collection</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><button class=\"carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10 w-12 h-full bg-gradient-to-l from-gray-900 to-transparent flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity duration-200\" data-action=\"scroll-carousel\" data-collection-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><button class=\"carousel-nav-right absolute right-0 top-0 z-10 flex h-full w-12 items-center justify-end bg-gradient-to-l from-surface to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100 focus-visible:opacity-100\" data-action=\"scroll-carousel\" data-collection-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -250,7 +295,15 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" data-direction=\"1\" aria-label=\"Scroll right\"><span class=\"text-3xl pr-2\" style=\"color: var(--text-primary);\"></span></button></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" data-direction=\"1\" aria-label=\"Scroll right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("chevron-right", "h-6 w-6 text-content").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</button></div></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -258,6 +311,10 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
})
}
// BookCard is the single, canonical cover-forward card used across the
// dashboard, bookshelf, series and collections. The metadata block sits on a
// real surface so cards always read as cohesive objects (previously the text
// floated with no background when wood-paneling was off).
func BookCard(item handlers.BookInfo) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -279,162 +336,162 @@ func BookCard(item handlers.BookInfo) templ.Component {
templ_7745c5c3_Var12 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 templ.SafeURL
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 39}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 112, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" title=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 112, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"><div class=\"book-card flex-shrink-0 w-36 rounded-lg overflow-hidden snap-start cursor-pointer transition-transform duration-200 hover:scale-105\" tabindex=\"0\" role=\"button\" aria-label=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" class=\"block w-36 flex-shrink-0 snap-start\"><div class=\"book-card\" tabindex=\"0\" role=\"button\" aria-label=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("View " + item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 114, Col: 36}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 113, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\"><div class=\"aspect-[2/3] overflow-hidden shadow-lg bg-gradient-to-br from-gray-700 to-gray-900\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\"><div class=\"aspect-[2/3] overflow-hidden bg-surface-hover\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.CoverImagePath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<img src=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 122, Col: 31}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 117, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" alt=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 123, Col: 22}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 118, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"w-full h-full object-cover\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" class=\"h-full w-full object-cover\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<img src=\"/static/placeholder-book.svg\" alt=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<img src=\"/static/placeholder-book.svg\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 22}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 124, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"w-full h-full object-cover\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" class=\"h-full w-full object-cover\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div><div class=\"book-card-text px-2 py-1 bg-[color-mix(in_srgb,var(--wood-border)_40%,transparent)]\"><h3 class=\"font-semibold text-base line-clamp-2\" style=\"color: var(--text-primary)\" title=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div><div class=\"book-card-meta\"><h3 class=\"line-clamp-2 text-sm font-semibold leading-snug text-content\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 137, Col: 106}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 128, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 138, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 128, Col: 110}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</h3>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.Author != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<p class=\"text-sm line-clamp-1\" style=\"color: var(--text-secondary)\" title=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<p class=\"mt-0.5 line-clamp-1 text-xs text-content-muted\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 141, Col: 93}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 130, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 142, Col: 19}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 130, Col: 98}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div></div></a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</div></div></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -463,138 +520,160 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
templ_7745c5c3_Var23 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div id=\"dashboard-settings-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center\" style=\"background-color: rgba(0, 0, 0, 0.7);\"><div class=\"rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl\" style=\"background-color: var(--bg-secondary);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Customize Dashboard</h2><button data-action=\"close-dashboard-settings\" class=\"p-2 hover:bg-gray-700 rounded transition-colors\">✕</button></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary);\">Drag to reorder collections, toggle visibility with the switch.</p><!-- Draggable Collection List --><div id=\"collection-list\" class=\"space-y-2 mb-6\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div id=\"dashboard-settings-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card w-full max-w-2xl p-6\"><div class=\"mb-6 flex items-center justify-between\"><div><h2 class=\"text-lg font-bold text-content\">Customize Dashboard</h2><p class=\"mt-0.5 text-sm text-content-muted\">Drag to reorder, toggle to show or hide.</p></div><button data-action=\"close-dashboard-settings\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</button></div><div id=\"collection-list\" class=\"mb-6 space-y-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, section := range sections {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"collection-item flex items-center justify-between p-3 rounded border cursor-move select-none\" data-collection-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<div class=\"flex items-center justify-between gap-3 rounded-xl border border-line bg-surface px-3 py-2.5 cursor-move select-none\" data-collection-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 178, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 158, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" data-is-system=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" data-is-system=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 179, Col: 58}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 159, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" draggable=\"true\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><div class=\"flex items-center gap-3\"><span class=\"text-xl\" style=\"color: var(--text-secondary);\">☰</span> <span class=\"text-xl\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" draggable=\"true\"><div class=\"flex items-center gap-3 min-w-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 185, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
templ_7745c5c3_Err = Icon("grip", "h-4 w-4 text-content-muted").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</span><div><span class=\"font-medium\" style=\"color: var(--text-primary);\">")
if section.Icon != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span class=\"text-base\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 165, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<div class=\"min-w-0\"><span class=\"block truncate text-sm font-medium text-content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 187, Col: 85}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 168, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if section.IsSystem {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<span class=\"text-xs ml-2 px-2 py-1 rounded\" style=\"background-color: var(--accent);\">System</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<span class=\"badge bg-brand/15 text-brand\">System</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</div></div><div class=\"flex items-center gap-3\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</div><div class=\"flex items-center gap-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if section.IsSystem {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<button data-action=\"restore-system-collection\" data-collection-name=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<button data-action=\"restore-system-collection\" data-collection-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 197, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 178, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" class=\"text-xs px-3 py-1 rounded border hover:opacity-80 transition-opacity\" style=\"border-color: var(--border); color: var(--text-secondary);\" title=\"Restore { section.Title } to defaults\">Restore</button> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" class=\"btn btn-ghost px-2 py-1 text-xs\" title=\"Restore\">Restore</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<label class=\"relative inline-flex items-center cursor-pointer\"><input type=\"checkbox\" class=\"sr-only peer\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<label class=\"relative inline-flex cursor-pointer items-center\"><input type=\"checkbox\" class=\"peer sr-only\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !ContainsString(hiddenCollections, section.ID) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " checked")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "><div class=\"w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600\"></div></label></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "><div class=\"h-6 w-11 rounded-full bg-surface-hover transition-colors after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-content-muted after:transition-all peer-checked:bg-brand peer-checked:after:translate-x-full peer-checked:after:bg-white peer-focus-visible:ring-2 peer-focus-visible:ring-brand/50\"></div></label></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div><!-- Items Per Section Slider --><div class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Items per Collection: <span id=\"items-count-display\" class=\"font-bold\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</div><div class=\"mb-6\"><label class=\"mb-2 block text-sm font-medium text-content-muted\">Items per Collection: <span id=\"items-count-display\" class=\"font-bold text-content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 228, Col: 93}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 202, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span></label> <input type=\"range\" min=\"10\" max=\"50\" step=\"5\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</span></label> <input type=\"range\" min=\"10\" max=\"50\" step=\"5\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 235, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 209, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" class=\"w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer\" data-input-action=\"update-items-count\" target=\"items-count-display\"></div><div class=\"flex justify-end gap-3\"><button data-action=\"close-dashboard-settings\" class=\"px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors\" style=\"border-color: var(--border); color: var(--text-primary);\">Cancel</button> <button data-action=\"save-dashboard-settings\" class=\"px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity\" style=\"background-color: var(--accent);\">Save Changes</button></div></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" class=\"h-2 w-full cursor-pointer appearance-none rounded-full bg-surface-hover\" data-input-action=\"update-items-count\" target=\"items-count-display\"></div><div class=\"flex justify-end gap-3\"><button data-action=\"close-dashboard-settings\" class=\"btn btn-secondary\">Cancel</button> <button data-action=\"save-dashboard-settings\" class=\"btn btn-primary\">Save Changes</button></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+262 -460
View File
@@ -1,531 +1,333 @@
package templates
templ Header(user User, currentPath string) {
<nav x-data="header" x-init="initializeSearch(); initializeTheme(); loadWoodPaneling(); updateWoodPanelingIndicators(); restoreLibrarySelection(); initializeScanListener()" class="border-b header-nav" style="border-color: var(--border); background-color: var(--bg-secondary);">
<div x-data="{}" x-init="console.log('Alpine is working!', $el)"></div>
<nav
x-data="header"
x-init="initializeSearch(); initializeTheme(); loadWoodPaneling(); updateWoodPanelingIndicators(); restoreLibrarySelection(); initializeScanListener()"
class="header-nav border-b border-line bg-surface-raised/95 backdrop-blur shadow-bar"
>
<div class="w-full px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- Left: App Title & Navigation -->
<div class="flex items-center space-x-6">
<a href="/dashboard" class="text-xl font-bold hover:opacity-80 transition-opacity flex items-center gap-2" style="color: var(--text-primary); text-decoration: none;">
<span>📚 Bookhoard</span>
<!-- Scan spinner -->
<svg
x-show="scanning"
x-transition
class="h-5 w-5 animate-spin"
style="color: var(--accent);"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span x-show="scanning && scanProgress > 0" x-transition class="text-xs font-normal" style="color: var(--text-secondary);" x-text="scanProgress + '%'"></span>
</a>
<div class="hidden nav:flex items-center space-x-4">
<a href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Library
</a>
<a
href="/bookshelf"
class="text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-secondary); text-decoration: none;"
>
All Books
</a>
<a href="/series" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Series
</a>
<a href="/collections" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Collections
</a>
<a href="/progress" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Progress
</a>
<a href="/devices" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Devices
</a>
</div>
<div class="flex h-16 items-center gap-4">
<!-- Brand -->
<a href="/dashboard" class="flex items-center gap-2 text-lg font-bold tracking-tight text-content transition-colors hover:text-brand">
@Icon("book-open", "h-6 w-6 text-brand")
<span>Bookhoard</span>
<!-- Scan progress indicator -->
<svg
x-show="scanning"
x-cloak
class="h-4 w-4 animate-spin text-brand"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span x-show="scanning && scanProgress > 0" x-cloak x-text="scanProgress + '%'" class="text-xs font-normal text-content-muted"></span>
</a>
<!-- Primary nav -->
<div class="hidden nav:flex items-center gap-1">
<a href="/dashboard" class={ navItemClass(currentPath, "/dashboard") }>Library</a>
<a href="/bookshelf" class={ navItemClass(currentPath, "/bookshelf") }>All Books</a>
<a href="/series" class={ navItemClass(currentPath, "/series") }>Series</a>
<a href="/collections" class={ navItemClass(currentPath, "/collections") }>Collections</a>
<a href="/progress" class={ navItemClass(currentPath, "/progress") }>Progress</a>
<a href="/devices" class={ navItemClass(currentPath, "/devices") }>Devices</a>
</div>
<!-- Center: Search Box -->
<div class="flex-1 max-w-2xl mx-8 hidden nav:block">
<div class="relative">
<!-- Search (center, grows) -->
<div class="hidden nav:flex flex-1 max-w-xl mx-auto">
<div class="relative w-full">
<div class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-content-muted">
@Icon("search", "h-4 w-4")
</div>
<input
type="text"
id="header-search"
placeholder="Search your library..."
class="w-full px-4 py-2 pl-10 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="Search your library"
class="input pl-9"
autocomplete="off"
/>
<svg class="absolute left-3 top-2.5 h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
</div>
<!-- Right: Theme Switcher & User Menu -->
<div x-data="{ themeDropdownOpen: false, userMenuOpen: false }" class="hidden nav:flex items-center space-x-4">
<!-- Theme Switcher -->
<div class="relative">
<button
@click="themeDropdownOpen = !themeDropdownOpen"
type="button"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-primary);"
>
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
</svg>
<!-- Right cluster -->
<div class="hidden nav:flex items-center gap-1">
<!-- Theme picker -->
<div class="relative" x-data="{ themeDropdownOpen: false }">
<button @click="themeDropdownOpen = !themeDropdownOpen" type="button" class="icon-btn" aria-label="Change theme">
@Icon("palette", "h-5 w-5")
</button>
<div
x-show="themeDropdownOpen"
x-cloak
@click.outside="themeDropdownOpen = false"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-1"
class="absolute right-0 mt-2 w-72 rounded-xl border border-line bg-surface-raised p-3 shadow-pop"
style="display: none;"
>
<div class="p-4">
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">Select Theme</h3>
<div class="space-y-2">
<p class="px-1 pb-2 text-xs font-semibold uppercase tracking-wide text-content-muted">Theme</p>
<div class="grid grid-cols-1 gap-1">
for _, t := range ThemeOptions {
<button
@click="changeTheme('tokyo-night'); themeDropdownOpen = false"
@click={ "changeTheme('" + t.Name + "'); themeDropdownOpen = false" }
type="button"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);"
class="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-sm text-content transition-colors hover:bg-surface-hover"
if t.Name == user.Theme {
class="flex w-full items-center gap-3 rounded-lg bg-surface-hover px-2 py-2 text-sm font-medium text-content"
}
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #7aa2f7;"></span>
Tokyo Night
<span class="h-4 w-4 shrink-0 rounded-full border border-line" style={ "background-color: " + t.Color }></span>
<span class="flex-1 text-left">{ t.Label }</span>
if t.Name == user.Theme {
@Icon("check", "h-4 w-4 text-brand")
}
</button>
<button
@click="changeTheme('dracula'); themeDropdownOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #bd93f9;"></span>
Dracula
</button>
<button
@click="changeTheme('nord'); themeDropdownOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #88c0d0;"></span>
Nord
</button>
<button
@click="changeTheme('solarized-dark'); themeDropdownOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #2aa198;"></span>
Solarized Dark
</button>
<button
@click="changeTheme('monokai'); themeDropdownOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #a6e22e;"></span>
Monokai
</button>
<button
@click="changeTheme('one-dark-pro'); themeDropdownOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #61dafb;"></span>
One Dark Pro
</button>
<button
@click="changeTheme('material-dark'); themeDropdownOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #80cbc4;"></span>
Material Dark
</button>
<div class="border-t pt-2 mt-2" style="border-color: var(--border);">
<p class="text-xs mb-2" style="color: var(--text-secondary)">Bookshelf Background</p>
}
</div>
<div class="mt-3 border-t border-line pt-3">
<p class="px-1 pb-2 text-xs font-semibold uppercase tracking-wide text-content-muted">Bookshelf background</p>
<div class="grid grid-cols-1 gap-1">
for _, w := range WoodOptions {
<button
@click="changeWoodPaneling('none'); themeDropdownOpen = false"
@click={ "changeWoodPaneling('" + w.Name + "'); themeDropdownOpen = false" }
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="none"
data-wood={ w.Name }
class="wood-paneling-btn flex w-full items-center gap-3 rounded-lg px-2 py-2 text-sm text-content transition-colors hover:bg-surface-hover"
>
None
if w.Name == "none" {
<span class="h-4 w-4 shrink-0 rounded-full border border-line bg-surface"></span>
} else {
<span
class="h-4 w-4 shrink-0 rounded-full border border-line"
style={ "background-image: url('/static/textures/" + w.Name + ".png'); background-size: cover;" }
></span>
}
<span class="flex-1 text-left">{ w.Label }</span>
</button>
<button
@click="changeWoodPaneling('wood-light'); themeDropdownOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-light"
>
<span
class="inline-block w-4 h-4 rounded mr-2"
style="background: url('/static/textures/wood-light.png'); background-size: cover;"
></span>
Wood Light
</button>
<button
@click="changeWoodPaneling('wood-dark'); themeDropdownOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-dark"
>
<span
class="inline-block w-4 h-4 rounded mr-2"
style="background: url('/static/textures/wood-dark.png'); background-size: cover;"
></span>
Wood Dark
</button>
<button
@click="changeWoodPaneling('wood-mahogany'); themeDropdownOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-mahogany"
>
<span
class="inline-block w-4 h-4 rounded mr-2"
style="background: url('/static/textures/wood-mahogany.png'); background-size: cover;"
></span>
Wood Mahogany
</button>
</div>
}
</div>
</div>
</div>
</div>
<!-- User Icon with Dropdown -->
<div class="relative">
<!-- User menu / login -->
<div class="relative" x-data="{ userMenuOpen: false }">
if user.ID != "" {
<!-- LOGGED IN: Show user menu with logout -->
<button
@click="userMenuOpen = !userMenuOpen"
type="button"
class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-primary);"
>
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">{ user.Username }</span>
<button @click="userMenuOpen = !userMenuOpen" type="button" class="flex items-center gap-2 rounded-lg p-1.5 pr-2 text-content transition-colors hover:bg-surface-hover" aria-label="Account menu">
<span class="flex h-8 w-8 items-center justify-center rounded-full bg-brand/15 text-brand">
@Icon("user", "h-5 w-5")
</span>
<span class="hidden sm:block text-sm font-medium">{ user.Username }</span>
</button>
<div
x-show="userMenuOpen"
x-cloak
@click.outside="userMenuOpen = false"
x-transition
class="absolute right-0 mt-2 w-48 rounded-lg shadow-lg z-50"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-1"
class="absolute right-0 mt-2 w-52 rounded-xl border border-line bg-surface-raised p-1.5 shadow-pop"
style="display: none;"
>
<div class="py-1">
<a href="/profile" class="block px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary); text-decoration: none;">
Profile
</a>
if user.Role == "admin" {
<a href="/admin" class="block px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary); text-decoration: none;">
Admin Panel
</a>
<div class="px-3 py-2">
<p class="text-sm font-semibold text-content">{ user.Username }</p>
if user.Email != "" {
<p class="truncate text-xs text-content-muted">{ user.Email }</p>
}
<div class="border-t my-1" style="border-color: var(--border);"></div>
<button
@click="logout(); userMenuOpen = false"
type="button"
class="block w-full text-left px-4 py-2 text-sm hover:opacity-80"
style="color: var(--text-primary); background-color: var(--bg-secondary);"
>
Logout
</button>
</div>
<div class="my-1 border-t border-line"></div>
<a href="/profile" class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-content transition-colors hover:bg-surface-hover">
@Icon("user", "h-4 w-4 text-content-muted")
Profile
</a>
if user.Role == "admin" {
<a href="/admin" class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-content transition-colors hover:bg-surface-hover">
@Icon("shield", "h-4 w-4 text-content-muted")
Admin Panel
</a>
}
<div class="my-1 border-t border-line"></div>
<button @click="logout()" type="button" class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-danger transition-colors hover:bg-danger/10">
@Icon("logout", "h-4 w-4")
Logout
</button>
</div>
} else {
<!-- LOGGED OUT: Show login form -->
<button
@click="userMenuOpen = !userMenuOpen"
type="button"
class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-primary);"
>
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">Login</span>
<button @click="userMenuOpen = !userMenuOpen" type="button" class="flex items-center gap-2 rounded-lg p-1.5 pr-2 text-content transition-colors hover:bg-surface-hover" aria-label="Login">
<span class="flex h-8 w-8 items-center justify-center rounded-full bg-brand/15 text-brand">
@Icon("user", "h-5 w-5")
</span>
<span class="hidden sm:block text-sm font-medium">Login</span>
</button>
<div
x-show="userMenuOpen"
x-cloak
@click.outside="userMenuOpen = false"
x-transition
class="absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50 p-4"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-1"
class="absolute right-0 mt-2 w-72 rounded-xl border border-line bg-surface-raised p-4 shadow-pop"
style="display: none;"
>
<form hx-post="/api/auth/login" hx-target="#login-result" hx-swap="innerHTML" class="space-y-3">
<input type="hidden" name="redirect" value="{ currentPath }"/>
<div>
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Email or Username</label>
<input type="text" name="login" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
</div>
<div>
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
</div>
<button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;">
Sign In
</button>
</form>
<div id="login-result"></div>
<div class="border-t my-2" style="border-color: var(--border);"></div>
<a href="/register" class="block text-center text-sm hover:opacity-80" style="color: var(--text-secondary); text-decoration: none;">
Don't have an account? Sign Up
</a>
@HeaderLoginForm("", currentPath)
</div>
}
</div>
</div>
<!-- Mobile menu toggle -->
<button
@click="mobileMenuOpen = !mobileMenuOpen"
type="button"
class="nav:hidden p-2 rounded-lg hover:opacity-80 transition-opacity"
style="background-color: var(--bg-primary); color: var(--text-primary);"
class="icon-btn nav:hidden"
aria-label="Toggle menu"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
@Icon("menu", "h-5 w-5")
</button>
</div>
<div
x-show="mobileMenuOpen"
@click.outside="mobileMenuOpen = false"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
class="nav:hidden border-t"
style="border-color: var(--border); background-color: var(--bg-secondary); display: none;"
x-data="{ mobileThemeOpen: false }"
>
<div class="px-4 py-3 space-y-1">
<div class="relative mb-3">
<input
type="text"
id="header-search-mobile"
placeholder="Search your library..."
class="w-full px-4 py-2 pl-10 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
autocomplete="off"
/>
<svg class="absolute left-3 top-2.5 h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
<!-- Mobile drawer -->
<div
x-show="mobileMenuOpen"
x-cloak
@click.outside="mobileMenuOpen = false"
x-data="{ mobileThemeOpen: false }"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
class="nav:hidden border-t border-line bg-surface-raised"
style="display: none;"
>
<div class="space-y-1 px-4 py-4">
<!-- Mobile search -->
<div class="relative mb-3">
<div class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-content-muted">
@Icon("search", "h-4 w-4")
</div>
<a href="/dashboard" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Library
</a>
<a href="/bookshelf" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
All Books
</a>
<a href="/series" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Series
</a>
<a href="/collections" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Collections
</a>
<a href="/progress" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Progress
</a>
<a href="/devices" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Devices
</a>
<div class="border-t my-2" style="border-color: var(--border);"></div>
<button
@click="mobileThemeOpen = !mobileThemeOpen"
type="button"
class="flex items-center space-x-2 w-full px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-secondary);"
>
<svg class="h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
</svg>
<span>Theme</span>
<svg class="h-4 w-4 ml-auto transform transition-transform" :class="{ 'rotate-180': mobileThemeOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div x-show="mobileThemeOpen" x-transition class="pl-8 pr-2 py-1 space-y-1">
<input
type="text"
id="header-search-mobile"
placeholder="Search your library…"
class="input pl-9"
autocomplete="off"
/>
</div>
<a href="/dashboard" class={ navItemClass(currentPath, "/dashboard") }>@Icon("library", "h-4 w-4") Library</a>
<a href="/bookshelf" class={ navItemClass(currentPath, "/bookshelf") }>@Icon("book", "h-4 w-4") All Books</a>
<a href="/series" class={ navItemClass(currentPath, "/series") }>@Icon("layers", "h-4 w-4") Series</a>
<a href="/collections" class={ navItemClass(currentPath, "/collections") }>@Icon("folder", "h-4 w-4") Collections</a>
<a href="/progress" class={ navItemClass(currentPath, "/progress") }>@Icon("clock", "h-4 w-4") Progress</a>
<a href="/devices" class={ navItemClass(currentPath, "/devices") }>@Icon("device", "h-4 w-4") Devices</a>
<div class="my-2 border-t border-line"></div>
<!-- Theme accordion -->
<button @click="mobileThemeOpen = !mobileThemeOpen" type="button" class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-content transition-colors hover:bg-surface-hover">
@Icon("palette", "h-4 w-4 text-content-muted")
<span>Theme</span>
<svg class={ "ml-auto h-4 w-4 transition-transform " } :class="{ 'rotate-180': mobileThemeOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div x-show="mobileThemeOpen" x-cloak x-transition class="space-y-1 pl-3">
for _, t := range ThemeOptions {
<button
@click="changeTheme('tokyo-night'); mobileThemeOpen = false"
@click={ "changeTheme('" + t.Name + "'); mobileThemeOpen = false" }
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-content transition-colors hover:bg-surface-hover"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #7aa2f7;"></span>
Tokyo Night
<span class="h-4 w-4 rounded-full border border-line" style={ "background-color: " + t.Color }></span>
{ t.Label }
</button>
}
<div class="my-1 border-t border-line"></div>
for _, w := range WoodOptions {
<button
@click="changeTheme('dracula'); mobileThemeOpen = false"
@click={ "changeWoodPaneling('" + w.Name + "'); mobileThemeOpen = false" }
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood={ w.Name }
class="wood-paneling-btn flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-content transition-colors hover:bg-surface-hover"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #bd93f9;"></span>
Dracula
</button>
<button
@click="changeTheme('nord'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #88c0d0;"></span>
Nord
</button>
<button
@click="changeTheme('solarized-dark'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #2aa198;"></span>
Solarized Dark
</button>
<button
@click="changeTheme('monokai'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #a6e22e;"></span>
Monokai
</button>
<button
@click="changeTheme('one-dark-pro'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #61dafb;"></span>
One Dark Pro
</button>
<button
@click="changeTheme('material-dark'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #80cbc4;"></span>
Material Dark
</button>
<div class="border-t pt-2 mt-2" style="border-color: var(--border);">
<p class="text-xs mb-2 px-3" style="color: var(--text-secondary)">Bookshelf Background</p>
<button
@click="changeWoodPaneling('none'); mobileThemeOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="none"
>
None
</button>
<button
@click="changeWoodPaneling('wood-light'); mobileThemeOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-light"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-light.png'); background-size: cover;"></span>
Wood Light
</button>
<button
@click="changeWoodPaneling('wood-dark'); mobileThemeOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-dark"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-dark.png'); background-size: cover;"></span>
Wood Dark
</button>
<button
@click="changeWoodPaneling('wood-mahogany'); mobileThemeOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-mahogany"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-mahogany.png'); background-size: cover;"></span>
Wood Mahogany
</button>
</div>
</div>
<div class="border-t my-2" style="border-color: var(--border);"></div>
if user.ID != "" {
<div class="space-y-1">
<div class="flex items-center space-x-2 px-3 py-2">
<svg class="h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
<span class="text-sm" style="color: var(--text-primary)">{ user.Username }</span>
</div>
<a href="/profile" class="block px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Profile
</a>
if user.Role == "admin" {
<a href="/admin" class="block px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Admin Panel
</a>
if w.Name == "none" {
<span class="h-4 w-4 rounded-full border border-line bg-surface"></span>
} else {
<span class="h-4 w-4 rounded-full border border-line" style={ "background-image: url('/static/textures/" + w.Name + ".png'); background-size: cover;" }></span>
}
<button
@click="logout()"
type="button"
class="block w-full text-left px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-secondary);"
>
Logout
</button>
</div>
} else {
<div class="space-y-3">
<form hx-post="/api/auth/login" hx-target="#login-result-mobile" hx-swap="innerHTML" class="space-y-3">
<input type="hidden" name="redirect" value="{ currentPath }"/>
<div>
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Email or Username</label>
<input type="text" name="login" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
</div>
<div>
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
</div>
<button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;">
Sign In
</button>
</form>
<div id="login-result-mobile"></div>
<a href="/register" class="block text-center text-sm hover:opacity-80" style="color: var(--text-secondary); text-decoration: none;">
Don't have an account? Sign Up
</a>
</div>
{ w.Label }
</button>
}
</div>
<div class="my-2 border-t border-line"></div>
if user.ID != "" {
<a href="/profile" class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-content transition-colors hover:bg-surface-hover">
@Icon("user", "h-4 w-4 text-content-muted")
Profile
</a>
if user.Role == "admin" {
<a href="/admin" class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-content transition-colors hover:bg-surface-hover">
@Icon("shield", "h-4 w-4 text-content-muted")
Admin Panel
</a>
}
<button @click="logout()" type="button" class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-danger transition-colors hover:bg-danger/10">
@Icon("logout", "h-4 w-4")
Logout
</button>
} else {
<div class="rounded-xl border border-line bg-surface p-4">
@HeaderLoginForm("-mobile", currentPath)
</div>
}
</div>
</div>
</nav>
<script type="module" src="/static/main.js"></script>
}
// HeaderLoginForm renders the compact inline login form used in the header
// dropdown (desktop) and mobile drawer. suffix is appended to element ids so
// the two instances do not collide.
templ HeaderLoginForm(suffix string, currentPath string) {
<form
hx-post="/api/auth/login"
hx-target={ "#login-result" + suffix }
hx-swap="innerHTML"
class="space-y-3"
>
<input type="hidden" name="redirect" value={ currentPath }/>
<div>
<label class="mb-1 block text-xs font-medium text-content-muted">Email or Username</label>
<input type="text" name="login" class="input" required/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-content-muted">Password</label>
<input type="password" name="password" class="input" required/>
</div>
<button type="submit" class="btn btn-primary w-full">Sign In</button>
</form>
<div id={ "login-result" + suffix }></div>
<div class="my-3 border-t border-line"></div>
<a href="/register" class="block text-center text-sm text-content-muted transition-colors hover:text-content">
Don't have an account? Sign Up
</a>
}
+858 -34
View File
File diff suppressed because one or more lines are too long
+22 -23
View File
@@ -1,28 +1,27 @@
package templates
templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...templ.Component) {
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
<div class="w-full px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-4">
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library:</label>
<div class="sticky top-16 z-30 border-b border-line bg-surface/95 backdrop-blur">
<div class="w-full px-4 sm:px-6 lg:px-8 py-2.5 flex items-center justify-between gap-4">
<div class="flex items-center gap-3 min-w-0">
<label class="hidden sm:block text-xs font-semibold uppercase tracking-wide text-content-muted" for="library-select">Library</label>
<select
id="library-select"
name="library_id"
class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-secondary); color: var(--text-primary);"
class="input w-auto min-w-[12rem] py-1.5"
>
<option value="">All Libraries ({ TotalMediaCount(libData) })</option>
for _, lib := range libData {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
} else {
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
<option value="">All Libraries ({ TotalMediaCount(libData) })</option>
for _, lib := range libData {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
} else {
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
}
}
}
</select>
</div>
if len(actions) > 0 {
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
for _, action := range actions {
@action
}
@@ -31,10 +30,10 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ..
</div>
<div
id="loading-spinner"
class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50"
style="background-color: var(--bg-primary);"
class="hidden fixed inset-0 z-50 flex items-center justify-center"
style="background-color: var(--surface-overlay);"
>
<div class="animate-spin rounded-full h-12 w-12 border-b-2" style="border-color: var(--accent);"></div>
<div class="loading-spinner"></div>
</div>
</div>
}
@@ -42,18 +41,18 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ..
templ DashboardActions() {
<button
data-action="open-dashboard-settings"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-secondary);"
class="icon-btn"
title="Customize Dashboard"
aria-label="Customize Dashboard"
>
⚙️
@Icon("settings", "h-5 w-5")
</button>
<button
data-action="reload-page"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-secondary);"
class="icon-btn"
title="Refresh"
aria-label="Refresh"
>
🔄
@Icon("refresh", "h-5 w-5")
</button>
}
+27 -11
View File
@@ -29,14 +29,14 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b\" style=\"background-color: var(--bg-primary);\"><div class=\"w-full px-4 py-3 flex items-center justify-between\"><div class=\"flex items-center gap-4\"><label class=\"text-sm font-medium\" style=\"color: var(--text-secondary)\">Library:</label> <select id=\"library-select\" name=\"library_id\" class=\"px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-secondary); color: var(--text-primary);\"><option value=\"\">All Libraries (")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"sticky top-16 z-30 border-b border-line bg-surface/95 backdrop-blur\"><div class=\"w-full px-4 sm:px-6 lg:px-8 py-2.5 flex items-center justify-between gap-4\"><div class=\"flex items-center gap-3 min-w-0\"><label class=\"hidden sm:block text-xs font-semibold uppercase tracking-wide text-content-muted\" for=\"library-select\">Library</label> <select id=\"library-select\" name=\"library_id\" class=\"input w-auto min-w-[12rem] py-1.5\"><option value=\"\">All Libraries (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libData))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 14, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 13, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -55,7 +55,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 16, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -68,7 +68,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 16, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -81,7 +81,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 16, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -99,7 +99,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 18, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
@@ -112,7 +112,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 18, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -125,7 +125,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 18, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -142,7 +142,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
return templ_7745c5c3_Err
}
if len(actions) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"flex items-center gap-2\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"flex items-center gap-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -157,7 +157,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50\" style=\"background-color: var(--bg-primary);\"><div class=\"animate-spin rounded-full h-12 w-12 border-b-2\" style=\"border-color: var(--accent);\"></div></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center\" style=\"background-color: var(--surface-overlay);\"><div class=\"loading-spinner\"></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -186,7 +186,23 @@ func DashboardActions() templ.Component {
templ_7745c5c3_Var9 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<button data-action=\"open-dashboard-settings\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-secondary);\" title=\"Customize Dashboard\">⚙️</button> <button data-action=\"reload-page\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-secondary);\" title=\"Refresh\">🔄</button>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<button data-action=\"open-dashboard-settings\" class=\"icon-btn\" title=\"Customize Dashboard\" aria-label=\"Customize Dashboard\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("settings", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button> <button data-action=\"reload-page\" class=\"icon-btn\" title=\"Refresh\" aria-label=\"Refresh\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+27 -40
View File
@@ -17,44 +17,39 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
<body x-data="seriesPage" x-init="initSeriesPage()" class="theme-{ user.Theme }">
@Header(user, "/series")
@LibrarySwitcher(libData, currentLibraryID)
<!-- Series Grid -->
<div id="series-container">
<main class="w-full px-4 py-8">
<h1 class="text-3xl font-bold mb-6" style="color: var(--text-primary)">Series</h1>
<main class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6 flex items-center gap-2">
@Icon("layers", "h-6 w-6 text-brand")
<h1 class="text-2xl font-bold tracking-tight text-content">Series</h1>
</div>
if len(seriesList) == 0 {
<div id="series-empty" class="text-center py-16" 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 Series Found</h3>
<p>Books with series metadata will appear here</p>
<div id="series-empty" class="flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-20 text-center">
<div class="mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted">
@Icon("layers", "h-7 w-7")
</div>
<h3 class="text-lg font-semibold text-content">No Series Found</h3>
<p class="mt-1 text-sm text-content-muted">Books with series metadata will appear here.</p>
</div>
}
<div id="series-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
<div id="series-grid" class="grid grid-cols-2 gap-4 md:grid-cols-4 lg:grid-cols-6">
for _, series := range seriesList {
@SeriesCard(series)
}
</div>
<!-- Pagination -->
if totalPages > 1 {
<div id="series-pagination" class="flex justify-center items-center gap-4 mt-8">
<div id="series-pagination" class="mt-8 flex items-center justify-center gap-3">
if currentPage > 1 {
<a
href={ fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage-1) }
class="px-4 py-2 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary);"
>
Previous
<a href={ fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage-1) } class="btn btn-secondary">
@Icon("chevron-left", "h-4 w-4")
Previous
</a>
}
<span class="text-sm" style="color: var(--text-secondary)">
Page { fmt.Sprintf("%d", currentPage) } of { fmt.Sprintf("%d", totalPages) }
</span>
<span class="text-sm text-content-muted">Page { fmt.Sprintf("%d", currentPage) } of { fmt.Sprintf("%d", totalPages) }</span>
if currentPage < totalPages {
<a
href={ fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage+1) }
class="px-4 py-2 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary);"
>
Next
<a href={ fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage+1) } class="btn btn-secondary">
Next
@Icon("chevron-right", "h-4 w-4")
</a>
}
</div>
@@ -68,28 +63,20 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
templ SeriesCard(series SeriesCardData) {
<a href={ "/series/detail?name=" + url.QueryEscape(series.Name) } class="block">
<div class="series-card rounded-lg overflow-hidden cursor-pointer hover:shadow-lg transition-shadow" style="background-color: var(--bg-secondary);">
<div class="book-card series-card overflow-hidden">
<div class={ "stacked-covers cover-count-" + fmt.Sprintf("%d", len(series.CoverPaths)) }>
for i, cover := range series.CoverPaths {
<img
src={ cover }
class={ "cover-img cover-" + fmt.Sprintf("%d", i) }
alt={ series.Name }
loading="lazy"
onerror="this.src='/static/placeholder-book.svg'"
/>
<img src={ cover } class={ "cover-img cover-" + fmt.Sprintf("%d", i) } alt={ series.Name } loading="lazy" onerror="this.src='/static/placeholder-book.svg'"/>
}
if len(series.CoverPaths) == 0 {
<div class="cover-img cover-0 flex items-center justify-center" style="background: var(--bg-primary);">
<span class="text-3xl">📚</span>
<div class="cover-img cover-0 flex items-center justify-center bg-surface-hover text-content-muted">
@Icon("book", "h-8 w-8")
</div>
}
</div>
<div class="series-card-info px-3 py-2">
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">{ series.Name }</h3>
<p class="text-xs mt-1" style="color: var(--text-secondary)">
{ fmt.Sprintf("%d", series.BookCount) } of { fmt.Sprintf("%d", series.TotalInSeries) } books
</p>
<div class="book-card-meta">
<h3 class="line-clamp-2 text-sm font-semibold leading-snug text-content">{ series.Name }</h3>
<p class="mt-0.5 text-xs text-content-muted">{ fmt.Sprintf("%d", series.BookCount) } of { fmt.Sprintf("%d", series.TotalInSeries) } books</p>
</div>
</div>
</a>
+78 -38
View File
@@ -46,17 +46,33 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<!-- Series Grid --><div id=\"series-container\"><main class=\"w-full px-4 py-8\"><h1 class=\"text-3xl font-bold mb-6\" style=\"color: var(--text-primary)\">Series</h1>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div id=\"series-container\"><main class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6 flex items-center gap-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("layers", "h-6 w-6 text-brand").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<h1 class=\"text-2xl font-bold tracking-tight text-content\">Series</h1></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(seriesList) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div id=\"series-empty\" class=\"text-center py-16\" 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 Series Found</h3><p>Books with series metadata will appear here</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div id=\"series-empty\" class=\"flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-20 text-center\"><div class=\"mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("layers", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><h3 class=\"text-lg font-semibold text-content\">No Series Found</h3><p class=\"mt-1 text-sm text-content-muted\">Books with series metadata will appear here.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div id=\"series-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div id=\"series-grid\" class=\"grid grid-cols-2 gap-4 md:grid-cols-4 lg:grid-cols-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -66,89 +82,105 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><!-- Pagination -->")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if totalPages > 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div id=\"series-pagination\" class=\"flex justify-center items-center gap-4 mt-8\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div id=\"series-pagination\" class=\"mt-8 flex items-center justify-center gap-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if currentPage > 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 templ.SafeURL
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage-1))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 41, Col: 93}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 43, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" class=\"px-4 py-2 rounded-lg border\" style=\"border-color: var(--border); color: var(--text-primary);\">← Previous</a> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("chevron-left", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Previous</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<span class=\"text-sm\" style=\"color: var(--text-secondary)\">Page ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<span class=\"text-sm text-content-muted\">Page ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", currentPage))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 49, Col: 45}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 48, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " of ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " of ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", totalPages))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 49, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 48, Col: 122}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if currentPage < totalPages {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 templ.SafeURL
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage+1))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 53, Col: 93}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 50, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"px-4 py-2 rounded-lg border\" style=\"border-color: var(--border); color: var(--text-primary);\">Next →</a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" class=\"btn btn-secondary\">Next")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("chevron-right", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</main></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</main></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -156,7 +188,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -185,20 +217,20 @@ func SeriesCard(series SeriesCardData) templ.Component {
templ_7745c5c3_Var6 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 templ.SafeURL
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs("/series/detail?name=" + url.QueryEscape(series.Name))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 70, Col: 64}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 65, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"block\"><div class=\"series-card rounded-lg overflow-hidden cursor-pointer hover:shadow-lg transition-shadow\" style=\"background-color: var(--bg-secondary);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" class=\"block\"><div class=\"book-card series-card overflow-hidden\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -207,7 +239,7 @@ func SeriesCard(series SeriesCardData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -220,7 +252,7 @@ func SeriesCard(series SeriesCardData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -230,20 +262,20 @@ func SeriesCard(series SeriesCardData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<img src=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(cover)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 75, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 69, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" class=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -256,70 +288,78 @@ func SeriesCard(series SeriesCardData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" alt=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(series.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 77, Col: 23}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 69, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\"> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(series.CoverPaths) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"cover-img cover-0 flex items-center justify-center\" style=\"background: var(--bg-primary);\"><span class=\"text-3xl\">📚</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"cover-img cover-0 flex items-center justify-center bg-surface-hover text-content-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("book", "h-8 w-8").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><div class=\"series-card-info px-3 py-2\"><h3 class=\"font-semibold text-sm line-clamp-2\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div><div class=\"book-card-meta\"><h3 class=\"line-clamp-2 text-sm font-semibold leading-snug text-content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(series.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 89, Col: 99}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 78, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</h3><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</h3><p class=\"mt-0.5 text-xs text-content-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.BookCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 91, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 79, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " of ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " of ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.TotalInSeries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 91, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 79, Col: 133}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " books</p></div></div></a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " books</p></div></div></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+1
View File
@@ -80,6 +80,7 @@ Alpine.data("bookshelf", () => ({
// Component state
showSaveModal: false,
showFiltersDropdown: false,
filtersOpen: false,
hasCoverState: null as boolean | null,
dropdownAlign: "right" as "left" | "right",
resizeTimeout: null as number | null,