CSP: replace 'unsafe-inline' with a content hash for the theme flash guard

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.
This commit is contained in:
John O'Keefe committed 2026-10-25 20:54:09 -04:00
1 parent 6a598786e9
commit fb95b6d400
26 files changed
+180 -54

No files matched your search

+19 -3
View File
@@ -4,6 +4,11 @@ import (
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
) )
// ScriptHashThemeFlashGuard is the CSP script-src hash of the theme flash
// guard's inline script (the app's only inline script). Enforced against
// the rendered component by security_headers_test.go.
const ScriptHashThemeFlashGuard = "'sha256-JYIVpFyKdLUtWizMha0v69f6yHds+GbJRBWjvpuECBY='"
// SecurityHeadersMiddleware adds security headers to all responses // SecurityHeadersMiddleware adds security headers to all responses
func SecurityHeadersMiddleware() echo.MiddlewareFunc { func SecurityHeadersMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
@@ -14,12 +19,23 @@ func SecurityHeadersMiddleware() echo.MiddlewareFunc {
c.Response().Header().Set("X-XSS-Protection", "1; mode=block") c.Response().Header().Set("X-XSS-Protection", "1; mode=block")
c.Response().Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload") c.Response().Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
// script-src notes: 'unsafe-inline' is required by the pre-paint // script-src notes: 'unsafe-inline' is required by the pre-paint
// theme flash guard (inline <script>). Alpine runs via its // CSP-safe Alpine build (@alpinejs/csp) needs no eval — do NOT
// CSP-safe build (@alpinejs/csp), which needs no eval — do NOT
// add 'unsafe-eval' here without removing that build. // add 'unsafe-eval' here without removing that build.
//
// The inline script allow-list: the theme flash guard is the
// ONLY inline script in the app, and its byte-stable content is
// hashed into script-src (sha256 of the bytes between
// <script> and </script> as rendered by
// templates.ThemeFlashGuard()). Editing the guard requires
// recomputing the hash — enforced by
// internal/middleware/security_headers_test.go, which renders
// the component and asserts the hash matches.
//
// style-src keeps 'unsafe-inline' for htmx-injected styles and
// Alpine :style attribute writes.
// font/img blob: — the reader renders EPUB-embedded fonts and // font/img blob: — the reader renders EPUB-embedded fonts and
// images from blob: URLs inside the foliate content frame. // images from blob: URLs inside the foliate content frame.
c.Response().Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data: blob:; frame-src 'self' blob:") c.Response().Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' "+ScriptHashThemeFlashGuard+"; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data: blob:; frame-src 'self' blob:")
c.Response().Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") c.Response().Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
c.Response().Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") c.Response().Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
@@ -0,0 +1,51 @@
package middleware_test
import (
"context"
"crypto/sha256"
"encoding/base64"
"regexp"
"strings"
"testing"
"bookhoard/internal/middleware"
"bookhoard/templates"
)
// TestThemeFlashGuardHashMatchesCSP pins the CSP script hash to the theme
// flash guard's rendered bytes: the guard is the app's ONLY inline script,
// and script-src allow-lists it by content hash. If the guard's script text
// is ever edited, this test fails and prints the replacement hash — copy it
// into middleware.ScriptHashThemeFlashGuard.
func TestThemeFlashGuardHashMatchesCSP(t *testing.T) {
var sb strings.Builder
if err := templates.ThemeFlashGuard().Render(context.Background(), &sb); err != nil {
t.Fatalf("render ThemeFlashGuard: %v", err)
}
rendered := sb.String()
m := regexp.MustCompile(`(?s)<script>(.*?)</script>`).FindStringSubmatch(rendered)
if m == nil {
t.Fatalf("rendered guard contains no <script> element:\n%s", rendered)
}
digest := sha256.Sum256([]byte(m[1]))
computed := "sha256-" + base64.StdEncoding.EncodeToString(digest[:])
if !strings.Contains(middleware.ScriptHashThemeFlashGuard, computed) {
t.Errorf(`
theme flash guard script hash drifted.
CSP constant contains: %s
rendered script hashes to: %s
Recompute: sha256 over the bytes between <script> and </script> of
templates/theme_flash_guard.templ, base64-encoded, then update
middleware.ScriptHashThemeFlashGuard and the CSP header string.`,
middleware.ScriptHashThemeFlashGuard, computed)
}
// The strict allow-list only holds the guard: no 'unsafe-inline' may
// sneak back into script-src (style-src keeps it for htmx/Alpine).
if strings.Contains(middleware.ScriptHashThemeFlashGuard, "unsafe-inline") {
t.Error("script-src must not re-introduce 'unsafe-inline' alongside the hash")
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ templ AdminArchived(user User, items []ArchivedItem, retentionDays int) {
</p> </p>
</div> </div>
if len(items) > 0 { if len(items) > 0 {
<button type="button" onclick="purgeArchivedItems()" class="btn btn-secondary"> <button type="button" @click="purgeArchivedItems()" class="btn btn-secondary">
@Icon("trash", "h-4 w-4") @Icon("trash", "h-4 w-4")
Purge All Archived Purge All Archived
</button> </button>
+1 -1
View File
@@ -100,7 +100,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(items) > 0 { if len(items) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<button type=\"button\" onclick=\"purgeArchivedItems()\" class=\"btn btn-secondary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<button type=\"button\" @click=\"purgeArchivedItems()\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+13 -13
View File
@@ -29,7 +29,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
<p class="text-sm" style="color: var(--text-secondary)">Manage media libraries, folders, and scanning</p> <p class="text-sm" style="color: var(--text-secondary)">Manage media libraries, folders, and scanning</p>
</div> </div>
<button <button
onclick="document.getElementById('create-library-modal').classList.remove('hidden')" @click="openCreateLibraryModal()"
class="btn btn-primary" class="btn btn-primary"
> >
@Icon("plus", "h-4 w-4") @Icon("plus", "h-4 w-4")
@@ -52,7 +52,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a href="/admin/archived" class="btn btn-secondary">View Archived</a> <a href="/admin/archived" class="btn btn-secondary">View Archived</a>
<button type="button" onclick="purgeArchivedItems()" class="btn btn-secondary"> <button type="button" @click="purgeArchivedItems()" class="btn btn-secondary">
@Icon("trash", "h-4 w-4") @Icon("trash", "h-4 w-4")
Purge Archived Now Purge Archived Now
</button> </button>
@@ -69,7 +69,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);"> <div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Library</h2> <h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Library</h2>
<button type="button" onclick="document.getElementById('create-library-modal').classList.add('hidden')" class="icon-btn" aria-label="Close"> <button type="button" @click="hideModal('create-library-modal')" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5") @Icon("close", "h-5 w-5")
</button> </button>
</div> </div>
@@ -77,7 +77,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
hx-post="/admin/library/create" hx-post="/admin/library/create"
hx-target="#libraries-container" hx-target="#libraries-container"
hx-swap="innerHTML" hx-swap="innerHTML"
onsubmit="document.getElementById('create-library-modal').classList.add('hidden')" @submit="hideModal('create-library-modal')"
> >
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Name</label> <label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Name</label>
@@ -94,7 +94,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
</select> </select>
</div> </div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" onclick="document.getElementById('create-library-modal').classList.add('hidden')" class="btn btn-secondary">Cancel</button> <button type="button" @click="hideModal('create-library-modal')" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
@Icon("plus", "h-4 w-4") @Icon("plus", "h-4 w-4")
Create Create
@@ -108,11 +108,11 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);"> <div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Edit Library</h2> <h2 class="text-xl font-bold" style="color: var(--text-primary)">Edit Library</h2>
<button type="button" onclick="document.getElementById('edit-library-modal').classList.add('hidden')" class="icon-btn" aria-label="Close"> <button type="button" @click="hideModal('edit-library-modal')" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5") @Icon("close", "h-5 w-5")
</button> </button>
</div> </div>
<form id="edit-library-form" hx-target="#libraries-container" hx-swap="innerHTML" onsubmit="document.getElementById('edit-library-modal').classList.add('hidden')"> <form id="edit-library-form" hx-target="#libraries-container" hx-swap="innerHTML" @submit="hideModal('edit-library-modal')">
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Name</label> <label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Name</label>
<input type="text" name="name" id="edit-library-name" class="input" required/> <input type="text" name="name" id="edit-library-name" class="input" required/>
@@ -122,7 +122,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
<textarea name="description" id="edit-library-desc" rows="3" class="input"></textarea> <textarea name="description" id="edit-library-desc" rows="3" class="input"></textarea>
</div> </div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" onclick="document.getElementById('edit-library-modal').classList.add('hidden')" class="btn btn-secondary">Cancel</button> <button type="button" @click="hideModal('edit-library-modal')" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
@Icon("save", "h-4 w-4") @Icon("save", "h-4 w-4")
Save Save
@@ -136,7 +136,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
<div class="card p-6 w-full max-w-lg mx-4" style="box-shadow: var(--shadow-pop);"> <div class="card p-6 w-full max-w-lg mx-4" style="box-shadow: var(--shadow-pop);">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Browse Folders</h2> <h2 class="text-xl font-bold" style="color: var(--text-primary)">Browse Folders</h2>
<button type="button" onclick="document.getElementById('folder-browser-modal').classList.add('hidden')" class="icon-btn" aria-label="Close"> <button type="button" @click="hideModal('folder-browser-modal')" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5") @Icon("close", "h-5 w-5")
</button> </button>
</div> </div>
@@ -148,7 +148,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
<div class="card p-6 w-full max-w-md mx-4" style="box-shadow: var(--shadow-pop);"> <div class="card p-6 w-full max-w-md mx-4" style="box-shadow: var(--shadow-pop);">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Delete Library</h2> <h2 class="text-xl font-bold" style="color: var(--text-primary)">Delete Library</h2>
<button type="button" onclick="document.getElementById('delete-library-modal').classList.add('hidden')" class="icon-btn" aria-label="Close"> <button type="button" @click="hideModal('delete-library-modal')" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5") @Icon("close", "h-5 w-5")
</button> </button>
</div> </div>
@@ -165,7 +165,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
</li> </li>
</ul> </ul>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" onclick="document.getElementById('delete-library-modal').classList.add('hidden')" class="btn btn-secondary">Cancel</button> <button type="button" @click="hideModal('delete-library-modal')" class="btn btn-secondary">Cancel</button>
<button type="button" id="delete-library-confirm" class="btn btn-danger"> <button type="button" id="delete-library-confirm" class="btn btn-danger">
@Icon("trash", "h-4 w-4") @Icon("trash", "h-4 w-4")
Delete Delete
@@ -195,7 +195,7 @@ templ LibraryList(user User, libraries []LibraryData, users []User) {
</span> </span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Libraries Yet</h3> <h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Libraries Yet</h3>
<p class="mb-4 text-sm" style="color: var(--text-secondary)">Create your first library to get started</p> <p class="mb-4 text-sm" style="color: var(--text-secondary)">Create your first library to get started</p>
<button onclick="document.getElementById('create-library-modal').classList.remove('hidden')" class="btn btn-primary">Create Your First Library</button> <button @click="openCreateLibraryModal()" class="btn btn-primary">Create Your First Library</button>
</div> </div>
} else { } else {
<div class="space-y-4"> <div class="space-y-4">
@@ -411,7 +411,7 @@ templ FolderBrowserContent(currentPath string, parentPath string, entries []DirE
type="button" type="button"
data-target-input={ targetInput } data-target-input={ targetInput }
data-current-path={ currentPath } data-current-path={ currentPath }
onclick="document.getElementById(this.dataset.targetInput).value = this.dataset.currentPath; document.getElementById('folder-browser-modal').classList.add('hidden')" @click="pickBrowsePath($event)"
class="btn btn-primary text-sm" class="btn btn-primary text-sm"
> >
@Icon("check", "h-4 w-4") @Icon("check", "h-4 w-4")
+12 -12
View File
@@ -69,7 +69,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Libraries</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Manage media libraries, folders, and scanning</p></div><button onclick=\"document.getElementById('create-library-modal').classList.remove('hidden')\" class=\"btn btn-primary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Libraries</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Manage media libraries, folders, and scanning</p></div><button @click=\"openCreateLibraryModal()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -95,7 +95,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</a></p><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Files missing from disk for two consecutive scans. Reading history is kept unless purged; items return automatically if their files come back.</p></div><div class=\"flex items-center gap-2\"><a href=\"/admin/archived\" class=\"btn btn-secondary\">View Archived</a> <button type=\"button\" onclick=\"purgeArchivedItems()\" class=\"btn btn-secondary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</a></p><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Files missing from disk for two consecutive scans. Reading history is kept unless purged; items return automatically if their files come back.</p></div><div class=\"flex items-center gap-2\"><a href=\"/admin/archived\" class=\"btn btn-secondary\">View Archived</a> <button type=\"button\" @click=\"purgeArchivedItems()\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -116,7 +116,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div></div></main><!-- Create / Edit Library Modal --><div id=\"create-library-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Create Library</h2><button type=\"button\" onclick=\"document.getElementById('create-library-modal').classList.add('hidden')\" class=\"icon-btn\" aria-label=\"Close\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div></div></main><!-- Create / Edit Library Modal --><div id=\"create-library-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Create Library</h2><button type=\"button\" @click=\"hideModal('create-library-modal')\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -124,7 +124,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</button></div><form hx-post=\"/admin/library/create\" hx-target=\"#libraries-container\" hx-swap=\"innerHTML\" onsubmit=\"document.getElementById('create-library-modal').classList.add('hidden')\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Library Name</label> <input type=\"text\" name=\"name\" placeholder=\"My Ebook Library\" class=\"input\" required></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" placeholder=\"Optional description\" rows=\"3\" class=\"input\"></textarea></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Library Type</label> <select name=\"type\" class=\"input\" required>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</button></div><form hx-post=\"/admin/library/create\" hx-target=\"#libraries-container\" hx-swap=\"innerHTML\" @submit=\"hideModal('create-library-modal')\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Library Name</label> <input type=\"text\" name=\"name\" placeholder=\"My Ebook Library\" class=\"input\" required></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" placeholder=\"Optional description\" rows=\"3\" class=\"input\"></textarea></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Library Type</label> <select name=\"type\" class=\"input\" required>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -132,7 +132,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</select></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" onclick=\"document.getElementById('create-library-modal').classList.add('hidden')\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</select></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"hideModal('create-library-modal')\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -140,7 +140,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Create</button></div></form></div></div><!-- Edit Library Modal --><div id=\"edit-library-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Edit Library</h2><button type=\"button\" onclick=\"document.getElementById('edit-library-modal').classList.add('hidden')\" class=\"icon-btn\" aria-label=\"Close\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Create</button></div></form></div></div><!-- Edit Library Modal --><div id=\"edit-library-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Edit Library</h2><button type=\"button\" @click=\"hideModal('edit-library-modal')\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -148,7 +148,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</button></div><form id=\"edit-library-form\" hx-target=\"#libraries-container\" hx-swap=\"innerHTML\" onsubmit=\"document.getElementById('edit-library-modal').classList.add('hidden')\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Library Name</label> <input type=\"text\" name=\"name\" id=\"edit-library-name\" class=\"input\" required></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" id=\"edit-library-desc\" rows=\"3\" class=\"input\"></textarea></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" onclick=\"document.getElementById('edit-library-modal').classList.add('hidden')\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</button></div><form id=\"edit-library-form\" hx-target=\"#libraries-container\" hx-swap=\"innerHTML\" @submit=\"hideModal('edit-library-modal')\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Library Name</label> <input type=\"text\" name=\"name\" id=\"edit-library-name\" class=\"input\" required></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" id=\"edit-library-desc\" rows=\"3\" class=\"input\"></textarea></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"hideModal('edit-library-modal')\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -156,7 +156,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "Save</button></div></form></div></div><!-- Folder Browser Modal --><div id=\"folder-browser-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-lg mx-4\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Browse Folders</h2><button type=\"button\" onclick=\"document.getElementById('folder-browser-modal').classList.add('hidden')\" class=\"icon-btn\" aria-label=\"Close\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "Save</button></div></form></div></div><!-- Folder Browser Modal --><div id=\"folder-browser-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-lg mx-4\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Browse Folders</h2><button type=\"button\" @click=\"hideModal('folder-browser-modal')\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -164,7 +164,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</button></div><div id=\"folder-browser-content\"></div></div></div><!-- Delete Library Modal --><div id=\"delete-library-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Delete Library</h2><button type=\"button\" onclick=\"document.getElementById('delete-library-modal').classList.add('hidden')\" class=\"icon-btn\" aria-label=\"Close\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</button></div><div id=\"folder-browser-content\"></div></div></div><!-- Delete Library Modal --><div id=\"delete-library-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Delete Library</h2><button type=\"button\" @click=\"hideModal('delete-library-modal')\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -172,7 +172,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</button></div><p class=\"mb-3\" style=\"color: var(--text-primary)\">Are you sure you want to delete <strong id=\"delete-library-name\"></strong>?</p><ul class=\"mb-6 space-y-2 text-sm list-disc list-inside\" style=\"color: var(--text-primary)\"><li><strong>Permanently removed, no archive or undo:</strong> the library, its folder mappings, and all book records — with their reading progress, highlights, notes, bookmarks, and ratings.</li><li><strong>Not touched:</strong> media files on disk.</li></ul><div class=\"flex justify-end space-x-3\"><button type=\"button\" onclick=\"document.getElementById('delete-library-modal').classList.add('hidden')\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" id=\"delete-library-confirm\" class=\"btn btn-danger\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</button></div><p class=\"mb-3\" style=\"color: var(--text-primary)\">Are you sure you want to delete <strong id=\"delete-library-name\"></strong>?</p><ul class=\"mb-6 space-y-2 text-sm list-disc list-inside\" style=\"color: var(--text-primary)\"><li><strong>Permanently removed, no archive or undo:</strong> the library, its folder mappings, and all book records — with their reading progress, highlights, notes, bookmarks, and ratings.</li><li><strong>Not touched:</strong> media files on disk.</li></ul><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"hideModal('delete-library-modal')\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" id=\"delete-library-confirm\" class=\"btn btn-danger\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -278,7 +278,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Libraries Yet</h3><p class=\"mb-4 text-sm\" style=\"color: var(--text-secondary)\">Create your first library to get started</p><button onclick=\"document.getElementById('create-library-modal').classList.remove('hidden')\" class=\"btn btn-primary\">Create Your First Library</button></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Libraries Yet</h3><p class=\"mb-4 text-sm\" style=\"color: var(--text-secondary)\">Create your first library to get started</p><button @click=\"openCreateLibraryModal()\" class=\"btn btn-primary\">Create Your First Library</button></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1038,7 +1038,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" onclick=\"document.getElementById(this.dataset.targetInput).value = this.dataset.currentPath; document.getElementById('folder-browser-modal').classList.add('hidden')\" class=\"btn btn-primary text-sm\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" @click=\"pickBrowsePath($event)\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -184,7 +184,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
src={ metadata.CoverImagePath } src={ metadata.CoverImagePath }
alt={ metadata.Title } alt={ metadata.Title }
class="w-full h-full object-cover" class="w-full h-full object-cover"
onerror="this.style.display='none'" data-fallback="hide"
/> />
} else { } else {
<span class="text-5xl flex items-center justify-center h-full" style="color: var(--text-secondary);">🎧</span> <span class="text-5xl flex items-center justify-center h-full" style="color: var(--text-secondary);">🎧</span>
+1 -1
View File
@@ -220,7 +220,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"w-full h-full object-cover\" onerror=\"this.style.display='none'\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"w-full h-full object-cover\" data-fallback=\"hide\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -88,7 +88,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
alt={ book.Title } alt={ book.Title }
class="w-48 md:w-56 h-72 md:h-80 object-cover rounded-2xl" class="w-48 md:w-56 h-72 md:h-80 object-cover rounded-2xl"
style="box-shadow: var(--shadow-pop);" style="box-shadow: var(--shadow-pop);"
onerror="this.src='/static/placeholder-book.svg'"
/> />
} else { } else {
<img <img
+1 -1
View File
@@ -244,7 +244,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"w-48 md:w-56 h-72 md:h-80 object-cover rounded-2xl\" style=\"box-shadow: var(--shadow-pop);\" onerror=\"this.src='/static/placeholder-book.svg'\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"w-48 md:w-56 h-72 md:h-80 object-cover rounded-2xl\" style=\"box-shadow: var(--shadow-pop);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+2 -2
View File
@@ -63,7 +63,7 @@ templ CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo)
src={ book.CoverImagePath } src={ book.CoverImagePath }
alt="Cover" alt="Cover"
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md" class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
onerror="this.src='/static/placeholder-book.svg'"
/> />
} else { } else {
<img <img
@@ -452,7 +452,7 @@ templ BookPickerGrid(books []handlers.BookInfo) {
> >
<div class="relative"> <div class="relative">
if book.CoverImagePath != "" { 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'"/> <img src={ book.CoverImagePath } alt={ book.Title } class="w-full aspect-[2/3] object-cover" loading="lazy" />
} else { } else {
<img src="/static/placeholder-book.svg" alt={ book.Title } class="w-full aspect-[2/3] object-cover" loading="lazy"/> <img src="/static/placeholder-book.svg" alt={ book.Title } class="w-full aspect-[2/3] object-cover" loading="lazy"/>
} }
+2 -2
View File
@@ -236,7 +236,7 @@ func CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo) t
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded-lg shadow-md\" onerror=\"this.src='/static/placeholder-book.svg'\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded-lg shadow-md\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -970,7 +970,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\" class=\"w-full aspect-[2/3] object-cover\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\" class=\"w-full aspect-[2/3] object-cover\" loading=\"lazy\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+2 -2
View File
@@ -139,7 +139,7 @@ templ BookCard(item handlers.BookInfo) {
alt={ item.Title } alt={ item.Title }
class="w-full h-full object-cover" class="w-full h-full object-cover"
loading="lazy" loading="lazy"
onerror="this.src='/static/placeholder-book.svg'"
/> />
} else { } else {
<img <img
@@ -347,7 +347,7 @@ templ DashboardWeekWidget(week WeekStatsData, activeLibType string) {
<span class="text-sm font-medium" style="color: var(--accent)">🔥 { fmt.Sprintf("%d", week.CurrentStreak) }-day streak</span> <span class="text-sm font-medium" style="color: var(--accent)">🔥 { fmt.Sprintf("%d", week.CurrentStreak) }-day streak</span>
} }
<span class="ml-auto flex items-center gap-3"> <span class="ml-auto flex items-center gap-3">
<a href="/stats" class="text-xs font-medium hover:underline" style="color: var(--accent);" onclick="event.stopPropagation()">Full stats</a> <a href="/stats" class="text-xs font-medium hover:underline" style="color: var(--accent);" @click.stop>Full stats</a>
@Icon("chevron-down", "h-4 w-4 transition-transform group-open:rotate-180") @Icon("chevron-down", "h-4 w-4 transition-transform group-open:rotate-180")
</span> </span>
</summary> </summary>
+2 -2
View File
@@ -456,7 +456,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" class=\"w-full h-full object-cover\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" class=\"w-full h-full object-cover\" loading=\"lazy\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1027,7 +1027,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<span class=\"ml-auto flex items-center gap-3\"><a href=\"/stats\" class=\"text-xs font-medium hover:underline\" style=\"color: var(--accent);\" onclick=\"event.stopPropagation()\">Full stats</a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<span class=\"ml-auto flex items-center gap-3\"><a href=\"/stats\" class=\"text-xs font-medium hover:underline\" style=\"color: var(--accent);\" @click.stop>Full stats</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+2 -2
View File
@@ -26,7 +26,7 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
<body x-data="docs" class={ "theme-" + user.Theme + " page-docs font-sans antialiased" }> <body x-data="docs" class={ "theme-" + user.Theme + " page-docs font-sans antialiased" }>
@DocsTopbar(user) @DocsTopbar(user)
<!-- Mobile drawer backdrop --> <!-- Mobile drawer backdrop -->
<div id="docs-overlay" class="hidden fixed inset-0 z-40 lg:hidden" style="background: var(--surface-overlay);" onclick="document.getElementById('docs-sidebar').classList.add('-translate-x-full'); this.classList.add('hidden')"></div> <div id="docs-overlay" class="hidden fixed inset-0 z-40 lg:hidden" style="background: var(--surface-overlay);" @click="closeDocsSidebar($event)"></div>
<div class="flex"> <div class="flex">
@DocsSidebar(nav, currentPath) @DocsSidebar(nav, currentPath)
<main class="flex-1 min-w-0"> <main class="flex-1 min-w-0">
@@ -243,7 +243,7 @@ templ DocsSearchResults(results []SearchResultItem) {
<ul class="py-1 list-none m-0 p-0"> <ul class="py-1 list-none m-0 p-0">
for _, r := range results { for _, r := range results {
<li> <li>
<a href={ r.URL } class="block px-4 py-2 no-underline transition-colors hover:bg-surface-hover" onclick="this.closest('#docs-search-results').classList.add('hidden')"> <a href={ r.URL } class="block px-4 py-2 no-underline transition-colors hover:bg-surface-hover" @click="closeSearchResults($event)">
<span class="block text-sm font-medium truncate" style="color: var(--text-primary);">{ r.Title }</span> <span class="block text-sm font-medium truncate" style="color: var(--text-primary);">{ r.Title }</span>
if r.Category != "" { if r.Category != "" {
<span class="block text-xs" style="color: var(--text-secondary);">{ r.Category }</span> <span class="block text-xs" style="color: var(--text-secondary);">{ r.Category }</span>
+2 -2
View File
@@ -92,7 +92,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<!-- Mobile drawer backdrop --><div id=\"docs-overlay\" class=\"hidden fixed inset-0 z-40 lg:hidden\" style=\"background: var(--surface-overlay);\" onclick=\"document.getElementById('docs-sidebar').classList.add('-translate-x-full'); this.classList.add('hidden')\"></div><div class=\"flex\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<!-- Mobile drawer backdrop --><div id=\"docs-overlay\" class=\"hidden fixed inset-0 z-40 lg:hidden\" style=\"background: var(--surface-overlay);\" @click=\"closeDocsSidebar($event)\"></div><div class=\"flex\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -823,7 +823,7 @@ func DocsSearchResults(results []SearchResultItem) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" class=\"block px-4 py-2 no-underline transition-colors hover:bg-surface-hover\" onclick=\"this.closest('#docs-search-results').classList.add('hidden')\"><span class=\"block text-sm font-medium truncate\" style=\"color: var(--text-primary);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" class=\"block px-4 py-2 no-underline transition-colors hover:bg-surface-hover\" @click=\"closeSearchResults($event)\"><span class=\"block text-sm font-medium truncate\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -44,7 +44,7 @@ templ Progress(user User, progressData []handlers.ProgressWithMedia, errorMessag
src={ item.CoverImagePath } src={ item.CoverImagePath }
alt="Cover" alt="Cover"
class="w-24 h-32 object-cover rounded-lg" class="w-24 h-32 object-cover rounded-lg"
onerror="this.src='/static/placeholder-book.svg'"
/> />
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
+1 -1
View File
@@ -102,7 +102,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" alt=\"Cover\" class=\"w-24 h-32 object-cover rounded-lg\" onerror=\"this.src='/static/placeholder-book.svg'\"></div><div class=\"flex-1 min-w-0\"><div class=\"flex justify-between items-start gap-4 mb-3\"><div class=\"min-w-0\"><a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" alt=\"Cover\" class=\"w-24 h-32 object-cover rounded-lg\"></div><div class=\"flex-1 min-w-0\"><div class=\"flex justify-between items-start gap-4 mb-3\"><div class=\"min-w-0\"><a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -85,7 +85,7 @@ templ SeriesCard(series SeriesCardData) {
class={ "cover-img cover-" + fmt.Sprintf("%d", i) } class={ "cover-img cover-" + fmt.Sprintf("%d", i) }
alt={ series.Name } alt={ series.Name }
loading="lazy" loading="lazy"
onerror="this.src='/static/placeholder-book.svg'"
/> />
} }
if len(series.CoverPaths) == 0 { if len(series.CoverPaths) == 0 {
+1 -1
View File
@@ -323,7 +323,7 @@ func SeriesCard(series SeriesCardData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\"> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" loading=\"lazy\"> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -28,7 +28,7 @@ templ UnlinkedMatchCards(progressId string, matches []services.BookMatch) {
src={ match.CoverImagePath } src={ match.CoverImagePath }
alt="Cover" alt="Cover"
class="w-16 h-24 object-cover rounded" class="w-16 h-24 object-cover rounded"
onerror="this.src='/static/placeholder-book.svg'"
/> />
<div> <div>
<h5 class="font-semibold" style="color: var(--text-primary)">{ match.Title }</h5> <h5 class="font-semibold" style="color: var(--text-primary)">{ match.Title }</h5>
+1 -1
View File
@@ -98,7 +98,7 @@ func UnlinkedMatchCards(progressId string, matches []services.BookMatch) templ.C
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" alt=\"Cover\" class=\"w-16 h-24 object-cover rounded\" onerror=\"this.src='/static/placeholder-book.svg'\"><div><h5 class=\"font-semibold\" style=\"color: var(--text-primary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" alt=\"Cover\" class=\"w-16 h-24 object-cover rounded\"><div><h5 class=\"font-semibold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+15 -2
View File
@@ -427,8 +427,6 @@ async function purgeArchivedItems(): Promise<void> {
} }
} }
(window as any).purgeArchivedItems = purgeArchivedItems;
// Restore a hidden/archived item to library visibility without waiting for // Restore a hidden/archived item to library visibility without waiting for
// its file to return (POST /api/media-items/:id/unarchive). If the file is // its file to return (POST /api/media-items/:id/unarchive). If the file is
// still gone the next scan hides it again. // still gone the next scan hides it again.
@@ -599,6 +597,21 @@ Alpine.data("admin", () => ({
this.loadWatchStatus(); this.loadWatchStatus();
this.checkActiveScans(); this.checkActiveScans();
}, },
purgeArchivedItems,
openCreateLibraryModal() {
document.getElementById("create-library-modal")?.classList.remove("hidden");
},
hideModal(id: string) {
document.getElementById(id)?.classList.add("hidden");
},
pickBrowsePath(e: Event) {
const button = e.currentTarget as HTMLElement;
const input = document.getElementById(
button.dataset.targetInput ?? "",
) as HTMLInputElement | null;
if (input) input.value = button.dataset.currentPath ?? "";
this.hideModal("folder-browser-modal");
},
cancelAllScans, cancelAllScans,
checkActiveScans, checkActiveScans,
hideScanProgress, hideScanProgress,
+7
View File
@@ -158,6 +158,13 @@ Alpine.data("docs", () => ({
this.initializeSearch(); this.initializeSearch();
this.initializeCodeCopyButtons(); this.initializeCodeCopyButtons();
}, },
closeDocsSidebar(e: Event) {
document.getElementById("docs-sidebar")?.classList.add("-translate-x-full");
(e.currentTarget as HTMLElement).classList.add("hidden");
},
closeSearchResults(e: Event) {
(e.currentTarget as HTMLElement).closest("#docs-search-results")?.classList.add("hidden");
},
toggleSidebar, toggleSidebar,
toggleCollapsible, toggleCollapsible,
initializeSearch: initializeDocsSearch, initializeSearch: initializeDocsSearch,
+38
View File
@@ -0,0 +1,38 @@
// Delegated image-fallback handling for strict CSP.
//
// Cover images used to carry inline `onerror` attributes; under the
// hash-allow-listed script-src (no 'unsafe-inline') the browser refuses to
// execute them. This module restores the same behavior with one
// capture-phase listener on <html>, so htmx-swapped containers are covered
// without any per-element re-binding.
//
// Opt-ins via data attributes on the element:
// data-fallback="hide" → hide the element instead of swapping the src
// (audiobook covers: placeholder would overflow)
// default → swap a broken cover to the placeholder SVG
const PLACEHOLDER = "/static/placeholder-book.svg";
let armed = false;
function onResourceError(event: Event) {
const target = event.target;
// Only interested in resource elements (img/audio/video/source), not
// generic events bubbling up (error does not bubble, but capture sees it).
if (!(target instanceof HTMLImageElement)) return;
if (target.dataset.fallback === "hide") {
target.style.display = "none";
return;
}
// Never chase our own placeholder (if it 404s, stop).
if (target.src.endsWith(PLACEHOLDER)) return;
target.src = PLACEHOLDER;
}
export function armImageFallbacks() {
if (armed) return;
armed = true;
// Capture is required: 'error' doesn't bubble.
document.documentElement.addEventListener("error", onResourceError, true);
}
+1
View File
@@ -17,6 +17,7 @@ import "./device-management";
import "./docs"; import "./docs";
import "./header"; import "./header";
import "./index"; import "./index";
import "./image_fallback";
import "./library-switcher"; import "./library-switcher";
import "./linking"; import "./linking";
import "./login"; import "./login";