Bring the tighten-ui brand treatment into new-ui:
- Replace the emoji book (📚) in the sidebar header with the book-open
icon rendered in the theme accent color, matching the tighten-ui
header brand (templates/header.templ).
- Add web/static/favicon.svg (book-open glyph, tokyo-night accent
#7aa2f7 stroke) and reference it from the <head> of all 27 page
templates, so the favicon is present on login/setup/error pages too.
- Regenerate templ output for all affected templates.
422 lines
15 KiB
Templ
422 lines
15 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"/>
|
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
|
</head>
|
|
<body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }">
|
|
@Header(user, "/collections")
|
|
<div id="modal-container"></div>
|
|
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
|
<div class="mb-8 flex flex-wrap justify-between items-center gap-4">
|
|
<div class="flex items-center gap-3">
|
|
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
|
@Icon("folder", "h-5 w-5")
|
|
</span>
|
|
<div>
|
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">My Collections</h1>
|
|
<p class="text-sm" style="color: var(--text-secondary)">Organize your books into custom collections</p>
|
|
</div>
|
|
</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")
|
|
<span>Restore System</span>
|
|
</button>
|
|
<button
|
|
hx-get="/collections/create-modal"
|
|
hx-target="#modal-container"
|
|
hx-swap="innerHTML"
|
|
class="btn btn-primary"
|
|
>
|
|
@Icon("plus", "h-4 w-4")
|
|
<span>New Collection</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="collections-list" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
if len(collections) == 0 {
|
|
<div class="card text-center py-16 px-6 col-span-full">
|
|
<div class="grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
|
|
@Icon("folder", "h-7 w-7")
|
|
</div>
|
|
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Collections Yet</h3>
|
|
<p class="mb-5" style="color: var(--text-secondary)">Create collections to organize your books</p>
|
|
<button
|
|
hx-get="/collections/create-modal"
|
|
hx-target="#modal-container"
|
|
hx-swap="innerHTML"
|
|
class="btn btn-primary"
|
|
>
|
|
@Icon("plus", "h-4 w-4")
|
|
<span>Create Your First Collection</span>
|
|
</button>
|
|
</div>
|
|
}
|
|
for _, col := range collections {
|
|
<div @click="navigateToCollection($el)" data-href={ "/collections/" + col.ID } class="block">
|
|
<div
|
|
class="card p-6 rounded-2xl border-l-4 cursor-pointer"
|
|
data-color={ col.Color }
|
|
>
|
|
<div class="flex justify-between items-start mb-4">
|
|
<div class="text-3xl">{ 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"
|
|
aria-label="Edit collection"
|
|
>
|
|
@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"
|
|
aria-label="Delete collection"
|
|
>
|
|
@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" style="color: var(--text-secondary)">{ 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"/>
|
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
|
</head>
|
|
<body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }">
|
|
@Header(user, "/collections")
|
|
@LibrarySwitcher(libData, libraryID)
|
|
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
|
<div class="mb-6">
|
|
<a href="/collections" class="btn btn-secondary mb-4">
|
|
@Icon("arrow-left", "h-4 w-4")
|
|
<span>Back to Collections</span>
|
|
</a>
|
|
<div class="flex items-center gap-4">
|
|
<div class="grid place-items-center h-14 w-14 rounded-2xl text-3xl" style="background-color: var(--accent-muted); color: { collection.Color }">{ collection.Icon }</div>
|
|
<div>
|
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">{ collection.Name }</h1>
|
|
<p class="text-sm" style="color: var(--text-secondary)">{ collection.Description }</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mb-6 flex flex-wrap justify-between items-center gap-4">
|
|
<div class="flex items-center gap-3">
|
|
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">Books in this Collection</h2>
|
|
<span x-show="selectedBooks.length > 0" x-text="selectedBooks.length + ' selected'" class="badge" style="display: none; background-color: var(--accent); color: var(--bg-primary);"></span>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2 items-center">
|
|
<div class="flex-1 min-w-[200px] max-w-md">
|
|
<input
|
|
type="text"
|
|
id="collection-search"
|
|
placeholder="Search within collection..."
|
|
@input="filterCollectionBooks()"
|
|
class="input"
|
|
/>
|
|
</div>
|
|
if !collection.IsSystem {
|
|
<button
|
|
@click="requestBulkRemove()"
|
|
:disabled="selectedBooks.length === 0"
|
|
:class="selectedBooks.length === 0 ? 'opacity-50 cursor-not-allowed' : ''"
|
|
class="btn btn-danger"
|
|
>
|
|
@Icon("trash", "h-4 w-4")
|
|
<span>Remove Selected</span>
|
|
</button>
|
|
<button
|
|
@click="$store.bookPicker.open()"
|
|
class="btn btn-primary"
|
|
>
|
|
@Icon("plus", "h-4 w-4")
|
|
<span>Add Books</span>
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
if len(books) == 0 {
|
|
<div id="empty-state" class="col-span-full card text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
|
|
}
|
|
for _, book := range books {
|
|
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }>
|
|
<div class="flex gap-4">
|
|
if !collection.IsSystem {
|
|
<div class="flex-shrink-0 pt-1">
|
|
<label class="flex items-center cursor-pointer p-2 -m-2">
|
|
<input
|
|
type="checkbox"
|
|
class="w-5 h-5"
|
|
:checked="selectedBooks.includes('{ book.MediaItemID }')"
|
|
@change="toggleSelection('{ book.MediaItemID }')"
|
|
/>
|
|
</label>
|
|
</div>
|
|
}
|
|
<div class="flex-1 min-w-0">
|
|
<a href={ "/media/" + book.MediaItemID }>
|
|
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">
|
|
{ book.Title }
|
|
</h3>
|
|
</a>
|
|
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">
|
|
<a href={ "/media/" + book.MediaItemID }>
|
|
if book.CoverImagePath != "" {
|
|
<img
|
|
src={ book.CoverImagePath }
|
|
alt="Cover"
|
|
class="w-full aspect-[3/4] object-cover rounded-lg 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-lg shadow-md"
|
|
/>
|
|
}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
if !collection.IsSystem {
|
|
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
|
<button
|
|
@click="requestRemoveBook('{ book.MediaItemID }')"
|
|
class="btn btn-secondary w-full"
|
|
>
|
|
@Icon("trash", "h-4 w-4")
|
|
<span>Remove from Collection</span>
|
|
</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
if !collection.IsSystem {
|
|
<div
|
|
x-data="bookPicker"
|
|
x-show="$store.bookPicker.isOpen"
|
|
@keyup.escape.window="$store.bookPicker.close()"
|
|
@click.self="$store.bookPicker.close()"
|
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
style="display: none; background-color: var(--surface-overlay);"
|
|
>
|
|
<div
|
|
@click.stop
|
|
x-show="$store.bookPicker.isOpen"
|
|
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-2xl w-full max-w-6xl mx-4 my-8"
|
|
style="display: none; box-shadow: var(--shadow-pop);"
|
|
>
|
|
<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="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 id="book-picker-filters" class="flex flex-wrap gap-3 items-center">
|
|
<div class="flex-1 min-w-[200px]">
|
|
<input
|
|
type="text"
|
|
name="q"
|
|
placeholder="Search books..."
|
|
class="input"
|
|
hx-get="/api/media-items/search?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="input"
|
|
hx-get="/api/media-items/search?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="input"
|
|
hx-get="/api/media-items/search?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")
|
|
<span>Clear</span>
|
|
</button>
|
|
<input type="hidden" name="limit" value="50"/>
|
|
<input type="hidden" name="offset" value="0"/>
|
|
</div>
|
|
</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"
|
|
hx-get="/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }"
|
|
hx-trigger="loadBooks"
|
|
hx-include="#book-picker-filters"
|
|
>
|
|
</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 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>
|
|
}
|
|
<div
|
|
x-show="showConfirm"
|
|
@click.self="closeConfirm()"
|
|
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
|
style="display: none; background-color: var(--surface-overlay);"
|
|
>
|
|
<div
|
|
@click.stop
|
|
class="card p-6 w-full max-w-md mx-4"
|
|
style="box-shadow: var(--shadow-pop);"
|
|
>
|
|
<h3 class="text-lg font-bold mb-2" style="color: var(--text-primary)">Remove from Collection</h3>
|
|
<p x-text="confirmMessage" class="text-sm mb-6" style="color: var(--text-secondary)"></p>
|
|
<div class="flex justify-end gap-3">
|
|
<button @click="closeConfirm()" class="btn btn-secondary">Cancel</button>
|
|
<button @click="executeConfirmed()" class="btn btn-danger">
|
|
@Icon("trash", "h-4 w-4")
|
|
<span x-text="confirmLabel"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
<div
|
|
id="collection-data"
|
|
data-id={ collection.ID }
|
|
data-library-id={ libraryID }
|
|
data-is-system={ collection.IsSystem }
|
|
style="display: none;"
|
|
></div>
|
|
</html>
|
|
}
|
|
|
|
templ BookPickerGrid(books []handlers.BookInfo) {
|
|
for _, book := range books {
|
|
<div
|
|
class="relative cursor-pointer rounded-lg overflow-hidden"
|
|
@click={ "$store.bookPicker.toggleBook('" + book.MediaItemID + "')" }
|
|
>
|
|
<div class="relative">
|
|
if book.CoverImagePath != "" {
|
|
<img src={ book.CoverImagePath } alt={ book.Title } class="w-full aspect-[2/3] object-cover" loading="lazy" onerror="this.src='/static/placeholder-book.svg'"/>
|
|
} else {
|
|
<img src="/static/placeholder-book.svg" alt={ book.Title } class="w-full aspect-[2/3] object-cover" loading="lazy"/>
|
|
}
|
|
<div
|
|
x-show={ "$store.bookPicker.isSelected('" + book.MediaItemID + "')" }
|
|
class="absolute inset-0 border-4 rounded-lg pointer-events-none"
|
|
style="border-color: var(--accent); background-color: color-mix(in srgb, var(--accent) 20%, transparent);"
|
|
></div>
|
|
<div
|
|
x-show={ "$store.bookPicker.isSelected('" + book.MediaItemID + "')" }
|
|
class="absolute top-1 right-1 w-6 h-6 rounded-full flex items-center justify-center text-sm font-bold pointer-events-none"
|
|
style="background-color: var(--accent); color: var(--bg-primary);"
|
|
>
|
|
✓
|
|
</div>
|
|
</div>
|
|
<p class="text-xs mt-1 line-clamp-2" style="color: var(--text-primary)">{ book.Title }</p>
|
|
</div>
|
|
}
|
|
}
|