chore(web): library-admin inline script moves to admin.ts; rendering guardrail

The library admin page carried an inline <script> block of global
functions (toggleLibraryPanel, openEditModal, openDeleteModal,
openFolderBrowser plus the delete-modal close listener). They move into
the admin Alpine component as typed methods; the page body gains
x-data="admin" and the call sites switch onclick= -> @click.
Behavior unchanged.

Guardrail: make lint-rendering (wired into make test) fails when a
web/src file builds more HTML template literals than its recorded
baseline (web/src/rendering-allowlist.txt, seeded at 18 sites across 9
files post-twin-deletion) or when a .templ file gains an inline script
block. theme_flash_guard.templ is the only sanctioned inline script;
web/src/reader is a sanctioned island.

theme.ts: Array.from(classList) — DOMTokenList spread needs
lib.dom.iterable, which the tsconfig does not include.
This commit is contained in:
John O'Keefe committed 2026-10-18 12:28:50 -04:00
1 parent f1844591b4
commit 506a04b55d
6 files changed
+164 -94

No files matched your search

+26 -1
View File
@@ -47,9 +47,34 @@ help:
@echo " ./release v0.3.0 - Tag, push, and release (notes auto-generated from commits)"
# Run unit tests locally (fast, no containers)
test:
test: lint-rendering
go test ./internal/... -v -short
# Rendering-ownership guard (AGENTS.md → Rendering ownership): templ renders
# UI markup, JS orchestrates. Fails when a web/src file builds more HTML
# template literals than its recorded baseline (web/src/rendering-allowlist.txt),
# or when a .templ file gains an inline <script> block (theme_flash_guard.templ
# is the only sanctioned one). Shrink baselines as fragments move to templ
# partials; grow one only with a written reason.
lint-rendering:
@violations=""; \
for f in $$(grep -rlE 'innerHTML[[:space:]]*=[[:space:]]*\`' web/src --include='*.ts' | grep -v 'web/src/reader/'); do \
count=$$(grep -cE 'innerHTML[[:space:]]*=[[:space:]]*\`' $$f); \
allowed=$$(grep -F "$$f:" web/src/rendering-allowlist.txt | cut -d: -f2); \
allowed=$${allowed:-0}; \
if [ $$count -gt $$allowed ]; then \
violations="$$violations $$f ($$count > $$allowed)"; \
fi; \
done; \
for f in $$(grep -rln '<script>' templates --include='*.templ'); do \
case $$f in *theme_flash_guard*) continue;; esac; \
violations="$$violations $$f (inline script)"; \
done; \
if [ -n "$$violations" ]; then \
echo "rendering-ownership violations:$$violations"; exit 1; \
fi; \
echo "rendering ownership OK"
# Run integration tests in containers (matches production environment)
test-integration:
@echo "Building test containers..."
+6 -52
View File
@@ -11,7 +11,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body class={ "theme-" + user.Theme }>
<body x-data="admin" class={ "theme-" + user.Theme }>
@Header(user, "/admin/library")
<main class="p-8">
<div class="max-w-4xl">
@@ -173,53 +173,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
</div>
</div>
</div>
<script>
function toggleLibraryPanel(btn) {
var libId = btn.dataset.libId;
var panel = document.getElementById('library-panel-' + libId);
if (panel.innerHTML.trim() !== '') {
panel.innerHTML = '';
} else {
htmx.ajax('GET', '/admin/library/' + libId + '/panel', {
target: '#library-panel-' + libId,
swap: 'innerHTML'
});
}
}
function openEditModal(id, name, description) {
document.getElementById('edit-library-form').setAttribute('hx-put', '/admin/library/' + id);
document.getElementById('edit-library-name').value = name;
document.getElementById('edit-library-desc').value = description;
htmx.process(document.getElementById('edit-library-form'));
document.getElementById('edit-library-modal').classList.remove('hidden');
}
function openDeleteModal(id, name) {
const btn = document.getElementById('delete-library-confirm');
btn.setAttribute('hx-delete', '/admin/library/' + id);
btn.setAttribute('hx-target', '#libraries-container');
btn.setAttribute('hx-swap', 'innerHTML');
document.getElementById('delete-library-name').textContent = name;
htmx.process(btn);
document.getElementById('delete-library-modal').classList.remove('hidden');
}
function openFolderBrowser(targetInputId, libraryId) {
const content = document.getElementById('folder-browser-content');
content.setAttribute('hx-get', '/admin/library/browse');
content.setAttribute('hx-vals', '{"target_input": "' + targetInputId + '", "library_id": "' + libraryId + '"}');
content.setAttribute('hx-trigger', 'load');
htmx.process(content);
document.getElementById('folder-browser-modal').classList.remove('hidden');
}
// Close the delete-library modal after a successful delete: the
// htmx swap already removes the deleted row from the libraries
// list, but the modal lives outside the swapped container and
// would otherwise stay open.
document.getElementById('delete-library-confirm').addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail.successful) {
document.getElementById('delete-library-modal').classList.add('hidden');
}
});
</script>
</body>
</html>
}
@@ -273,7 +227,7 @@ templ LibraryList(user User, libraries []LibraryData, users []User) {
</button>
<button
data-lib-id={ library.ID }
onclick="toggleLibraryPanel(this)"
@click="toggleLibraryPanel($el)"
class="btn btn-secondary text-xs px-3 py-1.5"
>
@Icon("chevron-down", "h-4 w-4")
@@ -331,7 +285,7 @@ templ LibraryPanel(user User, libraryID string, library LibraryData, folders []F
type="button"
data-library-id={ libraryID }
data-target-input={ "folder-input-" + libraryID }
onclick="openFolderBrowser(this.dataset.targetInput, this.dataset.libraryId)"
@click="openFolderBrowser($el.dataset.targetInput, $el.dataset.libraryId)"
class="btn btn-secondary text-sm shrink-0"
>
@Icon("folder", "h-4 w-4")
@@ -392,7 +346,7 @@ templ LibraryPanel(user User, libraryID string, library LibraryData, folders []F
data-edit-id={ libraryID }
data-edit-name={ library.Name }
data-edit-desc={ library.Description }
onclick="openEditModal(this.dataset.editId, this.dataset.editName, this.dataset.editDesc)"
@click="openEditModal($el.dataset.editId, $el.dataset.editName, $el.dataset.editDesc)"
class="btn btn-secondary text-sm"
>
@Icon("edit", "h-4 w-4")
@@ -401,7 +355,7 @@ templ LibraryPanel(user User, libraryID string, library LibraryData, folders []F
<button
data-delete-id={ libraryID }
data-delete-name={ library.Name }
onclick="openDeleteModal(this.dataset.deleteId, this.dataset.deleteName)"
@click="openDeleteModal($el.dataset.deleteId, $el.dataset.deleteName)"
class="btn btn-danger text-sm"
>
@Icon("trash", "h-4 w-4")
+40 -40
View File
@@ -38,7 +38,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, 2, "<body class=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body x-data=\"admin\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -170,7 +170,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, "Delete</button></div></div></div><script>\n\t\t\t\tfunction toggleLibraryPanel(btn) {\n\t\t\t\t\tvar libId = btn.dataset.libId;\n\t\t\t\t\tvar panel = document.getElementById('library-panel-' + libId);\n\t\t\t\t\tif (panel.innerHTML.trim() !== '') {\n\t\t\t\t\t\tpanel.innerHTML = '';\n\t\t\t\t\t} else {\n\t\t\t\t\t\thtmx.ajax('GET', '/admin/library/' + libId + '/panel', {\n\t\t\t\t\t\t\ttarget: '#library-panel-' + libId,\n\t\t\t\t\t\t\tswap: 'innerHTML'\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tfunction openEditModal(id, name, description) {\n\t\t\t\t\tdocument.getElementById('edit-library-form').setAttribute('hx-put', '/admin/library/' + id);\n\t\t\t\t\tdocument.getElementById('edit-library-name').value = name;\n\t\t\t\t\tdocument.getElementById('edit-library-desc').value = description;\n\t\t\t\t\thtmx.process(document.getElementById('edit-library-form'));\n\t\t\t\t\tdocument.getElementById('edit-library-modal').classList.remove('hidden');\n\t\t\t\t}\n\t\t\t\tfunction openDeleteModal(id, name) {\n\t\t\t\t\tconst btn = document.getElementById('delete-library-confirm');\n\t\t\t\t\tbtn.setAttribute('hx-delete', '/admin/library/' + id);\n\t\t\t\t\tbtn.setAttribute('hx-target', '#libraries-container');\n\t\t\t\t\tbtn.setAttribute('hx-swap', 'innerHTML');\n\t\t\t\t\tdocument.getElementById('delete-library-name').textContent = name;\n\t\t\t\t\thtmx.process(btn);\n\t\t\t\t\tdocument.getElementById('delete-library-modal').classList.remove('hidden');\n\t\t\t\t}\n\t\t\t\tfunction openFolderBrowser(targetInputId, libraryId) {\n\t\t\t\t\tconst content = document.getElementById('folder-browser-content');\n\t\t\t\t\tcontent.setAttribute('hx-get', '/admin/library/browse');\n\t\t\t\t\tcontent.setAttribute('hx-vals', '{\"target_input\": \"' + targetInputId + '\", \"library_id\": \"' + libraryId + '\"}');\n\t\t\t\t\tcontent.setAttribute('hx-trigger', 'load');\n\t\t\t\t\thtmx.process(content);\n\t\t\t\t\tdocument.getElementById('folder-browser-modal').classList.remove('hidden');\n\t\t\t\t}\n\t\t\t\t// Close the delete-library modal after a successful delete: the\n\t\t\t\t// htmx swap already removes the deleted row from the libraries\n\t\t\t\t// list, but the modal lives outside the swapped container and\n\t\t\t\t// would otherwise stay open.\n\t\t\t\tdocument.getElementById('delete-library-confirm').addEventListener('htmx:afterRequest', function(evt) {\n\t\t\t\t\tif (evt.detail.successful) {\n\t\t\t\t\t\tdocument.getElementById('delete-library-modal').classList.add('hidden');\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t</script></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "Delete</button></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -233,7 +233,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 247, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 201, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -251,7 +251,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 249, Col: 95}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 203, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -273,7 +273,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 254, Col: 26}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 208, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -295,7 +295,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 259, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 213, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -313,7 +313,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + library.ID + "/scan")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 265, Col: 58}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 219, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
@@ -334,13 +334,13 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 275, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 229, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" onclick=\"toggleLibraryPanel(this)\" class=\"btn btn-secondary text-xs px-3 py-1.5\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" @click=\"toggleLibraryPanel($el)\" class=\"btn btn-secondary text-xs px-3 py-1.5\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -355,7 +355,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-panel-" + library.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 284, Col: 44}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 238, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
@@ -430,7 +430,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 306, Col: 99}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 260, Col: 99}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -443,7 +443,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 309, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 263, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
@@ -456,7 +456,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderDelete(folder.FolderPath))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 310, Col: 55}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 264, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
@@ -469,7 +469,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 311, Col: 49}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 265, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
@@ -500,7 +500,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 321, Col: 80}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 275, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
@@ -513,7 +513,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 321, Col: 124}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 275, Col: 124}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
@@ -526,7 +526,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 325, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 279, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
@@ -539,7 +539,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 332, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 286, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
@@ -552,13 +552,13 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 333, Col: 52}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 287, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" onclick=\"openFolderBrowser(this.dataset.targetInput, this.dataset.libraryId)\" class=\"btn btn-secondary text-sm shrink-0\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" @click=\"openFolderBrowser($el.dataset.targetInput, $el.dataset.libraryId)\" class=\"btn btn-secondary text-sm shrink-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -609,7 +609,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 362, Col: 63}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 316, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
@@ -622,7 +622,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 363, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 317, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
@@ -635,7 +635,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 365, Col: 49}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 319, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
@@ -648,7 +648,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 368, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 322, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
@@ -661,7 +661,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 369, Col: 75}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 323, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
@@ -689,7 +689,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var28 templ.SafeURL
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 378, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 332, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
@@ -716,7 +716,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 383, Col: 24}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 337, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
@@ -743,7 +743,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 392, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 346, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil {
@@ -756,7 +756,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 393, Col: 33}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 347, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
if templ_7745c5c3_Err != nil {
@@ -769,13 +769,13 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 394, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 348, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\" onclick=\"openEditModal(this.dataset.editId, this.dataset.editName, this.dataset.editDesc)\" class=\"btn btn-secondary text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\" @click=\"openEditModal($el.dataset.editId, $el.dataset.editName, $el.dataset.editDesc)\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -790,7 +790,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 402, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 356, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
if templ_7745c5c3_Err != nil {
@@ -803,13 +803,13 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 403, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 357, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" onclick=\"openDeleteModal(this.dataset.deleteId, this.dataset.deleteName)\" class=\"btn btn-danger text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" @click=\"openDeleteModal($el.dataset.deleteId, $el.dataset.deleteName)\" class=\"btn btn-danger text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -861,7 +861,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 418, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 372, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil {
@@ -879,7 +879,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 424, Col: 117}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 378, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
if templ_7745c5c3_Err != nil {
@@ -910,7 +910,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 437, Col: 118}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 391, Col: 118}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
if templ_7745c5c3_Err != nil {
@@ -931,7 +931,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 442, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 396, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
@@ -949,7 +949,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 449, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 403, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
if templ_7745c5c3_Err != nil {
@@ -962,7 +962,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 450, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 404, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
if templ_7745c5c3_Err != nil {
+76
View File
@@ -450,10 +450,86 @@ export {
stopScanStatusPolling,
};
// Library admin page helpers (rendering-ownership: these orchestrate htmx
// swaps and modal visibility on server-rendered DOM; they render nothing).
function toggleLibraryPanel(btn: HTMLElement): void {
const libId = btn.dataset.libId || "";
const panel = document.getElementById(`library-panel-${libId}`);
if (!panel) return;
if (panel.innerHTML.trim() !== "") {
panel.innerHTML = "";
} else {
window.htmx.ajax("GET", `/admin/library/${libId}/panel`, {
target: `#library-panel-${libId}`,
swap: "innerHTML",
});
}
}
function openEditModal(id: string, name: string, description: string): void {
const form = document.getElementById("edit-library-form");
if (!form) return;
form.setAttribute("hx-put", `/admin/library/${id}`);
const nameInput = document.getElementById("edit-library-name");
if (nameInput) (nameInput as HTMLInputElement).value = name;
const descInput = document.getElementById("edit-library-desc");
if (descInput) (descInput as HTMLInputElement).value = description;
window.htmx.process(form);
document.getElementById("edit-library-modal")?.classList.remove("hidden");
}
function openDeleteModal(id: string, name: string): void {
const btn = document.getElementById("delete-library-confirm");
if (!btn) return;
btn.setAttribute("hx-delete", `/admin/library/${id}`);
btn.setAttribute("hx-target", "#libraries-container");
btn.setAttribute("hx-swap", "innerHTML");
const nameEl = document.getElementById("delete-library-name");
if (nameEl) nameEl.textContent = name;
window.htmx.process(btn);
document.getElementById("delete-library-modal")?.classList.remove("hidden");
}
function openFolderBrowser(targetInputId: string, libraryId: string): void {
const content = document.getElementById("folder-browser-content");
if (!content) return;
content.setAttribute("hx-get", "/admin/library/browse");
content.setAttribute(
"hx-vals",
JSON.stringify({ target_input: targetInputId, library_id: libraryId }),
);
content.setAttribute("hx-trigger", "load");
window.htmx.process(content);
document.getElementById("folder-browser-modal")?.classList.remove("hidden");
}
// Close the delete-library modal after a successful delete: the htmx swap
// already removes the deleted row from the libraries list, but the modal
// lives outside the swapped container and would otherwise stay open.
function bindDeleteLibraryModalClose(): void {
document
.getElementById("delete-library-confirm")
?.addEventListener("htmx:afterRequest", function (evt) {
const detail = (evt as CustomEvent).detail as { successful?: boolean };
if (detail.successful) {
document
.getElementById("delete-library-modal")
?.classList.add("hidden");
}
});
}
Alpine.data("admin", () => ({
init() {
bindDeleteLibraryModalClose();
},
checkActiveScans,
hideScanProgress,
loadWatchStatus,
scanAllLibraries,
stopScanStatusPolling,
openDeleteModal,
openEditModal,
openFolderBrowser,
toggleLibraryPanel,
}));
+15
View File
@@ -0,0 +1,15 @@
# Rendering-ownership baseline (AGENTS.md → Rendering ownership).
# Files allowed to build HTML via template literals, with their current
# site counts. `make lint-rendering` fails when a count grows above its
# baseline. Shrink counts as fragments move to templ partials — the end
# state of the triage is an empty list. The reader (web/src/reader/) is a
# sanctioned island and not counted here.
web/src/admin.ts:1
web/src/api-explorer.ts:2
web/src/collections.ts:1
web/src/custom-section-builder.ts:2
web/src/linking.ts:1
web/src/search.ts:3
web/src/stats.ts:6
web/src/toast.ts:1
web/src/unlinked_books.ts:1
+1 -1
View File
@@ -52,7 +52,7 @@ const TOKEN_STORAGE_KEY = "token";
// auth pages survive a theme change.
const applyTheme = (theme: string): void => {
const setThemeClass = (el: Element): void => {
[...el.classList].forEach((c) => {
Array.from(el.classList).forEach((c) => {
if (c.startsWith("theme-")) el.classList.remove(c);
});
el.classList.add(`theme-${theme}`);