라이트 테마 토글 추가 (기반 구축)
- 헤더 우측 토글 버튼 + 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:
parent
4fa3bdb4a6
commit
46ff03ced6
4 changed files with 195 additions and 26 deletions
|
|
@ -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>
|
||||
.
|
||||
|
|
|
|||
|
|
@ -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 ${
|
||||
<div
|
||||
className={`min-w-[1280px] flex flex-col transition-colors duration-300 ${
|
||||
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">
|
||||
}`}
|
||||
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] ${
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
11
frontend/src/stores/theme.js
Normal file
11
frontend/src/stores/theme.js
Normal 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' },
|
||||
))
|
||||
Loading…
Add table
Reference in a new issue