feat(ui): rebuild shared chrome and core browse/read pages
- Header: sticky app-wide, active nav highlighting, data-driven theme picker with active swatch, unified SVG icons, deduped login fragment (was duplicated verbatim for desktop + mobile), removed stray console.log - LibrarySwitcher: cleaner sticky bar, SVG action icons, theme-aware - BookCard: the atomic unit now has a real surface (was floating text when wood-paneling was off due to an undefined --wood-border), with cohesive shadow + hover lift; reused across dashboard/bookshelf/series - Dashboard: calmer section headers, snap carousels with SVG chevrons - Bookshelf: recompose the filter wall into a search toolbar + collapsible Filters panel (all 9 filters + save/load/clear preserved), redesigned empty state and pagination - Book detail, series, collections, browse: cohesive cards, SVG action icons, uppercase muted labels, theme-aware badges/links
This commit is contained in:
+240
-486
@@ -4,9 +4,9 @@ import (
|
||||
"bookhoard/internal/handlers"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"strings"
|
||||
)
|
||||
|
||||
templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
@@ -19,177 +19,128 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body x-data="bookDetail" class="theme-{ user.Theme }" data-format-group={ book.FormatGroup } data-library-id={ uuidToString(book.LibraryID) } data-rating={ fmt.Sprintf("%d", getBookRating(book.Rating)) } data-conflict-id={ conflictID(book.ActiveConflict) } data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }>
|
||||
<body
|
||||
x-data="bookDetail"
|
||||
class="theme-{ user.Theme }"
|
||||
data-format-group={ book.FormatGroup }
|
||||
data-library-id={ uuidToString(book.LibraryID) }
|
||||
data-rating={ fmt.Sprintf("%d", getBookRating(book.Rating)) }
|
||||
data-conflict-id={ conflictID(book.ActiveConflict) }
|
||||
data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }
|
||||
>
|
||||
@Header(user, "/media/{ uuidToString(book.ID) }")
|
||||
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
|
||||
<div class="w-full px-4 py-3 flex items-center gap-4">
|
||||
<a id="back-link" href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
← Back
|
||||
<!-- Back bar -->
|
||||
<div class="sticky top-16 z-30 border-b border-line bg-surface/95 backdrop-blur">
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-2.5">
|
||||
<a id="back-link" href="/dashboard" class="inline-flex items-center gap-1.5 text-sm font-medium text-content-muted transition-colors hover:text-content">
|
||||
@Icon("arrow-left", "h-4 w-4")
|
||||
Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Top Section: Cover + Basic Info + Actions -->
|
||||
<div class="flex flex-col md:flex-row gap-8 mb-8">
|
||||
<!-- Left: Cover Image (256x384px) -->
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Cover + header -->
|
||||
<div class="flex flex-col gap-8 md:flex-row">
|
||||
<div class="flex-shrink-0">
|
||||
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
|
||||
<img
|
||||
src={ book.CoverImagePath.String }
|
||||
alt={ book.Title }
|
||||
class="w-64 h-96 object-cover rounded-lg shadow-xl"
|
||||
onerror="this.src='/static/placeholder-book.svg'"
|
||||
/>
|
||||
} else {
|
||||
<img
|
||||
src="/static/placeholder-book.svg"
|
||||
alt="{ book.Title }"
|
||||
class="w-64 h-96 object-cover rounded-lg shadow-xl"
|
||||
/>
|
||||
}
|
||||
<div class="overflow-hidden rounded-xl shadow-card-hover">
|
||||
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
|
||||
<img
|
||||
src={ book.CoverImagePath.String }
|
||||
alt={ book.Title }
|
||||
class="w-48 object-cover md:w-56 lg:w-60"
|
||||
onerror="this.src='/static/placeholder-book.svg'"
|
||||
/>
|
||||
} else {
|
||||
<img src="/static/placeholder-book.svg" alt={ book.Title } class="w-48 object-cover md:w-56 lg:w-60"/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: Details -->
|
||||
<div class="flex-1">
|
||||
<!-- Title & Author -->
|
||||
<h1 class="text-4xl font-bold mb-2" style="color: var(--text-primary)">{ book.Title }</h1>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="text-3xl font-bold tracking-tight text-content">{ book.Title }</h1>
|
||||
if book.Author.Valid && book.Author.String != "" {
|
||||
<h2 class="text-2xl mb-6" style="color: var(--text-secondary)">
|
||||
by { book.Author.String }
|
||||
</h2>
|
||||
<p class="mt-1 text-lg text-content-muted">by { book.Author.String }</p>
|
||||
}
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-wrap gap-3 mb-6">
|
||||
<!-- Read Now (placeholder) -->
|
||||
<button
|
||||
@click={ "window.location.href = '/readers/" + uuidToString(book.ID) + "'" }
|
||||
class="px-6 py-3 rounded-lg font-semibold"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
>
|
||||
📖 Read Now
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-5 flex flex-wrap gap-2">
|
||||
<button @click={ "window.location.href = '/readers/" + uuidToString(book.ID) + "'" } class="btn btn-primary">
|
||||
@Icon("book-open", "h-4 w-4")
|
||||
Read Now
|
||||
</button>
|
||||
<!-- Mark as Read / Unread toggle -->
|
||||
if book.ReadingProgress != nil && book.ReadingProgress.Percentage.Valid && book.ReadingProgress.Percentage.Float64 >= 1.0 {
|
||||
<button
|
||||
@click="toggleRead(false)"
|
||||
:disabled="readSaving"
|
||||
class="px-6 py-3 rounded-lg border"
|
||||
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||
>
|
||||
<span x-show="!readSaving">✅ Mark as Unread</span>
|
||||
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||
<button @click="toggleRead(false)" :disabled="readSaving" class="btn btn-secondary">
|
||||
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("check-circle", "h-4 w-4") Mark as Unread</span>
|
||||
<svg x-show="readSaving" x-cloak class="inline-block h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
} else {
|
||||
<button
|
||||
@click="toggleRead(true)"
|
||||
:disabled="readSaving"
|
||||
class="px-6 py-3 rounded-lg border"
|
||||
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||
>
|
||||
<span x-show="!readSaving">📖 Mark as Read</span>
|
||||
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||
<button @click="toggleRead(true)" :disabled="readSaving" class="btn btn-secondary">
|
||||
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("book", "h-4 w-4") Mark as Read</span>
|
||||
<svg x-show="readSaving" x-cloak class="inline-block h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
<!-- Sync Progress -->
|
||||
if book.ActiveConflict != nil || book.ReadingProgress != nil {
|
||||
<button
|
||||
@click="showProgressSyncModal()"
|
||||
class="px-6 py-3 rounded-lg border"
|
||||
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||
>
|
||||
🔄 Sync Progress
|
||||
<button @click="showProgressSyncModal()" class="btn btn-secondary">
|
||||
@Icon("sync", "h-4 w-4")
|
||||
Sync Progress
|
||||
</button>
|
||||
}
|
||||
<!-- View Notes/Highlights -->
|
||||
<button
|
||||
@click="showNotesModal()"
|
||||
class="px-6 py-3 rounded-lg border"
|
||||
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||
>
|
||||
📝 Notes & Highlights
|
||||
<button @click="showNotesModal()" class="btn btn-secondary">
|
||||
@Icon("edit", "h-4 w-4")
|
||||
Notes
|
||||
if book.NotesCount + book.HighlightsCount > 0 {
|
||||
<span
|
||||
class="ml-2 px-2 py-0.5 rounded text-xs font-semibold"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
>
|
||||
{ book.NotesCount + book.HighlightsCount }
|
||||
</span>
|
||||
<span class="badge bg-brand/20 text-brand">{ book.NotesCount + book.HighlightsCount }</span>
|
||||
}
|
||||
</button>
|
||||
<!-- Edit Metadata (placeholder) -->
|
||||
<button
|
||||
@click="showMetadataEditor()"
|
||||
class="px-6 py-3 rounded-lg border"
|
||||
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||
>
|
||||
✏️ Edit Metadata
|
||||
<button @click="showMetadataEditor()" class="btn btn-ghost">
|
||||
@Icon("settings", "h-4 w-4")
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
<!-- Rating Display -->
|
||||
<div class="mb-6" @mouseleave="ratingHover = 0">
|
||||
<span class="text-2xl">
|
||||
|
||||
<!-- Rating -->
|
||||
<div class="mt-5 flex flex-wrap items-center gap-2" @mouseleave="ratingHover = 0">
|
||||
<span class="text-2xl tracking-tight">
|
||||
<template x-for="i in 5" :key="i">
|
||||
<span style="position: relative; display: inline-block;">
|
||||
<span :style="starFill(i)">★</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="setRating(i*2-1)"
|
||||
@mouseenter="ratingHover = i*2-1"
|
||||
:disabled="ratingSaving"
|
||||
:aria-label="'Rate ' + ((i*2-1)/2) + ' of 5 stars'"
|
||||
style="position: absolute; left: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
@click="setRating(i*2)"
|
||||
@mouseenter="ratingHover = i*2"
|
||||
:disabled="ratingSaving"
|
||||
:aria-label="'Rate ' + ((i*2)/2) + ' of 5 stars'"
|
||||
style="position: absolute; right: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
|
||||
></button>
|
||||
<span :style="starFill(i)" class="text-content-muted/40">★</span>
|
||||
<button type="button" @click="setRating(i*2-1)" @mouseenter="ratingHover = i*2-1" :disabled="ratingSaving" :aria-label="'Rate ' + ((i*2-1)/2) + ' of 5 stars'" style="position: absolute; left: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"></button>
|
||||
<button type="button" @click="setRating(i*2)" @mouseenter="ratingHover = i*2" :disabled="ratingSaving" :aria-label="'Rate ' + ((i*2)/2) + ' of 5 stars'" style="position: absolute; right: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"></button>
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
<span class="ml-2 text-sm align-middle" style="color: var(--text-secondary);" x-text="ratingText()"></span>
|
||||
<button
|
||||
type="button"
|
||||
x-show="userRating > 0 && !ratingSaving"
|
||||
@click="clearRating()"
|
||||
class="ml-2 text-xs underline hover:opacity-70"
|
||||
style="color: var(--text-secondary);"
|
||||
>Clear</button>
|
||||
<svg x-show="ratingSaving" class="animate-spin inline-block h-4 w-4 ml-1 align-middle" viewBox="0 0 24 24" fill="none" style="color: var(--text-secondary);">
|
||||
<span class="text-sm text-content-muted" x-text="ratingText()"></span>
|
||||
<button type="button" x-show="userRating > 0 && !ratingSaving" x-cloak @click="clearRating()" class="text-xs text-content-muted underline hover:text-content">Clear</button>
|
||||
<svg x-show="ratingSaving" x-cloak class="inline-block h-4 w-4 animate-spin text-content-muted" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Community Rating Display (from metadata) -->
|
||||
|
||||
<!-- Community rating -->
|
||||
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
|
||||
<div class="mb-2">
|
||||
<span class="text-lg" style="color: var(--text-secondary);">
|
||||
Community Rating:
|
||||
<span class="font-bold" style="color: var(--text-primary);">
|
||||
@templ.Raw(renderStars(int32(book.CommunityRating.Float64)))
|
||||
</span>
|
||||
<span class="ml-2 text-sm" style="color: var(--text-secondary);">
|
||||
{ fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) }
|
||||
</span>
|
||||
</span>
|
||||
<div class="mt-2 flex items-center gap-2 text-sm text-content-muted">
|
||||
<span>Community:</span>
|
||||
<span class="text-base">@templ.Raw(renderStars(int32(book.CommunityRating.Float64)))</span>
|
||||
<span>{ fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) }</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Series Badge -->
|
||||
|
||||
<!-- Series badge -->
|
||||
if book.Series.Valid && book.Series.String != "" {
|
||||
<div class="mb-4">
|
||||
<div class="mt-4">
|
||||
<a
|
||||
href={ "/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID) }
|
||||
class="px-3 py-1 rounded-full text-sm font-semibold inline-block hover:opacity-80 transition-opacity"
|
||||
style="background-color: var(--accent); color: white; text-decoration: none;"
|
||||
class="chip hover:opacity-90"
|
||||
>
|
||||
@Icon("layers", "h-3.5 w-3.5")
|
||||
{ book.Series.String }
|
||||
if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 {
|
||||
#{ book.SeriesNumber.Int32 }
|
||||
@@ -197,397 +148,200 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
<!-- Reading Direction Badge (for manga/comics) -->
|
||||
if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" {
|
||||
<div class="mb-4">
|
||||
<span
|
||||
class="px-3 py-1 rounded-full text-sm font-semibold"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
>
|
||||
📖 { strings.ToUpper(book.ReadingDirection.String) }
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Comic-Specific Badges -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<!-- Age Rating Badge -->
|
||||
|
||||
<!-- Reading direction + comic badges -->
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" {
|
||||
<span class="chip">@Icon("book-open", "h-3.5 w-3.5"){ strings.ToUpper(book.ReadingDirection.String) }</span>
|
||||
}
|
||||
if book.AgeRating.Valid && book.AgeRating.String != "" {
|
||||
<span
|
||||
class="px-2 py-1 rounded-full text-xs font-semibold"
|
||||
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
|
||||
>
|
||||
{ book.AgeRating.String }
|
||||
</span>
|
||||
<span class="badge bg-surface-hover text-content-muted">{ book.AgeRating.String }</span>
|
||||
}
|
||||
<!-- Black and White Badge -->
|
||||
if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool {
|
||||
<span
|
||||
class="px-2 py-1 rounded-full text-xs font-semibold"
|
||||
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
|
||||
>
|
||||
B&W
|
||||
</span>
|
||||
<span class="badge bg-surface-hover text-content-muted">B&W</span>
|
||||
}
|
||||
<!-- Story Arc Badge -->
|
||||
if book.StoryArc.Valid && book.StoryArc.String != "" {
|
||||
<span
|
||||
class="px-2 py-1 rounded-full text-xs font-semibold"
|
||||
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
|
||||
>
|
||||
📚 { book.StoryArc.String }
|
||||
</span>
|
||||
<span class="badge bg-surface-hover text-content-muted">{ book.StoryArc.String }</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
if len(book.Tags) > 0 {
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<div class="mt-4 flex flex-wrap gap-1.5">
|
||||
for _, tag := range book.Tags {
|
||||
<a
|
||||
href={ "/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID) }
|
||||
class="px-2 py-1 rounded-full text-xs font-semibold hover:opacity-80 transition-opacity"
|
||||
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary); text-decoration: none;"
|
||||
>
|
||||
<a href={ "/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID) } class="badge bg-surface-hover text-content-muted transition-colors hover:bg-surface-hover hover:text-content">
|
||||
{ tag }
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- Description/Synopsis -->
|
||||
|
||||
<!-- Synopsis -->
|
||||
if book.Description.Valid && book.Description.String != "" {
|
||||
<div class="mb-6">
|
||||
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Synopsis</h3>
|
||||
<div class="max-h-[20rem] overflow-y-auto pr-2" style="color: var(--text-secondary)">
|
||||
@UnsafeHTML(
|
||||
bluemonday.UGCPolicy().Sanitize(book.Description.String),
|
||||
).ToComponent()
|
||||
<div class="mt-5">
|
||||
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-content-muted">Synopsis</h3>
|
||||
<div class="max-h-80 overflow-y-auto pr-2 text-sm leading-relaxed text-content-muted">
|
||||
@UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Description.String)).ToComponent()
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Summary (from ComicInfo.xml, if different from description) -->
|
||||
if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String {
|
||||
<div class="mb-6">
|
||||
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Comic Summary</h3>
|
||||
<div class="max-h-[20rem] overflow-y-auto pr-2" style="color: var(--text-secondary)">
|
||||
@UnsafeHTML(
|
||||
bluemonday.UGCPolicy().Sanitize(book.Summary.String),
|
||||
).ToComponent()
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Metadata Notes (technical notes from metadata files) -->
|
||||
if book.MetadataNotes.Valid && book.MetadataNotes.String != "" {
|
||||
<div
|
||||
class="card p-6 rounded-lg border mb-6"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||
>
|
||||
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Metadata Notes</h3>
|
||||
<div class="text-sm" style="color: var(--text-secondary);">
|
||||
{ book.MetadataNotes.String }
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Progress Section -->
|
||||
if book.ReadingProgress != nil {
|
||||
<div
|
||||
class="card p-6 rounded-lg border mb-6"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||
>
|
||||
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Reading Progress</h3>
|
||||
if book.ActiveConflict != nil {
|
||||
<div
|
||||
class="mb-4 p-3 rounded-lg border"
|
||||
style="background-color: #f59e0b20; border-color: #f59e0b;"
|
||||
>
|
||||
<p style="color: var(--text-primary);">
|
||||
⚠️ Progress conflict detected - Click "Sync Progress" to review and resolve
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
<!-- Progress Bar -->
|
||||
<div class="mb-4">
|
||||
<div class="w-full rounded-full h-3" style="background-color: var(--bg-primary);">
|
||||
<div
|
||||
class="h-3 rounded-full transition-all"
|
||||
style={ fmt.Sprintf("width: %.1f%%; background-color: var(--accent);", book.ReadingProgress.Percentage.Float64*100) }
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Progress Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Progress</p>
|
||||
<p class="text-2xl font-bold" style="color: var(--accent);">
|
||||
{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64 * 100) }%
|
||||
</p>
|
||||
</div>
|
||||
if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Page</p>
|
||||
<p>{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }</p>
|
||||
</div>
|
||||
}
|
||||
if book.ReadingProgress.LastReadAt.Valid {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Last Read</p>
|
||||
<p>{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</p>
|
||||
</div>
|
||||
}
|
||||
if book.ReadingProgress.LastSyncSource.Valid {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Source</p>
|
||||
<p class="capitalize">{ book.ReadingProgress.LastSyncSource.String }</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Metadata Grid -->
|
||||
<div
|
||||
class="card p-6 rounded-lg border mb-6"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||
>
|
||||
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Metadata</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<!-- Publication Info -->
|
||||
if book.Publisher.Valid && book.Publisher.String != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Publisher</p>
|
||||
<p>{ book.Publisher.String }</p>
|
||||
</div>
|
||||
}
|
||||
if book.DatePublished.Valid {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Published</p>
|
||||
<p>{ book.DatePublished.Time.Format("01-02-2006") }</p>
|
||||
</div>
|
||||
}
|
||||
if book.Isbn.Valid && book.Isbn.String != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Isbn</p>
|
||||
<p>{ book.Isbn.String }</p>
|
||||
</div>
|
||||
}
|
||||
if book.Language.Valid && book.Language.String != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Language</p>
|
||||
<p class="capitalize">{ book.Language.String }</p>
|
||||
</div>
|
||||
}
|
||||
if book.Edition.Valid && book.Edition.String != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Edition</p>
|
||||
<p>{ book.Edition.String }</p>
|
||||
</div>
|
||||
}
|
||||
if book.PageCount.Valid && book.PageCount.Int32 > 0 {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Pages</p>
|
||||
<p>{ book.PageCount.Int32 }</p>
|
||||
</div>
|
||||
}
|
||||
if book.Genre.Valid && book.Genre.String != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Genre</p>
|
||||
<p>{ book.Genre.String }</p>
|
||||
</div>
|
||||
}
|
||||
<!-- Series Information (if not shown in badge) -->
|
||||
if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Series Count</p>
|
||||
<p>{ book.SeriesCount.Int32 } items</p>
|
||||
</div>
|
||||
}
|
||||
if book.Volume.Valid && book.Volume.Int32 > 0 {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Volume</p>
|
||||
<p>Vol. { book.Volume.Int32 }</p>
|
||||
</div>
|
||||
}
|
||||
if book.Imprint.Valid && book.Imprint.String != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Imprint</p>
|
||||
<p>{ book.Imprint.String }</p>
|
||||
</div>
|
||||
}
|
||||
if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Copyright Year</p>
|
||||
<p>{ book.CopyrightYear.Int32 }</p>
|
||||
</div>
|
||||
}
|
||||
<!-- Comic-Specific Fields -->
|
||||
if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Manga Type</p>
|
||||
<p class="capitalize">{ strings.ReplaceAll(book.MangaType.String, "_", " ") }</p>
|
||||
</div>
|
||||
}
|
||||
if book.ScanInformation.Valid && book.ScanInformation.String != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Scan Info</p>
|
||||
<p class="text-sm" style="color: var(--text-secondary);">{ book.ScanInformation.String }</p>
|
||||
</div>
|
||||
}
|
||||
if len(book.AlternateInfo) > 0 {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Alternate Series</p>
|
||||
<p class="text-sm">{ string(book.AlternateInfo) }</p>
|
||||
</div>
|
||||
}
|
||||
if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Alternate Series</p>
|
||||
<p>{ altSeries }</p>
|
||||
</div>
|
||||
}
|
||||
if len(book.Contributors) > 0 {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Contributors</p>
|
||||
<p>{ strings.Join(book.Contributors, ", ") }</p>
|
||||
</div>
|
||||
}
|
||||
<!-- Technical Info -->
|
||||
|
||||
<div class="mt-8 space-y-6">
|
||||
<!-- Summary -->
|
||||
if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Format</p>
|
||||
<p>{ book.MimeType.String }</p>
|
||||
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-content-muted">Comic Summary</h3>
|
||||
<div class="max-h-80 overflow-y-auto pr-2 text-sm leading-relaxed text-content-muted">
|
||||
@UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Summary.String)).ToComponent()
|
||||
</div>
|
||||
</div>
|
||||
if book.FileSize.Valid && book.FileSize.Int64 > 0 {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">File Size</p>
|
||||
<p>{ formatFileSize(book.FileSize.Int64) }</p>
|
||||
}
|
||||
|
||||
<!-- Metadata notes -->
|
||||
if book.MetadataNotes.Valid && book.MetadataNotes.String != "" {
|
||||
<div class="card p-5">
|
||||
<h3 class="mb-2 text-sm font-semibold text-content">Metadata Notes</h3>
|
||||
<p class="text-sm text-content-muted">{ book.MetadataNotes.String }</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Progress -->
|
||||
if book.ReadingProgress != nil {
|
||||
<div class="card p-5">
|
||||
<h3 class="mb-4 text-sm font-semibold text-content">Reading Progress</h3>
|
||||
if book.ActiveConflict != nil {
|
||||
<div class="mb-4 flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 p-3 text-sm text-content">
|
||||
@Icon("alert", "h-4 w-4 mt-0.5 shrink-0 text-warning")
|
||||
<span>Progress conflict detected — click "Sync Progress" to review and resolve.</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mb-4">
|
||||
<div class="h-2 w-full overflow-hidden rounded-full bg-surface-hover">
|
||||
<div class="h-2 rounded-full bg-brand transition-all" style={ fmt.Sprintf("width: %.1f%%", book.ReadingProgress.Percentage.Float64*100) }></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-content-muted">Progress</p>
|
||||
<p class="text-xl font-bold text-brand">{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100) }%</p>
|
||||
</div>
|
||||
if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-content-muted">Page</p>
|
||||
<p class="text-content">{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }</p>
|
||||
</div>
|
||||
}
|
||||
if book.ReadingProgress.LastReadAt.Valid {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-content-muted">Last Read</p>
|
||||
<p class="text-content">{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</p>
|
||||
</div>
|
||||
}
|
||||
if book.ReadingProgress.LastSyncSource.Valid {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-content-muted">Source</p>
|
||||
<p class="capitalize text-content">{ book.ReadingProgress.LastSyncSource.String }</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Metadata grid -->
|
||||
<div class="card p-5">
|
||||
<h3 class="mb-4 text-sm font-semibold text-content">Metadata</h3>
|
||||
<dl class="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
if book.Publisher.Valid && book.Publisher.String != "" {
|
||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Publisher</dt><dd class="text-content">{ book.Publisher.String }</dd></div>
|
||||
}
|
||||
if book.DatePublished.Valid {
|
||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Published</dt><dd class="text-content">{ book.DatePublished.Time.Format("01-02-2006") }</dd></div>
|
||||
}
|
||||
if book.Isbn.Valid && book.Isbn.String != "" {
|
||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">ISBN</dt><dd class="text-content">{ book.Isbn.String }</dd></div>
|
||||
}
|
||||
if book.Language.Valid && book.Language.String != "" {
|
||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Language</dt><dd class="capitalize text-content">{ book.Language.String }</dd></div>
|
||||
}
|
||||
if book.Edition.Valid && book.Edition.String != "" {
|
||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Edition</dt><dd class="text-content">{ book.Edition.String }</dd></div>
|
||||
}
|
||||
if book.PageCount.Valid && book.PageCount.Int32 > 0 {
|
||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Pages</dt><dd class="text-content">{ book.PageCount.Int32 }</dd></div>
|
||||
}
|
||||
if book.Genre.Valid && book.Genre.String != "" {
|
||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Genre</dt><dd class="text-content">{ book.Genre.String }</dd></div>
|
||||
}
|
||||
if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 {
|
||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Series Count</dt><dd class="text-content">{ book.SeriesCount.Int32 } items</dd></div>
|
||||
}
|
||||
if book.Volume.Valid && book.Volume.Int32 > 0 {
|
||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Volume</dt><dd class="text-content">Vol. { book.Volume.Int32 }</dd></div>
|
||||
}
|
||||
if book.Imprint.Valid && book.Imprint.String != "" {
|
||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Imprint</dt><dd class="text-content">{ book.Imprint.String }</dd></div>
|
||||
}
|
||||
if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {
|
||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Copyright Year</dt><dd class="text-content">{ book.CopyrightYear.Int32 }</dd></div>
|
||||
}
|
||||
if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" {
|
||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Manga Type</dt><dd class="capitalize text-content">{ strings.ReplaceAll(book.MangaType.String, "_", " ") }</dd></div>
|
||||
}
|
||||
if book.ScanInformation.Valid && book.ScanInformation.String != "" {
|
||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Scan Info</dt><dd class="text-sm text-content-muted">{ book.ScanInformation.String }</dd></div>
|
||||
}
|
||||
if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" {
|
||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Alternate Series</dt><dd class="text-content">{ altSeries }</dd></div>
|
||||
}
|
||||
if len(book.Contributors) > 0 {
|
||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Contributors</dt><dd class="text-content">{ strings.Join(book.Contributors, ", ") }</dd></div>
|
||||
}
|
||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">Format</dt><dd class="text-content">{ book.MimeType.String }</dd></div>
|
||||
if book.FileSize.Valid && book.FileSize.Int64 > 0 {
|
||||
<div><dt class="text-xs uppercase tracking-wide text-content-muted">File Size</dt><dd class="text-content">{ formatFileSize(book.FileSize.Int64) }</dd></div>
|
||||
}
|
||||
</dl>
|
||||
|
||||
<!-- External links -->
|
||||
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
|
||||
<div class="mt-5 border-t border-line pt-4">
|
||||
<h4 class="mb-2 text-xs font-semibold uppercase tracking-wide text-content-muted">External Links</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href={ getExternalURL("goodreads", book.GoodreadsID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("book", "h-3 w-3") Goodreads</a>
|
||||
<a href={ getExternalURL("openlibrary", book.OpenlibraryID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("book-open", "h-3 w-3") Open Library</a>
|
||||
<a href={ getExternalURL("googlebooks", book.GoogleBooksID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("search", "h-3 w-3") Google Books</a>
|
||||
if book.Asin.Valid && book.Asin.String != "" {
|
||||
<a href={ getExternalURL("amazon", book.Asin.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("external", "h-3 w-3") Amazon</a>
|
||||
} else if book.Isbn.Valid && book.Isbn.String != "" {
|
||||
<a href={ getExternalURL("amazon", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("external", "h-3 w-3") Amazon</a>
|
||||
}
|
||||
if book.WebUrl.Valid && book.WebUrl.String != "" {
|
||||
<a href={ book.WebUrl.String } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("external", "h-3 w-3") { getDomainName(book.WebUrl.String) }</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- External IDs Section -->
|
||||
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
|
||||
<div class="mt-4 pt-4 border-t" style="border-color: var(--border);">
|
||||
<h4 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">External Links</h4>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
if book.GoodreadsID.Valid && book.GoodreadsID.String != "" {
|
||||
<a
|
||||
href={ getExternalURL("goodreads", book.GoodreadsID.String, book.Isbn, book.Title, book.Author) }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm hover:underline flex items-center gap-1"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
📚 Goodreads
|
||||
</a>
|
||||
} else {
|
||||
<a
|
||||
href={ getExternalURL("goodreads", "", book.Isbn, book.Title, book.Author) }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm hover:underline flex items-center gap-1"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
📚 Goodreads
|
||||
</a>
|
||||
}
|
||||
if book.OpenlibraryID.Valid && book.OpenlibraryID.String != "" {
|
||||
<a
|
||||
href={ getExternalURL("openlibrary", book.OpenlibraryID.String, book.Isbn, book.Title, book.Author) }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm hover:underline flex items-center gap-1"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
📖 Open Library
|
||||
</a>
|
||||
} else {
|
||||
<a
|
||||
href={ getExternalURL("openlibrary", "", book.Isbn, book.Title, book.Author) }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm hover:underline flex items-center gap-1"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
📖 Open Library
|
||||
</a>
|
||||
}
|
||||
if book.GoogleBooksID.Valid && book.GoogleBooksID.String != "" {
|
||||
<a
|
||||
href={ getExternalURL("googlebooks", book.GoogleBooksID.String, book.Isbn, book.Title, book.Author) }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm hover:underline flex items-center gap-1"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
🔍 Google Books
|
||||
</a>
|
||||
} else {
|
||||
<a
|
||||
href={ getExternalURL("googlebooks", "", book.Isbn, book.Title, book.Author) }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm hover:underline flex items-center gap-1"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
🔍 Google Books
|
||||
</a>
|
||||
}
|
||||
if book.Asin.Valid && book.Asin.String != "" {
|
||||
<a
|
||||
href={ getExternalURL("amazon", book.Asin.String, book.Isbn, book.Title, book.Author) }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm hover:underline flex items-center gap-1"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
🛒 Amazon
|
||||
</a>
|
||||
} else if book.Isbn.Valid && book.Isbn.String != "" {
|
||||
<a
|
||||
href={ getExternalURL("amazon", "", book.Isbn, book.Title, book.Author) }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm hover:underline flex items-center gap-1"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
🛒 Amazon
|
||||
</a>
|
||||
}
|
||||
if book.WebUrl.Valid && book.WebUrl.String != "" {
|
||||
<a
|
||||
href={ book.WebUrl.String }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm hover:underline flex items-center gap-1"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
🔗 { getDomainName(book.WebUrl.String) }
|
||||
|
||||
<!-- Collections -->
|
||||
if len(book.Collections) > 0 {
|
||||
<div class="card p-5">
|
||||
<h3 class="mb-4 text-sm font-semibold text-content">Collections</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
for _, col := range book.Collections {
|
||||
<a href={ "/collections/" + uuidToString(col.ID) } class="flex items-center gap-2 rounded-lg border border-line bg-surface px-3 py-1.5 text-sm transition-colors hover:bg-surface-hover">
|
||||
<span style={ "color: " + col.Color.String }>{ col.Icon.String }</span>
|
||||
<span class="text-content">{ col.Name }</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Collections Section -->
|
||||
if len(book.Collections) > 0 {
|
||||
<div
|
||||
class="card p-6 rounded-lg border mb-6"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||
>
|
||||
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Collections</h3>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
for _, col := range book.Collections {
|
||||
<a
|
||||
href={ "/collections/" + uuidToString(col.ID) }
|
||||
class="px-3 py-2 rounded-lg border flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
style="border-color: { col.Color.String }; background-color: var(--bg-primary); text-decoration: none;"
|
||||
>
|
||||
<span style="color: { col.Color.String };">{ col.Icon.String }</span>
|
||||
<span style="color: var(--text-primary);">{ col.Name }</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Modals -->
|
||||
|
||||
@ProgressSyncModal(user, book)
|
||||
@NotesHighlightsModal(book)
|
||||
@MetadataEditorModal(book)
|
||||
|
||||
Reference in New Issue
Block a user