feat: implement dark mode support with theme provider and toggle component
This commit is contained in:
parent
0086c0142b
commit
cf70aa4a67
12 changed files with 248 additions and 31 deletions
|
|
@ -10,6 +10,7 @@
|
||||||
"format": "biome format --write"
|
"format": "biome format --write"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.8.0",
|
||||||
"next": "16.2.3",
|
"next": "16.2.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
|
|
||||||
|
|
@ -321,7 +321,7 @@ export default function AddPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 />
|
<Header />
|
||||||
|
|
||||||
<main className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
|
<main className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
|
|
||||||
|
|
@ -301,7 +301,7 @@ export default function DashboardPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 />
|
<Header />
|
||||||
|
|
||||||
<main className="mx-auto w-full max-w-6xl space-y-8 px-4 py-8 sm:px-6 lg:px-8">
|
<main className="mx-auto w-full max-w-6xl space-y-8 px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ export default async function DetailPage({ params }: { params: Promise<{ id: str
|
||||||
post = await postPromise;
|
post = await postPromise;
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
return (
|
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 />
|
<Header />
|
||||||
<main className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
|
<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">
|
<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 ?? [];
|
const tags = post.tags ?? [];
|
||||||
|
|
||||||
return (
|
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 />
|
<Header />
|
||||||
|
|
||||||
<main className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
|
<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}
|
alt={post.author ?? post._id}
|
||||||
loading="eager"
|
loading="eager"
|
||||||
decoding="async"
|
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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -294,7 +294,7 @@ export default async function DetailPage({ params }: { params: Promise<{ id: str
|
||||||
href={post.url}
|
href={post.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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>
|
</a>
|
||||||
|
|
@ -302,7 +302,7 @@ export default async function DetailPage({ params }: { params: Promise<{ id: str
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
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>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,7 @@ export default function EditPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 />
|
<Header />
|
||||||
|
|
||||||
<main className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
|
<main className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@
|
||||||
src: url("/fonts/Orbit-Regular.woff2") format("truetype");
|
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-background: hsl(340 40% 98%);
|
||||||
--color-foreground: hsl(315 21% 8%);
|
--color-foreground: hsl(315 21% 8%);
|
||||||
--color-card: hsl(340 40% 98%);
|
--color-card: hsl(340 40% 98%);
|
||||||
|
|
@ -45,10 +48,52 @@
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--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 {
|
:root {
|
||||||
--scrollbar: hsla(340 10% 60% / 0.5);
|
--scrollbar: hsla(340 10% 60% / 0.5);
|
||||||
--scrollbar-hover: hsla(340 10% 60% / 0.8);
|
--scrollbar-hover: hsla(340 10% 60% / 0.8);
|
||||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--selection: #ff69b4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-scale {
|
.image-scale {
|
||||||
|
|
@ -87,21 +132,31 @@
|
||||||
background: var(--scrollbar-hover);
|
background: var(--scrollbar-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ::-webkit-scrollbar:not(.highlighttable, .highlight table, .gist .highlight) {
|
|
||||||
background: var(--theme);
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
/* reset */
|
/* reset */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 19px;
|
width: 19px;
|
||||||
height: 11px;
|
height: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* from PaperMod https://github.com/adityatelange/hugo-PaperMod/blob/c98a924842fc7ee0c14212c316c69ede3ad76ca3/assets/css/includes/scroll-bar.css */
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground min-h-screen font-sans antialiased;
|
@apply bg-background text-foreground min-h-screen font-sans antialiased;
|
||||||
font-family: "Orbit", sans-serif;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import "./globals.css";
|
||||||
import "react-photo-album/masonry.css";
|
import "react-photo-album/masonry.css";
|
||||||
|
|
||||||
|
|
||||||
|
import { ThemeProvider } from "../components/theme-provider";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Akiyama Mizuki",
|
title: "Akiyama Mizuki",
|
||||||
description: "Gallery"
|
description: "Gallery"
|
||||||
|
|
@ -17,8 +19,13 @@ export default function RootLayout({
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
className={`h-full antialiased`}
|
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>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ type GalleryPhoto = {
|
||||||
key: string;
|
key: string;
|
||||||
alt: string;
|
alt: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
source: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Me = {
|
type Me = {
|
||||||
|
|
@ -414,6 +415,7 @@ export default function App() {
|
||||||
key: upload._id,
|
key: upload._id,
|
||||||
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
|
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
|
||||||
author: upload.author?.trim() || "unknown",
|
author: upload.author?.trim() || "unknown",
|
||||||
|
source: upload.tweet?.url || "",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
image.onerror = () => {
|
image.onerror = () => {
|
||||||
|
|
@ -424,6 +426,7 @@ export default function App() {
|
||||||
key: upload._id,
|
key: upload._id,
|
||||||
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
|
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
|
||||||
author: upload.author?.trim() || "unknown",
|
author: upload.author?.trim() || "unknown",
|
||||||
|
source: upload.tweet?.url || "",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
@ -443,7 +446,7 @@ export default function App() {
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="sticky top-0 z-50">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="border-b border-border bg-background/85 backdrop-blur px-6 py-2">
|
<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) => (
|
{Array.from({ length: 12 }).map((_, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="aspect-4/5 animate-pulse bg-black/8"
|
className="aspect-4/5 animate-pulse bg-muted/50"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -515,10 +518,19 @@ export default function App() {
|
||||||
decoding="async"
|
decoding="async"
|
||||||
className="block w-full"
|
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="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">
|
<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>
|
<span className="truncate">© {photo.author}</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
),
|
),
|
||||||
|
|
@ -564,7 +576,7 @@ export default function App() {
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="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 () => {
|
onClick={async () => {
|
||||||
await copyImage(contextMenu.photo);
|
await copyImage(contextMenu.photo);
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
|
|
@ -574,7 +586,7 @@ export default function App() {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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={() => {
|
onClick={() => {
|
||||||
saveImage(contextMenu.photo);
|
saveImage(contextMenu.photo);
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
|
|
@ -591,7 +603,7 @@ export default function App() {
|
||||||
{Array.from({ length: 5 }).map((_, index) => (
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
<div
|
<div
|
||||||
key={`loading-more-${index}`}
|
key={`loading-more-${index}`}
|
||||||
className="aspect-4/5 animate-pulse bg-black/8"
|
className="aspect-4/5 animate-pulse bg-muted/50"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -615,7 +627,7 @@ export default function App() {
|
||||||
<div className="mt-5 flex justify-end gap-2">
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="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)}
|
onClick={() => setDeleteTarget(null)}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import ThemeToggle from "./theme-toggle";
|
||||||
|
|
||||||
type Me = {
|
type Me = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -139,12 +140,17 @@ export default function Header() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="relative z-60 flex h-16 items-center justify-between border-b border-border bg-background/90 backdrop-blur px-6">
|
<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 ? (
|
{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" ? (
|
{me.role === "admin" || me.role === "writer" ? (
|
||||||
<Link href="/add" className="text-[16px] text-foreground/50">[ <span className="text-foreground">+</span> ]</Link>
|
<Link href="/add" className="text-[16px] text-foreground/50">[ <span className="text-foreground">+</span> ]</Link>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -170,7 +176,7 @@ export default function Header() {
|
||||||
{me.role === "admin" ? (
|
{me.role === "admin" ? (
|
||||||
<a
|
<a
|
||||||
href="/dashboard"
|
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)}
|
onClick={() => setMenuOpen(false)}
|
||||||
>
|
>
|
||||||
Dashboard
|
Dashboard
|
||||||
|
|
@ -178,7 +184,7 @@ export default function Header() {
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="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={() => {
|
onClick={() => {
|
||||||
void logout();
|
void logout();
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
52
apps/frontend/src/components/theme-provider.tsx
Normal file
52
apps/frontend/src/components/theme-provider.tsx
Normal 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;
|
||||||
|
}
|
||||||
81
apps/frontend/src/components/theme-toggle.tsx
Normal file
81
apps/frontend/src/components/theme-toggle.tsx
Normal 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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
bun.lock
5
bun.lock
|
|
@ -24,6 +24,7 @@
|
||||||
"name": "akiyama.mizuki.guru",
|
"name": "akiyama.mizuki.guru",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.8.0",
|
||||||
"next": "16.2.3",
|
"next": "16.2.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
|
@ -397,7 +398,7 @@
|
||||||
|
|
||||||
"bson": ["bson@7.2.0", "", {}, "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ=="],
|
"bson": ["bson@7.2.0", "", {}, "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
|
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001788", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001788", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="],
|
||||||
|
|
||||||
|
|
@ -459,6 +460,8 @@
|
||||||
|
|
||||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||||
|
|
||||||
|
"lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
|
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue