The play button on book cards now opens the reader directly, instead of always going to the detail page. Cards with an active progress sync conflict route the play button to the detail page (which hosts the conflict dialogue and resolves before writing progress), so the user is never silently dropped into the reader with an unresolved conflict. Backend: - Add HasConflict to BookInfo and stamp it via ListSyncConflictsByUser (MarkActiveConflicts / MarkActiveConflictsSections) on the dashboard, bookshelf, series, tag, and search result card builders. - Each page issues a single conflict query regardless of card count. BookCard: - Restructure into a detail link (cover + meta) with the play action as a sibling overlay using a pointer-events split: the container passes clicks through to detail while only the circular button routes to the reader. No nested anchors. - On touch devices (hover: none) the play button stays visible. Fix: carousel nav buttons had opacity-0 without pointer-events-none, so they swallowed hover/clicks over book cards on the dashboard. They are now click-through until the carousel is hovered.
248 lines
8.9 KiB
Templ
248 lines
8.9 KiB
Templ
package templates
|
|
|
|
import (
|
|
"bookhoard/internal/handlers"
|
|
"fmt"
|
|
)
|
|
|
|
templ Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, errorMessage string) {
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>Dashboard - Bookhoard</title>
|
|
<script src="/static/htmx.min.js"></script>
|
|
<link href="/static/style.css" rel="stylesheet"/>
|
|
</head>
|
|
<body x-data="dashboard" x-init="initDashboard()" class="theme-{ user.Theme }">
|
|
@Header(user, "/dashboard")
|
|
@LibrarySwitcher(libData, currentLibraryID, DashboardActions())
|
|
<main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 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 {
|
|
@CollectionCarousel(section)
|
|
}
|
|
</main>
|
|
@DashboardSettingsModal(allSections, hiddenCollections, itemsPerSection)
|
|
@ErrorToast(errorMessage)
|
|
</body>
|
|
</html>
|
|
}
|
|
|
|
templ CollectionCarousel(section handlers.SectionData) {
|
|
<div
|
|
class="dashboard-collection mb-10"
|
|
data-collection-id={ section.ID }
|
|
data-is-system={ section.IsSystem }
|
|
>
|
|
<div class="flex items-end justify-between mb-4">
|
|
<div class="flex items-center gap-3">
|
|
<span class="grid place-items-center h-9 w-9 rounded-lg text-lg" style="background-color: var(--accent-muted);">{ section.Icon }</span>
|
|
<div>
|
|
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">{ section.Title }</h2>
|
|
if section.Description != "" {
|
|
<p class="text-xs" style="color: var(--text-secondary)">{ section.Description }</p>
|
|
}
|
|
</div>
|
|
</div>
|
|
if section.ViewAllURL != "" {
|
|
<a
|
|
href={ section.ViewAllURL }
|
|
class="inline-flex items-center gap-1 text-sm font-medium hover:underline transition-colors"
|
|
style="color: var(--accent);"
|
|
>
|
|
View All
|
|
@Icon("arrow-right", "h-4 w-4")
|
|
</a>
|
|
}
|
|
</div>
|
|
<div class="carousel-container relative group">
|
|
<button
|
|
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 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 transition-opacity duration-200"
|
|
data-action="scroll-carousel"
|
|
data-collection-id={ section.ID }
|
|
data-direction="-1"
|
|
aria-label="Scroll left"
|
|
style="background: linear-gradient(to right, var(--bg-primary), transparent);"
|
|
>
|
|
@Icon("chevron-left", "h-7 w-7")
|
|
</button>
|
|
<div
|
|
id={ "carousel-track-" + section.ID }
|
|
class="carousel-track flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory px-6 pb-2"
|
|
style="scrollbar-width: none; -ms-overflow-style: none;"
|
|
>
|
|
for _, item := range section.Items {
|
|
<div class="flex-shrink-0 w-36 sm:w-40 snap-start">
|
|
@BookCard(item)
|
|
</div>
|
|
}
|
|
if len(section.Items) == 0 {
|
|
<div class="flex flex-col items-center justify-center text-center py-12 w-full gap-2" style="color: var(--text-secondary);">
|
|
@Icon("book", "h-8 w-8 opacity-50")
|
|
<p class="text-sm">No items in this collection</p>
|
|
</div>
|
|
}
|
|
</div>
|
|
<button
|
|
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 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 transition-opacity duration-200"
|
|
data-action="scroll-carousel"
|
|
data-collection-id={ section.ID }
|
|
data-direction="1"
|
|
aria-label="Scroll right"
|
|
style="background: linear-gradient(to left, var(--bg-primary), transparent);"
|
|
>
|
|
@Icon("chevron-right", "h-7 w-7")
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
templ BookCard(item handlers.BookInfo) {
|
|
<div class="book-card relative w-full h-full rounded-xl overflow-hidden cursor-pointer">
|
|
<a href={ "/media/" + item.MediaItemID } class="block h-full">
|
|
<div
|
|
class="book-card-cover aspect-[2/3] overflow-hidden"
|
|
style="background-color: color-mix(in srgb, var(--text-primary) 8%, transparent);"
|
|
>
|
|
if item.CoverImagePath != "" {
|
|
<img
|
|
src={ item.CoverImagePath }
|
|
alt={ item.Title }
|
|
class="w-full h-full object-cover"
|
|
loading="lazy"
|
|
onerror="this.src='/static/placeholder-book.svg'"
|
|
/>
|
|
} else {
|
|
<img
|
|
src="/static/placeholder-book.svg"
|
|
alt={ item.Title }
|
|
class="w-full h-full object-cover"
|
|
/>
|
|
}
|
|
</div>
|
|
<div class="book-card-meta">
|
|
<h3 class="font-semibold text-sm leading-snug line-clamp-2" style="color: var(--text-primary)" title={ item.Title }>
|
|
{ item.Title }
|
|
</h3>
|
|
if item.Author != "" {
|
|
<p class="text-xs mt-0.5 line-clamp-1" style="color: var(--text-secondary)" title={ item.Author }>
|
|
{ item.Author }
|
|
</p>
|
|
}
|
|
</div>
|
|
</a>
|
|
<div class="book-card-action">
|
|
if item.HasConflict {
|
|
<a
|
|
href={ "/media/" + item.MediaItemID }
|
|
class="book-card-action-btn"
|
|
aria-label={ "Resolve progress conflict for " + item.Title }
|
|
title="Resolve progress conflict"
|
|
>
|
|
@Icon("play", "h-5 w-5 translate-x-0.5")
|
|
</a>
|
|
} else {
|
|
<a
|
|
href={ "/readers/" + item.MediaItemID }
|
|
class="book-card-action-btn"
|
|
aria-label={ "Read " + item.Title }
|
|
title="Read"
|
|
>
|
|
@Icon("play", "h-5 w-5 translate-x-0.5")
|
|
</a>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections []string, itemsPerSection int) {
|
|
<div
|
|
id="dashboard-settings-modal"
|
|
class="hidden fixed inset-0 z-[70] flex items-center justify-center p-4"
|
|
style="background-color: var(--surface-overlay);"
|
|
>
|
|
<div class="card p-6 w-full max-w-2xl shadow-2xl" style="box-shadow: var(--shadow-pop);">
|
|
<div class="flex justify-between items-center mb-6">
|
|
<div>
|
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
|
|
<p class="text-sm mt-1" style="color: var(--text-secondary)">Drag to reorder, toggle to show or hide.</p>
|
|
</div>
|
|
<button data-action="close-dashboard-settings" class="icon-btn" aria-label="Close">
|
|
@Icon("close", "h-5 w-5")
|
|
</button>
|
|
</div>
|
|
<div id="collection-list" class="space-y-2 mb-6">
|
|
for _, section := range sections {
|
|
<div
|
|
class="collection-item flex items-center justify-between p-3 rounded-xl cursor-move select-none card"
|
|
data-collection-id={ section.ID }
|
|
data-is-system={ fmt.Sprintf("%v", section.IsSystem) }
|
|
draggable="true"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
@Icon("grip", "h-5 w-5 shrink-0")
|
|
<span class="text-xl">{ section.Icon }</span>
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-medium" style="color: var(--text-primary)">{ section.Title }</span>
|
|
if section.IsSystem {
|
|
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">System</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
if section.IsSystem {
|
|
<button
|
|
data-action="restore-system-collection"
|
|
data-collection-name={ section.ID }
|
|
class="btn btn-secondary py-1 px-2.5 text-xs"
|
|
title="Restore { section.Title } to defaults"
|
|
>
|
|
Restore
|
|
</button>
|
|
}
|
|
<label class="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
class="sr-only peer"
|
|
if !ContainsString(hiddenCollections, section.ID) {
|
|
checked
|
|
}
|
|
/>
|
|
<div
|
|
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"
|
|
style="background-color: var(--border-strong);"
|
|
></div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="mb-6">
|
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
|
Items per Collection: <span id="items-count-display" class="font-bold" style="color: var(--text-primary)">{ itemsPerSection }</span>
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min="10"
|
|
max="50"
|
|
step="5"
|
|
value={ itemsPerSection }
|
|
class="w-full h-2 rounded-lg appearance-none cursor-pointer"
|
|
style="background-color: var(--border-strong);"
|
|
data-input-action="update-items-count"
|
|
target="items-count-display"
|
|
/>
|
|
</div>
|
|
<div class="flex justify-end gap-3">
|
|
<button data-action="close-dashboard-settings" class="btn btn-secondary">Cancel</button>
|
|
<button data-action="save-dashboard-settings" class="btn btn-primary">Save Changes</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|