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:
+106
-251
@@ -14,82 +14,56 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||
</head>
|
||||
<body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }">
|
||||
@Header(user, "/collections")
|
||||
<!-- Modal Container -->
|
||||
<div id="modal-container"></div>
|
||||
<!-- Actual container page -->
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8 flex justify-between items-center">
|
||||
<div class="mb-8 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">My Collections</h1>
|
||||
<p style="color: var(--text-secondary)">Organize your books into custom collections</p>
|
||||
<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-3">
|
||||
<button
|
||||
hx-get="/collections/restore-modal"
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML"
|
||||
class="btn-secondary px-4 py-2 rounded-lg"
|
||||
>
|
||||
🔄 Restore System
|
||||
<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-primary px-4 py-2 rounded-lg"
|
||||
>
|
||||
➕ New Collection
|
||||
<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 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<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="text-center py-16 col-span-full" style="color: var(--text-secondary)">
|
||||
<div class="text-6xl mb-4">📚</div>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Collections Yet</h3>
|
||||
<p class="mb-4">Create collections to organize your books</p>
|
||||
<button
|
||||
hx-get="/collections/create-modal"
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML"
|
||||
class="btn-primary px-4 py-2 rounded-lg"
|
||||
>
|
||||
<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 p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow"
|
||||
style="background-color: var(--bg-secondary);"
|
||||
data-color={ col.Color }
|
||||
>
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="text-3xl">{ col.Icon }</div>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
hx-get={ "/collections/" + col.ID + "/edit-modal" }
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML"
|
||||
class="p-2 hover:opacity-80 rounded"
|
||||
style="color: var(--text-secondary); background-color: var(--bg-primary);"
|
||||
>
|
||||
✏️
|
||||
<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="p-2 hover:opacity-80 rounded"
|
||||
style="color: var(--text-secondary); background-color: var(--bg-primary);"
|
||||
>
|
||||
🗑️
|
||||
<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="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ col.Name }</h3>
|
||||
<p class="text-sm mb-4" style="color: var(--text-secondary)">{ col.Description }</p>
|
||||
<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>
|
||||
}
|
||||
@@ -115,243 +89,124 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
@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="btn-secondary px-4 py-2 rounded-lg mb-4 inline-block">
|
||||
← Back to Collections
|
||||
<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="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div>
|
||||
<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-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
|
||||
<p style="color: var(--text-secondary)">{ collection.Description }</p>
|
||||
<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 justify-between items-center">
|
||||
<div class="flex items-center gap-4">
|
||||
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Books in this Collection</h2>
|
||||
<span id="selected-count" class="hidden px-3 py-1 text-sm rounded" style="background-color: var(--accent); color: var(--bg-primary);">
|
||||
0 selected
|
||||
</span>
|
||||
<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 gap-3">
|
||||
<div class="flex-1 max-w-md">
|
||||
<input
|
||||
type="text"
|
||||
id="collection-search"
|
||||
placeholder="Search within collection..."
|
||||
onkeyup="filterCollectionBooks()"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
/>
|
||||
<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-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
🗑️ Remove Selected
|
||||
<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-primary px-4 py-2 rounded-lg"
|
||||
>
|
||||
➕ Add Books
|
||||
<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 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<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 text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
|
||||
<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 {
|
||||
<a href={ "/media/" + book.MediaItemID }>
|
||||
<div
|
||||
class="card p-4 rounded-lg border hover:shadow-lg transition-shadow"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-shrink-0 pt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
onchange="toggleBookForRemoval('{ book.MediaItemID }')"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3
|
||||
class="font-semibold text-lg mb-1 line-clamp-2"
|
||||
style="color: var(--text-primary)"
|
||||
>
|
||||
{ book.Title }
|
||||
</h3>
|
||||
if book.Author != "" {
|
||||
<p
|
||||
class="text-sm line-clamp-1"
|
||||
style="color: var(--text-secondary)"
|
||||
>
|
||||
by { book.Author }
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-shrink-0 w-16 sm:w-20">
|
||||
if book.CoverImagePath != "" {
|
||||
<img
|
||||
src={ book.CoverImagePath }
|
||||
alt="Cover"
|
||||
class="w-full aspect-[3/4] object-cover rounded shadow-md"
|
||||
onerror="this.src='/static/placeholder-book.svg'"
|
||||
/>
|
||||
} else {
|
||||
<img
|
||||
src="/static/placeholder-book.svg"
|
||||
alt="Cover"
|
||||
class="w-full aspect-[3/4] object-cover rounded shadow-md"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<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="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
||||
<button
|
||||
@click="removeBook('{ book.MediaItemID }')"
|
||||
class="px-3 py-1 text-sm border rounded hover:opacity-80"
|
||||
style="border-color: var(--border); color: var(--text-secondary);"
|
||||
>
|
||||
🗑️ Remove from Collection
|
||||
<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>
|
||||
</div>
|
||||
</a>
|
||||
</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;"
|
||||
>
|
||||
|
||||
<!-- 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 rounded-lg w-full max-w-6xl mx-4 my-8"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); display: none;"
|
||||
class="card my-8 w-full max-w-6xl overflow-hidden"
|
||||
style="display: none;"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between items-center p-6 border-b"
|
||||
style="border-color: var(--border);"
|
||||
>
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">
|
||||
Add Books to Collection
|
||||
</h2>
|
||||
<button
|
||||
@click="$store.bookPicker.close()"
|
||||
class="p-2 hover:opacity-80 rounded"
|
||||
style="color: var(--text-primary)"
|
||||
>✕</button>
|
||||
<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="p-4 border-b"
|
||||
style="border-color: var(--border);"
|
||||
>
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder="Search books..."
|
||||
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/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="flex-1 min-w-[150px]">
|
||||
<input
|
||||
type="text"
|
||||
name="author_filter"
|
||||
placeholder="Author"
|
||||
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/filtered?show_checkbox=true&collection_id={ collection.ID }"
|
||||
hx-target="#book-picker-grid"
|
||||
hx-trigger="change"
|
||||
hx-include="#book-picker-filters"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[150px]">
|
||||
<input
|
||||
type="text"
|
||||
name="genre_filter"
|
||||
placeholder="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);"
|
||||
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>
|
||||
<button
|
||||
@click="$store.bookPicker.clearFilters()"
|
||||
class="px-4 py-2 rounded-lg border"
|
||||
style="border-color: var(--border); color: var(--text-primary);"
|
||||
>
|
||||
✕ Clear
|
||||
</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 grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4 max-h-96 overflow-y-auto"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="p-4 border-t flex justify-between items-center"
|
||||
style="border-color: var(--border);"
|
||||
>
|
||||
<div class="text-sm" style="color: var(--text-secondary);">
|
||||
<span x-text="$store.bookPicker.selectedCount"></span> books selected
|
||||
</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="px-4 py-2 rounded-lg border"
|
||||
style="border-color: var(--border); color: var(--text-primary);"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="$store.bookPicker.submit()"
|
||||
class="px-4 py-2 rounded-lg font-medium"
|
||||
style="background-color: var(--accent); color: var(--bg-primary);"
|
||||
>
|
||||
Add Selected Books
|
||||
</button>
|
||||
<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>
|
||||
<div id="collection-data" data-id={ collection.ID } data-library-id={ libraryID } style="display: none;"></div>
|
||||
</html>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user