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) {
|
if (form) {
|
||||||
form.addEventListener("submit", saveCustomSection);
|
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 {
|
function addFilterRule(): void {
|
||||||
@@ -409,7 +430,8 @@ function displaySearchResults(books: BookInfo[]): void {
|
|||||||
(book) => `
|
(book) => `
|
||||||
<div class="flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer"
|
<div class="flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer"
|
||||||
data-book-id="${book.media_item_id}"
|
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"}"
|
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}"
|
||||||
alt="${builderEscapeHtml(book.title)}"
|
alt="${builderEscapeHtml(book.title)}"
|
||||||
class="w-10 h-15 object-cover rounded">
|
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"
|
<div class="inline-flex items-center gap-2 px-3 py-1 m-1 rounded-full text-sm"
|
||||||
style="background-color: var(--accent);">
|
style="background-color: var(--accent);">
|
||||||
<span>${builderEscapeHtml(book.title)}</span>
|
<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>
|
class="hover:opacity-70">×</button>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
@@ -627,13 +649,6 @@ function builderEscapeHtml(text: string): string {
|
|||||||
|
|
||||||
initCustomSectionBuilder();
|
initCustomSectionBuilder();
|
||||||
|
|
||||||
// Inline onclick handlers in the generated selection HTML resolve against
|
// Picker/selection clicks are delegated inside initCustomSectionBuilder —
|
||||||
// globals; bundled module functions need explicit exposure.
|
// the CSP build refuses inline onclick= handlers, so the generated markup
|
||||||
declare global {
|
// carries data attributes instead and no globals are needed.
|
||||||
interface Window {
|
|
||||||
addBookToSelection: typeof addBookToSelection;
|
|
||||||
removeBookFromSelection: typeof removeBookFromSelection;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addBookToSelection = addBookToSelection;
|
|
||||||
window.removeBookFromSelection = removeBookFromSelection;
|
|
||||||
+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>
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Confidence: ${Math.round(book.confidence_score * 100)}%</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-2">
|
<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>
|
||||||
</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-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>
|
<p class="text-xs" style="color: var(--text-secondary)">${match.author} (${Math.round(match.confidence * 100)}%)</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
@@ -158,7 +158,7 @@ function showSuggestionsModal(progressId: string, suggestions: PotentialMatchDat
|
|||||||
(s) => `
|
(s) => `
|
||||||
<div class="p-3 rounded border cursor-pointer hover:border-opacity-50"
|
<div class="p-3 rounded border cursor-pointer hover:border-opacity-50"
|
||||||
style="background-color: var(--bg-primary); border-color: var(--border)"
|
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="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-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>
|
<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("")}
|
.join("")}
|
||||||
</div>
|
</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>
|
</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", () => ({
|
Alpine.data("linking", () => ({
|
||||||
autoLinkBooks,
|
autoLinkBooks,
|
||||||
getSuggestions,
|
getSuggestions,
|
||||||
|
|||||||
+17
-2
@@ -510,7 +510,7 @@ function renderTopBooks(books: TopBook[]): void {
|
|||||||
(book) => `
|
(book) => `
|
||||||
<a href="/media/${book.media_item_id}" class="flex items-center gap-3 p-2 rounded-lg hover:bg-surface-hover">
|
<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)">
|
<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>
|
||||||
<span class="flex-1 min-w-0">
|
<span class="flex-1 min-w-0">
|
||||||
<span class="block text-sm font-medium truncate" style="color: var(--text-primary)">${escapeHtml(book.title)}</span>
|
<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">
|
<a href="/media/${item.media_item_id}" class="block p-2 rounded-lg hover:bg-surface-hover">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="w-10 h-14 rounded overflow-hidden shrink-0" style="background-color: var(--bg-primary)">
|
<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>
|
||||||
<span class="flex-1 min-w-0">
|
<span class="flex-1 min-w-0">
|
||||||
<span class="block text-sm font-medium truncate" style="color: var(--text-primary)">${escapeHtml(item.title)}</span>
|
<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 {
|
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 params = new URLSearchParams(window.location.search);
|
||||||
const initial = params.get("range") || "7";
|
const initial = params.get("range") || "7";
|
||||||
currentRange = ["7", "30", "90", "year"].includes(initial) ? initial : "7";
|
currentRange = ["7", "30", "90", "year"].includes(initial) ? initial : "7";
|
||||||
|
|||||||
Reference in new issue
Block a user