Files
bookhoard/web/src/theme.ts
T
John O'Keefe 506a04b55d 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.
2026-10-18 12:28:50 -04:00

300 lines
8.0 KiB
TypeScript

import { Alpine } from "./alpine";
// Theme management functionality
type ThemeType =
| "tokyo-night"
| "dracula"
| "nord"
| "solarized-dark"
| "monokai"
| "one-dark-pro"
| "material-dark"
| "catppuccin-mocha"
| "catppuccin-macchiato"
| "catppuccin-frappe"
// Omarchy dark themes
| "vantablack"
| "last-horizon"
| "ethereal"
| "hackerman"
| "matte-black"
| "solitude"
| "retro-82"
| "osaka-jade"
| "kanagawa"
| "miasma"
| "lumon"
| "ristretto"
| "gruvbox"
| "everforest"
// Light themes
| "tokyo-night-day"
| "solarized-light"
| "one-light"
| "ayu-light"
| "github-light"
| "gruvbox-light"
| "everforest-light"
| "kanagawa-lotus"
| "catppuccin-latte"
| "rose-pine"
| "lupine"
| "flexoki-light"
| "white";
const DEFAULT_THEME: ThemeType = "tokyo-night";
const THEME_STORAGE_KEY = "theme";
const TOKEN_STORAGE_KEY = "token";
// Apply theme to document body. Replaces only `theme-*` classes and keeps
// the class in sync on <html> as well (ThemeFlashGuard may have put one
// there on public pages), so unrelated classes like `min-h-screen` on the
// auth pages survive a theme change.
const applyTheme = (theme: string): void => {
const setThemeClass = (el: Element): void => {
Array.from(el.classList).forEach((c) => {
if (c.startsWith("theme-")) el.classList.remove(c);
});
el.classList.add(`theme-${theme}`);
};
setThemeClass(document.documentElement);
setThemeClass(document.body);
document.body.style.background = "";
document.body.style.backgroundSize = "";
document.body.style.backgroundAttachment = "";
localStorage.setItem(THEME_STORAGE_KEY, theme);
};
// Load theme from localStorage or use default
const loadTheme = (): void => {
const storedTheme = localStorage.getItem(
THEME_STORAGE_KEY,
) as ThemeType | null;
const theme = storedTheme || DEFAULT_THEME;
applyTheme(theme);
};
// Change: Remove DOM element lookup, accept theme as parameter
const changeTheme = async (theme: string): Promise<void> => {
applyTheme(theme);
updateThemeIndicators();
// Save to server if logged in
const token = localStorage.getItem(TOKEN_STORAGE_KEY);
if (!token) return;
try {
const response = await fetch("/api/auth/theme", {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ theme }),
});
if (!response.ok) {
console.log("Theme save failed");
}
} catch (error) {
console.log("Theme save failed", error);
}
};
// Load user's theme from server if logged in
const loadUserTheme = async (): Promise<void> => {
const token = localStorage.getItem(TOKEN_STORAGE_KEY);
if (!token) return;
try {
const response = await fetch("/api/auth/profile", {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data = await response.json();
if (data.theme) {
applyTheme(data.theme as string);
updateThemeIndicators();
}
}
} catch {
// Silently fail - user will get default theme
}
};
// Update the active-theme checkmark in the Appearance menu to follow the
// theme currently applied to <body>. Each theme button carries its theme name
// in data-theme and a .theme-check element that is shown for the active theme
// and hidden for the rest.
const updateThemeIndicators = (): void => {
const match = document.body.className.match(/theme-([\w-]+)/);
const current = match ? match[1] : DEFAULT_THEME;
document.querySelectorAll<HTMLElement>(".theme-btn").forEach((btn) => {
const check = btn.querySelector(".theme-check");
if (!check) return;
const theme = btn.getAttribute("data-theme");
check.classList.toggle("hidden", theme !== current);
});
};
// Scroll the active theme's row into view inside the Appearance panel's
// theme list, for the given mode. No-op when the active theme lives in the
// other mode (its row is filtered out there).
const revealCurrentTheme = (mode: string): void => {
const match = document.body.className.match(/theme-([\w-]+)/);
const current = match ? match[1] : DEFAULT_THEME;
const btn = document.querySelector<HTMLElement>(
`.theme-btn[data-theme="${current}"]`,
);
if (btn && btn.dataset.mode === mode) {
btn.scrollIntoView({ block: "nearest" });
}
};
// Initialize theme system
const initializeTheme = (): void => {
loadTheme();
loadUserTheme();
updateThemeIndicators();
// Set theme select value to current theme
const themeSelect = document.getElementById(
"theme-select",
) as HTMLSelectElement;
if (themeSelect) {
const currentTheme =
localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME;
themeSelect.value = currentTheme;
}
// Setup smooth scroll for anchor links
setupSmoothScroll();
};
// Setup smooth scrolling for anchor links
const setupSmoothScroll = (): void => {
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
anchor.addEventListener("click", (e) => {
e.preventDefault();
const href = anchor.getAttribute("href");
if (!href) return;
const target = document.querySelector(href);
if (target) {
target.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
});
});
};
// Wood paneling management functionality
type WoodPanelingType = "none" | "wood-light" | "wood-dark" | "wood-mahogany";
const WOOD_STORAGE_KEY = "wood-paneling";
// Apply wood paneling to collections container
const applyWoodPaneling = (paneling: WoodPanelingType): void => {
const container = document.getElementById("collections-container");
const body = document.body;
if (!container || !body) return;
// Remove all wood background classes from container
container.classList.remove(
"bg-wood-light",
"bg-wood-dark",
"bg-wood-mahogany",
);
container.removeAttribute("data-wood");
// Remove from body
body.classList.remove("bg-wood-light", "bg-wood-dark", "bg-wood-mahogany");
if (paneling !== "none") {
// Add selected wood background class to both container and body
const woodClass = `bg-${paneling}`;
container.classList.add(woodClass);
container.setAttribute("data-wood", paneling);
// Add to body for overscroll area
body.classList.add(woodClass);
}
// Save to localStorage
localStorage.setItem(WOOD_STORAGE_KEY, paneling);
};
// Load wood paneling from localStorage on page load
const loadWoodPaneling = (): void => {
const stored = localStorage.getItem(
WOOD_STORAGE_KEY,
) as WoodPanelingType | null;
if (stored) {
applyWoodPaneling(stored);
} else {
// Default to none
applyWoodPaneling("none");
}
};
// Change wood paneling (called from theme dropdown)
const changeWoodPaneling = (paneling: WoodPanelingType): void => {
applyWoodPaneling(paneling);
// Update active indicators
updateWoodPanelingIndicators();
// Alpine closes dropdown automatically via template state
};
// Update visual indicators for wood paneling buttons
const updateWoodPanelingIndicators = (): void => {
const currentWood = localStorage.getItem(WOOD_STORAGE_KEY) || "none";
// Update wood paneling buttons
document.querySelectorAll(".wood-paneling-btn").forEach((btn) => {
const wood = btn.getAttribute("data-wood");
if (wood === currentWood) {
// Active state - use CSS class instead of inline style
btn.classList.add("bg-wood-active");
btn.classList.remove("bg-wood-inactive");
} else {
// Inactive state
btn.classList.remove("bg-wood-active");
btn.classList.add("bg-wood-inactive");
}
});
};
Alpine.data("theme", () => ({
applyTheme,
changeTheme,
changeWoodPaneling,
initializeTheme,
loadTheme,
loadUserTheme,
loadWoodPaneling,
revealCurrentTheme,
updateThemeIndicators,
updateWoodPanelingIndicators,
}));
export {
applyTheme,
changeTheme,
changeWoodPaneling,
initializeTheme,
loadTheme,
loadUserTheme,
loadWoodPaneling,
revealCurrentTheme,
updateThemeIndicators,
updateWoodPanelingIndicators,
};
export type { ThemeType };