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
All checks were successful
Automated Container Build / build-and-push (push) Successful in 58s
This commit is contained in:
parent
ec8e601332
commit
87fc071961
5 changed files with 117 additions and 16 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,16 +49,19 @@ 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-3">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-sm font-medium px-3 py-1 bg-badge-bg text-badge-text rounded-full">
|
<ThemeToggle />
|
||||||
Read Only
|
<div className="flex items-center gap-3">
|
||||||
</span>
|
<span className="text-sm font-medium px-3 py-1 bg-badge-bg text-badge-text rounded-full">
|
||||||
<Link
|
Read Only
|
||||||
href="/login"
|
</span>
|
||||||
className="text-sm font-medium text-primary hover:text-primary-hover transition-colors"
|
<Link
|
||||||
>
|
href="/login"
|
||||||
Sign In
|
className="text-sm font-medium text-primary hover:text-primary-hover transition-colors"
|
||||||
</Link>
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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,12 +36,15 @@ export function Navbar() {
|
||||||
</svg>
|
</svg>
|
||||||
Study
|
Study
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<div className="flex items-center gap-4">
|
||||||
onClick={handleLogout}
|
<ThemeToggle />
|
||||||
className="text-sm text-text-muted hover:text-text-heading transition-colors cursor-pointer"
|
<button
|
||||||
>
|
onClick={handleLogout}
|
||||||
Sign out
|
className="text-sm text-text-muted hover:text-text-heading transition-colors cursor-pointer"
|
||||||
</button>
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
48
src/components/ui/ThemeToggle.tsx
Normal file
48
src/components/ui/ThemeToggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue