diff --git a/app/api/theme/custom-css/route.ts b/app/api/theme/custom-css/route.ts new file mode 100644 index 0000000..0768eee --- /dev/null +++ b/app/api/theme/custom-css/route.ts @@ -0,0 +1,34 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { configStore } from "@/lib/config/loader"; + +export const dynamic = "force-dynamic"; + +const CONFIG_DIR = path.join(process.cwd(), "config"); + +export async function GET(): Promise { + let customCssPath: string | undefined; + try { + customCssPath = configStore.get().theme.customCssPath; + } catch { + return new Response("", { status: 204 }); + } + + if (!customCssPath) { + return new Response("", { status: 204 }); + } + + const resolved = path.normalize(path.join(CONFIG_DIR, customCssPath)); + if (!resolved.startsWith(CONFIG_DIR + path.sep)) { + return new Response("", { status: 204 }); + } + + try { + const css = await readFile(resolved, "utf-8"); + return new Response(css, { + headers: { "Content-Type": "text/css; charset=utf-8", "Cache-Control": "no-cache" }, + }); + } catch { + return new Response("", { status: 204 }); + } +} diff --git a/app/globals.css b/app/globals.css index 1d9e961..2ac014f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,32 +1,47 @@ @import "tailwindcss"; :root { - --pn-bg: #0b0d12; - --pn-surface: #12151c; - --pn-surface-raised: #171b24; - --pn-border: #242938; - --pn-fg: #e6e8ee; - --pn-fg-muted: #8a90a2; - --pn-accent: #38bdf8; + --pn-bg: #0a0e13; + --pn-surface: #10151c; + --pn-surface-raised: #161d27; + --pn-border: #232b38; + --pn-fg: #e8ecf1; + --pn-fg-muted: #7c8798; + --pn-accent: #3ee8a8; --pn-radius: 0.75rem; - --pn-status-up: #34d399; - --pn-status-down: #f87171; - --pn-status-degraded: #fbbf24; + --pn-status-up: #3ee8a8; + --pn-status-down: #ff5f6d; + --pn-status-degraded: #ffb84c; } :root[data-theme="light"] { - --pn-bg: #f5f6f8; + --pn-bg: #f6f7f5; --pn-surface: #ffffff; - --pn-surface-raised: #f0f1f4; - --pn-border: #e2e4ea; - --pn-fg: #14161c; - --pn-fg-muted: #666b7a; - --pn-accent: #0284c7; - --pn-status-up: #059669; + --pn-surface-raised: #eef1ef; + --pn-border: #dde1de; + --pn-fg: #12181a; + --pn-fg-muted: #5c6b66; + --pn-accent: #0e9f6e; + --pn-status-up: #0e9f6e; --pn-status-down: #dc2626; --pn-status-degraded: #d97706; } +@media (prefers-color-scheme: light) { + :root:not([data-theme]) { + --pn-bg: #f6f7f5; + --pn-surface: #ffffff; + --pn-surface-raised: #eef1ef; + --pn-border: #dde1de; + --pn-fg: #12181a; + --pn-fg-muted: #5c6b66; + --pn-accent: #0e9f6e; + --pn-status-up: #0e9f6e; + --pn-status-down: #dc2626; + --pn-status-degraded: #d97706; + } +} + @theme inline { --color-bg: var(--pn-bg); --color-surface: var(--pn-surface); @@ -39,11 +54,32 @@ --color-status-down: var(--pn-status-down); --color-status-degraded: var(--pn-status-degraded); --radius-widget: var(--pn-radius); + --font-sans: var(--font-plex-sans); + --font-mono: var(--font-plex-mono); + --font-display: var(--font-space-grotesk); } body { background: var(--pn-bg); color: var(--pn-fg); - font-family: - ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +@keyframes pulse-glow { + 0%, + 100% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--pn-status-up) 55%, transparent); + } + 50% { + box-shadow: 0 0 0 4px color-mix(in srgb, var(--pn-status-up) 0%, transparent); + } +} + +.status-pulse { + animation: pulse-glow 2s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .status-pulse { + animation: none; + } } diff --git a/app/layout.tsx b/app/layout.tsx index 8480889..80fafc8 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,31 +1,67 @@ import type { Metadata } from "next"; +import { IBM_Plex_Sans, IBM_Plex_Mono, Space_Grotesk } from "next/font/google"; import "./globals.css"; import { configStore } from "@/lib/config/loader"; import { ThemeStyleInjector } from "@/components/layout/ThemeStyleInjector"; +const plexSans = IBM_Plex_Sans({ + variable: "--font-plex-sans", + subsets: ["latin"], + weight: ["400", "500", "600"], +}); + +const plexMono = IBM_Plex_Mono({ + variable: "--font-plex-mono", + subsets: ["latin"], + weight: ["400", "500"], +}); + +const spaceGrotesk = Space_Grotesk({ + variable: "--font-space-grotesk", + subsets: ["latin"], + weight: ["600"], +}); + export const metadata: Metadata = { title: "PulseNode", description: "Self-hosted infrastructure dashboard", }; +const THEME_STORAGE_KEY = "pulsenode-theme"; + +const themeInitScript = `try{var t=localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)});if(t==="dark"||t==="light"){document.documentElement.dataset.theme=t;}}catch(e){}`; + export default function RootLayout({ children }: LayoutProps<"/">) { - let mode: "dark" | "light" = "dark"; + let mode: "dark" | "light" | "auto" = "dark"; let variables: Record = {}; + let customCssPath: string | undefined; try { const config = configStore.get(); - mode = config.theme.mode === "light" ? "light" : "dark"; + mode = config.theme.mode; variables = config.theme.variables; + customCssPath = config.theme.customCssPath; } catch { // page.tsx surfaces the actual config error; layout falls back to defaults } return ( - + +