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"
|
"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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 "./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";
|
||||||
|
|||||||
Reference in new issue
Block a user