feat: Add persistent Dark Mode with ThemeToggle component and layout script
All checks were successful
Automated Container Build / build-and-push (push) Successful in 58s

This commit is contained in:
Elijah 2026-06-27 20:56:10 -07:00
parent ec8e601332
commit 87fc071961
5 changed files with 117 additions and 16 deletions

View file

@ -36,6 +36,36 @@
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
} }
@layer base {
.dark {
--color-bg-base: #0f1614;
--color-bg-surface: #17221f;
--color-bg-surface-alt: #1e2d28;
--color-bg-callout: #1a332a;
--color-primary: #4ab2b2;
--color-primary-hover: #5ec7c7;
--color-primary-light: #6cd1d1;
--color-text-heading: #e4f0ec;
--color-text-body: #c3d9d3;
--color-text-secondary: #9cb8b0;
--color-text-muted: #6b8a81;
--color-border: #38524a;
--color-border-light: #253832;
--color-success: #4ade80;
--color-success-bg: #143621;
--color-error: #f87171;
--color-error-bg: #3b1c1c;
--color-badge-bg: #214035;
--color-badge-text: #6fd6b8;
--color-danger: #ef4444;
--color-danger-hover: #f87171;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.6), 0 2px 4px rgba(0, 0, 0, 0.4);
--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.5), 0 8px 20px rgba(0, 0, 0, 0.3);
}
}
/* ── Base Styles ── */ /* ── Base Styles ── */
body { body {
background: var(--color-bg-base); background: var(--color-bg-base);

View file

@ -20,6 +20,21 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en" className={`${inter.variable} h-full`} suppressHydrationWarning> <html lang="en" className={`${inter.variable} h-full`} suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
try {
if (localStorage.theme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
} catch (_) {}
`,
}}
/>
</head>
<body className="min-h-full flex flex-col font-sans antialiased"> <body className="min-h-full flex flex-col font-sans antialiased">
{children} {children}
</body> </body>

View file

@ -2,6 +2,7 @@ import { getShareLink } from "@/services/shareService";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { FlashcardViewer } from "@/components/flashcards/FlashcardViewer"; import { FlashcardViewer } from "@/components/flashcards/FlashcardViewer";
import { QuizViewer } from "@/components/quizzes/QuizViewer"; import { QuizViewer } from "@/components/quizzes/QuizViewer";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
import Link from "next/link"; import Link from "next/link";
interface SharedPageProps { interface SharedPageProps {
@ -48,6 +49,8 @@ export default async function SharedPage(props: SharedPageProps) {
Shared {type === "flashcards" ? "Deck" : "Quiz"} Shared {type === "flashcards" ? "Deck" : "Quiz"}
</div> </div>
<div className="flex items-center gap-4">
<ThemeToggle />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm font-medium px-3 py-1 bg-badge-bg text-badge-text rounded-full"> <span className="text-sm font-medium px-3 py-1 bg-badge-bg text-badge-text rounded-full">
Read Only Read Only
@ -61,6 +64,7 @@ export default async function SharedPage(props: SharedPageProps) {
</div> </div>
</div> </div>
</div> </div>
</div>
</header> </header>
{/* Main Content */} {/* Main Content */}

View file

@ -2,6 +2,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { ThemeToggle } from "./ThemeToggle";
export function Navbar() { export function Navbar() {
const router = useRouter(); const router = useRouter();
@ -35,6 +36,8 @@ export function Navbar() {
</svg> </svg>
Study Study
</Link> </Link>
<div className="flex items-center gap-4">
<ThemeToggle />
<button <button
onClick={handleLogout} onClick={handleLogout}
className="text-sm text-text-muted hover:text-text-heading transition-colors cursor-pointer" className="text-sm text-text-muted hover:text-text-heading transition-colors cursor-pointer"
@ -43,6 +46,7 @@ export function Navbar() {
</button> </button>
</div> </div>
</div> </div>
</div>
</nav> </nav>
); );
} }

View file

@ -0,0 +1,48 @@
"use client";
import { useEffect, useState } from "react";
export function ThemeToggle() {
const [isDark, setIsDark] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
setIsDark(document.documentElement.classList.contains("dark"));
}, []);
if (!mounted) {
return <div className="w-8 h-8" />; // placeholder to prevent layout shift
}
function toggleTheme() {
const nextDark = !isDark;
setIsDark(nextDark);
if (nextDark) {
document.documentElement.classList.add("dark");
localStorage.theme = "dark";
} else {
document.documentElement.classList.remove("dark");
localStorage.theme = "light";
}
}
return (
<button
onClick={toggleTheme}
className="p-1.5 text-text-muted hover:text-text-heading transition-colors cursor-pointer rounded-lg hover:bg-bg-surface-alt flex items-center justify-center"
title={isDark ? "Switch to Light Mode" : "Switch to Dark Mode"}
aria-label="Toggle Dark Mode"
>
{isDark ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
</button>
);
}