feat(ui): redesign dashboard, bookshelf, book detail, and auth pages

- Dashboard: page heading, bold section headers with accent icon tiles,
  carousel chevrons as SVG icons with theme-aware gradients (wood-paneling
  gradient classes preserved), and a cinematic BookCard (hover overlay with
  a quick-action button). BookCard is now fluid so it fills both the
  carousel slot and the bookshelf grid.
- Bookshelf: the filter wall becomes a search toolbar + a slide-in filter
  drawer (filtersOpen state added to the bookshelf Alpine component). Every
  filter input, the tristate cover toggle, tag autocomplete, save/load/clear
  actions, HTMX search/sort, and pagination are preserved.
- Book detail: blurred cover backdrop hero, rounded-2xl cover, bold
  typography, .btn action bar, progress/metadata cards, chip-style external
  links. All interactive rating, modals, and data-attrs preserved.
- Auth/landing: brand-gradient hero for index/login/register, icon feature
  cards, data-driven theme select (ThemeOptions). All ids (#theme-select,
  #result, #auth-result, password-requirement ids) and Alpine init preserved.
This commit is contained in:
2026-08-05 16:44:52 -04:00
parent f402a3ee03
commit 07578e0206
7 changed files with 757 additions and 1050 deletions
+264 -429
View File
@@ -19,567 +19,403 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
</head> </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) }") @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);"> <!-- Hero with blurred cover backdrop -->
<div class="w-full px-4 py-3 flex items-center gap-4"> <div class="relative overflow-hidden">
<a id="back-link" href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;"> if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
Back <div class="hero-backdrop" style={ "background-image: url('" + book.CoverImagePath.String + "')" }></div>
}
<div class="relative z-10 xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pt-6 pb-8">
<a id="back-link" href="/dashboard" class="inline-flex items-center gap-1.5 text-sm font-medium hover:underline transition-colors mb-6" style="color: var(--text-secondary);">
@Icon("arrow-left", "h-4 w-4")
<span>Back</span>
</a> </a>
</div> <div class="flex flex-col md:flex-row gap-8">
</div> <!-- Cover -->
<div class="xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 py-8"> <div class="flex-shrink-0 mx-auto md:mx-0">
<!-- Top Section: Cover + Basic Info + Actions --> if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
<div class="flex flex-col md:flex-row gap-8 mb-8"> <img
<!-- Left: Cover Image (256x384px) --> src={ book.CoverImagePath.String }
<div class="flex-shrink-0"> alt={ book.Title }
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" { class="w-48 md:w-56 h-72 md:h-80 object-cover rounded-2xl"
<img style="box-shadow: var(--shadow-pop);"
src={ book.CoverImagePath.String } onerror="this.src='/static/placeholder-book.svg'"
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>
<!-- Right: Details -->
<div class="flex-1">
<!-- Title & Author -->
<h1 class="text-4xl font-bold mb-2" style="color: var(--text-primary)">{ 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>
}
<!-- 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
</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">
<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 { } else {
<button <img
@click="toggleRead(true)" src="/static/placeholder-book.svg"
:disabled="readSaving" alt={ book.Title }
class="px-6 py-3 rounded-lg border" class="w-48 md:w-56 h-72 md:h-80 object-cover rounded-2xl"
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);" style="box-shadow: var(--shadow-pop);"
> />
<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">
<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 --> </div>
if book.ActiveConflict != nil || book.ReadingProgress != nil { <!-- Details -->
<button <div class="flex-1 min-w-0">
@click="showProgressSyncModal()" <h1 class="text-3xl md:text-4xl font-bold tracking-tight mb-2" style="color: var(--text-primary)">{ book.Title }</h1>
class="px-6 py-3 rounded-lg border" if book.Author.Valid && book.Author.String != "" {
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);" <h2 class="text-lg md:text-xl mb-5" style="color: var(--text-secondary)">
> by { book.Author.String }
🔄 Sync Progress </h2>
</button>
} }
<!-- View Notes/Highlights --> <!-- Action buttons -->
<button <div class="flex flex-wrap gap-2.5 mb-6">
@click="showNotesModal()" <button
class="px-6 py-3 rounded-lg border" @click={ "window.location.href = '/readers/" + uuidToString(book.ID) + "'" }
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);" class="btn btn-primary px-5 py-2.5"
> >
📝 Notes & Highlights @Icon("book-open", "h-4 w-4")
if book.NotesCount + book.HighlightsCount > 0 { <span>Read Now</span>
<span </button>
class="ml-2 px-2 py-0.5 rounded text-xs font-semibold" if book.ReadingProgress != nil && book.ReadingProgress.Percentage.Valid && book.ReadingProgress.Percentage.Float64 >= 1.0 {
style="background-color: var(--accent); color: white;" <button @click="toggleRead(false)" :disabled="readSaving" class="btn btn-secondary px-5 py-2.5">
> <span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("check-circle", "h-4 w-4")<span>Mark Unread</span></span>
{ book.NotesCount + book.HighlightsCount } <svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
</span> <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="btn btn-secondary px-5 py-2.5">
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("check", "h-4 w-4")<span>Mark Read</span></span>
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" 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>
} }
</button> if book.ActiveConflict != nil || book.ReadingProgress != nil {
<!-- Edit Metadata (placeholder) --> <button @click="showProgressSyncModal()" class="btn btn-secondary px-5 py-2.5">
<button @Icon("sync", "h-4 w-4")
@click="showMetadataEditor()" <span>Sync Progress</span>
class="px-6 py-3 rounded-lg border" </button>
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);" }
> <button @click="showNotesModal()" class="btn btn-secondary px-5 py-2.5">
✏️ Edit Metadata @Icon("edit", "h-4 w-4")
</button> <span>Notes</span>
</div> if book.NotesCount + book.HighlightsCount > 0 {
<!-- Rating Display --> <span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
<div class="mb-6" @mouseleave="ratingHover = 0"> }
<span class="text-2xl"> </button>
<template x-for="i in 5" :key="i"> <button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
<span style="position: relative; display: inline-block;"> @Icon("edit", "h-4 w-4")
<span :style="starFill(i)"></span> <span>Edit</span>
<button </button>
type="button" </div>
@click="setRating(i*2-1)" <!-- Rating -->
@mouseenter="ratingHover = i*2-1" <div class="mb-5" @mouseleave="ratingHover = 0">
:disabled="ratingSaving" <span class="text-2xl">
:aria-label="'Rate ' + ((i*2-1)/2) + ' of 5 stars'" <template x-for="i in 5" :key="i">
style="position: absolute; left: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;" <span style="position: relative; display: inline-block;">
></button> <span :style="starFill(i)"></span>
<button <button
type="button" type="button"
@click="setRating(i*2)" @click="setRating(i*2-1)"
@mouseenter="ratingHover = i*2" @mouseenter="ratingHover = i*2-1"
:disabled="ratingSaving" :disabled="ratingSaving"
:aria-label="'Rate ' + ((i*2)/2) + ' of 5 stars'" :aria-label="'Rate ' + ((i*2-1)/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;" style="position: absolute; left: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
></button> ></button>
</span> <button
</template> type="button"
</span> @click="setRating(i*2)"
<span class="ml-2 text-sm align-middle" style="color: var(--text-secondary);" x-text="ratingText()"></span> @mouseenter="ratingHover = i*2"
<button :disabled="ratingSaving"
type="button" :aria-label="'Rate ' + ((i*2)/2) + ' of 5 stars'"
x-show="userRating > 0 && !ratingSaving" style="position: absolute; right: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
@click="clearRating()" ></button>
class="ml-2 text-xs underline hover:opacity-70" </span>
style="color: var(--text-secondary);" </template>
>Clear</button> </span>
<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="ml-2 text-sm align-middle" style="color: var(--text-secondary);" x-text="ratingText()"></span>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <button
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> type="button"
</svg> x-show="userRating > 0 && !ratingSaving"
</div> @click="clearRating()"
<!-- Community Rating Display (from metadata) --> class="ml-2 text-xs underline hover:opacity-70"
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 { style="color: var(--text-secondary);"
<div class="mb-2"> >Clear</button>
<span class="text-lg" style="color: var(--text-secondary);"> <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);">
Community Rating: <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<span class="font-bold" style="color: var(--text-primary);"> <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 -->
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
<div class="mb-3 text-sm" style="color: var(--text-secondary);">
Community:
<span class="font-bold ml-1" style="color: var(--text-primary);">
@templ.Raw(renderStars(int32(book.CommunityRating.Float64))) @templ.Raw(renderStars(int32(book.CommunityRating.Float64)))
</span> </span>
<span class="ml-2 text-sm" style="color: var(--text-secondary);"> <span class="ml-2">{ fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) }</span>
{ fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) } </div>
</span>
</span>
</div>
}
<!-- Series Badge -->
if book.Series.Valid && book.Series.String != "" {
<div class="mb-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;"
>
{ book.Series.String }
if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 {
#{ book.SeriesNumber.Int32 }
}
</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 -->
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>
} }
<!-- Black and White Badge --> <!-- Badges: series, direction, tags -->
if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool { <div class="flex flex-wrap items-center gap-2 mb-3">
<span if book.Series.Valid && book.Series.String != "" {
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>
}
<!-- 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>
}
</div>
<!-- Tags -->
if len(book.Tags) > 0 {
<div class="flex flex-wrap gap-2 mb-4">
for _, tag := range book.Tags {
<a <a
href={ "/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID) } href={ "/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID) }
class="px-2 py-1 rounded-full text-xs font-semibold hover:opacity-80 transition-opacity" class="chip hover:opacity-90"
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary); text-decoration: none;" style="background-color: var(--accent); color: var(--bg-primary); text-decoration: none;"
> >
{ tag } { book.Series.String }
if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 {
#{ book.SeriesNumber.Int32 }
}
</a> </a>
} }
if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" {
<span class="chip" style="background-color: var(--accent); color: var(--bg-primary);">{ strings.ToUpper(book.ReadingDirection.String) }</span>
}
if book.AgeRating.Valid && book.AgeRating.String != "" {
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">{ book.AgeRating.String }</span>
}
if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool {
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">B&W</span>
}
if book.StoryArc.Valid && book.StoryArc.String != "" {
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">{ book.StoryArc.String }</span>
}
</div> </div>
} if len(book.Tags) > 0 {
<!-- Description/Synopsis --> <div class="flex flex-wrap gap-2 mb-4">
if book.Description.Valid && book.Description.String != "" { for _, tag := range book.Tags {
<div class="mb-6"> <a
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Synopsis</h3> href={ "/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID) }
<div class="max-h-[20rem] overflow-y-auto pr-2" style="color: var(--text-secondary)"> class="badge hover:opacity-80 transition-opacity"
@UnsafeHTML( style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary); text-decoration: none;"
bluemonday.UGCPolicy().Sanitize(book.Description.String), >
).ToComponent() { tag }
</a>
}
</div> </div>
</div> }
} </div>
</div> </div>
</div> </div>
<!-- Summary (from ComicInfo.xml, if different from description) --> </div>
<div class="xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pb-10">
<!-- Synopsis -->
if book.Description.Valid && book.Description.String != "" {
<div class="card p-6 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 text-sm leading-relaxed" style="color: var(--text-secondary)">
@UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Description.String)).ToComponent()
</div>
</div>
}
if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String { if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String {
<div class="mb-6"> <div class="card p-6 mb-6">
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Comic Summary</h3> <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)"> <div class="max-h-[20rem] overflow-y-auto pr-2 text-sm leading-relaxed" style="color: var(--text-secondary)">
@UnsafeHTML( @UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Summary.String)).ToComponent()
bluemonday.UGCPolicy().Sanitize(book.Summary.String),
).ToComponent()
</div> </div>
</div> </div>
} }
<!-- Metadata Notes (technical notes from metadata files) -->
if book.MetadataNotes.Valid && book.MetadataNotes.String != "" { if book.MetadataNotes.Valid && book.MetadataNotes.String != "" {
<div <div class="card p-6 mb-6">
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> <h3 class="font-semibold mb-2" style="color: var(--text-primary)">Metadata Notes</h3>
<div class="text-sm" style="color: var(--text-secondary);"> <div class="text-sm" style="color: var(--text-secondary);">{ book.MetadataNotes.String }</div>
{ book.MetadataNotes.String }
</div>
</div> </div>
} }
<!-- Progress Section --> <!-- Progress -->
if book.ReadingProgress != nil { if book.ReadingProgress != nil {
<div <div class="card p-6 mb-6">
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> <h3 class="font-semibold mb-4" style="color: var(--text-primary)">Reading Progress</h3>
if book.ActiveConflict != nil { if book.ActiveConflict != nil {
<div <div class="mb-4 p-3 rounded-xl flex items-start gap-2" style="background-color: color-mix(in srgb, var(--status-warning) 14%, transparent); border: 1px solid var(--status-warning);">
class="mb-4 p-3 rounded-lg border" @Icon("alert", "h-5 w-5 shrink-0")
style="background-color: #f59e0b20; border-color: #f59e0b;" <p class="text-sm" style="color: var(--text-primary);">Progress conflict detected click "Sync Progress" to review and resolve.</p>
>
<p style="color: var(--text-primary);">
⚠️ Progress conflict detected - Click "Sync Progress" to review and resolve
</p>
</div> </div>
} }
<!-- Progress Bar -->
<div class="mb-4"> <div class="mb-4">
<div class="w-full rounded-full h-3" style="background-color: var(--bg-primary);"> <div class="w-full rounded-full h-2.5" style="background-color: var(--bg-primary);">
<div <div
class="h-3 rounded-full transition-all" class="h-2.5 rounded-full transition-all"
style={ fmt.Sprintf("width: %.1f%%; background-color: var(--accent);", book.ReadingProgress.Percentage.Float64*100) } style={ fmt.Sprintf("width: %.1f%%; background-color: var(--accent);", book.ReadingProgress.Percentage.Float64*100) }
></div> ></div>
</div> </div>
</div> </div>
<!-- Progress Stats Grid --> <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div> <div>
<p style="color: var(--text-secondary)">Progress</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Progress</p>
<p class="text-2xl font-bold" style="color: var(--accent);"> <p class="text-2xl font-bold" style="color: var(--accent);">{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100) }%</p>
{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64 * 100) }%
</p>
</div> </div>
if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid { if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {
<div> <div>
<p style="color: var(--text-secondary)">Page</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Page</p>
<p>{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }</p> <p style="color: var(--text-primary)">{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }</p>
</div> </div>
} }
if book.ReadingProgress.LastReadAt.Valid { if book.ReadingProgress.LastReadAt.Valid {
<div> <div>
<p style="color: var(--text-secondary)">Last Read</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Last Read</p>
<p>{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</p> <p class="text-sm" style="color: var(--text-primary)">{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</p>
</div> </div>
} }
if book.ReadingProgress.LastSyncSource.Valid { if book.ReadingProgress.LastSyncSource.Valid {
<div> <div>
<p style="color: var(--text-secondary)">Source</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Source</p>
<p class="capitalize">{ book.ReadingProgress.LastSyncSource.String }</p> <p class="capitalize" style="color: var(--text-primary)">{ book.ReadingProgress.LastSyncSource.String }</p>
</div> </div>
} }
</div> </div>
</div> </div>
} }
<!-- Metadata Grid --> <!-- Metadata grid -->
<div <div class="card p-6 mb-6">
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> <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"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-4 text-sm">
<!-- Publication Info -->
if book.Publisher.Valid && book.Publisher.String != "" { if book.Publisher.Valid && book.Publisher.String != "" {
<div> <div>
<p style="color: var(--text-secondary)">Publisher</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Publisher</p>
<p>{ book.Publisher.String }</p> <p style="color: var(--text-primary)">{ book.Publisher.String }</p>
</div> </div>
} }
if book.DatePublished.Valid { if book.DatePublished.Valid {
<div> <div>
<p style="color: var(--text-secondary)">Published</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Published</p>
<p>{ book.DatePublished.Time.Format("01-02-2006") }</p> <p style="color: var(--text-primary)">{ book.DatePublished.Time.Format("01-02-2006") }</p>
</div> </div>
} }
if book.Isbn.Valid && book.Isbn.String != "" { if book.Isbn.Valid && book.Isbn.String != "" {
<div> <div>
<p style="color: var(--text-secondary)">Isbn</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">ISBN</p>
<p>{ book.Isbn.String }</p> <p style="color: var(--text-primary)">{ book.Isbn.String }</p>
</div> </div>
} }
if book.Language.Valid && book.Language.String != "" { if book.Language.Valid && book.Language.String != "" {
<div> <div>
<p style="color: var(--text-secondary)">Language</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Language</p>
<p class="capitalize">{ book.Language.String }</p> <p class="capitalize" style="color: var(--text-primary)">{ book.Language.String }</p>
</div> </div>
} }
if book.Edition.Valid && book.Edition.String != "" { if book.Edition.Valid && book.Edition.String != "" {
<div> <div>
<p style="color: var(--text-secondary)">Edition</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Edition</p>
<p>{ book.Edition.String }</p> <p style="color: var(--text-primary)">{ book.Edition.String }</p>
</div> </div>
} }
if book.PageCount.Valid && book.PageCount.Int32 > 0 { if book.PageCount.Valid && book.PageCount.Int32 > 0 {
<div> <div>
<p style="color: var(--text-secondary)">Pages</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Pages</p>
<p>{ book.PageCount.Int32 }</p> <p style="color: var(--text-primary)">{ book.PageCount.Int32 }</p>
</div> </div>
} }
if book.Genre.Valid && book.Genre.String != "" { if book.Genre.Valid && book.Genre.String != "" {
<div> <div>
<p style="color: var(--text-secondary)">Genre</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Genre</p>
<p>{ book.Genre.String }</p> <p style="color: var(--text-primary)">{ book.Genre.String }</p>
</div> </div>
} }
<!-- Series Information (if not shown in badge) -->
if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 { if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 {
<div> <div>
<p style="color: var(--text-secondary)">Series Count</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Series Count</p>
<p>{ book.SeriesCount.Int32 } items</p> <p style="color: var(--text-primary)">{ book.SeriesCount.Int32 } items</p>
</div> </div>
} }
if book.Volume.Valid && book.Volume.Int32 > 0 { if book.Volume.Valid && book.Volume.Int32 > 0 {
<div> <div>
<p style="color: var(--text-secondary)">Volume</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Volume</p>
<p>Vol. { book.Volume.Int32 }</p> <p style="color: var(--text-primary)">Vol. { book.Volume.Int32 }</p>
</div> </div>
} }
if book.Imprint.Valid && book.Imprint.String != "" { if book.Imprint.Valid && book.Imprint.String != "" {
<div> <div>
<p style="color: var(--text-secondary)">Imprint</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Imprint</p>
<p>{ book.Imprint.String }</p> <p style="color: var(--text-primary)">{ book.Imprint.String }</p>
</div> </div>
} }
if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 { if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {
<div> <div>
<p style="color: var(--text-secondary)">Copyright Year</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Copyright</p>
<p>{ book.CopyrightYear.Int32 }</p> <p style="color: var(--text-primary)">{ book.CopyrightYear.Int32 }</p>
</div> </div>
} }
<!-- Comic-Specific Fields -->
if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" { if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" {
<div> <div>
<p style="color: var(--text-secondary)">Manga Type</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Manga Type</p>
<p class="capitalize">{ strings.ReplaceAll(book.MangaType.String, "_", " ") }</p> <p class="capitalize" style="color: var(--text-primary)">{ strings.ReplaceAll(book.MangaType.String, "_", " ") }</p>
</div> </div>
} }
if book.ScanInformation.Valid && book.ScanInformation.String != "" { if book.ScanInformation.Valid && book.ScanInformation.String != "" {
<div> <div>
<p style="color: var(--text-secondary)">Scan Info</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Scan Info</p>
<p class="text-sm" style="color: var(--text-secondary);">{ book.ScanInformation.String }</p> <p class="text-sm" style="color: var(--text-secondary);">{ book.ScanInformation.String }</p>
</div> </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 != "" { if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" {
<div> <div>
<p style="color: var(--text-secondary)">Alternate Series</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Alternate Series</p>
<p>{ altSeries }</p> <p style="color: var(--text-primary)">{ altSeries }</p>
</div> </div>
} }
if len(book.Contributors) > 0 { if len(book.Contributors) > 0 {
<div> <div>
<p style="color: var(--text-secondary)">Contributors</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Contributors</p>
<p>{ strings.Join(book.Contributors, ", ") }</p> <p style="color: var(--text-primary)">{ strings.Join(book.Contributors, ", ") }</p>
</div> </div>
} }
<!-- Technical Info -->
<div> <div>
<p style="color: var(--text-secondary)">Format</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Format</p>
<p>{ book.MimeType.String }</p> <p style="color: var(--text-primary)">{ book.MimeType.String }</p>
</div> </div>
if book.FileSize.Valid && book.FileSize.Int64 > 0 { if book.FileSize.Valid && book.FileSize.Int64 > 0 {
<div> <div>
<p style="color: var(--text-secondary)">File Size</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">File Size</p>
<p>{ formatFileSize(book.FileSize.Int64) }</p> <p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p>
</div> </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 { 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);"> <div class="mt-5 pt-4 border-t flex flex-wrap gap-2" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
<h4 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">External Links</h4> if book.GoodreadsID.Valid && book.GoodreadsID.String != "" {
<div class="flex flex-wrap gap-3"> <a href={ getExternalURL("goodreads", book.GoodreadsID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Goodreads</a>
if book.GoodreadsID.Valid && book.GoodreadsID.String != "" { } else {
<a <a href={ getExternalURL("goodreads", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Goodreads</a>
href={ getExternalURL("goodreads", book.GoodreadsID.String, book.Isbn, book.Title, book.Author) } }
target="_blank" if book.OpenlibraryID.Valid && book.OpenlibraryID.String != "" {
rel="noopener noreferrer" <a href={ getExternalURL("openlibrary", book.OpenlibraryID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Open Library</a>
class="text-sm hover:underline flex items-center gap-1" } else {
style="color: var(--accent);" <a href={ getExternalURL("openlibrary", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Open Library</a>
> }
📚 Goodreads if book.GoogleBooksID.Valid && book.GoogleBooksID.String != "" {
</a> <a href={ getExternalURL("googlebooks", book.GoogleBooksID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Google Books</a>
} else { } else {
<a <a href={ getExternalURL("googlebooks", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Google Books</a>
href={ getExternalURL("goodreads", "", book.Isbn, book.Title, book.Author) } }
target="_blank" if book.Asin.Valid && book.Asin.String != "" {
rel="noopener noreferrer" <a href={ getExternalURL("amazon", book.Asin.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Amazon</a>
class="text-sm hover:underline flex items-center gap-1" } else if book.Isbn.Valid && book.Isbn.String != "" {
style="color: var(--accent);" <a href={ getExternalURL("amazon", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Amazon</a>
> }
📚 Goodreads if book.WebUrl.Valid && book.WebUrl.String != "" {
</a> <a href={ book.WebUrl.String } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">{ getDomainName(book.WebUrl.String) }</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) }
</a>
}
</div>
</div> </div>
} }
</div> </div>
<!-- Collections Section --> <!-- Collections -->
if len(book.Collections) > 0 { if len(book.Collections) > 0 {
<div <div class="card p-6 mb-6">
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> <h3 class="font-semibold mb-4" style="color: var(--text-primary)">Collections</h3>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-2.5">
for _, col := range book.Collections { for _, col := range book.Collections {
<a <a
href={ "/collections/" + uuidToString(col.ID) } href={ "/collections/" + uuidToString(col.ID) }
class="px-3 py-2 rounded-lg border flex items-center gap-2 hover:opacity-80 transition-opacity" class="inline-flex items-center gap-2 px-3 py-2 rounded-xl border hover:opacity-90 transition-opacity"
style="border-color: { col.Color.String }; background-color: var(--bg-primary); text-decoration: none;" 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: " + col.Color.String }>{ col.Icon.String }</span>
<span style="color: var(--text-primary);">{ col.Name }</span> <span style="color: var(--text-primary);">{ col.Name }</span>
</a> </a>
} }
@@ -587,7 +423,6 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
</div> </div>
} }
</div> </div>
<!-- Modals -->
@ProgressSyncModal(user, book) @ProgressSyncModal(user, book)
@NotesHighlightsModal(book) @NotesHighlightsModal(book)
@MetadataEditorModal(book) @MetadataEditorModal(book)
+250 -348
View File
@@ -3,6 +3,7 @@ package templates
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/handlers" "bookhoard/internal/handlers"
"fmt"
) )
templ BookShelf( templ BookShelf(
@@ -11,10 +12,10 @@ templ BookShelf(
currentLibraryID string, currentLibraryID string,
errorMessage string, errorMessage string,
savedFilters []database.SavedFilters, savedFilters []database.SavedFilters,
books []handlers.BookInfo, // NEW books []handlers.BookInfo,
limit int, // NEW limit int,
offset int, // NEW offset int,
count int, // NEW count int,
) { ) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -31,363 +32,287 @@ templ BookShelf(
x-init="initBookshelf()" x-init="initBookshelf()"
> >
@Header(user, "/bookshelf") @Header(user, "/bookshelf")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8"> <div class="w-full px-4 sm:px-6 lg:px-8 py-6">
<!-- Filter Bar --> <form id="filter-form" hx-get="/api/media-items/search" hx-target="#books-grid">
<div <!-- Toolbar -->
class="mb-6 card p-4 rounded-lg border" <div class="flex flex-wrap items-center gap-3 mb-6">
style="background-color: var(--bg-secondary); border-color: var(--border);" <div class="relative flex-1 min-w-[200px]">
> <span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
<form id="filter-form" hx-get="/api/media-items/search" hx-target="#books-grid"> @Icon("search", "h-4 w-4")
<div class="flex flex-wrap gap-4 items-center"> </span>
<!-- Library Selector --> <input
<div class="flex-1 min-w-[200px]"> name="q"
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)"> type="text"
Library class="input pl-10"
</label> placeholder="Search all fields…"
<select />
id="library-select" </div>
name="library_id" <select
class="w-full px-3 py-2 border rounded-lg" name="sort"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" class="input w-auto cursor-pointer"
> hx-get="/api/media-items/search"
if len(libraries) == 0 { hx-target="#books-grid"
<option value="">No libraries available</option> hx-trigger="change"
} else { hx-include="#filter-form"
if currentLibraryID == "" { >
<option value="" selected>All Books ({ TotalMediaCount(libraries) })</option> <option value="title ASC">Title (A-Z)</option>
} else { <option value="title DESC">Title (Z-A)</option>
<option value="">All Books ({ TotalMediaCount(libraries) })</option> <option value="author ASC">Author (A-Z)</option>
} <option value="created_at DESC">Date Added</option>
for _, lib := range libraries { <option value="page_count DESC">Page Count</option>
if lib.ID == currentLibraryID { </select>
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option> <button type="button" @click="filtersOpen = true" class="btn btn-secondary">
} else { @Icon("filter", "h-4 w-4")
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option> <span>Filters</span>
} </button>
<button type="button" @click="showSaveFilterModal()" class="btn btn-secondary">
@Icon("save", "h-4 w-4")
<span>Save</span>
</button>
<div class="relative">
<button type="button" @click="toggleFiltersDropdown()" data-load-filter-btn class="btn btn-secondary">
@Icon("folder", "h-4 w-4")
<span>Load</span>
</button>
<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 right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl shadow-lg z-50 card p-4"
x-cloak
>
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">Saved Filters</h3>
<div class="space-y-2" id="saved-filters-list">
for _, filter := range savedFilters {
<div class="flex items-center justify-between p-2 rounded-lg hover:bg-surface-hover" data-filter-id={ uuidToString(filter.ID) }>
<button data-action="load-filter" @click="loadFilter($event)" class="flex-1 text-left px-2 py-1 rounded text-sm" style="color: var(--text-primary);">
{ filter.Name }
</button>
<button data-action="delete-filter" @click="deleteFilter($event)" class="icon-btn h-8 w-8" title="Delete filter">
@Icon("trash", "h-4 w-4")
</button>
</div>
} }
</div>
if len(savedFilters) == 0 {
<div class="text-sm py-4 text-center" style="color: var(--text-secondary);">
No saved filters yet
</div>
} }
</select>
</div> </div>
<!-- Search Input --> </div>
<div class="flex-1 min-w-[150px]"> <button type="button" @click="clearFilters()" class="btn btn-ghost">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)"> @Icon("close", "h-4 w-4")
Search <span>Clear</span>
</label> </button>
<input </div>
name="q" <!-- Filter drawer -->
type="text" <div
class="w-full px-3 py-2 border rounded-lg" x-show="filtersOpen"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" x-cloak
placeholder="Search all fields..." class="fixed inset-0 z-[65]"
/> @keydown.escape.window="filtersOpen = false"
>
<div class="absolute inset-0" style="background-color: var(--surface-overlay);" @click="filtersOpen = false"></div>
<div
class="absolute right-0 top-0 bottom-0 w-full max-w-md overflow-y-auto"
style="background-color: var(--bg-secondary); box-shadow: var(--shadow-pop);"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full"
>
<div class="sticky top-0 flex items-center justify-between px-6 py-4 border-b" style="background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
<h2 class="text-lg font-bold" style="color: var(--text-primary)">Filters</h2>
<button type="button" @click="filtersOpen = false" class="icon-btn" aria-label="Close filters">
@Icon("close", "h-5 w-5")
</button>
</div> </div>
<!-- Author Filter with Autocomplete --> <div class="p-6 space-y-5">
<div class="flex-1 min-w-[150px]"> <div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)"> <label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library</label>
Author <select id="library-select" name="library_id" class="input">
</label> if len(libraries) == 0 {
<input <option value="">No libraries available</option>
type="text" } else {
name="author_filter" if currentLibraryID == "" {
placeholder="Filter by author" <option value="" selected>All Books ({ TotalMediaCount(libraries) })</option>
class="w-full px-3 py-2 border rounded-lg" } else {
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" <option value="">All Books ({ TotalMediaCount(libraries) })</option>
list="author-datalist" }
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)" for _, lib := range libraries {
/> if lib.ID == currentLibraryID {
<datalist id="author-datalist"></datalist> <option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
</div> } else {
<!-- Tags Filter with Autocomplete --> <option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
<div class="flex-1 min-w-[150px] relative"> }
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Tags</label> }
<input }
type="text" </select>
name="tags_filter" </div>
placeholder="Filter by tags" <div>
class="w-full px-3 py-2 border rounded-lg" <label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Author</label>
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" <input
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)" type="text"
@keydown="onTagFilterKeydown($event)" name="author_filter"
@blur="hideTagDropdown()" placeholder="Filter by author"
/> class="input"
<div list="author-datalist"
x-show="showTagDropdown" @input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
x-transition />
class="absolute z-50 mt-1 w-full rounded-lg shadow-lg border max-h-48 overflow-y-auto" <datalist id="author-datalist"></datalist>
style="background-color: var(--bg-primary); border-color: var(--border);" </div>
> <div class="relative">
<template x-for="sug in tagSuggestions" :key="sug.value"> <label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Tags</label>
<button <input
type="button" type="text"
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center" name="tags_filter"
:class="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''" placeholder="Filter by tags"
:style="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'" class="input"
@click="selectTagSuggestion(sug.value)" @input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
> @keydown="onTagFilterKeydown($event)"
<span x-text="sug.value"></span> @blur="hideTagDropdown()"
<span class="text-xs" style="color: var(--text-secondary);" x-text="sug.count + ' books'"></span> />
</button> <div
x-show="showTagDropdown"
x-transition
x-cloak
class="absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto"
style="background-color: var(--bg-primary); border: 1px solid var(--border);"
>
<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);'"
@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>
</button>
</template>
</div>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Series</label>
<input
type="text"
name="series_filter"
placeholder="Filter by series"
class="input"
list="series-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
/>
<datalist id="series-datalist"></datalist>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Language</label>
<input
type="text"
name="language_filter"
placeholder="Filter by language"
class="input"
list="language-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
/>
<datalist id="language-datalist"></datalist>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Year Range</label>
<div class="flex gap-2">
<input type="number" name="year_min" placeholder="From" class="input"/>
<input type="number" name="year_max" placeholder="To" class="input"/>
</div>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">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 ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'"
@click="cycleHasCover()"
/>
<template x-if="hasCoverState !== null">
<input
type="hidden"
name="has_cover"
:value="hasCoverState ? 'true' : 'false'"
/>
</template> </template>
</div> </div>
</div> </div>
// <!-- Genre Filter with Autocomplete --> <div class="sticky bottom-0 px-6 py-4 border-t" style="background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
// <div class="flex-1 min-w-[150px]"> <button type="submit" @click="filtersOpen = false" class="btn btn-primary w-full">Apply Filters</button>
// <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>
<input
type="text"
name="series_filter"
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>
<input
type="text"
name="language_filter"
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>
<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);"
/>
</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>
<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'"
@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'"
/>
</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>
</div> </div>
</form> </div>
</div> </form>
<!-- Books Grid --> <div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
@BooksGrid(books, limit, offset, count, currentLibraryID) @BooksGrid(books, limit, offset, count, currentLibraryID)
<!-- Error Message -->
if errorMessage != "" { if errorMessage != "" {
<div class="mt-6 p-4 rounded-lg border bg-red-500/10 border-red-500"> <div class="col-span-full mt-2 p-4 rounded-xl border" style="background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);">
<p style="color: var(--text-primary)">{ errorMessage }</p> <p style="color: var(--text-primary)">{ errorMessage }</p>
</div> </div>
} }
</div> </div>
<!-- Pagination --> <div id="pagination" class="mt-6 flex justify-center items-center gap-2">
<div id="pagination" class="mt-6 flex justify-center gap-2">
if count > 0 { if count > 0 {
<button <button
type="button" type="button"
class="px-4 py-2 rounded-lg border disabled:opacity-50" class="btn btn-secondary disabled:opacity-40"
style="border-color: var(--border); color: var(--text-primary);" hx-get={ fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit) }
hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset - limit }"
hx-target="#books-grid" hx-target="#books-grid"
hx-include="#filter-form" hx-include="#filter-form"
disabled?={ offset <= 0 } disabled?={ offset <= 0 }
> >
Previous @Icon("chevron-left", "h-4 w-4")
<span>Prev</span>
</button> </button>
<span class="px-4 py-2" style="color: var(--text-secondary);"> <span class="px-4 py-2 text-sm" style="color: var(--text-secondary);">
Page { offset / limit + 1 } Page { offset / limit + 1 }
</span> </span>
<button <button
class="px-4 py-2 rounded-lg border disabled:opacity-50" type="button"
style="border-color: var(--border); color: var(--text-primary);" class="btn btn-secondary disabled:opacity-40"
hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset + limit }" hx-get={ fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit) }
hx-target="#books-grid" hx-target="#books-grid"
hx-include="#filter-form" hx-include="#filter-form"
disabled?={ offset+limit >= count } disabled?={ offset+limit >= count }
> >
Next <span>Next</span>
@Icon("chevron-right", "h-4 w-4")
</button> </button>
} }
</div> </div>
</div> </div>
<!-- Save Filter Modal -->
<div <div
x-show="showSaveModal" x-show="showSaveModal"
@click.self="showSaveModal = false" @click.self="showSaveModal = false"
x-transition x-transition
class="fixed inset-0 z-50 flex items-center justify-center" x-cloak
style="background-color: rgba(0, 0, 0, 0.7); display: none;" class="fixed inset-0 z-[70] flex items-center justify-center p-4"
style="background-color: var(--surface-overlay);"
> >
<div <div @click.stop class="card rounded-2xl p-6 w-full max-w-md">
@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"> <div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Save Filter</h2> <h2 class="text-xl font-bold" style="color: var(--text-primary)">Save Filter</h2>
<button <button @click="showSaveModal = false" class="icon-btn" aria-label="Close">
@click="showSaveModal = false" @Icon("close", "h-5 w-5")
class="p-2 hover:opacity-80 rounded" </button>
style="color: var(--text-primary)"
></button>
</div> </div>
<form <form
hx-post="/api/saved-filters" hx-post="/api/saved-filters"
@@ -397,38 +322,15 @@ templ BookShelf(
@htmx:afterRequest="if(event.detail.xhr.status < 400) { afterFilterSave() }" @htmx:afterRequest="if(event.detail.xhr.status < 400) { afterFilterSave() }"
> >
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)"> <label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Filter Name</label>
Filter Name <input type="text" name="filter_name" placeholder="My Custom Filter" class="input" required/>
</label> <div id="filter-save-error" style="display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;"></div>
<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>
<input type="hidden" name="resource_type" value="media-items"/> <input type="hidden" name="resource_type" value="media-items"/>
</div> </div>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button <button type="button" @click="showSaveModal = false" class="btn btn-secondary">Cancel</button>
type="button" <button type="submit" class="btn btn-primary">Save</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>
</div> </div>
<!-- Hidden pagination state -->
<input type="hidden" name="limit" value="50"/> <input type="hidden" name="limit" value="50"/>
<input type="hidden" name="offset" value="0"/> <input type="hidden" name="offset" value="0"/>
</form> </form>
+58 -83
View File
@@ -18,13 +18,15 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
<body x-data="dashboard" x-init="initDashboard()" class="theme-{ user.Theme }"> <body x-data="dashboard" x-init="initDashboard()" class="theme-{ user.Theme }">
@Header(user, "/dashboard") @Header(user, "/dashboard")
@LibrarySwitcher(libData, currentLibraryID, DashboardActions()) @LibrarySwitcher(libData, currentLibraryID, DashboardActions())
<!-- Collections Container --> <main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 py-8">
<main id="collections-container" class="w-full px-4 py-8"> <div class="mb-8">
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Your Library</h1>
<p class="text-sm mt-1" style="color: var(--text-secondary)">Pick up where you left off, or explore something new.</p>
</div>
for _, section := range sections { for _, section := range sections {
@CollectionCarousel(section) @CollectionCarousel(section)
} }
</main> </main>
<!-- Dashboard Settings Modal -->
@DashboardSettingsModal(allSections, hiddenCollections, itemsPerSection) @DashboardSettingsModal(allSections, hiddenCollections, itemsPerSection)
@ErrorToast(errorMessage) @ErrorToast(errorMessage)
</body> </body>
@@ -33,89 +35,84 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
templ CollectionCarousel(section handlers.SectionData) { templ CollectionCarousel(section handlers.SectionData) {
<div <div
class="dashboard-collection mb-8" class="dashboard-collection mb-10"
data-collection-id={ section.ID } data-collection-id={ section.ID }
data-is-system={ section.IsSystem } data-is-system={ section.IsSystem }
> >
<!-- Collection Header --> <div class="flex items-end justify-between mb-4">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="text-2xl">{ section.Icon }</span> <span class="grid place-items-center h-9 w-9 rounded-lg text-lg" style="background-color: var(--accent-muted);">{ section.Icon }</span>
<div> <div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">{ section.Title }</h2> <h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">{ section.Title }</h2>
if section.Description != "" { if section.Description != "" {
<p class="text-sm" style="color: var(--text-secondary)">{ section.Description }</p> <p class="text-xs" style="color: var(--text-secondary)">{ section.Description }</p>
} }
</div> </div>
</div> </div>
if section.ViewAllURL != "" { if section.ViewAllURL != "" {
<a <a
href={ section.ViewAllURL } href={ section.ViewAllURL }
class="text-sm font-medium hover:underline transition-colors" class="inline-flex items-center gap-1 text-sm font-medium hover:underline transition-colors"
style="color: var(--accent);" style="color: var(--accent);"
> >
View All View All
@Icon("arrow-right", "h-4 w-4")
</a> </a>
} }
</div> </div>
<!-- Carousel -->
<div class="carousel-container relative group"> <div class="carousel-container relative group">
<button <button
class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10 class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10 h-full w-12 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200"
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-action="scroll-carousel"
data-collection-id={ section.ID } data-collection-id={ section.ID }
data-direction="-1" data-direction="-1"
aria-label="Scroll left" aria-label="Scroll left"
style="background: linear-gradient(to right, var(--bg-primary), transparent);"
> >
<span class="text-3xl pl-2" style="color: var(--text-primary);"></span> @Icon("chevron-left", "h-7 w-7")
</button> </button>
<div <div
id={ "carousel-track-" + section.ID } id={ "carousel-track-" + section.ID }
class="carousel-track flex gap-4 overflow-x-auto class="carousel-track flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory px-6 pb-2"
scroll-smooth snap-x snap-mandatory
px-12 pb-4"
style="scrollbar-width: none; -ms-overflow-style: none;" style="scrollbar-width: none; -ms-overflow-style: none;"
> >
for _, item := range section.Items { for _, item := range section.Items {
@BookCard(item) <div class="flex-shrink-0 w-36 sm:w-40 snap-start">
@BookCard(item)
</div>
} }
if len(section.Items) == 0 { if len(section.Items) == 0 {
<div class="text-center py-8 w-full" style="color: var(--text-secondary);"> <div class="flex flex-col items-center justify-center text-center py-12 w-full gap-2" style="color: var(--text-secondary);">
<p>No items in this collection</p> @Icon("book", "h-8 w-8 opacity-50")
<p class="text-sm">No items in this collection</p>
</div> </div>
} }
</div> </div>
<button <button
class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10 class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10 h-full w-12 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200"
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-action="scroll-carousel"
data-collection-id={ section.ID } data-collection-id={ section.ID }
data-direction="1" data-direction="1"
aria-label="Scroll right" aria-label="Scroll right"
style="background: linear-gradient(to left, var(--bg-primary), transparent);"
> >
<span class="text-3xl pr-2" style="color: var(--text-primary);"></span> @Icon("chevron-right", "h-7 w-7")
</button> </button>
</div> </div>
</div> </div>
} }
templ BookCard(item handlers.BookInfo) { templ BookCard(item handlers.BookInfo) {
<a href={ "/media/" + item.MediaItemID } title={ item.Title }> <a href={ "/media/" + item.MediaItemID } title={ item.Title } class="block h-full">
<div <div
class="book-card flex-shrink-0 w-36 rounded-lg overflow-hidden snap-start cursor-pointer class="book-card w-full h-full rounded-xl overflow-hidden cursor-pointer"
transition-transform duration-200 hover:scale-105"
tabindex="0" tabindex="0"
role="button" role="button"
aria-label={ "View " + item.Title } aria-label={ "View " + item.Title }
> >
<div <div
class="aspect-[2/3] overflow-hidden shadow-lg class="book-card-cover aspect-[2/3] overflow-hidden"
bg-gradient-to-br from-gray-700 to-gray-900" style="background-color: color-mix(in srgb, var(--text-primary) 8%, transparent);"
> >
if item.CoverImagePath != "" { if item.CoverImagePath != "" {
<img <img
@@ -132,13 +129,16 @@ templ BookCard(item handlers.BookInfo) {
class="w-full h-full object-cover" class="w-full h-full object-cover"
/> />
} }
<div class="book-card-action">
<span class="book-card-action-btn">@Icon("play", "h-5 w-5 translate-x-0.5")</span>
</div>
</div> </div>
<div class="book-card-text px-2 py-1 bg-[color-mix(in_srgb,var(--wood-border)_40%,transparent)]"> <div class="book-card-meta">
<h3 class="font-semibold text-base line-clamp-2" style="color: var(--text-primary)" title={ item.Title }> <h3 class="font-semibold text-sm leading-snug line-clamp-2" style="color: var(--text-primary)" title={ item.Title }>
{ item.Title } { item.Title }
</h3> </h3>
if item.Author != "" { if item.Author != "" {
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)" title={ item.Author }> <p class="text-xs mt-0.5 line-clamp-1" style="color: var(--text-secondary)" title={ item.Author }>
{ item.Author } { item.Author }
</p> </p>
} }
@@ -150,43 +150,34 @@ templ BookCard(item handlers.BookInfo) {
templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections []string, itemsPerSection int) { templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections []string, itemsPerSection int) {
<div <div
id="dashboard-settings-modal" id="dashboard-settings-modal"
class="hidden fixed inset-0 z-50 flex items-center justify-center" class="hidden fixed inset-0 z-[70] flex items-center justify-center p-4"
style="background-color: rgba(0, 0, 0, 0.7);" style="background-color: var(--surface-overlay);"
> >
<div <div class="card p-6 w-full max-w-2xl shadow-2xl" style="box-shadow: var(--shadow-pop);">
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"> <div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2> <div>
<button <h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
data-action="close-dashboard-settings" <p class="text-sm mt-1" style="color: var(--text-secondary)">Drag to reorder, toggle to show or hide.</p>
class="p-2 hover:bg-gray-700 rounded transition-colors" </div>
> <button data-action="close-dashboard-settings" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button> </button>
</div> </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="space-y-2 mb-6">
for _, section := range sections { for _, section := range sections {
<div <div
class="collection-item flex items-center justify-between p-3 rounded border class="collection-item flex items-center justify-between p-3 rounded-xl cursor-move select-none card"
cursor-move select-none"
data-collection-id={ section.ID } data-collection-id={ section.ID }
data-is-system={ fmt.Sprintf("%v", section.IsSystem) } data-is-system={ fmt.Sprintf("%v", section.IsSystem) }
draggable="true" draggable="true"
style="background-color: var(--bg-primary); border-color: var(--border);"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="text-xl" style="color: var(--text-secondary);"></span> @Icon("grip", "h-5 w-5 shrink-0")
<span class="text-xl">{ section.Icon }</span> <span class="text-xl">{ section.Icon }</span>
<div> <div class="flex items-center gap-2">
<span class="font-medium" style="color: var(--text-primary);">{ section.Title }</span> <span class="font-medium" style="color: var(--text-primary)">{ section.Title }</span>
if section.IsSystem { if section.IsSystem {
<span class="text-xs ml-2 px-2 py-1 rounded" style="background-color: var(--accent);">System</span> <span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">System</span>
} }
</div> </div>
</div> </div>
@@ -195,8 +186,7 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
<button <button
data-action="restore-system-collection" data-action="restore-system-collection"
data-collection-name={ section.ID } data-collection-name={ section.ID }
class="text-xs px-3 py-1 rounded border hover:opacity-80 transition-opacity" class="btn btn-secondary py-1 px-2.5 text-xs"
style="border-color: var(--border); color: var(--text-secondary);"
title="Restore { section.Title } to defaults" title="Restore { section.Title } to defaults"
> >
Restore Restore
@@ -211,21 +201,17 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
} }
/> />
<div <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 class="w-11 h-6 rounded-full peer peer-checked:after:translate-x-full 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:after:translate-x-full peer-checked:after:border-white style="background-color: var(--border-strong);"
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>
</label> </label>
</div> </div>
</div> </div>
} }
</div> </div>
<!-- Items Per Section Slider -->
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)"> <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> Items per Collection: <span id="items-count-display" class="font-bold" style="color: var(--text-primary)">{ itemsPerSection }</span>
</label> </label>
<input <input
type="range" type="range"
@@ -233,26 +219,15 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
max="50" max="50"
step="5" step="5"
value={ itemsPerSection } value={ itemsPerSection }
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer" class="w-full h-2 rounded-lg appearance-none cursor-pointer"
style="background-color: var(--border-strong);"
data-input-action="update-items-count" data-input-action="update-items-count"
target="items-count-display" target="items-count-display"
/> />
</div> </div>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button <button data-action="close-dashboard-settings" class="btn btn-secondary">Cancel</button>
data-action="close-dashboard-settings" <button data-action="save-dashboard-settings" class="btn btn-primary">Save Changes</button>
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> </div>
</div> </div>
+71 -83
View File
@@ -12,108 +12,96 @@ templ Index(loggedIn bool) {
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
</head> </head>
<body class="theme-tokyo-night" x-data="index" x-init="initIndexTheme(); checkAuthRedirect()"> <body class="theme-tokyo-night" x-data="index" x-init="initIndexTheme(); checkAuthRedirect()">
<!-- Hero Section --> <div class="absolute top-4 right-4 z-10">
<div class="relative overflow-hidden"> <select
<div class="absolute inset-0 bg-gradient-to-r from-blue-600 to-purple-700 opacity-10"></div> id="theme-select"
<div class="relative max-w-7xl mx-auto px-4 py-16 sm:px-6 sm:py-24 lg:py-32 lg:px-8"> @change="changeTheme"
<div class="flex justify-between items-start mb-8"> class="input w-auto py-1.5 pr-8 text-sm cursor-pointer"
<div></div> aria-label="Theme"
<select id="theme-select" @change="changeTheme" class="px-3 py-2 border rounded text-sm" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"> >
<option value="tokyo-night">Tokyo Night</option> for _, opt := range ThemeOptions {
<option value="dracula">Dracula</option> <option value={ opt.Name }>{ opt.Label }</option>
<option value="nord">Nord</option> }
<option value="solarized-dark">Solarized Dark</option> </select>
<option value="monokai">Monokai</option> </div>
<option value="one-dark-pro">One Dark Pro</option> <!-- Hero -->
<option value="material-dark">Material Dark</option> <div class="brand-gradient relative overflow-hidden">
<option value="catppuccin-mocha">Catppuccin Mocha</option> <div class="max-w-7xl mx-auto px-4 py-20 sm:px-6 sm:py-28 lg:py-36 lg:px-8 text-center">
<option value="catppuccin-macchiato">Catppuccin Macchiato</option> <div class="inline-flex items-center gap-3 mb-6">
<option value="catppuccin-frappe">Catppuccin Frappé</option> <span class="text-5xl">📚</span>
<option value="catppuccin-latte">Catppuccin Latte</option> <span class="text-4xl sm:text-6xl font-extrabold tracking-tight" style="color: var(--text-primary)">Bookhoard</span>
</select>
</div> </div>
<div class="text-center"> <p class="mt-4 max-w-2xl mx-auto text-lg sm:text-xl" style="color: var(--text-secondary)">
<h1 class="text-4xl sm:text-6xl lg:text-7xl font-extrabold" style="color: var(--text-primary)"> Your personal ebook management system. Track reading progress, organize your library, and sync across every device.
📚 Bookhoard </p>
</h1> <div class="mt-10 flex items-center justify-center gap-3">
<p class="mt-6 max-w-2xl mx-auto text-xl" style="color: var(--text-secondary)"> if loggedIn {
Your personal ebook management system. Track reading progress, organize your library, and enjoy beautiful themes. <a href="/dashboard" class="btn btn-primary px-8 py-3 text-base">Go to Library</a>
</p> } else {
<div class="mt-10"> <a href="#auth" class="btn btn-primary px-8 py-3 text-base">Get Started</a>
<a href="#auth" class="btn-primary px-8 py-3 rounded-lg font-medium text-lg"> <a href="#features" class="btn btn-secondary px-6 py-3 text-base">Learn more</a>
Get Started }
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Features Section --> <!-- Features -->
<div class="py-16" style="background-color: var(--bg-secondary)"> <div id="features" class="py-16 sm:py-20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12"> <div class="text-center mb-12">
<h2 class="text-3xl font-extrabold" style="color: var(--text-primary)">Why Bookhoard?</h2> <h2 class="text-3xl font-extrabold tracking-tight" style="color: var(--text-primary)">Why Bookhoard?</h2>
<p class="mt-4 text-lg" style="color: var(--text-secondary)">Discover the features that make ebook management effortless</p> <p class="mt-3 text-lg" style="color: var(--text-secondary)">Everything you need to manage your ebook library</p>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="card p-6 rounded-xl border"> <div class="card p-6">
<div class="text-center"> <div class="grid place-items-center h-12 w-12 rounded-xl mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
<div class="mx-auto w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center mb-4"> @Icon("book-open", "h-6 w-6")
<span class="text-white text-2xl">📖</span>
</div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Reading Progress</h3>
<p style="color: var(--text-secondary)">Track your reading progress across all your ebooks with detailed statistics.</p>
</div> </div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Reading Progress</h3>
<p class="text-sm" style="color: var(--text-secondary)">Track progress across all your ebooks with detailed statistics and sync.</p>
</div> </div>
<div class="card p-6 rounded-xl border"> <div class="card p-6">
<div class="text-center"> <div class="grid place-items-center h-12 w-12 rounded-xl mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
<div class="mx-auto w-12 h-12 bg-purple-500 rounded-lg flex items-center justify-center mb-4"> @Icon("palette", "h-6 w-6")
<span class="text-white text-2xl">🎨</span>
</div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Beautiful Themes</h3>
<p style="color: var(--text-secondary)">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin variants.</p>
</div> </div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Beautiful Themes</h3>
<p class="text-sm" style="color: var(--text-secondary)">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin.</p>
</div> </div>
<div class="card p-6 rounded-xl border"> <div class="card p-6">
<div class="text-center"> <div class="grid place-items-center h-12 w-12 rounded-xl mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
<div class="mx-auto w-12 h-12 bg-green-500 rounded-lg flex items-center justify-center mb-4"> @Icon("device", "h-6 w-6")
<span class="text-white text-2xl"></span>
</div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Fast & Modern</h3>
<p style="color: var(--text-secondary)">Built with Go and HTMX for lightning-fast performance and smooth interactions.</p>
</div> </div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Cross-Device Sync</h3>
<p class="text-sm" style="color: var(--text-secondary)">Built with Go and HTMX for fast performance and seamless device syncing.</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Auth Section --> <!-- Auth -->
if !loggedIn { if !loggedIn {
<div id="auth" class="py-16"> <div id="auth" class="pb-20 px-4">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-md mx-auto">
<div class="text-center mb-12"> <div class="text-center mb-8">
<h2 class="text-3xl font-extrabold" style="color: var(--text-primary)">Join Bookhoard Today</h2> <h2 class="text-2xl font-extrabold tracking-tight" style="color: var(--text-primary)">Welcome back</h2>
<p class="mt-4 text-lg" style="color: var(--text-secondary)">Sign in to start managing your ebook library</p> <p class="mt-2" style="color: var(--text-secondary)">Sign in to start managing your ebook library</p>
</div> </div>
<div class="grid grid-cols-1 gap-8"> <div class="card p-8">
<div class="card p-8 rounded-xl border"> <form hx-post="/api/auth/login" hx-target="#auth-result" hx-swap="innerHTML" class="space-y-4">
<h3 class="text-2xl font-semibold mb-6 text-center" style="color: var(--text-primary)">Login</h3> <div>
<form hx-post="/api/auth/login" hx-target="#auth-result" hx-swap="innerHTML"> <label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Email or Username</label>
<div class="space-y-4"> <input type="text" name="login" class="input" required/>
<div> </div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email or Username</label> <div>
<input type="text" name="login" 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/> <label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Password</label>
</div> <input type="password" name="password" class="input" required/>
<div> </div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label> <button type="submit" class="btn btn-primary w-full py-2.5">Sign In</button>
<input type="password" name="password" 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/> </form>
</div> <p class="text-center text-sm mt-4" style="color: var(--text-secondary)">
<button type="submit" class="w-full btn-primary py-2 rounded-lg font-medium"> No account?
Sign In <a href="/register" class="font-medium hover:underline" style="color: var(--accent)">Register</a>
</button> </p>
</div>
</form>
</div>
</div> </div>
<div id="auth-result" class="mt-8 text-center"></div> <div id="auth-result" class="mt-4 text-center text-sm"></div>
</div> </div>
</div> </div>
} }
+45 -48
View File
@@ -6,60 +6,57 @@ templ Login(sessionExpired bool, deleted bool) {
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Login</title> <title>Login - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script type="module" src="/static/main.js"></script> <script type="module" src="/static/main.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
</head> </head>
<body class="theme-tokyo-night min-h-screen" x-data="login" x-init="initLoginTheme()"> <body class="theme-tokyo-night min-h-screen" x-data="login" x-init="initLoginTheme()">
<div class="container mx-auto px-4 py-8 max-w-md"> <div class="brand-gradient min-h-screen flex flex-col items-center justify-center p-4 relative">
<div class="flex justify-between items-center mb-8"> <select
<h1 class="text-3xl font-bold">Login to Bookhoard</h1> id="theme-select"
<select id="theme-select" @change="changeTheme()" class="px-3 py-2 border rounded" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"> @change="changeTheme()"
<option value="tokyo-night">Tokyo Night</option> class="input absolute top-4 right-4 w-auto py-1.5 pr-8 text-sm cursor-pointer"
<option value="dracula">Dracula</option> aria-label="Theme"
<option value="nord">Nord</option> >
<option value="solarized-dark">Solarized Dark</option> for _, opt := range ThemeOptions {
<option value="monokai">Monokai</option> <option value={ opt.Name }>{ opt.Label }</option>
<option value="one-dark-pro">One Dark Pro</option> }
<option value="material-dark">Material Dark</option> </select>
<option value="catppuccin-mocha">Catppuccin Mocha</option> <div class="w-full max-w-sm">
<option value="catppuccin-macchiato">Catppuccin Macchiato</option> <a href="/" class="flex items-center justify-center gap-2.5 mb-8">
<option value="catppuccin-frappe">Catppuccin Frappé</option> <span class="text-3xl">📚</span>
<option value="catppuccin-latte">Catppuccin Latte</option> <span class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Bookhoard</span>
</select> </a>
if sessionExpired {
<div class="mb-4 p-3 rounded-xl flex items-start gap-2" style="background-color: color-mix(in srgb, var(--accent) 14%, transparent); border: 1px solid var(--accent);">
@Icon("info", "h-5 w-5 shrink-0")
<p class="text-sm" style="color: var(--text-primary)">Your session has expired. Please log in again to continue.</p>
</div>
}
if deleted {
<div class="mb-4 p-3 rounded-xl flex items-start gap-2" style="background-color: color-mix(in srgb, var(--status-success) 14%, transparent); border: 1px solid var(--status-success);">
@Icon("check-circle", "h-5 w-5 shrink-0")
<p class="text-sm" style="color: var(--text-primary)">Your account has been deleted successfully.</p>
</div>
}
<form hx-post="/api/auth/login" hx-target="#result" hx-swap="innerHTML" class="card p-6 space-y-4">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Email or Username</label>
<input type="text" name="login" class="input" required/>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="input" required/>
</div>
<button type="submit" class="btn btn-primary w-full py-2.5">Sign In</button>
</form>
<div id="result" class="mt-4 text-center text-sm"></div>
<p class="text-center mt-6 text-sm" style="color: var(--text-secondary)">
Don't have an account?
<a href="/register" class="font-medium hover:underline" style="color: var(--accent)">Sign up</a>
</p>
</div> </div>
if sessionExpired {
<div class="mb-4 p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--accent);">
<p style="color: var(--text-primary);">
Your session has expired. Please log in again to continue.
</p>
</div>
}
if deleted {
<div class="mb-4 p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--accent);">
<p style="color: var(--text-primary);">
Your account has been deleted successfully.
</p>
</div>
}
<form hx-post="/api/auth/login" hx-target="#result" hx-swap="innerHTML" class="card p-6 rounded-lg shadow-md border">
<div class="mb-4">
<label class="block mb-2" style="color: var(--text-secondary)">Email or Username</label>
<input type="text" name="login" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
</div>
<div class="mb-4">
<label class="block mb-2" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
</div>
<button type="submit" class="btn-primary w-full py-2 rounded">
Sign In
</button>
</form>
<div id="result" class="mt-4 text-center"></div>
<p class="text-center mt-4">
<a href="/register" class="text-blue-500 hover:underline">Don't have an account? Sign Up</a>
</p>
</div> </div>
</body> </body>
</html> </html>
+68 -59
View File
@@ -6,70 +6,79 @@ templ Register() {
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Register</title> <title>Register - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script type="module" src="/static/main.js"></script> <script type="module" src="/static/main.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
</head> </head>
<body class="theme-tokyo-night min-h-screen" x-data="register" x-init="initRegisterTheme()"> <body class="theme-tokyo-night min-h-screen" x-data="register" x-init="initRegisterTheme()">
<div class="container mx-auto px-4 py-8 max-w-md"> <div class="brand-gradient min-h-screen flex flex-col items-center justify-center p-4">
<h1 class="text-3xl font-bold text-center mb-8">Register for Bookhoard</h1> <div class="w-full max-w-sm">
<form hx-post="/api/auth/register" hx-target="#result" hx-swap="innerHTML" class="card p-6 rounded-lg shadow-md border"> <a href="/" class="flex items-center justify-center gap-2.5 mb-8">
<div class="mb-4"> <span class="text-3xl">📚</span>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email</label> <span class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Bookhoard</span>
<input type="email" id="email" name="email" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/> </a>
</div> <h1 class="text-xl font-bold text-center mb-6" style="color: var(--text-primary)">Create your account</h1>
<div class="mb-4"> <form hx-post="/api/auth/register" hx-target="#result" hx-swap="innerHTML" class="card p-6 space-y-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label> <div>
<input type="text" id="username" name="username" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/> <label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Email</label>
</div> <input type="email" id="email" name="email" class="input" required/>
<div class="mb-4"> </div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label> <div>
<input type="text" name="first_name" placeholder="Optional" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"/> <label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Username</label>
</div> <input type="text" id="username" name="username" class="input" required/>
<div class="mb-4"> </div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label> <div class="grid grid-cols-2 gap-3">
<input type="text" name="last_name" placeholder="Optional" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"/> <div>
</div> <label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">First Name</label>
<div id="password-requirements" class="mb-4 p-4 rounded-lg border text-sm" style="background-color: var(--bg-secondary); border-color: var(--border)"> <input type="text" name="first_name" placeholder="Optional" class="input"/>
<p class="font-semibold mb-2" style="color: var(--text-primary)">Password Requirements:</p> </div>
<ul class="space-y-1" style="color: var(--text-secondary)"> <div>
<li id="req-length" class="flex items-center gap-2"> <label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Last Name</label>
<span class="requirement-icon"></span> At least 8 characters <input type="text" name="last_name" placeholder="Optional" class="input"/>
</li> </div>
<li id="req-upper" class="flex items-center gap-2"> </div>
<span class="requirement-icon"></span> One uppercase letter <div id="password-requirements" class="p-4 rounded-xl text-sm" style="background-color: var(--bg-primary); border: 1px solid var(--border);">
</li> <p class="font-semibold mb-2" style="color: var(--text-primary)">Password Requirements:</p>
<li id="req-lower" class="flex items-center gap-2"> <ul class="space-y-1" style="color: var(--text-secondary)">
<span class="requirement-icon"></span> One lowercase letter <li id="req-length" class="flex items-center gap-2">
</li> <span class="requirement-icon"></span> At least 8 characters
<li id="req-number" class="flex items-center gap-2"> </li>
<span class="requirement-icon"></span> One number <li id="req-upper" class="flex items-center gap-2">
</li> <span class="requirement-icon"></span> One uppercase letter
<li id="req-special" class="flex items-center gap-2"> </li>
<span class="requirement-icon"></span> One special character <li id="req-lower" class="flex items-center gap-2">
</li> <span class="requirement-icon"></span> One lowercase letter
<li id="req-match" class="flex items-center gap-2"> </li>
<span class="requirement-icon"></span> Passwords match <li id="req-number" class="flex items-center gap-2">
</li> <span class="requirement-icon"></span> One number
</ul> </li>
</div> <li id="req-special" class="flex items-center gap-2">
<div class="mb-4"> <span class="requirement-icon"></span> One special character
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label> </li>
<input type="password" id="password" name="password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/> <li id="req-match" class="flex items-center gap-2">
</div> <span class="requirement-icon"></span> Passwords match
<div class="mb-4"> </li>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm Password</label> </ul>
<input type="password" id="confirm-password" name="confirm_password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/> </div>
</div> <div>
<button type="submit" id="register-btn" class="btn-primary w-full py-2 rounded opacity-50 cursor-not-allowed" disabled> <label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Password</label>
Register <input type="password" id="password" name="password" class="input" required/>
</button> </div>
</form> <div>
<div id="result" class="mt-4 text-center"></div> <label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Confirm Password</label>
<p class="text-center mt-4"> <input type="password" id="confirm-password" name="confirm_password" class="input" required/>
<a href="/login" class="text-blue-500 hover:underline">Already have an account? Login</a> </div>
</p> <button type="submit" id="register-btn" class="btn btn-primary w-full py-2.5 opacity-50 cursor-not-allowed" disabled>
Register
</button>
</form>
<div id="result" class="mt-4 text-center text-sm"></div>
<p class="text-center mt-6 text-sm" style="color: var(--text-secondary)">
Already have an account?
<a href="/login" class="font-medium hover:underline" style="color: var(--accent)">Login</a>
</p>
</div>
</div> </div>
</body> </body>
</html> </html>
+1
View File
@@ -80,6 +80,7 @@ Alpine.data("bookshelf", () => ({
// Component state // Component state
showSaveModal: false, showSaveModal: false,
showFiltersDropdown: false, showFiltersDropdown: false,
filtersOpen: false,
hasCoverState: null as boolean | null, hasCoverState: null as boolean | null,
dropdownAlign: "right" as "left" | "right", dropdownAlign: "right" as "left" | "right",
resizeTimeout: null as number | null, resizeTimeout: null as number | null,