feat: implement dark mode support with theme provider and toggle component

This commit is contained in:
암냥 2026-04-21 22:26:20 +09:00
commit cf70aa4a67
No known key found for this signature in database
12 changed files with 248 additions and 31 deletions

View file

@ -10,6 +10,7 @@
"format": "biome format --write"
},
"dependencies": {
"lucide-react": "^1.8.0",
"next": "16.2.3",
"react": "19.2.4",
"react-dom": "19.2.4",

View file

@ -321,7 +321,7 @@ export default function AddPage() {
}
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.9),rgba(247,229,236,0.85)_45%,rgba(244,240,243,1)_100%)] text-foreground">
<div className="min-h-screen bg-main-gradient text-foreground transition-colors duration-500">
<Header />
<main className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">

View file

@ -301,7 +301,7 @@ export default function DashboardPage() {
}
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.9),rgba(247,229,236,0.85)_45%,rgba(244,240,243,1)_100%)] text-foreground">
<div className="min-h-screen bg-main-gradient text-foreground transition-colors duration-500">
<Header />
<main className="mx-auto w-full max-w-6xl space-y-8 px-4 py-8 sm:px-6 lg:px-8">

View file

@ -190,7 +190,7 @@ export default async function DetailPage({ params }: { params: Promise<{ id: str
post = await postPromise;
} catch (loadError) {
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.9),rgba(247,229,236,0.85)_45%,rgba(244,240,243,1)_100%)] text-foreground">
<div className="min-h-screen bg-main-gradient text-foreground transition-colors duration-500">
<Header />
<main className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
<section className="border-b border-border/70 pb-6">
@ -226,7 +226,7 @@ export default async function DetailPage({ params }: { params: Promise<{ id: str
const tags = post.tags ?? [];
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.9),rgba(247,229,236,0.85)_45%,rgba(244,240,243,1)_100%)] text-foreground">
<div className="min-h-screen bg-main-gradient text-foreground transition-colors duration-500">
<Header />
<main className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
@ -249,10 +249,10 @@ export default async function DetailPage({ params }: { params: Promise<{ id: str
alt={post.author ?? post._id}
loading="eager"
decoding="async"
className="w-full border border-border bg-white object-contain image-scale"
className="w-full border border-border bg-card object-contain image-scale"
/>
) : (
<div className="aspect-4/5 w-full border border-border bg-black/5" />
<div className="aspect-4/5 w-full border border-border bg-muted/30" />
)}
</div>
@ -294,7 +294,7 @@ export default async function DetailPage({ params }: { params: Promise<{ id: str
href={post.url}
target="_blank"
rel="noopener noreferrer"
className="border border-border px-4 py-2 text-sm text-foreground/80 transition hover:bg-black/5"
className="border border-border px-4 py-2 text-sm text-foreground/80 transition hover:bg-accent"
>
</a>
@ -302,7 +302,7 @@ export default async function DetailPage({ params }: { params: Promise<{ id: str
<div className="flex flex-row gap-2">
<Link
href="/"
className="border border-border px-4 py-2 text-sm text-foreground/70 transition hover:bg-black/5 w-full"
className="border border-border px-4 py-2 text-sm text-foreground/70 transition hover:bg-accent w-full"
>
</Link>

View file

@ -168,7 +168,7 @@ export default function EditPage() {
}
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.9),rgba(247,229,236,0.85)_45%,rgba(244,240,243,1)_100%)] text-foreground">
<div className="min-h-screen bg-main-gradient text-foreground transition-colors duration-500">
<Header />
<main className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">

View file

@ -5,7 +5,10 @@
src: url("/fonts/Orbit-Regular.woff2") format("truetype");
}
@theme inline {
/* Custom dark variant for class-based dark mode in Tailwind v4 */
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-background: hsl(340 40% 98%);
--color-foreground: hsl(315 21% 8%);
--color-card: hsl(340 40% 98%);
@ -45,10 +48,52 @@
--radius-xl: calc(var(--radius) + 4px);
}
@layer theme {
.dark {
--color-background: hsl(240 10% 4%);
--color-foreground: hsl(0 0% 95%);
--color-card: hsl(240 10% 5%);
--color-card-foreground: hsl(0 0% 95%);
--color-popover: hsl(240 10% 4%);
--color-popover-foreground: hsl(0 0% 95%);
--color-primary: hsl(340 50% 70%);
--color-primary-foreground: hsl(240 10% 4%);
--color-secondary: hsl(240 5% 15%);
--color-secondary-foreground: hsl(0 0% 95%);
--color-muted: hsl(240 5% 12%);
--color-muted-foreground: hsl(240 5% 65%);
--color-accent: hsl(240 5% 15%);
--color-accent-foreground: hsl(0 0% 95%);
--color-destructive: hsl(0 62.8% 30.6%);
--color-destructive-foreground: hsl(0 0% 95%);
--color-border: hsl(240 5% 18%);
--color-input: hsl(240 5% 18%);
--color-ring: hsl(340 50% 70%);
--color-chart-1: hsl(220 70% 50%);
--color-chart-2: hsl(160 60% 45%);
--color-chart-3: hsl(30 80% 55%);
--color-chart-4: hsl(280 65% 60%);
--color-chart-5: hsl(340 75% 55%);
--color-sidebar: hsl(240 10% 5%);
--color-sidebar-foreground: hsl(240 5% 90%);
--color-sidebar-primary: hsl(340 50% 70%);
--color-sidebar-primary-foreground: hsl(240 10% 4%);
--color-sidebar-accent: hsl(240 5% 15%);
--color-sidebar-accent-foreground: hsl(0 0% 95%);
--color-sidebar-border: hsl(240 5% 18%);
--color-sidebar-ring: hsl(340 50% 70%);
--scrollbar: hsla(240 5% 40% / 0.5);
--scrollbar-hover: hsla(240 5% 40% / 0.8);
--selection: #ff1493;
}
}
:root {
--scrollbar: hsla(340 10% 60% / 0.5);
--scrollbar-hover: hsla(340 10% 60% / 0.8);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--selection: #ff69b4;
}
.image-scale {
@ -87,21 +132,31 @@
background: var(--scrollbar-hover);
}
/* ::-webkit-scrollbar:not(.highlighttable, .highlight table, .gist .highlight) {
background: var(--theme);
}
*/
/* reset */
::-webkit-scrollbar {
width: 19px;
height: 11px;
}
/* from PaperMod https://github.com/adityatelange/hugo-PaperMod/blob/c98a924842fc7ee0c14212c316c69ede3ad76ca3/assets/css/includes/scroll-bar.css */
@layer base {
body {
@apply bg-background text-foreground min-h-screen font-sans antialiased;
font-family: "Orbit", sans-serif;
}
}
@layer utilities {
.bg-main-gradient {
background-image: radial-gradient(circle at top, rgba(255, 255, 255, 0.9), rgba(247, 229, 236, 0.85) 45%, rgba(244, 240, 243, 1) 100%);
}
.dark .bg-main-gradient {
background-image:
radial-gradient(circle at top left, rgba(255, 105, 180, 0.03), transparent 40%),
radial-gradient(circle at top, rgba(80, 80, 100, 0.08), rgba(10, 10, 15, 0.95) 40%, rgba(5, 5, 8, 1) 100%);
}
.dark img {
filter: invert(1) hue-rotate(180deg);
}
}

View file

@ -3,6 +3,8 @@ import "./globals.css";
import "react-photo-album/masonry.css";
import { ThemeProvider } from "../components/theme-provider";
export const metadata: Metadata = {
title: "Akiyama Mizuki",
description: "Gallery"
@ -17,8 +19,13 @@ export default function RootLayout({
<html
lang="en"
className={`h-full antialiased`}
suppressHydrationWarning
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col bg-background text-foreground transition-colors duration-300">
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}

View file

@ -29,6 +29,7 @@ type GalleryPhoto = {
key: string;
alt: string;
author: string;
source: string;
};
type Me = {
@ -414,6 +415,7 @@ export default function App() {
key: upload._id,
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
author: upload.author?.trim() || "unknown",
source: upload.tweet?.url || "",
});
};
image.onerror = () => {
@ -424,6 +426,7 @@ export default function App() {
key: upload._id,
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
author: upload.author?.trim() || "unknown",
source: upload.tweet?.url || "",
});
};
}),
@ -443,7 +446,7 @@ export default function App() {
}, [items]);
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.9),rgba(247,229,236,0.85)_45%,rgba(244,240,243,1)_100%)] text-foreground">
<div className="min-h-screen bg-main-gradient text-foreground transition-colors duration-500">
<div className="sticky top-0 z-50">
<Header />
<div className="border-b border-border bg-background/85 backdrop-blur px-6 py-2">
@ -482,7 +485,7 @@ export default function App() {
{Array.from({ length: 12 }).map((_, index) => (
<div
key={index}
className="aspect-4/5 animate-pulse bg-black/8"
className="aspect-4/5 animate-pulse bg-muted/50"
/>
))}
</div>
@ -515,10 +518,19 @@ export default function App() {
decoding="async"
className="block w-full"
/>
<div className="pointer-events-none absolute inset-x-3 bottom-3 translate-y-3 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100">
<div className="inline-flex max-w-full items-center rounded-t-sm rounded-b-lg bg-black/72 px-3 py-1.5 text-xs text-white backdrop-blur-sm">
<div className="absolute inset-x-3 bottom-3 translate-y-3 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100">
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
window.open(photo.source, "_blank", "noopener,noreferrer");
}}
className="inline-flex max-w-full items-center rounded-t-sm rounded-b-lg bg-black/72 px-3 py-1.5 text-xs text-white backdrop-blur-sm pointer-events-auto"
title="원본 이미지 열기"
>
<span className="truncate">© {photo.author}</span>
</div>
</button>
</div>
</a>
),
@ -564,7 +576,7 @@ export default function App() {
) : null}
<button
type="button"
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-black/5"
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-accent"
onClick={async () => {
await copyImage(contextMenu.photo);
setContextMenu(null);
@ -574,7 +586,7 @@ export default function App() {
</button>
<button
type="button"
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-black/5"
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-accent"
onClick={() => {
saveImage(contextMenu.photo);
setContextMenu(null);
@ -591,7 +603,7 @@ export default function App() {
{Array.from({ length: 5 }).map((_, index) => (
<div
key={`loading-more-${index}`}
className="aspect-4/5 animate-pulse bg-black/8"
className="aspect-4/5 animate-pulse bg-muted/50"
/>
))}
</div>
@ -615,7 +627,7 @@ export default function App() {
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
className="rounded px-4 py-2 text-sm text-foreground/70 hover:bg-black/5 disabled:opacity-50"
className="rounded px-4 py-2 text-sm text-foreground/70 hover:bg-accent disabled:opacity-50"
onClick={() => setDeleteTarget(null)}
disabled={isDeleting}
>

View file

@ -2,6 +2,7 @@
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import ThemeToggle from "./theme-toggle";
type Me = {
id: string;
@ -139,12 +140,17 @@ export default function Header() {
return (
<header className="relative z-60 flex h-16 items-center justify-between border-b border-border bg-background/90 backdrop-blur px-6">
<Link href="/" className="text-2xl" id="icon" onClick={handleIconClick}>🎀</Link>
<div className="flex items-center gap-6">
<Link href="/" className="text-2xl" id="icon" onClick={handleIconClick}>🎀</Link>
</div>
{me ? (
<div className="relative flex items-center gap-4" id="menu" ref={menuRef}>
<div className="relative flex items-center gap-4" id="menu" ref={menuRef}>
{me.role === "admin" || me.role === "writer" ? (
<Link href="/add" className="text-[16px] text-foreground/50">[ <span className="text-foreground">+</span> ]</Link>
) : null}
<div>
<ThemeToggle />
</div>
<button
type="button"
@ -170,7 +176,7 @@ export default function Header() {
{me.role === "admin" ? (
<a
href="/dashboard"
className="block rounded px-3 py-2 text-sm text-foreground/80 hover:bg-black/5"
className="block rounded px-3 py-2 text-sm text-foreground/80 hover:bg-accent"
onClick={() => setMenuOpen(false)}
>
Dashboard
@ -178,7 +184,7 @@ export default function Header() {
) : null}
<button
type="button"
className="block w-full rounded px-3 py-2 text-left text-sm text-red-600 hover:bg-red-50"
className="block w-full rounded px-3 py-2 text-left text-sm text-red-600 hover:bg-destructive/10 transition-colors"
onClick={() => {
void logout();
}}

View file

@ -0,0 +1,52 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "light" | "dark";
type ThemeContextType = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>("light");
const [mounted, setMounted] = useState(false);
useEffect(() => {
const savedTheme = localStorage.getItem("theme") as Theme | null;
if (savedTheme) {
setThemeState(savedTheme);
}
setMounted(true);
}, []);
useEffect(() => {
if (!mounted) return;
const root = window.document.documentElement;
root.classList.remove("light", "dark");
root.classList.add(theme);
localStorage.setItem("theme", theme);
}, [theme, mounted]);
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
};
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}

View file

@ -0,0 +1,81 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useTheme } from "./theme-provider";
import { MoonIcon, SunIcon } from "lucide-react";
export default function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [showWarning, setShowWarning] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const handleToggle = () => {
if (theme === "light") {
setShowWarning(true);
} else {
setTheme("light");
}
};
const confirmDarkMode = () => {
setTheme("dark");
setShowWarning(false);
};
const modalUI = showWarning && mounted ? createPortal(
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm px-4 animate-in fade-in duration-300">
<div className="w-full max-w-xl overflow-hidden rounded-2xl border border-border bg-background p-0 shadow-2xl animate-in zoom-in-95 duration-300">
<div className="bg-primary/10 px-6 py-4 border-b border-border">
<h3 className="font-bold text-primary"> </h3>
</div>
<div className="p-6">
<p className="text-foreground/80 leading-relaxed">
.<br />
.<br />
- <br />
- <br />
-
</p>
<p className="mt-4 text-sm font-medium text-red-500">
?
</p>
<div className="mt-8 flex justify-end gap-3">
<button
type="button"
onClick={() => setShowWarning(false)}
className="rounded-lg px-5 py-2.5 text-sm font-medium text-foreground/70 transition-colors hover:bg-black/5"
>
</button>
<button
type="button"
onClick={confirmDarkMode}
className="rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-all hover:opacity-90 hover:scale-[1.02] active:scale-[0.98]"
>
, .
</button>
</div>
</div>
</div>
</div>,
document.body
) : null;
return (
<>
<button
type="button"
onClick={handleToggle}
className="flex h-8 w-8 items-center justify-center rounded-full bg-background/50 text-xl transition-all"
title="Toggle Theme"
>
{theme === "light" ? <MoonIcon width={16} height={16} /> : <SunIcon width={16} height={16} />}
</button>
{modalUI}
</>
);
}