Files
mal/static/theme.ts

90 lines
2.4 KiB
TypeScript

type Theme = 'light' | 'dark';
const STORAGE_KEY = 'theme';
const COOKIE_KEY = 'theme';
const getLocalStorage = (): Storage | null => {
try {
return window.localStorage;
} catch {
return null;
}
};
const readCookie = (key: string): string | null => {
const entries = document.cookie.split(';').map(part => part.trim());
for (const entry of entries) {
if (!entry) continue;
const eqIndex = entry.indexOf('=');
if (eqIndex === -1) continue;
const k = entry.slice(0, eqIndex).trim();
if (k !== key) continue;
return decodeURIComponent(entry.slice(eqIndex + 1));
}
return null;
};
const writeCookie = (key: string, value: string): void => {
const maxAgeSeconds = 60 * 60 * 24 * 365;
document.cookie = `${encodeURIComponent(key)}=${encodeURIComponent(value)}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax`;
};
const getPreferredTheme = (): Theme => {
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ?? false;
return prefersDark ? 'dark' : 'light';
};
const normalizeTheme = (raw: string | null): Theme | null => {
if (raw === 'light' || raw === 'dark') return raw;
return null;
};
const getSavedTheme = (): Theme => {
const storage = getLocalStorage();
const fromStorage = normalizeTheme(storage?.getItem(STORAGE_KEY) ?? null);
if (fromStorage) return fromStorage;
const fromCookie = normalizeTheme(readCookie(COOKIE_KEY));
if (fromCookie) return fromCookie;
return getPreferredTheme();
};
const applyTheme = (theme: Theme): void => {
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme;
const storage = getLocalStorage();
try {
storage?.setItem(STORAGE_KEY, theme);
} catch {
// ignore
}
writeCookie(COOKIE_KEY, theme);
};
const cycleTheme = (): void => {
const current = getSavedTheme();
const next: Theme = current === 'light' ? 'dark' : 'light';
applyTheme(next);
};
const initTheme = (): void => {
const saved = getSavedTheme();
applyTheme(saved);
// delegated click handler on theme buttons
document.addEventListener('click', e => {
const target = e.target;
if (!(target instanceof Element)) return;
const btn = target.closest('#theme-toggle, #footer-theme-toggle');
if (!(btn instanceof HTMLButtonElement)) return;
cycleTheme();
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTheme);
} else {
initTheme();
}