Files
ipx/web/src/theme.ts
rays 60007d4e6a Page type-check: strict throughout, a typed $, null checks (#236)
$<T> returns the element type it is given and assumes the element is there: 122 lookups take
their type from the tag their id is on in the markup. A handler reads its target through a
cast, since a typed element will not take a handler whose event names a narrower target.
strictNullChecks found no bug the page had; its 94 places were values the code already handles
in ways the type-check could not follow (data- attributes the page writes, a map entry set the
line before, Share drawn only for an item with a link), each now said so.

isPlayable stays a plain boolean: as a type guard its false would have meant "no file", and it
is also false for a file that is not audio or video.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:50:33 +00:00

68 lines
3.9 KiB
TypeScript

/* ---------------- theme ---------------- */
// A theme, and for those that come in both, light, dark or Auto, chosen in Settings and kept in a
// cookie, so each device has its own: Glass on a phone, Dracula on a desktop (issue #69). The
// server reads it and writes it onto the page's <html> tag (data-theme, data-choice) so the page
// is drawn in it from the start. The
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
// the system, so no palette is written twice.
const THEMES: Record<string, {name: string, modes: boolean}> = {
adwaita: {name: 'Adwaita', modes: true},
catppuccin: {name: 'Catppuccin', modes: true},
classic: {name: 'Classic', modes: false},
dracula: {name: 'Dracula', modes: true},
flatremix: {name: 'Flat Remix', modes: true},
glass: {name: 'Glass', modes: true},
gruvbox: {name: 'Gruvbox', modes: true},
contrast: {name: 'High contrast', modes: true},
material: {name: 'Material', modes: true},
modern: {name: 'Modern', modes: true},
nordic: {name: 'Nordic', modes: true},
paper: {name: 'Paper', modes: false},
solarized: {name: 'Solarized', modes: true},
};
const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'};
// Before themes came in light and dark, ipx.theme in localStorage held one of these.
const OLD_THEMES: Record<string, [string, string]> = {dark: ['modern', 'dark'], light: ['modern', 'light'], auto: ['modern', 'auto']};
const systemDark = window.matchMedia?.('(prefers-color-scheme: dark)');
const theme = {name: 'modern', mode: 'dark'};
/// `save` for a choice made in Settings, which goes to this browser's cookie; not for applying one.
function setTheme(name = theme.name, mode = theme.mode, save = false){
theme.name = THEMES[name] ? name : 'modern';
theme.mode = MODES[mode] ? mode : 'dark';
const both = THEMES[theme.name].modes;
const root = document.documentElement;
root.dataset.theme = theme.name;
// A theme with one palette has it whatever the mode; both of those are light.
root.dataset.mode = !both ? 'light'
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
// The tab's icon in the same variant as the logo on the page.
const fav = $<HTMLLinkElement>('#favicon'); if(fav) fav.href = fav.dataset[root.dataset.mode!]!;
// The top bar's colour for an iPhone before iOS 26 to paint behind the status bar, as Android's
// toolbar does; the page's where the bar is see-through (Glass). iOS 26 reads the bar itself.
const tc = $<HTMLMetaElement>('meta[name=theme-color]'), bar = $('#topbar');
if(tc){ const c = bar ? getComputedStyle(bar).backgroundColor : ''; tc.content = /^rgb\(/.test(c) ? c : getComputedStyle(root).backgroundColor; }
const sel = $<HTMLSelectElement>('#stheme'); if(sel) sel.value = theme.name;
const ms = $<HTMLSelectElement>('#smode'); if(ms) ms.value = theme.mode;
const mf = $('#smodefield'); if(mf) mf.hidden = !both;
if(save) saveTheme();
}
/// Kept a year, for the whole site: the admin page is drawn in it too.
function saveTheme(){
document.cookie = `ipx_theme=${theme.name}.${theme.mode}; Path=/; Max-Age=31536000; SameSite=Lax`;
}
systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); });
(() => {
const root = document.documentElement;
// The head script put this browser's theme from its cookie on the page. Without one, /api/me's
// answer brings the account's from before (dialogs.ts).
if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice);
// Nothing on the account either. A theme this browser kept in localStorage, from before that,
// becomes its cookie, once.
let name: string | null = null, mode: string | null = null;
try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{}
if(name&&OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
setTheme(name ?? undefined, mode ?? undefined, !!name);
})();