Add light/dark theme toggle with CSS variable system
- globals.css: surface colors flip via CSS custom properties (.dark class) - Navbar: sun/moon toggle button, persists to localStorage, respects OS preference - Footer: theme-aware logo (light logo for dark mode, dark logo for light mode) - Layout: no-FOUC inline script applies theme before first paint - Both DashCaddy wide logos (light+dark) swap based on theme - Sami's personal samiahmed7777 logo in footer copyright is untouched
This commit is contained in:
+75
-13
@@ -1,5 +1,9 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ── Theme system: light is default, .dark activates dark mode ── */
|
||||||
|
/* Surface colors are remapped so bg-surface-950 = page background, */
|
||||||
|
/* text-surface-50 = main text — in BOTH themes. The scale flips. */
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-brand-50: #eef6ff;
|
--color-brand-50: #eef6ff;
|
||||||
--color-brand-100: #d9eaff;
|
--color-brand-100: #d9eaff;
|
||||||
@@ -13,22 +17,55 @@
|
|||||||
--color-brand-900: #18378f;
|
--color-brand-900: #18378f;
|
||||||
--color-brand-950: #142357;
|
--color-brand-950: #142357;
|
||||||
|
|
||||||
--color-surface-50: #f8fafc;
|
/* Surface scale — mapped to semantic vars that flip with theme */
|
||||||
--color-surface-100: #f1f5f9;
|
--color-surface-50: var(--s-50);
|
||||||
--color-surface-200: #e2e8f0;
|
--color-surface-100: var(--s-100);
|
||||||
--color-surface-300: #cbd5e1;
|
--color-surface-200: var(--s-200);
|
||||||
--color-surface-400: #94a3b8;
|
--color-surface-300: var(--s-300);
|
||||||
--color-surface-500: #64748b;
|
--color-surface-400: var(--s-400);
|
||||||
--color-surface-600: #475569;
|
--color-surface-500: var(--s-500);
|
||||||
--color-surface-700: #334155;
|
--color-surface-600: var(--s-600);
|
||||||
--color-surface-800: #1e293b;
|
--color-surface-700: var(--s-700);
|
||||||
--color-surface-900: #0f172a;
|
--color-surface-800: var(--s-800);
|
||||||
--color-surface-950: #020617;
|
--color-surface-900: var(--s-900);
|
||||||
|
--color-surface-950: var(--s-950);
|
||||||
|
|
||||||
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
|
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
|
||||||
--font-mono: "JetBrains Mono", "Fira Code", monospace;
|
--font-mono: "JetBrains Mono", "Fira Code", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Light theme (default) ── */
|
||||||
|
:root {
|
||||||
|
--s-50: #020617; /* main text — near black */
|
||||||
|
--s-100: #0f172a; /* heading text */
|
||||||
|
--s-200: #1e293b; /* strong text */
|
||||||
|
--s-300: #475569; /* secondary text */
|
||||||
|
--s-400: #64748b; /* muted text */
|
||||||
|
--s-500: #94a3b8; /* very muted */
|
||||||
|
--s-600: #cbd5e1; /* faint text */
|
||||||
|
--s-700: #e2e8f0; /* borders */
|
||||||
|
--s-800: #f1f5f9; /* card backgrounds */
|
||||||
|
--s-900: #f8fafc; /* secondary page bg */
|
||||||
|
--s-950: #ffffff; /* page background — white */
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dark theme ── */
|
||||||
|
.dark {
|
||||||
|
--s-50: #f8fafc; /* main text — near white */
|
||||||
|
--s-100: #f1f5f9; /* heading text */
|
||||||
|
--s-200: #e2e8f0; /* strong text */
|
||||||
|
--s-300: #cbd5e1; /* secondary text */
|
||||||
|
--s-400: #94a3b8; /* muted text */
|
||||||
|
--s-500: #64748b; /* very muted */
|
||||||
|
--s-600: #475569; /* faint text */
|
||||||
|
--s-700: #334155; /* borders */
|
||||||
|
--s-800: #1e293b; /* card backgrounds */
|
||||||
|
--s-900: #0f172a; /* secondary page bg */
|
||||||
|
--s-950: #020617; /* page background — near black */
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
@@ -39,6 +76,7 @@ body {
|
|||||||
background-color: var(--color-surface-950);
|
background-color: var(--color-surface-950);
|
||||||
color: var(--color-surface-100);
|
color: var(--color-surface-100);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
@@ -54,9 +92,18 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.glass-card {
|
.glass-card {
|
||||||
background: rgba(30, 41, 59, 0.5);
|
background: var(--glass-bg);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--glass-bg: rgba(241, 245, 249, 0.5);
|
||||||
|
--glass-border: rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--glass-bg: rgba(30, 41, 59, 0.5);
|
||||||
|
--glass-border: rgba(148, 163, 184, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glow-border {
|
.glow-border {
|
||||||
@@ -71,3 +118,18 @@ body {
|
|||||||
transparent
|
transparent
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Theme toggle button */
|
||||||
|
.theme-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background-color 0.2s, color 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.theme-toggle:hover {
|
||||||
|
background-color: var(--color-surface-800);
|
||||||
|
}
|
||||||
|
|||||||
+20
-4
@@ -4,7 +4,7 @@ import "./globals.css";
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "DashCaddy - Self-Hosting Made Beautiful",
|
title: "DashCaddy - Self-Hosting Made Beautiful",
|
||||||
description:
|
description:
|
||||||
"Deploy 50+ Docker apps with one click. Automatic SSL, DNS, and reverse proxy configuration. The all-in-one self-hosting dashboard.",
|
"Deploy 76+ Docker apps with one click. AI-powered self-hosting with automatic SSL, DNS, reverse proxy, security center, and fleet management.",
|
||||||
keywords: [
|
keywords: [
|
||||||
"self-hosting",
|
"self-hosting",
|
||||||
"docker",
|
"docker",
|
||||||
@@ -14,11 +14,12 @@ export const metadata: Metadata = {
|
|||||||
"ssl",
|
"ssl",
|
||||||
"dns",
|
"dns",
|
||||||
"homelab",
|
"homelab",
|
||||||
|
"AI self-hosting",
|
||||||
],
|
],
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "DashCaddy - Self-Hosting Made Beautiful",
|
title: "DashCaddy - Self-Hosting Made Beautiful",
|
||||||
description:
|
description:
|
||||||
"Deploy 50+ Docker apps with one click. Automatic SSL, DNS, and reverse proxy configuration.",
|
"Deploy 76+ Docker apps with one click. AI-powered self-hosting with automatic SSL, DNS, and reverse proxy.",
|
||||||
url: "https://dashcaddy.net",
|
url: "https://dashcaddy.net",
|
||||||
siteName: "DashCaddy",
|
siteName: "DashCaddy",
|
||||||
type: "website",
|
type: "website",
|
||||||
@@ -27,17 +28,32 @@ export const metadata: Metadata = {
|
|||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "DashCaddy - Self-Hosting Made Beautiful",
|
title: "DashCaddy - Self-Hosting Made Beautiful",
|
||||||
description:
|
description:
|
||||||
"Deploy 50+ Docker apps with one click. Automatic SSL, DNS, and reverse proxy.",
|
"Deploy 76+ Docker apps with one click. AI-powered self-hosting.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Inline script to set theme class BEFORE first paint — prevents FOUC
|
||||||
|
const themeScript = `
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var stored = localStorage.getItem('theme');
|
||||||
|
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
var theme = stored || (prefersDark ? 'dark' : 'light');
|
||||||
|
if (theme === 'dark') document.documentElement.classList.add('dark');
|
||||||
|
} catch(e) {}
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="h-full antialiased">
|
<html lang="en" className="h-full antialiased" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||||
|
</head>
|
||||||
<body className="min-h-full flex flex-col bg-surface-950 text-surface-100" style={{ fontFamily: "'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif" }}>
|
<body className="min-h-full flex flex-col bg-surface-950 text-surface-100" style={{ fontFamily: "'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif" }}>
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
const [theme, setTheme] = useState<'light' | 'dark'>('dark');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem('theme') as 'light' | 'dark' | null;
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
setTheme(stored || (prefersDark ? 'dark' : 'light'));
|
||||||
|
document.documentElement.classList.toggle('dark', (stored || (prefersDark ? 'dark' : 'light')) === 'dark');
|
||||||
|
}, []);
|
||||||
|
|
||||||
const footerSections = [
|
const footerSections = [
|
||||||
{
|
{
|
||||||
@@ -52,7 +63,13 @@ export default function Footer() {
|
|||||||
{/* Brand Section */}
|
{/* Brand Section */}
|
||||||
<div>
|
<div>
|
||||||
<Link href="/" className="flex items-center transition-opacity hover:opacity-80 mb-4">
|
<Link href="/" className="flex items-center transition-opacity hover:opacity-80 mb-4">
|
||||||
<Image src="/images/brand-wide-light.png" alt="DashCaddy" width={160} height={53} className="h-9 w-auto" />
|
<Image
|
||||||
|
src={theme === 'dark' ? '/images/brand-wide-light.png' : '/images/brand-wide-dark.png'}
|
||||||
|
alt="DashCaddy"
|
||||||
|
width={160}
|
||||||
|
height={53}
|
||||||
|
className="h-9 w-auto"
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-surface-400 text-sm leading-relaxed mb-4">
|
<p className="text-surface-400 text-sm leading-relaxed mb-4">
|
||||||
Self-hosted Docker dashboard with automatic SSL, DNS, and reverse proxy. Making self-hosting beautiful and effortless.
|
Self-hosted Docker dashboard with automatic SSL, DNS, and reverse proxy. Making self-hosting beautiful and effortless.
|
||||||
|
|||||||
+73
-18
@@ -2,10 +2,27 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [theme, setTheme] = useState<'light' | 'dark'>('dark');
|
||||||
|
|
||||||
|
// Initialize theme from localStorage or system preference
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem('theme') as 'light' | 'dark' | null;
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
const initial = stored || (prefersDark ? 'dark' : 'light');
|
||||||
|
setTheme(initial);
|
||||||
|
document.documentElement.classList.toggle('dark', initial === 'dark');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const next = theme === 'dark' ? 'light' : 'dark';
|
||||||
|
setTheme(next);
|
||||||
|
document.documentElement.classList.toggle('dark', next === 'dark');
|
||||||
|
localStorage.setItem('theme', next);
|
||||||
|
};
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ href: '/features', label: 'Features' },
|
{ href: '/features', label: 'Features' },
|
||||||
@@ -20,7 +37,14 @@ export default function Navbar() {
|
|||||||
<div className="flex h-16 items-center justify-between">
|
<div className="flex h-16 items-center justify-between">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link href="/" className="flex items-center transition-opacity hover:opacity-80">
|
<Link href="/" className="flex items-center transition-opacity hover:opacity-80">
|
||||||
<Image src="/images/brand-wide-light.png" alt="DashCaddy" width={180} height={60} className="h-10 w-auto" priority />
|
<Image
|
||||||
|
src={theme === 'dark' ? '/images/brand-wide-light.png' : '/images/brand-wide-dark.png'}
|
||||||
|
alt="DashCaddy"
|
||||||
|
width={180}
|
||||||
|
height={60}
|
||||||
|
className="h-10 w-auto"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
{/* Desktop Navigation */}
|
||||||
@@ -34,22 +58,46 @@ export default function Navbar() {
|
|||||||
{link.label}
|
{link.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
{/* Theme toggle */}
|
||||||
|
<button
|
||||||
{/* CTA Button (Desktop) */}
|
onClick={toggleTheme}
|
||||||
<div className="hidden md:block">
|
className="theme-toggle text-surface-300 hover:text-brand-400"
|
||||||
<Link
|
aria-label="Toggle theme"
|
||||||
href="/pricing"
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600 transition-all duration-200 hover:shadow-lg hover:shadow-brand-500/30"
|
|
||||||
>
|
>
|
||||||
Get Started
|
{theme === 'dark' ? (
|
||||||
</Link>
|
/* Sun icon */
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
/* Moon icon */
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
{/* CTA + Mobile toggle */}
|
||||||
|
<div className="flex items-center gap-2 md:hidden">
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="theme-toggle text-surface-300"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="md:hidden inline-flex items-center justify-center rounded-lg p-2 text-surface-400 hover:bg-surface-800 hover:text-surface-200 transition-colors"
|
className="inline-flex items-center justify-center rounded-lg p-2 text-surface-400 hover:bg-surface-800 hover:text-surface-200 transition-colors"
|
||||||
aria-label="Toggle menu"
|
aria-label="Toggle menu"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -59,15 +107,22 @@ export default function Navbar() {
|
|||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
>
|
>
|
||||||
<path
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop CTA */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<Link
|
||||||
|
href="/pricing"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600 transition-all duration-200 hover:shadow-lg hover:shadow-brand-500/30"
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Mobile Navigation */}
|
{/* Mobile Navigation */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="border-t border-surface-700/50 bg-surface-900/50 backdrop-blur md:hidden">
|
<div className="border-t border-surface-700/50 bg-surface-900/50 backdrop-blur md:hidden">
|
||||||
|
|||||||
Reference in New Issue
Block a user