script-src now allow-lists the guard by sha256 of its rendered bytes (middleware.ScriptHashThemeFlashGuard) instead of 'unsafe-inline' — the guard is the app's only inline <script>. security_headers_test.go renders templates.ThemeFlashGuard() and fails with the replacement hash if the script text ever drifts. Executing anti-XSS strict CSP surfaced three latent violations the eval-removal sweep couldn't see (hash mode is stricter than eval mode, because inline event handler ATTRIBUTES now count against script-src): 1. admin_archived / admin_library: 15 native onclick=/onsubmit= handlers (modal open/close, folder-browser picker, purge button) converted to Alpine @click/@submit component methods — openCreateLibraryModal, hideModal, pickBrowsePath added to the admin component; purgeArchivedItems promoted from its window global into the component and the (window as any) exposure removed. 2. dashboard/docs: onclick handlers converted — dashboard's event.stopPropagation() → @click.stop; docs' sidebar/search closers → closeDocsSidebar/closeSearchResults component methods. 3. 8 onerror= image-fallback attributes (cover placeholders app-wide + audiobook cover hide) replaced by data-fallback attributes handled by one delegated capture-phase listener in web/src/image_fallback.ts — works for htmx-swapped content without re-binding. Verified: theme guard applies a saved theme on public pages under the hash CSP; 14-page authenticated sweep clean; admin pages clean under the promoted admin; admin-library modal open/close bindings exercised; readers render error-free; go build/vet/middleware-test, tsc, vp check/test (146), build:ts all green.
106 lines
4.0 KiB
Templ
106 lines
4.0 KiB
Templ
package templates
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
)
|
|
|
|
templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, currentLibraryID string, totalPages int, currentPage 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>Series - Bookhoard</title>
|
|
<link href="/static/style.css" rel="stylesheet"/>
|
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
|
</head>
|
|
<body x-data="seriesPage" x-init="initSeriesPage()" class={ "theme-" + user.Theme }>
|
|
@Header(user, "/series")
|
|
@LibrarySwitcher(libData, currentLibraryID, "")
|
|
<div id="series-container">
|
|
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
|
<div class="mb-6 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("layers", "h-5 w-5")
|
|
</span>
|
|
<div>
|
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Series</h1>
|
|
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Grouped by series metadata</p>
|
|
</div>
|
|
</div>
|
|
if len(seriesList) == 0 {
|
|
<div id="series-empty" class="card text-center py-16 px-6">
|
|
<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("layers", "h-7 w-7")
|
|
</div>
|
|
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Series Found</h3>
|
|
<p style="color: var(--text-secondary)">Books with series metadata will appear here</p>
|
|
</div>
|
|
}
|
|
<div id="series-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
|
for _, series := range seriesList {
|
|
@SeriesCard(series)
|
|
}
|
|
</div>
|
|
if totalPages > 1 {
|
|
<div id="series-pagination" class="flex justify-center items-center gap-3 mt-10">
|
|
if currentPage > 1 {
|
|
<a
|
|
href={ fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage-1) }
|
|
class="btn btn-secondary"
|
|
>
|
|
@Icon("chevron-left", "h-4 w-4")
|
|
<span>Previous</span>
|
|
</a>
|
|
}
|
|
<span class="text-sm" style="color: var(--text-secondary)">
|
|
Page { fmt.Sprintf("%d", currentPage) } of { fmt.Sprintf("%d", totalPages) }
|
|
</span>
|
|
if currentPage < totalPages {
|
|
<a
|
|
href={ fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage+1) }
|
|
class="btn btn-secondary"
|
|
>
|
|
<span>Next</span>
|
|
@Icon("chevron-right", "h-4 w-4")
|
|
</a>
|
|
}
|
|
</div>
|
|
}
|
|
</main>
|
|
</div>
|
|
@ErrorToast(errorMessage)
|
|
</body>
|
|
</html>
|
|
}
|
|
|
|
templ SeriesCard(series SeriesCardData) {
|
|
<a href={ "/series/detail?name=" + url.QueryEscape(series.Name) + "&library_id=" + url.QueryEscape(series.LibraryID) } class="block">
|
|
<div class="series-card card rounded-2xl overflow-hidden cursor-pointer" style="box-shadow: var(--shadow-card);">
|
|
<div class={ "stacked-covers cover-count-" + fmt.Sprintf("%d", len(series.CoverPaths)) }>
|
|
for i, cover := range series.CoverPaths {
|
|
<img
|
|
src={ cover }
|
|
class={ "cover-img cover-" + fmt.Sprintf("%d", i) }
|
|
alt={ series.Name }
|
|
loading="lazy"
|
|
|
|
/>
|
|
}
|
|
if len(series.CoverPaths) == 0 {
|
|
<div class="cover-img cover-0 flex items-center justify-center" style="background: var(--bg-primary); color: var(--text-secondary);">
|
|
@Icon("book", "h-8 w-8 opacity-60")
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="series-card-info px-3 py-2">
|
|
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">{ series.Name }</h3>
|
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">
|
|
{ fmt.Sprintf("%d", series.BookCount) } of { fmt.Sprintf("%d", series.TotalInSeries) } books
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
}
|