Add clickable links to book detail page (/media/:uuid) from: - Dashboard: BookCard components now link to detail page - Changed from data-action pattern to direct <a> tags - Removes unused viewBook() function and switch case - Follows progressive enhancement (works without JS) - Collections: Book titles link to detail page - Books displayed in collection detail view - Progress: Book titles link to detail page - Progress cards now have clickable title links All links use direct navigation for better UX and progressive enhancement. Book detail page can display comprehensive metadata, reading progress, sync status, and external service links.
306 lines
10 KiB
Templ
306 lines
10 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")
|
||
<!-- Sticky Library Selector -->
|
||
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
|
||
<div class="w-full px-4 py-3 flex items-center justify-between">
|
||
<div class="flex items-center gap-4">
|
||
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library:</label>
|
||
<select
|
||
id="library-select"
|
||
name="library_id"
|
||
class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||
style="background-color: var(--bg-secondary); color: var(--text-primary);"
|
||
>
|
||
for _, lib := range libData {
|
||
if lib.ID == currentLibraryID {
|
||
<option value={ lib.ID } selected>{ lib.Name }</option>
|
||
} else {
|
||
<option value={ lib.ID }>{ lib.Name }</option>
|
||
}
|
||
}
|
||
</select>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<button
|
||
data-action="open-dashboard-settings"
|
||
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||
style="background-color: var(--bg-secondary);"
|
||
title="Customize Dashboard"
|
||
>
|
||
⚙️
|
||
</button>
|
||
<button
|
||
data-action="reload-page"
|
||
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||
style="background-color: var(--bg-secondary);"
|
||
title="Refresh"
|
||
>
|
||
🔄
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div
|
||
id="loading-spinner"
|
||
class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50"
|
||
style="background-color: var(--bg-primary);"
|
||
>
|
||
<div class="animate-spin rounded-full h-12 w-12 border-b-2" style="border-color: var(--accent);"></div>
|
||
</div>
|
||
</div>
|
||
<!-- Collections Container -->
|
||
<main id="collections-container" class="w-full px-4 py-8">
|
||
for _, section := range sections {
|
||
@CollectionCarousel(section)
|
||
}
|
||
</main>
|
||
<!-- Dashboard Settings Modal -->
|
||
@DashboardSettingsModal(allSections, hiddenCollections, itemsPerSection)
|
||
@ErrorToast(errorMessage)
|
||
</body>
|
||
</html>
|
||
}
|
||
|
||
templ CollectionCarousel(section handlers.SectionData) {
|
||
<div
|
||
class="dashboard-collection mb-8"
|
||
data-collection-id={ section.ID }
|
||
data-is-system={ section.IsSystem }
|
||
>
|
||
<!-- Collection Header -->
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-2xl">{ section.Icon }</span>
|
||
<div>
|
||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">{ section.Title }</h2>
|
||
if section.Description != "" {
|
||
<p class="text-sm" style="color: var(--text-secondary)">{ section.Description }</p>
|
||
}
|
||
</div>
|
||
</div>
|
||
if section.ViewAllURL != "" {
|
||
<a
|
||
href={ section.ViewAllURL }
|
||
class="text-sm font-medium hover:underline transition-colors"
|
||
style="color: var(--accent);"
|
||
>
|
||
View All →
|
||
</a>
|
||
}
|
||
</div>
|
||
<!-- Carousel -->
|
||
<div class="carousel-container relative group">
|
||
<button
|
||
class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
|
||
w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
|
||
flex items-center justify-start opacity-0 group-hover:opacity-100
|
||
transition-opacity duration-200"
|
||
data-action="scroll-carousel"
|
||
data-collection-id={ section.ID }
|
||
data-direction="-1"
|
||
aria-label="Scroll left"
|
||
>
|
||
<span class="text-3xl pl-2" style="color: var(--text-primary);">‹</span>
|
||
</button>
|
||
<div
|
||
id={ "carousel-track-" + section.ID }
|
||
class="carousel-track flex gap-4 overflow-x-auto
|
||
scroll-smooth snap-x snap-mandatory
|
||
px-12 pb-4"
|
||
style="scrollbar-width: none; -ms-overflow-style: none;"
|
||
>
|
||
for _, item := range section.Items {
|
||
@BookCard(item)
|
||
}
|
||
if len(section.Items) == 0 {
|
||
<div class="text-center py-8 w-full" style="color: var(--text-secondary);">
|
||
<p>No items in this collection</p>
|
||
</div>
|
||
}
|
||
</div>
|
||
<button
|
||
class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
|
||
w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
|
||
flex items-center justify-end opacity-0 group-hover:opacity-100
|
||
transition-opacity duration-200"
|
||
data-action="scroll-carousel"
|
||
data-collection-id={ section.ID }
|
||
data-direction="1"
|
||
aria-label="Scroll right"
|
||
>
|
||
<span class="text-3xl pr-2" style="color: var(--text-primary);">›</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
templ BookCard(item handlers.BookInfo) {
|
||
<a href={ "/media/" + item.MediaItemID }>
|
||
<div
|
||
class="book-card flex-shrink-0 w-36 rounded-lg overflow-hidden snap-start cursor-pointer
|
||
transition-transform duration-200 hover:scale-105"
|
||
tabindex="0"
|
||
role="button"
|
||
aria-label={ "View " + item.Title }
|
||
>
|
||
<div
|
||
class="aspect-[2/3] overflow-hidden shadow-lg
|
||
bg-gradient-to-br from-gray-700 to-gray-900"
|
||
>
|
||
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-text px-2 py-1 bg-[color-mix(in_srgb,var(--wood-border)_40%,transparent)]">
|
||
<h3 class="font-semibold text-base line-clamp-2" style="color: var(--text-primary)">
|
||
{ item.Title }
|
||
</h3>
|
||
if item.Author != "" {
|
||
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">
|
||
{ item.Author }
|
||
</p>
|
||
}
|
||
</div>
|
||
</div>
|
||
</a>
|
||
}
|
||
|
||
templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections []string, itemsPerSection int) {
|
||
<div
|
||
id="dashboard-settings-modal"
|
||
class="hidden fixed inset-0 z-50 flex items-center justify-center"
|
||
style="background-color: rgba(0, 0, 0, 0.7);"
|
||
>
|
||
<div
|
||
class="rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl"
|
||
style="background-color: var(--bg-secondary);"
|
||
>
|
||
<div class="flex justify-between items-center mb-6">
|
||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
|
||
<button
|
||
data-action="close-dashboard-settings"
|
||
class="p-2 hover:bg-gray-700 rounded transition-colors"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<p class="text-sm mb-4" style="color: var(--text-secondary);">
|
||
Drag to reorder collections, toggle visibility with the switch.
|
||
</p>
|
||
<!-- Draggable Collection List -->
|
||
<div id="collection-list" class="space-y-2 mb-6">
|
||
for _, section := range sections {
|
||
<div
|
||
class="collection-item flex items-center justify-between p-3 rounded border
|
||
cursor-move select-none"
|
||
data-collection-id={ section.ID }
|
||
data-is-system={ fmt.Sprintf("%v", section.IsSystem) }
|
||
draggable="true"
|
||
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-xl" style="color: var(--text-secondary);">☰</span>
|
||
<span class="text-xl">{ section.Icon }</span>
|
||
<div>
|
||
<span class="font-medium" style="color: var(--text-primary);">{ section.Title }</span>
|
||
if section.IsSystem {
|
||
<span class="text-xs ml-2 px-2 py-1 rounded" style="background-color: var(--accent);">System</span>
|
||
}
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
if section.IsSystem {
|
||
<button
|
||
data-action="restore-system-collection"
|
||
data-collection-name={ section.ID }
|
||
class="text-xs px-3 py-1 rounded border hover:opacity-80 transition-opacity"
|
||
style="border-color: var(--border); color: var(--text-secondary);"
|
||
title="Restore { section.Title } to defaults"
|
||
>
|
||
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 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer
|
||
peer-checked:after:translate-x-full peer-checked:after:border-white
|
||
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
|
||
after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all
|
||
peer-checked:bg-blue-600"
|
||
></div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
<!-- Items Per Section Slider -->
|
||
<div class="mb-6">
|
||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||
Items per Collection: <span id="items-count-display" class="font-bold">{ itemsPerSection }</span>
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min="10"
|
||
max="50"
|
||
step="5"
|
||
value={ itemsPerSection }
|
||
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
|
||
data-input-action="update-items-count"
|
||
target="items-count-display"
|
||
/>
|
||
</div>
|
||
<div class="flex justify-end gap-3">
|
||
<button
|
||
data-action="close-dashboard-settings"
|
||
class="px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors"
|
||
style="border-color: var(--border); color: var(--text-primary);"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
data-action="save-dashboard-settings"
|
||
class="px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
|
||
style="background-color: var(--accent);"
|
||
>
|
||
Save Changes
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|