- 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
213 lines
10 KiB
Templ
213 lines
10 KiB
Templ
package templates
|
|
|
|
import "bookhoard/internal/handlers"
|
|
|
|
templ Collection(user User, collections []CollectionData, errorMessage string) {
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>Collections - Bookhoard</title>
|
|
<script src="/static/htmx.min.js"></script>
|
|
<link href="/static/style.css" rel="stylesheet"/>
|
|
</head>
|
|
<body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }">
|
|
@Header(user, "/collections")
|
|
<div id="modal-container"></div>
|
|
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
|
<div class="mb-8 flex flex-wrap items-end justify-between gap-4">
|
|
<div>
|
|
<div class="mb-1 flex items-center gap-2">
|
|
@Icon("folder", "h-6 w-6 text-brand")
|
|
<h1 class="text-2xl font-bold tracking-tight text-content">My Collections</h1>
|
|
</div>
|
|
<p class="text-sm text-content-muted">Organize your books into custom collections</p>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button hx-get="/collections/restore-modal" hx-target="#modal-container" hx-swap="innerHTML" class="btn btn-secondary">
|
|
@Icon("refresh", "h-4 w-4")
|
|
Restore System
|
|
</button>
|
|
<button hx-get="/collections/create-modal" hx-target="#modal-container" hx-swap="innerHTML" class="btn btn-primary">
|
|
@Icon("plus", "h-4 w-4")
|
|
New Collection
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="collections-list" class="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
|
|
if len(collections) == 0 {
|
|
<div class="col-span-full flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-20 text-center">
|
|
<div class="mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted">
|
|
@Icon("folder", "h-7 w-7")
|
|
</div>
|
|
<h3 class="text-lg font-semibold text-content">No Collections Yet</h3>
|
|
<p class="mb-4 mt-1 text-sm text-content-muted">Create collections to organize your books.</p>
|
|
<button hx-get="/collections/create-modal" hx-target="#modal-container" hx-swap="innerHTML" class="btn btn-primary">
|
|
Create Your First Collection
|
|
</button>
|
|
</div>
|
|
}
|
|
for _, col := range collections {
|
|
<div @click="navigateToCollection($el)" data-href={ "/collections/" + col.ID } class="block">
|
|
<div class="card cursor-pointer p-5" style={ "border-left: 4px solid " + col.Color } data-color={ col.Color }>
|
|
<div class="mb-3 flex items-start justify-between">
|
|
<div class="text-2xl">{ col.Icon }</div>
|
|
<div class="flex gap-1">
|
|
<button hx-get={ "/collections/" + col.ID + "/edit-modal" } hx-target="#modal-container" hx-swap="innerHTML" class="icon-btn h-8 w-8" title="Edit">
|
|
@Icon("edit", "h-4 w-4")
|
|
</button>
|
|
<button hx-delete={ "/api/collections/" + col.ID + "" } hx-redirect="/collections" hx-confirm="Are you sure you want to delete this collection?" class="icon-btn h-8 w-8 hover:text-danger" title="Delete">
|
|
@Icon("trash", "h-4 w-4")
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<h3 class="mb-1 text-base font-semibold text-content">{ col.Name }</h3>
|
|
<p class="text-sm text-content-muted">{ col.Description }</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
@ErrorToast(errorMessage)
|
|
</body>
|
|
</html>
|
|
}
|
|
|
|
templ CollectionDetail(user User, collection CollectionData, books []handlers.BookInfo, libraryID string, libData []LibraryData) {
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>{ collection.Name } - Bookhoard</title>
|
|
<script src="/static/htmx.min.js"></script>
|
|
<link href="/static/style.css" rel="stylesheet"/>
|
|
</head>
|
|
<body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }">
|
|
@Header(user, "/collections")
|
|
@LibrarySwitcher(libData, libraryID)
|
|
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
|
<div class="mb-6">
|
|
<a href="/collections" class="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-content-muted transition-colors hover:text-content">
|
|
@Icon("arrow-left", "h-4 w-4")
|
|
Back to Collections
|
|
</a>
|
|
<div class="flex items-center gap-4">
|
|
<div class="flex h-12 w-12 items-center justify-center rounded-xl text-2xl" style={ "background-color: " + collection.Color + "20; color: " + collection.Color }>{ collection.Icon }</div>
|
|
<div>
|
|
<h1 class="text-2xl font-bold tracking-tight text-content">{ collection.Name }</h1>
|
|
<p class="text-sm text-content-muted">{ collection.Description }</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
|
<div class="flex items-center gap-3">
|
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-content-muted">Books in this Collection</h2>
|
|
<span id="selected-count" class="hidden badge bg-brand/15 text-brand">0 selected</span>
|
|
</div>
|
|
<div class="flex flex-1 justify-end gap-2">
|
|
<div class="relative w-full max-w-xs">
|
|
<div class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-content-muted">
|
|
@Icon("search", "h-4 w-4")
|
|
</div>
|
|
<input type="text" id="collection-search" placeholder="Search within collection…" onkeyup="filterCollectionBooks()" class="input pl-9"/>
|
|
</div>
|
|
<button id="bulk-remove-btn" disabled class="btn btn-danger disabled:opacity-40 disabled:cursor-not-allowed">
|
|
@Icon("trash", "h-4 w-4")
|
|
Remove Selected
|
|
</button>
|
|
<button @click="$store.bookPicker.open()" class="btn btn-primary">
|
|
@Icon("plus", "h-4 w-4")
|
|
Add Books
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="books-container" class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
if len(books) == 0 {
|
|
<div id="empty-state" class="col-span-full rounded-2xl border border-dashed border-line py-16 text-center text-sm text-content-muted">No books in this collection yet.</div>
|
|
}
|
|
for _, book := range books {
|
|
<div class="card flex gap-4 p-4">
|
|
<div class="flex flex-shrink-0 items-center pt-1">
|
|
<input type="checkbox" onchange="toggleBookForRemoval('{ book.MediaItemID }')" class="h-5 w-5 rounded border-line"/>
|
|
</div>
|
|
<a href={ "/media/" + book.MediaItemID } class="flex min-w-0 flex-1 gap-3">
|
|
<div class="flex-shrink-0 w-12 sm:w-16">
|
|
if book.CoverImagePath != "" {
|
|
<img src={ book.CoverImagePath } alt="Cover" class="aspect-[3/4] w-full rounded object-cover shadow-card" onerror="this.src='/static/placeholder-book.svg'"/>
|
|
} else {
|
|
<img src="/static/placeholder-book.svg" alt="Cover" class="aspect-[3/4] w-full rounded object-cover shadow-card"/>
|
|
}
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<h3 class="line-clamp-2 text-sm font-semibold text-content">{ book.Title }</h3>
|
|
if book.Author != "" {
|
|
<p class="mt-0.5 line-clamp-1 text-xs text-content-muted">by { book.Author }</p>
|
|
}
|
|
<button @click={ "removeBook('" + book.MediaItemID + "')" } class="mt-2 inline-flex items-center gap-1 text-xs text-content-muted transition-colors hover:text-danger">
|
|
@Icon("trash", "h-3 w-3")
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Book picker modal -->
|
|
<div x-data="bookPicker" @keyup.escape.window="$store.bookPicker.close()" class="fixed inset-0 z-50 flex items-center justify-center" style="display: none;">
|
|
<div
|
|
@click.stop
|
|
x-show="$store.bookPicker.isOpen"
|
|
x-cloak
|
|
x-transition:enter="transition ease-out duration-200"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100"
|
|
x-transition:leave="transition ease-in duration-150"
|
|
x-transition:leave-start="opacity-100 scale-100"
|
|
x-transition:leave-end="opacity-0 scale-95"
|
|
class="card my-8 w-full max-w-6xl overflow-hidden"
|
|
style="display: none;"
|
|
>
|
|
<div class="flex items-center justify-between border-b border-line p-5">
|
|
<h2 class="text-lg font-bold text-content">Add Books to Collection</h2>
|
|
<button @click="$store.bookPicker.close()" class="icon-btn" aria-label="Close">
|
|
@Icon("close", "h-5 w-5")
|
|
</button>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-3 border-b border-line p-4">
|
|
<div class="min-w-[12rem] flex-1">
|
|
<input type="text" name="search" placeholder="Search books…" class="input" hx-get={ "/api/media-items/filtered?show_checkbox=true&collection_id=" + collection.ID } hx-target="#book-picker-grid" hx-trigger="keyup changed delay:300ms" hx-include="#book-picker-filters"/>
|
|
</div>
|
|
<div class="min-w-[10rem] flex-1">
|
|
<input type="text" name="author_filter" placeholder="Author" class="input" hx-get={ "/api/media-items/filtered?show_checkbox=true&collection_id=" + collection.ID } hx-target="#book-picker-grid" hx-trigger="change" hx-include="#book-picker-filters"/>
|
|
</div>
|
|
<div class="min-w-[10rem] flex-1">
|
|
<input type="text" name="genre_filter" placeholder="Genre" class="input" hx-get={ "/api/media-items/filtered?show_checkbox=true&collection_id=" + collection.ID } hx-target="#book-picker-grid" hx-trigger="change" hx-include="#book-picker-filters"/>
|
|
</div>
|
|
<button @click="$store.bookPicker.clearFilters()" class="btn btn-ghost">
|
|
@Icon("close", "h-4 w-4")
|
|
Clear
|
|
</button>
|
|
<form id="filter-form" class="hidden">
|
|
<input type="hidden" name="limit" value="50"/>
|
|
<input type="hidden" name="offset" value="0"/>
|
|
</form>
|
|
</div>
|
|
<div id="book-picker-grid" class="grid max-h-96 grid-cols-2 gap-4 overflow-y-auto p-4 md:grid-cols-4 lg:grid-cols-6"></div>
|
|
<div class="flex items-center justify-between border-t border-line p-4">
|
|
<div class="text-sm text-content-muted"><span x-text="$store.bookPicker.selectedCount"></span> books selected</div>
|
|
<div class="flex gap-2">
|
|
<button @click="$store.bookPicker.close()" class="btn btn-secondary">Cancel</button>
|
|
<button @click="$store.bookPicker.submit()" class="btn btn-primary">Add Selected Books</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
<div id="collection-data" data-id={ collection.ID } data-library-id={ libraryID } style="display: none;"></div>
|
|
</html>
|
|
}
|