라이트 테마 토글 추가 (기반 구축)

- 헤더 우측 토글 버튼 + View Transition API 크로스페이드
- zustand persist 테마 스토어
- CSS 변수 기반 semantic 토큰 (:root / [data-theme="light"])
- 헤더/푸터/스크롤바(OverlayScrollbars 포함) 테마 대응

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-17 21:59:35 +09:00
parent 4fa3bdb4a6
commit 46ff03ced6
4 changed files with 195 additions and 26 deletions

View file

@ -1,18 +1,24 @@
export default function Footer() {
return (
<footer className="border-t border-white/5 mt-16">
<footer
className="border-t mt-16 transition-colors duration-300"
style={{ borderColor: 'var(--header-border)' }}
>
<div className="mx-auto max-w-5xl px-6 py-8 space-y-4">
<div className="flex items-center gap-2.5">
<img src="/favicon.ico" alt="" className="w-6 h-6" />
<span className="font-bold text-sm">메이플스토리 유틸리티</span>
</div>
<div className="grid gap-2 sm:grid-cols-2 text-xs text-gray-500">
<div
className="grid gap-2 sm:grid-cols-2 text-xs transition-colors duration-300"
style={{ color: 'var(--text-dim)' }}
>
<div className="space-y-1">
<p>This site is not associated with NEXON Korea.</p>
<p>
Data based on{' '}
<a href="https://openapi.nexon.com" target="_blank" rel="noopener noreferrer" className="text-emerald-400/80 hover:text-emerald-300 transition">
<a href="https://openapi.nexon.com" target="_blank" rel="noopener noreferrer" className="text-emerald-500 hover:text-emerald-400 transition">
NEXON Open API
</a>
.

View file

@ -3,6 +3,7 @@ import { Outlet, Link, useLocation, useMatch } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api/client'
import Footer from './Footer'
import { useThemeStore } from '../stores/theme'
const SITE_NAME = '메이플스토리 유틸리티'
@ -40,29 +41,100 @@ function CurrentMenuTitle() {
if (!menu) return null
return (
<div className="flex items-center gap-3 text-gray-400">
<span className="text-white/20">/</span>
<div
className="flex items-center gap-3 transition-colors duration-300"
style={{ color: 'var(--text-muted)' }}
>
<span style={{ color: 'var(--text-slash)' }}>/</span>
<div className="flex items-center gap-2">
{menu.image?.url && (
<img src={menu.image.url} alt="" className="w-5 h-5 object-contain" />
)}
<span className="text-sm font-medium text-gray-200">{menu.title}</span>
<span
className="text-sm font-medium transition-colors duration-300"
style={{ color: 'var(--text-emphasis)' }}
>
{menu.title}
</span>
</div>
</div>
)
}
function ThemeToggle() {
const theme = useThemeStore((s) => s.theme)
const toggleTheme = useThemeStore((s) => s.toggleTheme)
const isLight = theme === 'light'
const handleToggle = () => {
if (typeof document !== 'undefined' && document.startViewTransition) {
document.startViewTransition(() => toggleTheme())
} else {
toggleTheme()
}
}
return (
<button
type="button"
onClick={handleToggle}
aria-label={isLight ? '다크 모드로 전환' : '라이트 모드로 전환'}
title={isLight ? '다크 모드' : '라이트 모드'}
className="relative inline-flex h-8 w-14 items-center rounded-full border transition-colors duration-300 hover:border-emerald-500/40"
style={{
background: 'var(--toggle-bg)',
borderColor: 'var(--toggle-border)',
}}
>
<span
className="absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full shadow-md transition-[transform,background] duration-300 ease-out"
style={{
transform: isLight ? 'translateX(24px)' : 'translateX(0px)',
backgroundImage: 'linear-gradient(to bottom right, var(--toggle-thumb-from), var(--toggle-thumb-to))',
color: 'var(--toggle-thumb-icon)',
}}
>
{isLight ? (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3.5 w-3.5">
<path fillRule="evenodd" d="M10 2a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 2zM10 15a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 15zM18 10a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 0118 10zM5 10a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 015 10zM15.657 4.343a.75.75 0 010 1.06l-1.06 1.061a.75.75 0 11-1.061-1.06l1.06-1.061a.75.75 0 011.061 0zM6.464 13.536a.75.75 0 010 1.06l-1.06 1.061a.75.75 0 01-1.061-1.06l1.06-1.061a.75.75 0 011.061 0zM15.657 15.657a.75.75 0 01-1.06 0l-1.061-1.06a.75.75 0 011.06-1.061l1.061 1.06a.75.75 0 010 1.061zM6.464 6.464a.75.75 0 01-1.06 0L4.343 5.404a.75.75 0 011.06-1.06l1.061 1.06a.75.75 0 010 1.06zM10 6a4 4 0 100 8 4 4 0 000-8z" clipRule="evenodd" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3.5 w-3.5">
<path fillRule="evenodd" d="M7.455 2.004a.75.75 0 01.26.77 7 7 0 009.958 7.967.75.75 0 011.067.853A8.5 8.5 0 116.647 1.921a.75.75 0 01.808.083z" clipRule="evenodd" />
</svg>
)}
</span>
</button>
)
}
export default function Layout() {
const [fullscreen, setFullscreen] = useState(false)
const isAdmin = !!useMatch('/admin/*')
const homeTo = isAdmin ? '/admin' : '/'
const theme = useThemeStore((s) => s.theme)
useEffect(() => {
const root = document.documentElement
if (theme === 'light') root.setAttribute('data-theme', 'light')
else root.removeAttribute('data-theme')
}, [theme])
return (
<LayoutContext.Provider value={{ fullscreen, setFullscreen }}>
<div className={`min-w-[1280px] text-white flex flex-col ${
fullscreen ? 'h-dvh' : 'min-h-screen'
}`}>
<header className="sticky top-0 z-20 border-b border-white/5 bg-gray-950/80 backdrop-blur-md shrink-0">
<div
className={`min-w-[1280px] flex flex-col transition-colors duration-300 ${
fullscreen ? 'h-dvh' : 'min-h-screen'
}`}
style={{ color: 'var(--text-strong)' }}
>
<header
className="sticky top-0 z-20 border-b backdrop-blur-md shrink-0 transition-colors duration-300"
style={{
background: 'var(--header-bg)',
borderColor: 'var(--header-border)',
}}
>
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<Link to={homeTo} className="group flex items-center gap-2.5">
@ -73,6 +145,7 @@ export default function Layout() {
</Link>
<CurrentMenuTitle />
</div>
<ThemeToggle />
</div>
</header>
<main className={`flex-1 mx-auto w-full max-w-[1400px] ${

View file

@ -6,11 +6,64 @@
--font-maple: "Maplestory", "Noto Sans KR", sans-serif;
}
/* 테마 토큰 - dark (default) */
:root {
color-scheme: dark;
--bg-from: #030712;
--bg-via: #030712;
--bg-to: #0f172a;
--scrollbar-thumb: rgba(255, 255, 255, 0.12);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.22);
--scrollbar-track: transparent;
--header-bg: rgba(3, 7, 18, 0.8);
--header-border: rgba(255, 255, 255, 0.05);
--text-strong: #ffffff;
--text-emphasis: #e5e7eb;
--text-muted: #9ca3af;
--text-dim: #6b7280;
--text-slash: rgba(255, 255, 255, 0.2);
--toggle-bg: rgba(17, 24, 39, 0.6);
--toggle-border: rgba(255, 255, 255, 0.1);
--toggle-thumb-from: #e5e7eb;
--toggle-thumb-to: #9ca3af;
--toggle-thumb-icon: #0f172a;
}
/* 테마 토큰 - light */
[data-theme="light"] {
color-scheme: light;
--bg-from: #ffffff;
--bg-via: #ffffff;
--bg-to: #ffffff;
--scrollbar-thumb: #a0a0a0;
--scrollbar-thumb-hover: #707070;
--scrollbar-track: transparent;
--header-bg: rgba(255, 255, 255, 0.8);
--header-border: rgba(0, 0, 0, 0.08);
--text-strong: #0f172a;
--text-emphasis: #1f2937;
--text-muted: #475569;
--text-dim: #64748b;
--text-slash: rgba(0, 0, 0, 0.2);
--toggle-bg: rgba(241, 245, 249, 0.9);
--toggle-border: rgba(0, 0, 0, 0.08);
--toggle-thumb-from: #fde68a;
--toggle-thumb-to: #f59e0b;
--toggle-thumb-icon: #78350f;
}
html, body, #root {
min-height: 100%;
background-color: #030712;
background-image: linear-gradient(to bottom right, #030712, #030712, #0f172a);
background-color: var(--bg-from);
background-image: linear-gradient(to bottom right, var(--bg-from), var(--bg-via), var(--bg-to));
background-attachment: fixed;
transition: background-color 400ms ease, background-image 400ms ease;
}
html {
overscroll-behavior-y: contain;
@ -26,6 +79,13 @@ html {
--os-padding-axis: 2px;
}
/* 라이트 테마에서는 어두운 handle */
[data-theme="light"] .os-theme-maple.os-theme-dark {
--os-handle-bg: #a0a0a0;
--os-handle-bg-hover: #707070;
--os-handle-bg-active: #505050;
}
html {
font-family: "Maplestory", "Noto Sans KR", system-ui, sans-serif;
}
@ -62,27 +122,46 @@ input[type="number"] {
to { opacity: 1; transform: translateY(0); }
}
/* 테마 전환 뷰 트랜지션 - 부드러운 크로스페이드 */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 400ms;
animation-timing-function: ease;
}
::view-transition-old(root) {
animation-name: themeFadeOut;
}
::view-transition-new(root) {
animation-name: themeFadeIn;
}
@keyframes themeFadeOut {
to { opacity: 0; }
}
@keyframes themeFadeIn {
from { opacity: 0; }
}
/* 내부 스크롤 영역만 얇은 커스텀 스크롤바 (메인 페이지 스크롤은 기본) */
*:not(html):not(body) {
/* 커스텀 스크롤바 (테마별) */
* {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
*:not(html):not(body)::-webkit-scrollbar {
width: 8px;
height: 8px;
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*:not(html):not(body)::-webkit-scrollbar-track {
background: transparent;
*::-webkit-scrollbar-track {
background: var(--scrollbar-track);
}
*:not(html):not(body)::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 4px;
*::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 5px;
transition: background 0.2s;
}
*:not(html):not(body)::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.22);
*::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
*:not(html):not(body)::-webkit-scrollbar-corner {
*::-webkit-scrollbar-corner {
background: transparent;
}

View file

@ -0,0 +1,11 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useThemeStore = create(persist(
(set) => ({
theme: 'dark',
setTheme: (theme) => set({ theme }),
toggleTheme: () => set((s) => ({ theme: s.theme === 'dark' ? 'light' : 'dark' })),
}),
{ name: 'maple-theme' },
))