fix(ui): replace CSP-blocked inline handlers with delegated listeners
The hash-allow-list CSP refuses inline onclick=/onerror= attributes, so three TS islands shipped dead controls: - stats.ts rendered Most-Read/Longest covers with onerror= — every /stats load logged violations (5+ on the dev data) and the broken cover fallback never ran. Now: capture-phase error listeners on the three list containers (they persist across innerHTML swaps; error does not bubble but capture still reaches them). - custom-section-builder.ts picker rows and selection chips used onclick= against window globals. Rows now carry data-book-id/title/ author and chips data-remove-book; initCustomSectionBuilder wires one delegated click listener per container. window.addBookToSelection/ removeBookFromSelection exports removed — no remaining references. - linking.ts: the unlinked-books 'Link' button called window.showMatchModal, which never existed anywhere (doubly dead: missing global AND CSP-blocked); suggestion rows and Cancel used onclick= too. All now data-attribute driven through one document-level delegated listener; row Link opens the suggestions flow via getSuggestions(progressId), which fetches matches and opens the modal. Verified: tsc --noEmit, vp check, vitest (198), manual modal flows.
This commit is contained in:
1 parent
88adf30aa9
commit
2df6f8e55c
3 files changed
+70
-18
No files matched your search
@@ -257,6 +257,27 @@ function initCustomSectionBuilder(): void {
|
||||
if (form) {
|
||||
form.addEventListener("submit", saveCustomSection);
|
||||
}
|
||||
|
||||
// Delegated clicks for the generated picker/selection markup: the CSP
|
||||
// build refuses inline onclick= handlers, so the rows carry data
|
||||
// attributes and one listener per container survives innerHTML swaps.
|
||||
const resultsContainer = document.getElementById("search-results");
|
||||
resultsContainer?.addEventListener("click", (e) => {
|
||||
const row = (e.target as HTMLElement).closest<HTMLElement>("[data-book-id]");
|
||||
if (!row) return;
|
||||
addBookToSelection(
|
||||
row.dataset.bookId ?? "",
|
||||
row.dataset.bookTitle ?? "",
|
||||
row.dataset.bookAuthor ?? "",
|
||||
);
|
||||
});
|
||||
|
||||
const selectedContainer = document.getElementById("selected-books");
|
||||
selectedContainer?.addEventListener("click", (e) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLElement>("[data-remove-book]");
|
||||
if (!btn) return;
|
||||
removeBookFromSelection(btn.dataset.removeBook ?? "");
|
||||
});
|
||||
}
|
||||
|
||||
function addFilterRule(): void {
|
||||
@@ -409,7 +430,8 @@ function displaySearchResults(books: BookInfo[]): void {
|
||||
(book) => `
|
||||
<div class="flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer"
|
||||
data-book-id="${book.media_item_id}"
|
||||
onclick="addBookToSelection('${book.media_item_id}', '${builderEscapeHtml(book.title)}', '${builderEscapeHtml(book.author)}')">
|
||||
data-book-title="${builderEscapeHtml(book.title)}"
|
||||
data-book-author="${builderEscapeHtml(book.author)}">
|
||||
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}"
|
||||
alt="${builderEscapeHtml(book.title)}"
|
||||
class="w-10 h-15 object-cover rounded">
|
||||
@@ -464,7 +486,7 @@ function updateSelectedBooksDisplay(): void {
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1 m-1 rounded-full text-sm"
|
||||
style="background-color: var(--accent);">
|
||||
<span>${builderEscapeHtml(book.title)}</span>
|
||||
<button type="button" onclick="removeBookFromSelection('${book.media_item_id}')"
|
||||
<button type="button" data-remove-book="${book.media_item_id}"
|
||||
class="hover:opacity-70">×</button>
|
||||
</div>
|
||||
`,
|
||||
@@ -627,13 +649,6 @@ function builderEscapeHtml(text: string): string {
|
||||
|
||||
initCustomSectionBuilder();
|
||||
|
||||
// Inline onclick handlers in the generated selection HTML resolve against
|
||||
// globals; bundled module functions need explicit exposure.
|
||||
declare global {
|
||||
interface Window {
|
||||
addBookToSelection: typeof addBookToSelection;
|
||||
removeBookFromSelection: typeof removeBookFromSelection;
|
||||
}
|
||||
}
|
||||
window.addBookToSelection = addBookToSelection;
|
||||
window.removeBookFromSelection = removeBookFromSelection;
|
||||
// Picker/selection clicks are delegated inside initCustomSectionBuilder —
|
||||
// the CSP build refuses inline onclick= handlers, so the generated markup
|
||||
// carries data attributes instead and no globals are needed.
|
||||
+26
-4
@@ -41,7 +41,7 @@ function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Confidence: ${Math.round(book.confidence_score * 100)}%</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button onclick="window.showMatchModal('${book.progress_id}')" class="btn-primary px-3 py-1 rounded text-sm">Link</button>
|
||||
<button data-show-match="${book.progress_id}" class="btn-primary px-3 py-1 rounded text-sm">Link</button>
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
@@ -58,7 +58,7 @@ function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
|
||||
<p class="text-sm" style="color: var(--text-primary)">${match.title}</p>
|
||||
<p class="text-xs" style="color: var(--text-secondary)">${match.author} (${Math.round(match.confidence * 100)}%)</p>
|
||||
</div>
|
||||
<button onclick="window.linkBook('${book.progress_id}', '${match.media_item_id}')" class="btn-secondary px-2 py-1 rounded text-xs">Link</button>
|
||||
<button data-link-book="${book.progress_id}" data-media-id="${match.media_item_id}" class="btn-secondary px-2 py-1 rounded text-xs">Link</button>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
@@ -158,7 +158,7 @@ function showSuggestionsModal(progressId: string, suggestions: PotentialMatchDat
|
||||
(s) => `
|
||||
<div class="p-3 rounded border cursor-pointer hover:border-opacity-50"
|
||||
style="background-color: var(--bg-primary); border-color: var(--border)"
|
||||
onclick="window.linkBook('${progressId}', '${s.media_item_id}'); window.hideMatchModal();">
|
||||
data-link-book="${progressId}" data-media-id="${s.media_item_id}">
|
||||
<p class="font-medium" style="color: var(--text-primary)">${s.title}</p>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">${s.author}</p>
|
||||
<p class="text-xs" style="color: var(--text-secondary)">${Math.round(s.confidence * 100)}% match</p>
|
||||
@@ -167,7 +167,7 @@ function showSuggestionsModal(progressId: string, suggestions: PotentialMatchDat
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
<button onclick="window.hideMatchModal()" class="mt-4 btn-secondary w-full py-2 rounded">Cancel</button>
|
||||
<button data-hide-match class="mt-4 btn-secondary w-full py-2 rounded">Cancel</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -181,6 +181,28 @@ function hideMatchModal(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Delegated click wiring for the generated markup above: the CSP build
|
||||
// refuses inline onclick= handlers (and module functions are not globals).
|
||||
// The row-level "Link" opens the suggestions flow via getSuggestions — the
|
||||
// old window.showMatchModal it referenced never existed.
|
||||
document.addEventListener("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const show = target.closest<HTMLElement>("[data-show-match]");
|
||||
if (show) {
|
||||
void getSuggestions(show.dataset.showMatch ?? "");
|
||||
return;
|
||||
}
|
||||
const link = target.closest<HTMLElement>("[data-link-book]");
|
||||
if (link) {
|
||||
void linkBook(link.dataset.linkBook ?? "", link.dataset.mediaId ?? "");
|
||||
hideMatchModal();
|
||||
return;
|
||||
}
|
||||
if (target.closest("[data-hide-match]")) {
|
||||
hideMatchModal();
|
||||
}
|
||||
});
|
||||
|
||||
Alpine.data("linking", () => ({
|
||||
autoLinkBooks,
|
||||
getSuggestions,
|
||||
|
||||
+17
-2
@@ -510,7 +510,7 @@ function renderTopBooks(books: TopBook[]): void {
|
||||
(book) => `
|
||||
<a href="/media/${book.media_item_id}" class="flex items-center gap-3 p-2 rounded-lg hover:bg-surface-hover">
|
||||
<span class="w-10 h-14 rounded overflow-hidden shrink-0" style="background-color: var(--bg-primary)">
|
||||
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}" alt="" class="w-full h-full object-cover" loading="lazy" onerror="this.style.display='none'"/>
|
||||
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}" alt="" class="w-full h-full object-cover" loading="lazy"/>
|
||||
</span>
|
||||
<span class="flex-1 min-w-0">
|
||||
<span class="block text-sm font-medium truncate" style="color: var(--text-primary)">${escapeHtml(book.title)}</span>
|
||||
@@ -684,7 +684,7 @@ function renderUsageItems(
|
||||
<a href="/media/${item.media_item_id}" class="block p-2 rounded-lg hover:bg-surface-hover">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-10 h-14 rounded overflow-hidden shrink-0" style="background-color: var(--bg-primary)">
|
||||
<img src="${item.cover_image_path || "/static/placeholder-book.svg"}" alt="" class="w-full h-full object-cover" loading="lazy" onerror="this.style.display='none'"/>
|
||||
<img src="${item.cover_image_path || "/static/placeholder-book.svg"}" alt="" class="w-full h-full object-cover" loading="lazy"/>
|
||||
</span>
|
||||
<span class="flex-1 min-w-0">
|
||||
<span class="block text-sm font-medium truncate" style="color: var(--text-primary)">${escapeHtml(item.title)}</span>
|
||||
@@ -710,6 +710,21 @@ function escapeHtml(value: string): string {
|
||||
}
|
||||
|
||||
function initStatsPage(): void {
|
||||
// Broken-cover fallback without inline onerror: the CSP build refuses
|
||||
// inline handlers, so listen in capture phase on each list container
|
||||
// (error does not bubble, but capture still reaches it; the containers
|
||||
// persist across innerHTML swaps).
|
||||
for (const containerID of ["stats-top-books", "stats-lib-longest", "stats-lib-largest"]) {
|
||||
document.getElementById(containerID)?.addEventListener(
|
||||
"error",
|
||||
(e) => {
|
||||
const img = e.target as HTMLElement;
|
||||
if (img instanceof HTMLImageElement) img.style.display = "none";
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const initial = params.get("range") || "7";
|
||||
currentRange = ["7", "30", "90", "year"].includes(initial) ? initial : "7";
|
||||
|
||||
Reference in new issue
Block a user