Standard Alpine evaluates every attribute expression through new Function(), which a strict Content-Security-Policy cannot allow. Swap to the official CSP-safe build (@alpinejs/csp), whose parser evaluates expressions against registered component scope without eval. script-src now carries only 'self' and 'unsafe-inline' (the latter still required by the pre-paint theme flash guard). Bindings rewritten out of the unsupported expression shapes: - 21 multi-statement sequences across admin, audio_reader, docs, header, index, and reader became single component methods (init/loadWatchStatus hooks, commitScrub, pickSkip, pickSpeed, pickDefaultSpeed, chooseSleepOption, chooseSleepClockMode, toggleFxInvert, openPageThumbs, submitNote, clearSearch, pickReadingTheme) - the header sidebar's inline x-data (openPanel/themeMode IIFE) became a registered sidebarShell component; openPanel itself moved to the header root so every sidebar panel shares one open state - the appearance-panel moved into the component init() - header init() now also seeds openPanel from localStorage (or the admin section on /admin pages) Alpine magics (, , , ) remain available — the CSP evaluator injects them into scope. The Playwright suite covers the converted surfaces end to end.
215 lines
7.0 KiB
TypeScript
215 lines
7.0 KiB
TypeScript
import { Alpine } from "./alpine";
|
|
import { initializeSearch } from "./search";
|
|
import {
|
|
initializeTheme,
|
|
changeTheme,
|
|
changeWoodPaneling,
|
|
loadWoodPaneling,
|
|
revealCurrentTheme,
|
|
updateWoodPanelingIndicators,
|
|
} from "./theme";
|
|
import { getSelectedLibrary } from "./storage";
|
|
import { addListener, removeListener } from "./websocket";
|
|
|
|
const logout = (): void => {
|
|
localStorage.removeItem("token");
|
|
localStorage.removeItem("user");
|
|
window.location.href = "/";
|
|
};
|
|
|
|
const restoreLibrarySelection = (): void => {
|
|
const savedLibrary = getSelectedLibrary();
|
|
if (!savedLibrary) return;
|
|
|
|
const dropdownSelectors = ["#library-select", "#selected-library", "#library_id"];
|
|
|
|
for (const selector of dropdownSelectors) {
|
|
const dropdown = document.querySelector(selector) as HTMLSelectElement;
|
|
if (dropdown) {
|
|
const option = dropdown.querySelector(`option[value="${savedLibrary}"]`);
|
|
if (option) {
|
|
// SSR pages already render the select with the stored library
|
|
// (setSelectedLibrary keeps the cookie in sync and resolveLibrary
|
|
// renders from it). Re-firing `change` here made every page load
|
|
// re-run the page's switch handler — which re-fetches content at
|
|
// hardcoded page 1 / offset 0 and stomps URL-tracked state (this
|
|
// broke the /series paginator: page 2 snapped back to page 1).
|
|
// Only re-fire when the select actually drifted from storage.
|
|
if (dropdown.value === savedLibrary) return;
|
|
dropdown.value = savedLibrary;
|
|
dropdown.dispatchEvent(new Event("change", { bubbles: true }));
|
|
console.log("Restored library selection:", savedLibrary);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const initializeScanListener = function (this: any) {
|
|
// Restore state on page load: a scan that started before this page
|
|
// was opened (or a refresh mid-scan) emits no websocket event until
|
|
// its next progress tick, so ask the server directly first.
|
|
const restoreActiveScan = async () => {
|
|
try {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
const resp = await fetch("/api/scanner/active-summary", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (!resp.ok) return;
|
|
const data = await resp.json();
|
|
if (data.scanning) {
|
|
this.scanning = true;
|
|
this.scanProgress = Math.round((data.progress || 0) * 100);
|
|
}
|
|
} catch {
|
|
// Offline or endpoint unavailable — websocket events still drive
|
|
// the state once they arrive.
|
|
}
|
|
};
|
|
restoreActiveScan();
|
|
|
|
const handler = (message: any) => {
|
|
switch (message.type) {
|
|
case "scan_progress":
|
|
this.scanning = true;
|
|
this.scanProgress = Math.round((message.data.progress || 0) * 100);
|
|
this.scanNewItems = message.data.new_items || 0;
|
|
this.scanFilesScanned = message.data.files_scanned || 0;
|
|
break;
|
|
case "scan_complete":
|
|
this.scanning = false;
|
|
this.scanProgress = 100;
|
|
this.scanNewItems = message.data?.new_items || this.scanNewItems;
|
|
window.dispatchEvent(
|
|
new CustomEvent("bookhoard:scan-complete", {
|
|
detail: message.data,
|
|
}),
|
|
);
|
|
setTimeout(() => {
|
|
this.scanProgress = 0;
|
|
this.scanNewItems = 0;
|
|
this.scanFilesScanned = 0;
|
|
}, 3000);
|
|
break;
|
|
case "scan_error":
|
|
this.scanning = false;
|
|
this.scanProgress = 0;
|
|
break;
|
|
case "scan_cancelled":
|
|
// A cancelled scan is not an error: stop the spinner quietly.
|
|
this.scanning = false;
|
|
this.scanProgress = 0;
|
|
break;
|
|
}
|
|
};
|
|
|
|
addListener(handler);
|
|
|
|
const headerEl = document.querySelector("[x-data='header']");
|
|
if (headerEl) {
|
|
const observer = new MutationObserver(() => {
|
|
if (!document.contains(headerEl)) {
|
|
removeListener(handler);
|
|
observer.disconnect();
|
|
}
|
|
});
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
}
|
|
};
|
|
|
|
export { logout, restoreLibrarySelection };
|
|
|
|
Alpine.data("header", () => ({
|
|
init() {
|
|
// Shared panel state: one open panel across all sidebar panels. Default
|
|
// to the persisted panel (or the admin section on /admin pages).
|
|
try {
|
|
const v = localStorage.getItem("sidebar-open-panel");
|
|
this.openPanel =
|
|
v !== null
|
|
? v === ""
|
|
? null
|
|
: v
|
|
: window.location.pathname.startsWith("/admin")
|
|
? "admin"
|
|
: null;
|
|
} catch {
|
|
this.openPanel = window.location.pathname.startsWith("/admin") ? "admin" : null;
|
|
}
|
|
initializeSearch();
|
|
this.initializeTheme();
|
|
this.loadWoodPaneling();
|
|
this.updateWoodPanelingIndicators();
|
|
this.restoreLibrarySelection();
|
|
this.initializeScanListener();
|
|
// Persist the sidebar's open panel across navigations (was an inline
|
|
// x-init $watch on the panel element).
|
|
this.$watch("openPanel", (v: unknown) => {
|
|
try {
|
|
localStorage.setItem("sidebar-open-panel", v === null ? "" : String(v));
|
|
} catch {
|
|
/* private-mode localStorage failures are non-fatal */
|
|
}
|
|
});
|
|
},
|
|
openPanel: null as string | null,
|
|
// Shadow copy for the root-level toggleAppearancePanel: the sidebar shell's
|
|
// own themeMode (initialized from the server) is the one that matters at
|
|
// runtime — the merged Alpine scope reads the nearest declaration.
|
|
themeMode: "",
|
|
mobileMenuOpen: false,
|
|
// Shared panel toggles: invoked from the sidebar shell's scope, so `this`
|
|
// is the merged data stack (shell themeMode + root openPanel).
|
|
toggleAppearancePanel() {
|
|
this.openPanel = this.openPanel === "appearance" ? null : "appearance";
|
|
if (this.openPanel === "appearance") {
|
|
this.$nextTick(() => this.revealCurrentTheme(this.themeMode));
|
|
}
|
|
},
|
|
setThemeMode(mode: string) {
|
|
this.themeMode = mode;
|
|
this.$nextTick(() => this.revealCurrentTheme(mode));
|
|
},
|
|
selectTheme(name: string) {
|
|
changeTheme(name);
|
|
this.openPanel = null;
|
|
},
|
|
logout,
|
|
initializeSearch,
|
|
initializeTheme,
|
|
changeTheme,
|
|
changeWoodPaneling,
|
|
loadWoodPaneling,
|
|
revealCurrentTheme,
|
|
updateWoodPanelingIndicators,
|
|
restoreLibrarySelection,
|
|
initializeScanListener,
|
|
scanning: false,
|
|
scanProgress: 0,
|
|
scanNewItems: 0,
|
|
scanFilesScanned: 0,
|
|
}));
|
|
|
|
// Sidebar shell: owns the appearance panel's theme-mode state. The shared
|
|
// openPanel lives on the header root (one open panel across all panels).
|
|
// Registered (not inline x-data) so its methods work under the CSP-safe
|
|
// Alpine build, which cannot evaluate arbitrary inline sentences.
|
|
Alpine.data("sidebarShell", (initialThemeMode: string) => ({
|
|
themeMode: initialThemeMode,
|
|
toggleAppearancePanel() {
|
|
this.openPanel = this.openPanel === "appearance" ? null : "appearance";
|
|
if (this.openPanel === "appearance") {
|
|
this.$nextTick(() => this.revealCurrentTheme(this.themeMode));
|
|
}
|
|
},
|
|
setThemeMode(mode: string) {
|
|
this.themeMode = mode;
|
|
this.$nextTick(() => this.revealCurrentTheme(mode));
|
|
},
|
|
selectTheme(name: string) {
|
|
changeTheme(name);
|
|
this.openPanel = null;
|
|
},
|
|
}));
|