diff --git a/frontend/public/paskia.webp b/frontend/public/paskia.webp new file mode 100755 index 0000000..f420989 Binary files /dev/null and b/frontend/public/paskia.webp differ diff --git a/frontend/src/utils/theme.js b/frontend/src/utils/theme.js new file mode 100644 index 0000000..a4dee13 --- /dev/null +++ b/frontend/src/utils/theme.js @@ -0,0 +1,101 @@ +// Theme override utilities - shared across apps +// User preference or URL hash can force light/dark mode + +export const themeColors = { + light: { + 'color-canvas': '#ffffff', + 'color-surface': '#eff6ff', + 'color-surface-subtle': '#dbeafe', + 'color-border': '#2563eb', + 'color-border-strong': '#1e40af', + 'color-heading': '#1e3a8a', + 'color-text': '#1e293b', + 'color-text-muted': '#475569', + 'color-link': '#1d4ed8', + 'color-link-hover': '#1e40af', + 'color-accent': '#2563eb', + 'color-accent-strong': '#1e40af', + 'color-accent-contrast': '#ffffff', + 'color-success-text': '#166534', + 'color-success-bg': '#dcfce7', + 'color-error-text': '#b91c1c', + 'color-error-bg': '#fee2e2', + 'color-info-text': '#1e40af', + 'color-info-bg': '#dbeafe', + 'color-danger': '#dc2626', + 'shadow-soft': '0 10px 30px rgba(30, 64, 175, 0.15)', + }, + dark: { + 'color-canvas': '#0f172a', + 'color-surface': '#141b2f', + 'color-surface-subtle': '#1b243b', + 'color-border': '#25304a', + 'color-border-strong': '#3d4d6b', + 'color-heading': '#fff', + 'color-text': '#e2e8f0', + 'color-text-muted': '#94a3b8', + 'color-link': '#60a5fa', + 'color-link-hover': '#93c5fd', + 'color-accent': '#60a5fa', + 'color-accent-strong': '#3b82f6', + 'color-accent-contrast': '#0b1120', + 'color-success-text': '#34d399', + 'color-success-bg': '#1a4d2e', + 'color-error-text': '#fca5a5', + 'color-error-bg': '#4a1f1f', + 'color-info-text': '#bae6fd', + 'color-info-bg': '#1e3a5f', + 'color-danger': '#f87171', + 'shadow-soft': '0 0 0 #000000', + } +} + +const STYLE_ID = 'theme-override' +const TRANSITION_ID = 'theme-transition' +const STORAGE_KEY = 'paskia-theme' + +/** Apply theme override CSS - selector targets .surface for restricted app, :root for main apps */ +export function applyTheme(theme, selector = ':root', animate = false) { + // Add temporary transition for smooth theme change + if (animate) { + let transitionStyle = document.getElementById(TRANSITION_ID) + if (!transitionStyle) { + transitionStyle = document.createElement('style') + transitionStyle.id = TRANSITION_ID + transitionStyle.textContent = '*, *::before, *::after { transition: background-color 0.3s, color 0.3s, border-color 0.3s, box-shadow 0.3s !important; }' + document.head.appendChild(transitionStyle) + } + setTimeout(() => document.getElementById(TRANSITION_ID)?.remove(), 350) + } + document.getElementById(STYLE_ID)?.remove() + if (theme && themeColors[theme]) { + const css = `${selector} { ${Object.entries(themeColors[theme]).map(([k, v]) => `--${k}: ${v}`).join('; ')}; }` + const style = document.createElement('style') + style.id = STYLE_ID + style.textContent = css + document.head.appendChild(style) + } +} + +/** Get theme from localStorage cache */ +export function getCachedTheme() { + return localStorage.getItem(STORAGE_KEY) || '' +} + +/** Cache theme in localStorage */ +export function setCachedTheme(theme) { + if (theme) localStorage.setItem(STORAGE_KEY, theme) + else localStorage.removeItem(STORAGE_KEY) +} + +/** Initialize theme from user preference (with localStorage cache for fast load) */ +export function initThemeFromCache() { + applyTheme(getCachedTheme()) +} + +/** Update theme from session context (call after login/session load) */ +export function updateThemeFromSession(ctx, animate = false) { + const theme = ctx?.user?.theme || '' + setCachedTheme(theme) + applyTheme(theme, ':root', animate) +} diff --git a/paskia/fastapi/mainapp.py b/paskia/fastapi/mainapp.py index 6abb6c5..1837959 100644 --- a/paskia/fastapi/mainapp.py +++ b/paskia/fastapi/mainapp.py @@ -26,6 +26,7 @@ _access_logger = logging.getLogger("paskia.access") frontend = Frontend( Path(__file__).parent.parent / "frontend-build", cached=["/auth/assets/"], + favicon="/paskia.webp", ) @@ -135,6 +136,11 @@ async def examples_page(): return FileResponse(index_file, media_type="text/html") +# Frontend static files - must be before /{token} catch-all routes +# (actual routes registered during lifespan after frontend.load()) +frontend.route(app, "/") + + # Note: this catch-all handler must be the last route defined @app.get("/{token}") @app.get("/auth/{token}") @@ -147,7 +153,3 @@ async def token_link(token: str): raise HTTPException(status_code=404) return Response(*await vitedev.read("/int/reset/index.html")) - - -# Final catch-all route for frontend files (keep at end of file) -frontend.route(app, "/")