Manifest: short name, blue background/theme colour for the Android splash. index.html shows a plain blue screen with the logo until React mounts. theme-color starts blue and then follows the active theme background. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
187 lines
6.2 KiB
TypeScript
187 lines
6.2 KiB
TypeScript
import type { UserTheme } from './types';
|
|
|
|
// Built-in themes are compiled CSS files (src/themes/); custom themes are user CSS from the server,
|
|
// injected at runtime and addressed as "custom:<id>"
|
|
export type ThemeId = string;
|
|
|
|
export interface ThemeOption {
|
|
id: ThemeId;
|
|
name: string;
|
|
subtitle: string;
|
|
file: string;
|
|
previewBg: string;
|
|
previewPanel: string;
|
|
previewAccent: string;
|
|
previewText: string;
|
|
isRounded: boolean;
|
|
isCustom?: boolean;
|
|
}
|
|
|
|
export const THEME_STORAGE_KEY = 'mindsift_active_theme';
|
|
// CSS of the active custom theme, so it applies on boot before the API answers (no flash)
|
|
const CUSTOM_CSS_CACHE_KEY = 'mindsift_custom_theme_css';
|
|
const CUSTOM_STYLE_ID = 'mindsift-custom-theme';
|
|
export const CUSTOM_THEME_PREFIX = 'custom:';
|
|
|
|
export const AVAILABLE_THEMES: ThemeOption[] = [
|
|
{
|
|
id: 'dark',
|
|
name: 'MindSift Dark (Standard)',
|
|
subtitle: 'Monochrom, dunkel & eckig (0px)',
|
|
file: 'src/themes/theme-dark.css',
|
|
previewBg: '#121316',
|
|
previewPanel: '#1a1c20',
|
|
previewAccent: '#2563eb',
|
|
previewText: '#f4f4f5',
|
|
isRounded: false,
|
|
},
|
|
{
|
|
id: 'light',
|
|
name: 'MindSift Light (Tagmodus)',
|
|
subtitle: 'Helles Papier-Design & klare Kontraste',
|
|
file: 'src/themes/theme-light.css',
|
|
previewBg: '#f3f4f6',
|
|
previewPanel: '#ffffff',
|
|
previewAccent: '#2563eb',
|
|
previewText: '#111827',
|
|
isRounded: false,
|
|
},
|
|
{
|
|
id: 'high-contrast',
|
|
name: 'High Contrast (Scharf)',
|
|
subtitle: 'Tiefschwarz (#000) & weiße Konturen',
|
|
file: 'src/themes/theme-high-contrast.css',
|
|
previewBg: '#000000',
|
|
previewPanel: '#09090b',
|
|
previewAccent: '#38bdf8',
|
|
previewText: '#ffffff',
|
|
isRounded: false,
|
|
},
|
|
{
|
|
id: 'soft',
|
|
name: 'Soft Slate (Abgerundet)',
|
|
subtitle: 'Schiefer-Blau mit weichen Radien (10px)',
|
|
file: 'src/themes/theme-soft.css',
|
|
previewBg: '#0f172a',
|
|
previewPanel: '#1e293b',
|
|
previewAccent: '#3b82f6',
|
|
previewText: '#f8fafc',
|
|
isRounded: true,
|
|
},
|
|
];
|
|
|
|
export const isCustomThemeId = (id: ThemeId) => id.startsWith(CUSTOM_THEME_PREFIX);
|
|
export const customThemeId = (t: UserTheme): ThemeId => CUSTOM_THEME_PREFIX + t.id;
|
|
|
|
// Comments are ignored when reading the CSS (the template mentions [data-theme="mein-theme"] in one)
|
|
const stripComments = (css: string) => css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
|
|
const readCssVar = (css: string, name: string): string | undefined =>
|
|
stripComments(css).match(new RegExp(`--${name}\\s*:\\s*([^;]+);`))?.[1].trim();
|
|
|
|
// The CSS decides its own [data-theme="…"]; without one it targets plain :root
|
|
const themeAttribute = (css: string): string =>
|
|
stripComments(css).match(/data-theme\s*=\s*["']?([\w-]+)/)?.[1] || 'custom';
|
|
|
|
// Light background -> no Tailwind dark class
|
|
function isLightColor(color?: string): boolean {
|
|
const hex = color?.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1];
|
|
if (!hex) return false;
|
|
const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex;
|
|
const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16));
|
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 140;
|
|
}
|
|
|
|
export function customThemeOption(t: UserTheme): ThemeOption {
|
|
const radius = readCssVar(t.css, 'radius-card');
|
|
return {
|
|
id: customThemeId(t),
|
|
name: t.name,
|
|
subtitle: 'Eigenes Theme',
|
|
file: 'Eigenes Theme (gespeichert auf dem Server)',
|
|
previewBg: readCssVar(t.css, 'bg-app') || '#121316',
|
|
previewPanel: readCssVar(t.css, 'bg-panel') || '#1a1c20',
|
|
previewAccent: readCssVar(t.css, 'accent-primary') || '#2563eb',
|
|
previewText: readCssVar(t.css, 'text-main') || '#f4f4f5',
|
|
isRounded: !!radius && !/^0(px)?$/.test(radius),
|
|
isCustom: true,
|
|
};
|
|
}
|
|
|
|
function injectCustomCss(css: string | null): void {
|
|
if (typeof document === 'undefined') return;
|
|
let el = document.getElementById(CUSTOM_STYLE_ID) as HTMLStyleElement | null;
|
|
if (css === null) {
|
|
el?.remove();
|
|
return;
|
|
}
|
|
if (!el) {
|
|
el = document.createElement('style');
|
|
el.id = CUSTOM_STYLE_ID;
|
|
}
|
|
el.textContent = css;
|
|
// Last in <head>, so it wins over the compiled themes
|
|
document.head.appendChild(el);
|
|
}
|
|
|
|
function applyToDocument(themeId: ThemeId, customCss: string | null): void {
|
|
if (typeof document === 'undefined') return;
|
|
injectCustomCss(customCss);
|
|
const root = document.documentElement;
|
|
const attr = customCss !== null ? themeAttribute(customCss) : themeId;
|
|
root.setAttribute('data-theme', attr);
|
|
const light = customCss !== null ? isLightColor(readCssVar(customCss, 'bg-app')) : themeId === 'light';
|
|
root.classList.toggle('dark', !light);
|
|
syncThemeColorMeta();
|
|
}
|
|
|
|
// The status bar (theme-color) starts blue for the splash screen; once the app runs it follows the
|
|
// active theme's background so it blends with the header.
|
|
function syncThemeColorMeta(): void {
|
|
const meta = document.querySelector('meta[name="theme-color"]');
|
|
if (!meta || typeof requestAnimationFrame === 'undefined') return;
|
|
requestAnimationFrame(() => {
|
|
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg-app').trim();
|
|
if (bg) meta.setAttribute('content', bg);
|
|
});
|
|
}
|
|
|
|
function store(key: string, value: string | null): void {
|
|
try {
|
|
if (value === null) localStorage.removeItem(key);
|
|
else localStorage.setItem(key, value);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function load(key: string): string | null {
|
|
try {
|
|
return localStorage.getItem(key);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function getActiveTheme(): ThemeId {
|
|
const saved = load(THEME_STORAGE_KEY);
|
|
if (saved && AVAILABLE_THEMES.some((t) => t.id === saved)) return saved;
|
|
if (saved && isCustomThemeId(saved) && load(CUSTOM_CSS_CACHE_KEY)) return saved;
|
|
return 'dark';
|
|
}
|
|
|
|
// customCss is required for custom themes and ignored for built-in ones
|
|
export function setActiveTheme(themeId: ThemeId, customCss?: string): void {
|
|
const css = isCustomThemeId(themeId) ? customCss ?? null : null;
|
|
const effectiveId = isCustomThemeId(themeId) && css === null ? 'dark' : themeId;
|
|
store(THEME_STORAGE_KEY, effectiveId);
|
|
store(CUSTOM_CSS_CACHE_KEY, css);
|
|
applyToDocument(effectiveId, css);
|
|
}
|
|
|
|
export function initThemeOnBoot(): ThemeId {
|
|
const active = getActiveTheme();
|
|
applyToDocument(active, isCustomThemeId(active) ? load(CUSTOM_CSS_CACHE_KEY) : null);
|
|
return active;
|
|
}
|