.
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m5s

This commit is contained in:
wypark
2026-05-28 22:27:29 +09:00
parent 390778da3e
commit 8a2e05b003
31 changed files with 1221 additions and 385 deletions

View File

@@ -57,7 +57,10 @@ export default function AdminRouteShell({ children }: { children: React.ReactNod
return (
<div className="mx-auto max-w-7xl space-y-6 px-1 py-4 md:px-4">
<nav className="flex gap-2 overflow-x-auto border-b border-[var(--color-line)] pb-3" aria-label="관리자 메뉴">
<nav
className="flex gap-2 overflow-x-auto rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-1 shadow-[var(--shadow-control)] backdrop-blur-2xl"
aria-label="관리자 메뉴"
>
{navItems.map((item) => {
const Icon = item.icon;
const isActive = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
@@ -67,10 +70,10 @@ export default function AdminRouteShell({ children }: { children: React.ReactNod
key={item.href}
href={item.href}
className={clsx(
'inline-flex shrink-0 items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold transition',
'inline-flex h-9 shrink-0 items-center gap-2 rounded-full px-4 text-sm font-semibold transition',
isActive
? 'bg-[var(--color-text)] text-white shadow-sm dark:bg-white dark:text-black'
: 'border border-[var(--color-line)] bg-white/70 text-[var(--color-text-muted)] hover:bg-white hover:text-[var(--color-text)] dark:bg-white/10',
? 'bg-[var(--color-surface-strong)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
)}
>
<Icon size={16} />

View File

@@ -56,7 +56,7 @@ export default function AdminDashboardTrafficChart({
</div>
{points.length > 0 ? (
<div className="flex h-64 items-end gap-1.5 overflow-hidden rounded-lg border border-[var(--color-line)] bg-white/50 px-3 py-4 dark:bg-white/5">
<div className="flex h-64 items-end gap-1.5 overflow-hidden rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 py-4">
{points.map((point) => {
const height = Math.max((point.views / maxViews) * 100, 4);

View File

@@ -62,7 +62,7 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
};
return (
<form onSubmit={handleSubmit} className="rounded-lg border border-[var(--color-line)] bg-white/50 p-4 dark:bg-white/5">
<form onSubmit={handleSubmit} className="rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-4">
{/* 비회원 입력 필드 */}
{!isLoggedIn && (
<div className="flex gap-2 mb-3">
@@ -71,7 +71,7 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
placeholder="닉네임"
value={guestInfo.nickname}
onChange={(e) => setGuestInfo({ ...guestInfo, nickname: e.target.value })}
className="w-1/2 rounded-lg border border-[var(--color-line)] bg-white/70 px-3 py-2 text-sm text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)] focus:ring-2 focus:ring-blue-500/15 dark:bg-white/10"
className="w-1/2 rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 py-2 text-sm text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
required
/>
<input
@@ -79,7 +79,7 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
placeholder="비밀번호"
value={guestInfo.password}
onChange={(e) => setGuestInfo({ ...guestInfo, password: e.target.value })}
className="w-1/2 rounded-lg border border-[var(--color-line)] bg-white/70 px-3 py-2 text-sm text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)] focus:ring-2 focus:ring-blue-500/15 dark:bg-white/10"
className="w-1/2 rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 py-2 text-sm text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
required
/>
</div>
@@ -91,13 +91,13 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={isLoggedIn ? placeholder : '댓글을 남겨보세요.'}
className="h-24 w-full resize-none rounded-lg border border-[var(--color-line)] bg-white/70 p-3 pr-12 text-sm text-[var(--color-text)] outline-none transition placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)] focus:ring-2 focus:ring-blue-500/15 dark:bg-white/10"
className="h-24 w-full resize-none rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] p-3 pr-12 text-sm text-[var(--color-text)] outline-none transition placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)]"
required
/>
<button
type="submit"
disabled={mutation.isPending}
className="absolute bottom-3 right-3 p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-300"
className="absolute bottom-3 right-3 rounded-lg bg-[var(--color-accent)] p-2 text-white transition-colors hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-50"
title="등록"
>
{mutation.isPending ? <Loader2 className="animate-spin" size={16} /> : <Send size={16} />}

View File

@@ -85,8 +85,8 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
className={clsx(
"relative p-4 rounded-xl transition-colors group",
comment.isPostAuthor
? "border border-blue-500/15 bg-blue-500/10"
: "border border-[var(--color-line)] bg-white/60 dark:bg-white/5"
? "border border-blue-500/15 bg-[var(--color-accent-soft)]"
: "border border-[var(--color-line)] bg-[var(--color-surface)]"
)}
>
<div className="flex items-center justify-between mb-2">
@@ -94,7 +94,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
<div
className={clsx(
"flex items-center justify-center rounded-full p-1.5",
comment.isPostAuthor ? "bg-blue-500/15 text-blue-600 dark:text-blue-300" :
comment.isPostAuthor ? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]" :
!isGuestComment ? "bg-green-500/15 text-green-600 dark:text-green-300" :
"bg-black/[0.05] text-[var(--color-text-muted)] dark:bg-white/10"
)}
@@ -105,9 +105,9 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-0 sm:gap-2">
<span className={clsx("flex items-center gap-1 text-sm font-bold", comment.isPostAuthor ? "text-blue-700 dark:text-blue-300" : "text-[var(--color-text)]")}>
<span className={clsx("flex items-center gap-1 text-sm font-bold", comment.isPostAuthor ? "text-[var(--color-accent)]" : "text-[var(--color-text)]")}>
{comment.author}
{comment.isPostAuthor && <span className="px-1.5 py-0.5 bg-blue-100 text-blue-600 text-[10px] rounded-full font-medium"></span>}
{comment.isPostAuthor && <span className="rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]"></span>}
</span>
<span className="text-xs text-[var(--color-text-subtle)]">
{format(new Date(comment.createdAt), 'yyyy.MM.dd HH:mm')}
@@ -118,7 +118,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => setIsReplying(!isReplying)}
className="rounded p-1.5 text-[var(--color-text-subtle)] hover:bg-blue-500/10 hover:text-blue-600 dark:hover:text-blue-300"
className="rounded p-1.5 text-[var(--color-text-subtle)] hover:bg-[var(--color-accent-soft)] hover:text-[var(--color-accent)]"
title="답글 달기"
>
<MessageSquare size={14} />
@@ -152,7 +152,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
placeholder="비밀번호 입력"
value={guestPassword}
onChange={(e) => setGuestPassword(e.target.value)}
className="rounded border border-[var(--color-line)] bg-white/70 px-2 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none dark:bg-white/10"
className="rounded border border-[var(--color-line)] bg-[var(--color-control)] px-2 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
autoFocus
/>
<button

View File

@@ -27,7 +27,7 @@ export default function CommentList({ postSlug }: CommentListProps) {
const totalCount = comments ? countComments(comments) : 0;
if (isLoading) {
return <div className="py-10 flex justify-center"><Loader2 className="animate-spin text-blue-500" /></div>;
return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-[var(--color-accent)]" /></div>;
}
if (error) {
@@ -37,9 +37,9 @@ export default function CommentList({ postSlug }: CommentListProps) {
return (
<div className="mt-16 border-t border-[var(--color-line)] pt-10">
<div className="flex items-center gap-2 mb-6">
<MessageCircle className="text-blue-600" size={24} />
<MessageCircle className="text-[var(--color-accent)]" size={24} />
<h3 className="text-xl font-bold text-[var(--color-text)]">
<span className="text-blue-600">{totalCount}</span>
<span className="text-[var(--color-accent)]">{totalCount}</span>
</h3>
</div>
@@ -55,7 +55,7 @@ export default function CommentList({ postSlug }: CommentListProps) {
<CommentItem key={comment.id} comment={comment} postSlug={postSlug} />
))
) : (
<div className="rounded-lg border border-dashed border-[var(--color-line)] bg-white/45 py-10 text-center text-sm text-[var(--color-text-muted)] dark:bg-white/5">
<div className="rounded-lg border border-dashed border-[var(--color-line)] bg-[var(--color-surface)] py-10 text-center text-sm text-[var(--color-text-muted)]">
. !
</div>
)}

View File

@@ -149,7 +149,7 @@ function SidebarContent() {
<button
type="button"
onClick={() => setIsOpen((previous) => !previous)}
className="fixed left-4 top-4 z-50 rounded-full border border-[var(--color-line)] bg-[var(--color-surface-strong)] p-2 shadow-md transition-colors hover:bg-white md:hidden"
className="fixed left-4 top-4 z-50 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-2 shadow-[var(--shadow-control)] backdrop-blur-xl transition-colors hover:bg-[var(--color-surface-strong)] md:hidden"
aria-label={isOpen ? '사이드바 닫기' : '사이드바 열기'}
>
{isOpen ? <X size={20} /> : <Menu size={20} />}
@@ -274,14 +274,14 @@ function SidebarContent() {
href={displayProfile.githubUrl || '#'}
target="_blank"
rel="noreferrer"
className="rounded-full border border-[var(--color-line)] bg-white/70 p-2.5 text-[var(--color-text-muted)] shadow-sm transition-all hover:bg-[var(--color-text)] hover:text-white dark:bg-white/10 dark:hover:bg-white dark:hover:text-black"
className="rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-2.5 text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition-all hover:bg-[var(--color-text)] hover:text-white dark:hover:bg-white dark:hover:text-black"
aria-label="GitHub"
>
<Github size={18} />
</a>
<a
href={`mailto:${displayProfile.email}`}
className="rounded-full border border-[var(--color-line)] bg-white/70 p-2.5 text-[var(--color-text-muted)] shadow-sm transition-all hover:bg-[var(--color-accent)] hover:text-white dark:bg-white/10"
className="rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-2.5 text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition-all hover:bg-[var(--color-accent)] hover:text-white"
aria-label="이메일"
>
<Mail size={18} />

View File

@@ -5,6 +5,7 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/store/authStore';
import { LogOut, PenLine, Settings, User, UserPlus } from 'lucide-react';
import ThemeToggle from '@/components/theme/ThemeToggle';
export default function TopHeader() {
const router = useRouter();
@@ -19,58 +20,60 @@ export default function TopHeader() {
}
};
if (!_hasHydrated) return null;
return (
<div className="absolute top-6 right-6 z-30 flex items-center gap-3">
{isLoggedIn ? (
<>
{isAdmin && (
<>
<Link
href="/admin"
className="flex items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/75 px-4 py-2 text-sm font-semibold text-[var(--color-text-muted)] shadow-sm backdrop-blur-xl transition-colors hover:bg-white hover:text-[var(--color-accent)] dark:bg-white/10"
>
<Settings size={16} />
<span className="hidden sm:inline"></span>
</Link>
<div className="absolute right-4 top-4 z-30 flex max-w-[calc(100vw-5.5rem)] flex-wrap items-center justify-end gap-2 md:right-6 md:top-6 md:max-w-none md:gap-3">
<ThemeToggle />
<Link
href="/admin/posts/new"
className="flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-bold text-white shadow-sm transition-all hover:shadow-md"
>
<PenLine size={16} />
<span> </span>
</Link>
</>
)}
<button
onClick={handleLogout}
className="flex items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/75 px-4 py-2 text-sm font-medium text-[var(--color-text-muted)] shadow-sm backdrop-blur-xl transition-colors hover:bg-white hover:text-red-500 dark:bg-white/10"
>
<LogOut size={16} />
<span className="hidden sm:inline"></span>
</button>
</>
) : (
<>
<Link
href="/login"
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-accent)]"
>
<User size={18} />
<span></span>
</Link>
{_hasHydrated && (
isLoggedIn ? (
<>
{isAdmin && (
<>
<Link
href="/admin"
className="flex h-10 items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] backdrop-blur-2xl transition-colors hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-accent)] sm:px-4"
>
<Settings size={16} />
<span className="hidden sm:inline"></span>
</Link>
<Link
href="/signup"
className="flex items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/75 px-4 py-2 text-sm font-bold text-[var(--color-accent)] shadow-sm backdrop-blur-xl transition-all hover:bg-white hover:shadow-md dark:bg-white/10"
>
<UserPlus size={16} />
<span></span>
</Link>
</>
<Link
href="/admin/posts/new"
className="flex h-10 items-center gap-2 rounded-full bg-[var(--color-accent)] px-3 text-sm font-bold text-white shadow-[var(--shadow-control)] transition-all hover:brightness-105 sm:px-4"
>
<PenLine size={16} />
<span> </span>
</Link>
</>
)}
<button
onClick={handleLogout}
className="flex h-10 items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-3 text-sm font-medium text-[var(--color-text-muted)] shadow-[var(--shadow-control)] backdrop-blur-2xl transition-colors hover:bg-[var(--color-surface-strong)] hover:text-red-500 sm:px-4"
>
<LogOut size={16} />
<span className="hidden sm:inline"></span>
</button>
</>
) : (
<>
<Link
href="/login"
className="flex h-10 items-center gap-2 rounded-full px-2 text-sm font-semibold text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-accent)] sm:px-4"
>
<User size={18} />
<span></span>
</Link>
<Link
href="/signup"
className="flex h-10 items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-3 text-sm font-bold text-[var(--color-accent)] shadow-[var(--shadow-control)] backdrop-blur-2xl transition-all hover:bg-[var(--color-surface-strong)] sm:px-4"
>
<UserPlus size={16} />
<span></span>
</Link>
</>
)
)}
</div>
);

View File

@@ -67,7 +67,7 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
// 4. 테이블
table({ children }) {
return (
<div className="my-8 overflow-x-auto rounded-lg border border-[var(--color-line)] bg-white/70 shadow-sm dark:bg-white/10">
<div className="my-8 overflow-x-auto rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] shadow-[var(--shadow-card)]">
<table className="w-full text-left text-sm text-[var(--color-text-muted)]">
{children}
</table>

View File

@@ -55,7 +55,7 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
if (isPostLoading) {
return (
<div className="flex justify-center items-center h-screen">
<Loader2 className="animate-spin text-blue-500" size={40} />
<Loader2 className="animate-spin text-[var(--color-accent)]" size={40} />
</div>
);
}
@@ -78,7 +78,7 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
{isAuthError ? '로그인이 필요하거나 비공개 게시글일 수 있습니다.' : errorMessage}
</p>
<div className="flex justify-center gap-3">
<button onClick={() => router.push('/')} className="rounded-lg border border-[var(--color-line)] bg-white/70 px-5 py-2.5 font-semibold text-[var(--color-text-muted)] transition-colors hover:bg-white"></button>
<button onClick={() => router.push('/')} className="rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-5 py-2.5 font-semibold text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-strong)]"></button>
</div>
</div>
);
@@ -95,7 +95,7 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
<span className="text-sm font-medium"></span>
</Link>
<div className="relative grid gap-8 xl:grid-cols-[minmax(0,820px)_220px] xl:justify-center xl:gap-16">
<div className="relative grid gap-8 xl:grid-cols-[minmax(0,800px)_220px] xl:justify-center xl:gap-16">
<main className="min-w-0">
<article>
@@ -128,25 +128,25 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
</div>
</header>
<div className="prose prose-lg mb-20 max-w-none prose-headings:font-bold prose-a:text-[var(--color-accent)] prose-pre:bg-[#1e1e1e] prose-pre:text-gray-100">
<div className="prose prose-lg mb-20 max-w-none prose-headings:font-bold prose-headings:text-[var(--color-text)] prose-p:text-[var(--color-text)] prose-strong:text-[var(--color-text)] prose-li:text-[var(--color-text-muted)] prose-a:text-[var(--color-accent)] prose-hr:border-[var(--color-line)] prose-pre:bg-[#1e1e1e] prose-pre:text-gray-100">
<MarkdownRenderer content={post.content || ''} />
</div>
</article>
<nav className="mb-16 grid grid-cols-1 gap-4 border-y border-[var(--color-line)] py-8 md:grid-cols-2">
{prevPost ? (
<Link href={`/posts/${prevPost.slug}`} className="group flex w-full flex-col items-start gap-1 rounded-lg border border-transparent bg-white/60 p-5 transition-colors hover:border-[var(--color-line)] hover:bg-white dark:bg-white/10">
<Link href={`/posts/${prevPost.slug}`} className="group flex w-full flex-col items-start gap-1 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-5 transition-colors hover:bg-[var(--color-surface-strong)]">
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]"><ChevronLeft size={16} /> </span>
<span className="line-clamp-1 w-full text-left font-bold text-[var(--color-text-muted)] transition-colors group-hover:text-[var(--color-accent)]">{prevPost.title}</span>
</Link>
) : <div className="hidden w-full cursor-not-allowed rounded-lg bg-white/40 p-5 opacity-60 dark:bg-white/5 md:block"><span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]"><ChevronLeft size={16} /> </span></div>}
) : <div className="hidden w-full cursor-not-allowed rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-5 opacity-60 md:block"><span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]"><ChevronLeft size={16} /> </span></div>}
{nextPost ? (
<Link href={`/posts/${nextPost.slug}`} className="group flex w-full flex-col items-end gap-1 rounded-lg border border-transparent bg-white/60 p-5 transition-colors hover:border-[var(--color-line)] hover:bg-white dark:bg-white/10">
<Link href={`/posts/${nextPost.slug}`} className="group flex w-full flex-col items-end gap-1 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-5 transition-colors hover:bg-[var(--color-surface-strong)]">
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]"> <ChevronRight size={16} /></span>
<span className="line-clamp-1 w-full text-right font-bold text-[var(--color-text-muted)] transition-colors group-hover:text-[var(--color-accent)]">{nextPost.title}</span>
</Link>
) : <div className="hidden w-full cursor-not-allowed flex-col items-end gap-1 rounded-lg bg-white/40 p-5 opacity-60 dark:bg-white/5 md:flex"><span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]"> <ChevronRight size={16} /></span></div>}
) : <div className="hidden w-full cursor-not-allowed flex-col items-end gap-1 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-5 opacity-60 md:flex"><span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]"> <ChevronRight size={16} /></span></div>}
</nav>
<CommentList postSlug={post.slug} />

View File

@@ -48,7 +48,7 @@ export default function PostSearch({
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="w-full rounded-full border border-[var(--color-line)] bg-white/70 py-2.5 pl-10 pr-10 text-sm text-[var(--color-text)] shadow-sm outline-none transition-all placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)] focus:bg-white focus:ring-2 focus:ring-blue-500/15 dark:bg-white/10 dark:focus:bg-white/15"
className="w-full rounded-full border border-[var(--color-line)] bg-[var(--color-control)] py-2.5 pl-10 pr-10 text-sm text-[var(--color-text)] shadow-[var(--shadow-control)] outline-none transition-all placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)] focus:bg-[var(--color-surface-strong)]"
/>
<Search
className="absolute left-3.5 top-1/2 -translate-y-1/2 cursor-pointer text-[var(--color-text-subtle)] transition-colors hover:text-[var(--color-accent)]"

View File

@@ -104,7 +104,7 @@ export default function TOC({ content }: TOCProps) {
return (
<aside className="w-full">
<div className="rounded-lg border border-[var(--color-line)] bg-white/65 p-4 shadow-sm backdrop-blur-xl dark:bg-white/10">
<div className="rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] p-4 shadow-[var(--shadow-control)] backdrop-blur-2xl">
<h4 className="mb-4 text-sm font-bold text-[var(--color-text)]"></h4>
<ul className="space-y-2.5">
{headings.map((heading, index) => (

View File

@@ -0,0 +1,125 @@
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
useSyncExternalStore,
} from 'react';
import {
isThemeMode,
ResolvedTheme,
ThemeMode,
THEME_STORAGE_KEY,
} from './theme';
interface ThemeContextValue {
mode: ThemeMode;
resolvedTheme: ResolvedTheme;
setMode: (mode: ThemeMode) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
const THEME_MODE_EVENT = 'wyp-theme-mode-change';
const getSystemTheme = (): ResolvedTheme => {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
const resolveTheme = (mode: ThemeMode): ResolvedTheme => {
if (mode === 'system') return getSystemTheme();
return mode;
};
const applyTheme = (mode: ThemeMode, resolvedTheme = resolveTheme(mode)) => {
if (typeof document === 'undefined') return;
const root = document.documentElement;
root.dataset.theme = resolvedTheme;
root.dataset.themeMode = mode;
root.style.colorScheme = resolvedTheme;
};
const getStoredMode = (): ThemeMode => {
if (typeof window === 'undefined') return 'system';
const savedMode = window.localStorage.getItem(THEME_STORAGE_KEY);
if (isThemeMode(savedMode)) return savedMode;
const documentMode = document.documentElement.dataset.themeMode;
if (isThemeMode(documentMode)) return documentMode;
return 'system';
};
const subscribeThemeMode = (callback: () => void) => {
if (typeof window === 'undefined') return () => undefined;
const handleStorage = (event: StorageEvent) => {
if (event.key === THEME_STORAGE_KEY) callback();
};
window.addEventListener(THEME_MODE_EVENT, callback);
window.addEventListener('storage', handleStorage);
return () => {
window.removeEventListener(THEME_MODE_EVENT, callback);
window.removeEventListener('storage', handleStorage);
};
};
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const mode = useSyncExternalStore<ThemeMode>(subscribeThemeMode, getStoredMode, () => 'system');
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(() => getSystemTheme());
const resolvedTheme = mode === 'system' ? systemTheme : mode;
useEffect(() => {
applyTheme(mode, resolvedTheme);
}, [mode, resolvedTheme]);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = () => {
setSystemTheme(getSystemTheme());
};
mediaQuery.addEventListener('change', handleSystemThemeChange);
return () => {
mediaQuery.removeEventListener('change', handleSystemThemeChange);
};
}, []);
const setMode = useCallback((nextMode: ThemeMode) => {
window.localStorage.setItem(THEME_STORAGE_KEY, nextMode);
applyTheme(nextMode);
window.dispatchEvent(new Event(THEME_MODE_EVENT));
}, []);
const value = useMemo(
() => ({
mode,
resolvedTheme,
setMode,
}),
[mode, resolvedTheme, setMode],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
};

View File

@@ -0,0 +1,53 @@
'use client';
import { Monitor, Moon, Sun } from 'lucide-react';
import { clsx } from 'clsx';
import { ThemeMode } from './theme';
import { useTheme } from './ThemeProvider';
const options: Array<{
value: ThemeMode;
label: string;
icon: typeof Sun;
}> = [
{ value: 'light', label: '라이트', icon: Sun },
{ value: 'system', label: '시스템', icon: Monitor },
{ value: 'dark', label: '다크', icon: Moon },
];
export default function ThemeToggle() {
const { mode, setMode } = useTheme();
return (
<div
className="inline-flex h-10 shrink-0 items-center rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-1 shadow-[var(--shadow-control)] backdrop-blur-2xl"
role="group"
aria-label="화면 테마"
>
{options.map((option) => {
const Icon = option.icon;
const isSelected = mode === option.value;
return (
<button
key={option.value}
type="button"
aria-pressed={isSelected}
aria-label={`${option.label} 모드`}
title={`${option.label} 모드`}
onClick={() => setMode(option.value)}
className={clsx(
'inline-flex h-8 min-w-8 items-center justify-center gap-1.5 rounded-full px-2 text-xs font-semibold transition duration-150 sm:min-w-10 lg:px-3',
isSelected
? 'bg-[var(--color-surface-strong)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-subtle)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
)}
>
<Icon size={15} strokeWidth={2.2} />
<span className="hidden lg:inline">{option.label}</span>
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,30 @@
export type ThemeMode = 'light' | 'system' | 'dark';
export type ResolvedTheme = 'light' | 'dark';
export const THEME_STORAGE_KEY = 'wyp-theme-mode';
export const themeModes: ThemeMode[] = ['light', 'system', 'dark'];
export const isThemeMode = (value: unknown): value is ThemeMode => {
return typeof value === 'string' && themeModes.includes(value as ThemeMode);
};
export const THEME_INIT_SCRIPT = `
(function () {
try {
var storageKey = '${THEME_STORAGE_KEY}';
var savedMode = window.localStorage.getItem(storageKey);
var mode = savedMode === 'light' || savedMode === 'dark' || savedMode === 'system' ? savedMode : 'system';
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = mode === 'dark' || (mode === 'system' && prefersDark) ? 'dark' : 'light';
var root = document.documentElement;
root.dataset.theme = theme;
root.dataset.themeMode = mode;
root.style.colorScheme = theme;
} catch (error) {
document.documentElement.dataset.theme = 'light';
document.documentElement.dataset.themeMode = 'system';
document.documentElement.style.colorScheme = 'light';
}
})();
`;

View File

@@ -17,7 +17,7 @@ export default function EmptyState({
return (
<div
className={clsx(
'flex min-h-36 flex-col items-center justify-center rounded-lg border border-dashed border-[var(--color-line)] bg-white/45 px-5 py-10 text-center dark:bg-white/5',
'flex min-h-36 flex-col items-center justify-center rounded-lg border border-dashed border-[var(--color-line)] bg-[var(--color-surface)] px-5 py-10 text-center',
className,
)}
>

View File

@@ -23,7 +23,7 @@ export default function SegmentedControl<T extends string>({
return (
<div
className={clsx(
'inline-flex h-9 rounded-full border border-[var(--color-line)] bg-white/60 p-1 shadow-sm backdrop-blur-xl dark:bg-white/10',
'inline-flex h-9 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-1 shadow-[var(--shadow-control)] backdrop-blur-xl',
className,
)}
role="group"
@@ -41,7 +41,7 @@ export default function SegmentedControl<T extends string>({
className={clsx(
'min-w-14 rounded-full px-3 text-sm font-semibold transition duration-150',
isSelected
? 'bg-[var(--color-text)] text-white shadow-sm dark:bg-white dark:text-black'
? 'bg-[var(--color-surface-strong)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
)}
>

View File

@@ -9,7 +9,7 @@ interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
const toneClass: Record<BadgeTone, string> = {
neutral: 'border-black/10 bg-black/[0.04] text-[var(--color-text-muted)] dark:border-white/10 dark:bg-white/10',
info: 'border-blue-500/15 bg-blue-500/10 text-blue-700 dark:text-blue-300',
info: 'border-blue-500/15 bg-[var(--color-accent-soft)] text-[var(--color-accent)]',
success: 'border-emerald-500/15 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
warning: 'border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300',
danger: 'border-red-500/15 bg-red-500/10 text-red-700 dark:text-red-300',

View File

@@ -21,7 +21,7 @@ export default function Surface({
className={clsx(
'rounded-lg border border-[var(--color-line)] backdrop-blur-xl',
strong ? 'bg-[var(--color-surface-strong)] shadow-[var(--shadow-panel)]' : 'bg-[var(--color-surface)] shadow-[var(--shadow-card)]',
interactive && 'transition duration-150 hover:border-black/10 hover:bg-white/90 hover:shadow-[var(--shadow-panel)] dark:hover:border-white/15 dark:hover:bg-white/10',
interactive && 'transition duration-150 hover:border-[var(--color-line)] hover:bg-[var(--color-surface-strong)] hover:shadow-[var(--shadow-panel)]',
className,
)}
{...props}