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:
1 parent
6a598786e9
commit
fb95b6d400
26 files changed
+180
-54
No files matched your search
@@ -4,6 +4,11 @@ import (
|
||||
"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
|
||||
func SecurityHeadersMiddleware() echo.MiddlewareFunc {
|
||||
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("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
|
||||
// script-src notes: 'unsafe-inline' is required by the pre-paint
|
||||
// theme flash guard (inline <script>). Alpine runs via its
|
||||
// CSP-safe build (@alpinejs/csp), which needs no eval — do NOT
|
||||
// CSP-safe Alpine build (@alpinejs/csp) needs no eval — do NOT
|
||||
// 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
|
||||
// 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("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")
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ templ AdminArchived(user User, items []ArchivedItem, retentionDays int) {
|
||||
</p>
|
||||
</div>
|
||||
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")
|
||||
Purge All Archived
|
||||
</button>
|
||||
|
||||
@@ -100,7 +100,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
<button
|
||||
onclick="document.getElementById('create-library-modal').classList.remove('hidden')"
|
||||
@click="openCreateLibraryModal()"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
@Icon("plus", "h-4 w-4")
|
||||
@@ -52,7 +52,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
|
||||
</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">
|
||||
<button type="button" @click="purgeArchivedItems()" class="btn btn-secondary">
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Purge Archived Now
|
||||
</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="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">
|
||||
<button type="button" @click="hideModal('create-library-modal')" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
@@ -77,7 +77,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
|
||||
hx-post="/admin/library/create"
|
||||
hx-target="#libraries-container"
|
||||
hx-swap="innerHTML"
|
||||
onsubmit="document.getElementById('create-library-modal').classList.add('hidden')"
|
||||
@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>
|
||||
@@ -94,7 +94,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
|
||||
</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="button" @click="hideModal('create-library-modal')" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
@Icon("plus", "h-4 w-4")
|
||||
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="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">
|
||||
<button type="button" @click="hideModal('edit-library-modal')" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</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">
|
||||
<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/>
|
||||
@@ -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>
|
||||
</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="button" @click="hideModal('edit-library-modal')" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
@Icon("save", "h-4 w-4")
|
||||
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="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">
|
||||
<button type="button" @click="hideModal('folder-browser-modal')" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</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="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">
|
||||
<button type="button" @click="hideModal('delete-library-modal')" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
@@ -165,7 +165,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
|
||||
</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" @click="hideModal('delete-library-modal')" class="btn btn-secondary">Cancel</button>
|
||||
<button type="button" id="delete-library-confirm" class="btn btn-danger">
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Delete
|
||||
@@ -195,7 +195,7 @@ templ LibraryList(user User, libraries []LibraryData, users []User) {
|
||||
</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>
|
||||
<button @click="openCreateLibraryModal()" class="btn btn-primary">Create Your First Library</button>
|
||||
</div>
|
||||
} else {
|
||||
<div class="space-y-4">
|
||||
@@ -411,7 +411,7 @@ templ FolderBrowserContent(currentPath string, parentPath string, entries []DirE
|
||||
type="button"
|
||||
data-target-input={ targetInput }
|
||||
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"
|
||||
>
|
||||
@Icon("check", "h-4 w-4")
|
||||
|
||||
@@ -69,7 +69,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -124,7 +124,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -148,7 +148,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -164,7 +164,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -172,7 +172,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -278,7 +278,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1038,7 +1038,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
src={ metadata.CoverImagePath }
|
||||
alt={ metadata.Title }
|
||||
class="w-full h-full object-cover"
|
||||
onerror="this.style.display='none'"
|
||||
data-fallback="hide"
|
||||
/>
|
||||
} else {
|
||||
<span class="text-5xl flex items-center justify-center h-full" style="color: var(--text-secondary);">🎧</span>
|
||||
|
||||
@@ -220,7 +220,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
alt={ book.Title }
|
||||
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'"
|
||||
|
||||
/>
|
||||
} else {
|
||||
<img
|
||||
|
||||
@@ -244,7 +244,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ templ CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo)
|
||||
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
|
||||
@@ -452,7 +452,7 @@ templ BookPickerGrid(books []handlers.BookInfo) {
|
||||
>
|
||||
<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'"/>
|
||||
<img src={ book.CoverImagePath } alt={ book.Title } class="w-full aspect-[2/3] object-cover" loading="lazy" />
|
||||
} else {
|
||||
<img src="/static/placeholder-book.svg" alt={ book.Title } class="w-full aspect-[2/3] object-cover" loading="lazy"/>
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ func CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo) t
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -970,7 +970,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ templ BookCard(item handlers.BookInfo) {
|
||||
alt={ item.Title }
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onerror="this.src='/static/placeholder-book.svg'"
|
||||
|
||||
/>
|
||||
} else {
|
||||
<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="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")
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
@@ -456,7 +456,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1027,7 +1027,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -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" }>
|
||||
@DocsTopbar(user)
|
||||
<!-- 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">
|
||||
@DocsSidebar(nav, currentPath)
|
||||
<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">
|
||||
for _, r := range results {
|
||||
<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>
|
||||
if r.Category != "" {
|
||||
<span class="block text-xs" style="color: var(--text-secondary);">{ r.Category }</span>
|
||||
|
||||
@@ -92,7 +92,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -823,7 +823,7 @@ func DocsSearchResults(results []SearchResultItem) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ templ Progress(user User, progressData []handlers.ProgressWithMedia, errorMessag
|
||||
src={ item.CoverImagePath }
|
||||
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">
|
||||
|
||||
@@ -102,7 +102,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ templ SeriesCard(series SeriesCardData) {
|
||||
class={ "cover-img cover-" + fmt.Sprintf("%d", i) }
|
||||
alt={ series.Name }
|
||||
loading="lazy"
|
||||
onerror="this.src='/static/placeholder-book.svg'"
|
||||
|
||||
/>
|
||||
}
|
||||
if len(series.CoverPaths) == 0 {
|
||||
|
||||
@@ -323,7 +323,7 @@ func SeriesCard(series SeriesCardData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ templ UnlinkedMatchCards(progressId string, matches []services.BookMatch) {
|
||||
src={ match.CoverImagePath }
|
||||
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)">{ match.Title }</h5>
|
||||
|
||||
@@ -98,7 +98,7 @@ func UnlinkedMatchCards(progressId string, matches []services.BookMatch) templ.C
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+15
-2
@@ -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
|
||||
// its file to return (POST /api/media-items/:id/unarchive). If the file is
|
||||
// still gone the next scan hides it again.
|
||||
@@ -599,6 +597,21 @@ Alpine.data("admin", () => ({
|
||||
this.loadWatchStatus();
|
||||
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,
|
||||
checkActiveScans,
|
||||
hideScanProgress,
|
||||
|
||||
@@ -158,6 +158,13 @@ Alpine.data("docs", () => ({
|
||||
this.initializeSearch();
|
||||
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,
|
||||
toggleCollapsible,
|
||||
initializeSearch: initializeDocsSearch,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import "./device-management";
|
||||
import "./docs";
|
||||
import "./header";
|
||||
import "./index";
|
||||
import "./image_fallback";
|
||||
import "./library-switcher";
|
||||
import "./linking";
|
||||
import "./login";
|
||||
|
||||
Reference in new issue
Block a user