This commit is contained in:
@@ -275,14 +275,14 @@ export default function AdminPage() {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/admin/posts"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full border border-[var(--color-line)] bg-white/70 px-5 py-3 text-sm font-semibold text-[var(--color-text-muted)] transition hover:bg-white hover:text-[var(--color-text)] dark:bg-white/10"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-5 py-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
<FileText size={17} />
|
||||
게시글 관리
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/posts/new"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-[var(--color-text)] px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:opacity-90 dark:bg-white dark:text-black"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-[var(--color-accent)] px-5 py-3 text-sm font-semibold text-white shadow-[var(--shadow-control)] transition hover:bg-[var(--color-accent-hover)]"
|
||||
>
|
||||
<PenLine size={17} />
|
||||
새 글 작성
|
||||
|
||||
@@ -8,6 +8,9 @@ import { useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { PostListResponse } from '@/types';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
|
||||
const getTotalElements = (data?: PostListResponse) => {
|
||||
return data?.page?.totalElements ?? data?.totalElements ?? 0;
|
||||
@@ -53,29 +56,29 @@ export default function ArchivePage() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[50vh]">
|
||||
<Loader2 className="animate-spin text-blue-500" size={40} />
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={40} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-10 px-4">
|
||||
<div className="mx-auto max-w-5xl px-4 py-10">
|
||||
{/* 헤더 섹션 */}
|
||||
<div className="mb-12 text-center md:text-left border-b border-gray-100 pb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center justify-center md:justify-start gap-3 mb-3">
|
||||
<Archive className="text-blue-600" size={32} />
|
||||
<div className="mb-12 border-b border-[var(--color-line)] pb-8 text-center md:text-left">
|
||||
<h1 className="mb-3 flex items-center justify-center gap-3 text-3xl font-bold tracking-normal text-[var(--color-text)] md:justify-start">
|
||||
<Archive className="text-[var(--color-accent)]" size={32} />
|
||||
<span>아카이브</span>
|
||||
</h1>
|
||||
<p className="text-gray-500">
|
||||
지금까지 작성한 <span className="text-blue-600 font-bold">{totalPosts}</span>개의 글이 기록되어 있습니다.
|
||||
<p className="text-[var(--color-text-muted)]">
|
||||
지금까지 작성한 <span className="font-bold text-[var(--color-accent)]">{totalPosts}</span>개의 글이 기록되어 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 타임라인 컨텐츠 */}
|
||||
{sortedYears.length > 0 ? (
|
||||
<div className="space-y-12 relative">
|
||||
<div className="relative space-y-12">
|
||||
{/* 타임라인 수직선 (좌측 장식) */}
|
||||
<div className="absolute left-4 top-4 bottom-4 w-0.5 bg-gray-100 hidden md:block" />
|
||||
<div className="absolute bottom-4 left-4 top-4 hidden w-px bg-[var(--color-line)] md:block" />
|
||||
|
||||
{sortedYears.map((year) => {
|
||||
const months = archiveGroups[year];
|
||||
@@ -85,11 +88,11 @@ export default function ArchivePage() {
|
||||
return (
|
||||
<div key={year} className="relative">
|
||||
{/* 연도 헤더 */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="hidden md:flex items-center justify-center w-9 h-9 rounded-full bg-blue-50 border-4 border-white shadow-sm z-10">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-blue-500" />
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<div className="z-10 hidden h-9 w-9 items-center justify-center rounded-full border-4 border-[var(--color-page)] bg-[var(--color-accent-soft)] shadow-[var(--shadow-control)] md:flex">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-[var(--color-accent)]" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-800">{year}</h2>
|
||||
<h2 className="text-2xl font-bold text-[var(--color-text)]">{year}</h2>
|
||||
</div>
|
||||
|
||||
{/* 월별 그룹 */}
|
||||
@@ -101,12 +104,10 @@ export default function ArchivePage() {
|
||||
|
||||
return (
|
||||
<div key={month} className="group">
|
||||
<h3 className="text-lg font-bold text-gray-600 mb-4 flex items-center gap-2">
|
||||
<Calendar size={18} className="text-gray-400" />
|
||||
<h3 className="mb-4 flex items-center gap-2 text-lg font-bold text-[var(--color-text-muted)]">
|
||||
<Calendar size={18} className="text-[var(--color-text-subtle)]" />
|
||||
{month}월
|
||||
<span className="text-xs font-normal bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">
|
||||
{posts.length}
|
||||
</span>
|
||||
<StatusBadge tone="neutral">{posts.length}</StatusBadge>
|
||||
</h3>
|
||||
|
||||
<div className="grid gap-3">
|
||||
@@ -114,25 +115,23 @@ export default function ArchivePage() {
|
||||
<Link
|
||||
key={post.id}
|
||||
href={`/posts/${post.slug}`}
|
||||
className="block bg-white border border-gray-100 rounded-lg p-4 hover:border-blue-200 hover:shadow-md transition-all duration-200 group/item"
|
||||
className="group/item block"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Surface interactive className="flex items-center justify-between gap-4 p-4 shadow-none">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-base font-medium text-gray-800 truncate group-hover/item:text-blue-600 transition-colors">
|
||||
<h4 className="truncate text-base font-semibold text-[var(--color-text)] transition-colors group-hover/item:text-[var(--color-accent)]">
|
||||
{post.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 mt-1.5 text-xs text-gray-400">
|
||||
<span className="bg-gray-50 px-1.5 py-0.5 rounded text-gray-500">
|
||||
{post.categoryName}
|
||||
</span>
|
||||
<div className="mt-1.5 flex items-center gap-2 text-xs text-[var(--color-text-subtle)]">
|
||||
<StatusBadge tone="neutral" className="px-1.5 py-0.5">{post.categoryName || '미분류'}</StatusBadge>
|
||||
<span>•</span>
|
||||
<span className="tabular-nums">
|
||||
{format(new Date(post.createdAt), 'yyyy.MM.dd')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="text-gray-300 group-hover/item:text-blue-400 transition-colors" size={20} />
|
||||
</div>
|
||||
<ChevronRight className="text-[var(--color-text-subtle)] transition-colors group-hover/item:text-[var(--color-accent)]" size={20} />
|
||||
</Surface>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
@@ -145,10 +144,11 @@ export default function ArchivePage() {
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20 bg-gray-50 rounded-xl border border-dashed border-gray-200">
|
||||
<FileText className="mx-auto text-gray-300 mb-3" size={48} />
|
||||
<p className="text-gray-500">아직 작성된 기록이 없습니다.</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="아직 작성된 기록이 없습니다."
|
||||
icon={<FileText size={48} />}
|
||||
className="py-20"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,8 @@ import PostListItem from '@/components/post/PostListItem';
|
||||
import PostSearch from '@/components/post/PostSearch'; // 🆕 검색 컴포넌트
|
||||
import { Loader2, ChevronLeft, ChevronRight, LayoutGrid, List, Search as SearchIcon } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
|
||||
export default function CategoryPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
@@ -54,18 +56,18 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
|
||||
if (isLoading || (postsData === undefined && !error)) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<Loader2 className="animate-spin text-blue-500" size={40} />
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={40} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-10 text-red-500">
|
||||
<Surface className="mx-auto max-w-3xl px-5 py-10 text-center text-red-500">
|
||||
게시글을 불러오는 중 오류가 발생했습니다.<br/>
|
||||
<span className="text-sm text-gray-400">카테고리 이름을 확인해주세요.</span>
|
||||
</div>
|
||||
<span className="text-sm text-[var(--color-text-subtle)]">카테고리 이름을 확인해주세요.</span>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,53 +90,61 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="mx-auto max-w-5xl px-4 py-8">
|
||||
{/* 헤더 영역 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between mb-8 gap-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 flex items-baseline gap-2 shrink-0">
|
||||
{apiCategoryName} <span className="text-gray-400 text-lg font-normal">글 목록</span>
|
||||
</h1>
|
||||
<Surface strong className="mb-8 p-5">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<h1 className="flex shrink-0 items-baseline gap-2 text-2xl font-bold tracking-normal text-[var(--color-text)]">
|
||||
{apiCategoryName} <span className="text-lg font-normal text-[var(--color-text-subtle)]">글 목록</span>
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
{/* 🔍 카테고리 내 검색바 */}
|
||||
<PostSearch
|
||||
onSearch={handleSearch}
|
||||
placeholder={`${apiCategoryName} 내 검색`}
|
||||
className="w-full md:w-64"
|
||||
/>
|
||||
<div className="flex w-full items-center gap-3 md:w-auto">
|
||||
{/* 🔍 카테고리 내 검색바 */}
|
||||
<PostSearch
|
||||
onSearch={handleSearch}
|
||||
placeholder={`${apiCategoryName} 내 검색`}
|
||||
className="w-full md:w-64"
|
||||
/>
|
||||
|
||||
{/* 뷰 모드 버튼 */}
|
||||
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg shrink-0">
|
||||
<button
|
||||
onClick={() => handleViewModeChange('grid')}
|
||||
className={clsx(
|
||||
"p-2 rounded-md transition-all duration-200",
|
||||
activeViewMode === 'grid' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
|
||||
)}
|
||||
title="카드형 보기"
|
||||
aria-label="카드형 보기"
|
||||
>
|
||||
<LayoutGrid size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleViewModeChange('list')}
|
||||
className={clsx(
|
||||
"p-2 rounded-md transition-all duration-200",
|
||||
activeViewMode === 'list' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
|
||||
)}
|
||||
title="리스트형 보기"
|
||||
aria-label="리스트형 보기"
|
||||
>
|
||||
<List size={18} />
|
||||
</button>
|
||||
{/* 뷰 모드 버튼 */}
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-1 shadow-[var(--shadow-control)] backdrop-blur-xl">
|
||||
<button
|
||||
onClick={() => handleViewModeChange('grid')}
|
||||
className={clsx(
|
||||
'rounded-full p-2 transition-all duration-150',
|
||||
activeViewMode === 'grid'
|
||||
? 'bg-[var(--color-surface-strong)] text-[var(--color-accent)] shadow-sm'
|
||||
: 'text-[var(--color-text-subtle)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
|
||||
)}
|
||||
title="카드형 보기"
|
||||
aria-label="카드형 보기"
|
||||
aria-pressed={activeViewMode === 'grid'}
|
||||
>
|
||||
<LayoutGrid size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleViewModeChange('list')}
|
||||
className={clsx(
|
||||
'rounded-full p-2 transition-all duration-150',
|
||||
activeViewMode === 'list'
|
||||
? 'bg-[var(--color-surface-strong)] text-[var(--color-accent)] shadow-sm'
|
||||
: 'text-[var(--color-text-subtle)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
|
||||
)}
|
||||
title="리스트형 보기"
|
||||
aria-label="리스트형 보기"
|
||||
aria-pressed={activeViewMode === 'list'}
|
||||
>
|
||||
<List size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
{/* 검색 결과 안내 (검색 중일 때만 표시) */}
|
||||
{keyword && (
|
||||
<div className="mb-6 flex items-center gap-2 text-sm text-gray-600 bg-blue-50 px-4 py-3 rounded-lg border border-blue-100">
|
||||
<SearchIcon size={16} className="text-blue-500" />
|
||||
<div className="mb-6 flex items-center gap-2 rounded-lg border border-[var(--color-line)] bg-[var(--color-accent-soft)] px-4 py-3 text-sm text-[var(--color-text-muted)]">
|
||||
<SearchIcon size={16} className="text-[var(--color-accent)]" />
|
||||
<span>
|
||||
검색어 <strong>{keyword}</strong> 결과: <strong>{totalElements}</strong>건
|
||||
</span>
|
||||
@@ -142,11 +152,10 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
)}
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<div className="text-center py-20 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<p className="text-gray-400 mb-2">
|
||||
{keyword ? `${keyword}에 대한 검색 결과가 없습니다.` : '아직 작성된 글이 없습니다.'}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title={keyword ? `${keyword}에 대한 검색 결과가 없습니다.` : '아직 작성된 글이 없습니다.'}
|
||||
className="py-20"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{activeViewMode === 'grid' ? (
|
||||
@@ -156,31 +165,31 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col border-t border-gray-100">
|
||||
<Surface className="flex flex-col p-2 shadow-none">
|
||||
{posts.map((post) => (
|
||||
<PostListItem key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
</Surface>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center items-center gap-6 mt-12 mb-8">
|
||||
<div className="mb-8 mt-12 flex items-center justify-center gap-6">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={page === 0}
|
||||
className="p-2 rounded-full hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors disabled:cursor-not-allowed"
|
||||
className="rounded-full p-2 text-[var(--color-text-muted)] transition-colors hover:bg-black/[0.04] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-white/10"
|
||||
aria-label="이전 페이지"
|
||||
>
|
||||
<ChevronLeft size={24} />
|
||||
</button>
|
||||
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
페이지 <span className="text-gray-900 font-bold">{page + 1}</span> {totalPages > 0 && `/ ${totalPages}`}
|
||||
<span className="text-sm font-medium text-[var(--color-text-muted)]">
|
||||
페이지 <span className="font-bold text-[var(--color-text)]">{page + 1}</span> {totalPages > 0 && `/ ${totalPages}`}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={isLast || isPlaceholderData}
|
||||
className="p-2 rounded-full hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors disabled:cursor-not-allowed"
|
||||
className="rounded-full p-2 text-[var(--color-text-muted)] transition-colors hover:bg-black/[0.04] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-white/10"
|
||||
aria-label="다음 페이지"
|
||||
>
|
||||
<ChevronRight size={24} />
|
||||
|
||||
@@ -1,59 +1,84 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||
|
||||
:root {
|
||||
--color-page: #f5f5f7;
|
||||
--color-surface: rgba(255, 255, 255, 0.78);
|
||||
--color-surface-strong: #ffffff;
|
||||
--color-line: rgba(0, 0, 0, 0.08);
|
||||
--color-text: #1d1d1f;
|
||||
--color-text-muted: #6e6e73;
|
||||
--color-text-subtle: #86868b;
|
||||
--color-accent: #007aff;
|
||||
--color-accent-soft: rgba(0, 122, 255, 0.1);
|
||||
--shadow-panel: 0 18px 50px rgba(0, 0, 0, 0.08);
|
||||
--shadow-card: 0 10px 28px rgba(0, 0, 0, 0.06);
|
||||
--background: var(--color-page);
|
||||
--foreground: var(--color-text);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-page: #0b0b0c;
|
||||
--color-surface: rgba(28, 28, 30, 0.72);
|
||||
--color-surface-strong: #1c1c1e;
|
||||
--color-line: rgba(255, 255, 255, 0.1);
|
||||
--color-text: #f5f5f7;
|
||||
--color-text-muted: #a1a1a6;
|
||||
--color-text-subtle: #8e8e93;
|
||||
--color-accent: #0a84ff;
|
||||
--color-accent-soft: rgba(10, 132, 255, 0.16);
|
||||
--shadow-panel: 0 18px 50px rgba(0, 0, 0, 0.36);
|
||||
--shadow-card: 0 10px 28px rgba(0, 0, 0, 0.28);
|
||||
--background: var(--color-page);
|
||||
--foreground: var(--color-text);
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family:
|
||||
--font-apple-sans:
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"SF Pro Display",
|
||||
"SF Pro Text",
|
||||
"Apple SD Gothic Neo",
|
||||
"Helvetica Neue",
|
||||
Pretendard,
|
||||
"Noto Sans KR",
|
||||
system-ui,
|
||||
sans-serif;
|
||||
--font-apple-mono:
|
||||
"SF Mono",
|
||||
ui-monospace,
|
||||
Menlo,
|
||||
Monaco,
|
||||
Consolas,
|
||||
"Liberation Mono",
|
||||
monospace;
|
||||
--color-page: #f5f5f7;
|
||||
--color-surface: rgba(255, 255, 255, 0.72);
|
||||
--color-surface-strong: #ffffff;
|
||||
--color-control: rgba(255, 255, 255, 0.66);
|
||||
--color-line: rgba(0, 0, 0, 0.1);
|
||||
--color-text: #1d1d1f;
|
||||
--color-text-muted: #6e6e73;
|
||||
--color-text-subtle: #86868b;
|
||||
--color-accent: #0066cc;
|
||||
--color-accent-hover: #0071e3;
|
||||
--color-accent-soft: rgba(0, 102, 204, 0.1);
|
||||
--color-danger-soft: rgba(255, 59, 48, 0.1);
|
||||
--shadow-panel: 0 22px 70px rgba(0, 0, 0, 0.1);
|
||||
--shadow-card: 0 14px 34px rgba(0, 0, 0, 0.065);
|
||||
--shadow-control: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
--focus-ring: 0 0 0 4px rgba(0, 102, 204, 0.14);
|
||||
--background: var(--color-page);
|
||||
--foreground: var(--color-text);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-apple-sans);
|
||||
--font-mono: var(--font-apple-mono);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--color-page: #0b0b0c;
|
||||
--color-surface: rgba(28, 28, 30, 0.72);
|
||||
--color-surface-strong: #1c1c1e;
|
||||
--color-control: rgba(44, 44, 46, 0.74);
|
||||
--color-line: rgba(255, 255, 255, 0.12);
|
||||
--color-text: #f5f5f7;
|
||||
--color-text-muted: #a1a1a6;
|
||||
--color-text-subtle: #8e8e93;
|
||||
--color-accent: #2997ff;
|
||||
--color-accent-hover: #46a6ff;
|
||||
--color-accent-soft: rgba(41, 151, 255, 0.16);
|
||||
--color-danger-soft: rgba(255, 69, 58, 0.16);
|
||||
--shadow-panel: 0 22px 70px rgba(0, 0, 0, 0.42);
|
||||
--shadow-card: 0 14px 34px rgba(0, 0, 0, 0.3);
|
||||
--shadow-control: 0 10px 28px rgba(0, 0, 0, 0.32);
|
||||
--focus-ring: 0 0 0 4px rgba(41, 151, 255, 0.18);
|
||||
--background: var(--color-page);
|
||||
--foreground: var(--color-text);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-apple-sans);
|
||||
font-feature-settings: "kern";
|
||||
letter-spacing: 0;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@@ -61,145 +86,156 @@ body {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* Keep older Tailwind-only surfaces aligned with the app color tokens. */
|
||||
.bg-white {
|
||||
background-color: var(--color-surface-strong);
|
||||
}
|
||||
code,
|
||||
kbd,
|
||||
pre,
|
||||
samp,
|
||||
.tabular-nums {
|
||||
font-family: var(--font-apple-mono);
|
||||
}
|
||||
|
||||
.bg-gray-50,
|
||||
.bg-gray-100 {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.bg-blue-50,
|
||||
.bg-blue-100 {
|
||||
background-color: rgba(10, 132, 255, 0.14);
|
||||
}
|
||||
/* Keep older Tailwind-only surfaces aligned with the app color tokens. */
|
||||
html[data-theme="dark"] .bg-white {
|
||||
background-color: var(--color-surface-strong);
|
||||
}
|
||||
|
||||
.bg-red-50,
|
||||
.bg-red-100 {
|
||||
background-color: rgba(255, 69, 58, 0.13);
|
||||
}
|
||||
html[data-theme="dark"] .bg-gray-50,
|
||||
html[data-theme="dark"] .bg-gray-100 {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.bg-green-100 {
|
||||
background-color: rgba(48, 209, 88, 0.14);
|
||||
}
|
||||
html[data-theme="dark"] .bg-blue-50,
|
||||
html[data-theme="dark"] .bg-blue-100 {
|
||||
background-color: rgba(10, 132, 255, 0.14);
|
||||
}
|
||||
|
||||
.border-gray-100,
|
||||
.border-gray-200,
|
||||
.border-gray-300 {
|
||||
border-color: var(--color-line);
|
||||
}
|
||||
html[data-theme="dark"] .bg-red-50,
|
||||
html[data-theme="dark"] .bg-red-100 {
|
||||
background-color: rgba(255, 69, 58, 0.13);
|
||||
}
|
||||
|
||||
.border-blue-100 {
|
||||
border-color: rgba(10, 132, 255, 0.28);
|
||||
}
|
||||
html[data-theme="dark"] .bg-green-100 {
|
||||
background-color: rgba(48, 209, 88, 0.14);
|
||||
}
|
||||
|
||||
.border-red-100 {
|
||||
border-color: rgba(255, 105, 97, 0.28);
|
||||
}
|
||||
html[data-theme="dark"] .border-gray-100,
|
||||
html[data-theme="dark"] .border-gray-200,
|
||||
html[data-theme="dark"] .border-gray-300 {
|
||||
border-color: var(--color-line);
|
||||
}
|
||||
|
||||
.text-gray-950,
|
||||
.text-gray-900,
|
||||
.text-gray-800,
|
||||
.text-gray-700 {
|
||||
color: var(--color-text);
|
||||
}
|
||||
html[data-theme="dark"] .border-blue-100 {
|
||||
border-color: rgba(10, 132, 255, 0.28);
|
||||
}
|
||||
|
||||
.text-gray-600,
|
||||
.text-gray-500 {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
html[data-theme="dark"] .border-red-100 {
|
||||
border-color: rgba(255, 105, 97, 0.28);
|
||||
}
|
||||
|
||||
.text-gray-400,
|
||||
.text-gray-300 {
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
html[data-theme="dark"] .text-gray-950,
|
||||
html[data-theme="dark"] .text-gray-900,
|
||||
html[data-theme="dark"] .text-gray-800,
|
||||
html[data-theme="dark"] .text-gray-700 {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.text-blue-700,
|
||||
.text-blue-600 {
|
||||
color: #8ecbff;
|
||||
}
|
||||
html[data-theme="dark"] .text-gray-600,
|
||||
html[data-theme="dark"] .text-gray-500 {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.text-red-950,
|
||||
.text-red-700,
|
||||
.text-red-600 {
|
||||
color: #ffb4ad;
|
||||
}
|
||||
html[data-theme="dark"] .text-gray-400,
|
||||
html[data-theme="dark"] .text-gray-300 {
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.text-green-600 {
|
||||
color: #8ff0a4;
|
||||
}
|
||||
html[data-theme="dark"] .text-blue-700,
|
||||
html[data-theme="dark"] .text-blue-600 {
|
||||
color: #8ecbff;
|
||||
}
|
||||
|
||||
.divide-gray-100 > :not([hidden]) ~ :not([hidden]) {
|
||||
border-color: var(--color-line);
|
||||
}
|
||||
html[data-theme="dark"] .text-red-950,
|
||||
html[data-theme="dark"] .text-red-700,
|
||||
html[data-theme="dark"] .text-red-600 {
|
||||
color: #ffb4ad;
|
||||
}
|
||||
|
||||
.hover\:bg-white:hover,
|
||||
.hover\:bg-gray-50:hover,
|
||||
.hover\:bg-gray-100:hover,
|
||||
.hover\:bg-gray-200:hover,
|
||||
.focus\:bg-white:focus {
|
||||
background-color: rgba(255, 255, 255, 0.11);
|
||||
}
|
||||
html[data-theme="dark"] .text-green-600 {
|
||||
color: #8ff0a4;
|
||||
}
|
||||
|
||||
.hover\:bg-blue-50:hover {
|
||||
background-color: rgba(10, 132, 255, 0.16);
|
||||
}
|
||||
html[data-theme="dark"] .divide-gray-100 > :not([hidden]) ~ :not([hidden]) {
|
||||
border-color: var(--color-line);
|
||||
}
|
||||
|
||||
.hover\:bg-red-50:hover {
|
||||
background-color: rgba(255, 69, 58, 0.16);
|
||||
}
|
||||
html[data-theme="dark"] .hover\:bg-white:hover,
|
||||
html[data-theme="dark"] .hover\:bg-gray-50:hover,
|
||||
html[data-theme="dark"] .hover\:bg-gray-100:hover,
|
||||
html[data-theme="dark"] .hover\:bg-gray-200:hover,
|
||||
html[data-theme="dark"] .focus\:bg-white:focus {
|
||||
background-color: rgba(255, 255, 255, 0.11);
|
||||
}
|
||||
|
||||
.hover\:text-gray-600:hover,
|
||||
.hover\:text-gray-700:hover,
|
||||
.hover\:text-gray-800:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
html[data-theme="dark"] .hover\:bg-blue-50:hover {
|
||||
background-color: rgba(10, 132, 255, 0.16);
|
||||
}
|
||||
|
||||
.placeholder\:text-gray-300::placeholder,
|
||||
.placeholder\:text-gray-400::placeholder {
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
html[data-theme="dark"] .hover\:bg-red-50:hover {
|
||||
background-color: rgba(255, 69, 58, 0.16);
|
||||
}
|
||||
|
||||
.disabled\:bg-gray-50:disabled,
|
||||
.disabled\:bg-gray-300:disabled,
|
||||
.disabled\:bg-gray-400:disabled {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
html[data-theme="dark"] .hover\:text-gray-600:hover,
|
||||
html[data-theme="dark"] .hover\:text-gray-700:hover,
|
||||
html[data-theme="dark"] .hover\:text-gray-800:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
input:not([type="checkbox"]):not([type="radio"]),
|
||||
textarea,
|
||||
select {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border-color: var(--color-line);
|
||||
color: var(--color-text);
|
||||
}
|
||||
html[data-theme="dark"] .placeholder\:text-gray-300::placeholder,
|
||||
html[data-theme="dark"] .placeholder\:text-gray-400::placeholder {
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
input:not([type="checkbox"]):not([type="radio"]):focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
background-color: rgba(255, 255, 255, 0.11);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
html[data-theme="dark"] .disabled\:bg-gray-50:disabled,
|
||||
html[data-theme="dark"] .disabled\:bg-gray-300:disabled,
|
||||
html[data-theme="dark"] .disabled\:bg-gray-400:disabled {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
html[data-theme="dark"] input:not([type="checkbox"]):not([type="radio"]),
|
||||
html[data-theme="dark"] textarea,
|
||||
html[data-theme="dark"] select {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border-color: var(--color-line);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: var(--color-surface-strong);
|
||||
color: var(--color-text);
|
||||
}
|
||||
html[data-theme="dark"] input:not([type="checkbox"]):not([type="radio"]):focus,
|
||||
html[data-theme="dark"] textarea:focus,
|
||||
html[data-theme="dark"] select:focus {
|
||||
background-color: rgba(255, 255, 255, 0.11);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.shadow-xl,
|
||||
.shadow-md,
|
||||
.shadow-sm {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
html[data-theme="dark"] input::placeholder,
|
||||
html[data-theme="dark"] textarea::placeholder {
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] option {
|
||||
background-color: var(--color-surface-strong);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .shadow-xl,
|
||||
html[data-theme="dark"] .shadow-md,
|
||||
html[data-theme="dark"] .shadow-sm {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.w-md-editor {
|
||||
@@ -213,14 +249,12 @@ body {
|
||||
background-color: #f9fafb !important;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.w-md-editor {
|
||||
background-color: var(--color-surface-strong) !important;
|
||||
border-color: var(--color-line) !important;
|
||||
}
|
||||
|
||||
.w-md-editor-toolbar {
|
||||
background-color: rgba(255, 255, 255, 0.08) !important;
|
||||
border-color: var(--color-line) !important;
|
||||
}
|
||||
html[data-theme="dark"] .w-md-editor {
|
||||
background-color: var(--color-surface-strong) !important;
|
||||
border-color: var(--color-line) !important;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .w-md-editor-toolbar {
|
||||
background-color: rgba(255, 255, 255, 0.08) !important;
|
||||
border-color: var(--color-line) !important;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Providers from './providers';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import TopHeader from '@/components/layout/TopHeader';
|
||||
import Script from 'next/script';
|
||||
import { THEME_INIT_SCRIPT } from '@/components/theme/theme';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'WYPark Blog',
|
||||
@@ -16,11 +17,15 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<html lang="ko" data-theme="light" data-theme-mode="system" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="google-site-verification" content="cFJSK1ayy2Y4lqRKNv8wZ_vybg5De22zYCdbKSfvAJA" />
|
||||
</head>
|
||||
<body className="bg-[var(--color-page)] text-[var(--color-text)]">
|
||||
<Script id="theme-init" strategy="beforeInteractive">
|
||||
{THEME_INIT_SCRIPT}
|
||||
</Script>
|
||||
|
||||
{/* 🌟 Google Analytics 스크립트 */}
|
||||
<Script
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-2GLCM9ZKMK"
|
||||
|
||||
@@ -72,7 +72,7 @@ function LoginForm() {
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(event) => setFormData({ ...formData, email: event.target.value })}
|
||||
className="w-full rounded-lg border border-[var(--color-line)] bg-white/70 px-4 py-2 text-[var(--color-text)] outline-none transition-all placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)] focus:ring-2 focus:ring-blue-500/15 dark:bg-white/10"
|
||||
className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-2 text-[var(--color-text)] outline-none transition-all placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)]"
|
||||
placeholder="example@email.com"
|
||||
required
|
||||
/>
|
||||
@@ -84,7 +84,7 @@ function LoginForm() {
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(event) => setFormData({ ...formData, password: event.target.value })}
|
||||
className="w-full rounded-lg border border-[var(--color-line)] bg-white/70 px-4 py-2 text-[var(--color-text)] outline-none transition-all placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)] focus:ring-2 focus:ring-blue-500/15 dark:bg-white/10"
|
||||
className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-2 text-[var(--color-text)] outline-none transition-all placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)]"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
@@ -95,7 +95,7 @@ function LoginForm() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 py-3 font-bold text-white transition-colors hover:bg-blue-700 disabled:bg-blue-400"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-[var(--color-accent)] py-3 font-bold text-white transition-colors hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading && <Loader2 className="animate-spin" size={20} />}
|
||||
로그인
|
||||
@@ -104,7 +104,7 @@ function LoginForm() {
|
||||
|
||||
<div className="mt-6 text-center text-sm text-[var(--color-text-muted)]">
|
||||
계정이 없으신가요?{' '}
|
||||
<Link href="/signup" className="font-medium text-blue-600 hover:underline">
|
||||
<Link href="/signup" className="font-medium text-[var(--color-accent)] hover:underline">
|
||||
회원가입
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -350,7 +350,7 @@ function HomeContent() {
|
||||
</Link>
|
||||
<Link
|
||||
href="#blog-search"
|
||||
className="inline-flex h-10 items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/70 px-4 text-sm font-semibold text-[var(--color-text-muted)] transition hover:bg-white hover:text-[var(--color-text)] dark:bg-white/10"
|
||||
className="inline-flex h-10 items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-4 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
검색으로 찾기
|
||||
<Search size={16} />
|
||||
@@ -450,7 +450,7 @@ function HomeContent() {
|
||||
</div>
|
||||
<Link
|
||||
href="/archive"
|
||||
className="inline-flex w-fit items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/70 px-4 py-2 text-sm font-semibold text-[var(--color-text)] transition hover:bg-white dark:bg-white/10"
|
||||
className="inline-flex w-fit items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-2 text-sm font-semibold text-[var(--color-text)] shadow-[var(--shadow-control)] transition hover:bg-[var(--color-surface-strong)]"
|
||||
>
|
||||
아카이브 보기
|
||||
<ChevronRight size={16} />
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Loader2 } from 'lucide-react';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-screen">
|
||||
<Loader2 className="animate-spin text-blue-500" size={40} />
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={40} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useState, useEffect } from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import axios from 'axios';
|
||||
import { ThemeProvider } from '@/components/theme/ThemeProvider';
|
||||
|
||||
// 🛠️ JWT 토큰 만료 여부 체크 함수 (라이브러리 없이 구현)
|
||||
function isTokenExpired(token: string) {
|
||||
@@ -93,13 +94,15 @@ export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthInitializer /> {/* 👈 앱 실행 시 토큰 자동 검사 */}
|
||||
{children}
|
||||
<Toaster position="top-right" />
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthInitializer /> {/* 👈 앱 실행 시 토큰 자동 검사 */}
|
||||
{children}
|
||||
<Toaster position="top-right" />
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,14 +71,13 @@ export default function SignupPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
// 🎨 배경색 수정: bg-gray-50 -> bg-white
|
||||
<div className="min-h-screen flex items-center justify-center bg-white px-4 py-12">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
|
||||
<div className="flex min-h-screen items-center justify-center bg-[var(--color-page)] px-4 py-12">
|
||||
<div className="w-full max-w-md rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-8 shadow-[var(--shadow-panel)] backdrop-blur-xl">
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">회원가입</h1>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-bold text-[var(--color-text)]">회원가입</h1>
|
||||
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||
{step === 'FORM' ? '' : '이메일로 전송된 6자리 코드를 입력하세요.'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -87,34 +86,34 @@ export default function SignupPage() {
|
||||
{step === 'FORM' && (
|
||||
<form onSubmit={handleSubmit(onSignupSubmit)} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">이메일</label>
|
||||
<label className="mb-1 block text-sm font-medium text-[var(--color-text-muted)]">이메일</label>
|
||||
<input
|
||||
{...register('email', {
|
||||
required: '이메일은 필수입니다.',
|
||||
pattern: { value: /\S+@\S+\.\S+/, message: '이메일 형식이 올바르지 않습니다.' }
|
||||
})}
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-3 text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
{errors.email && <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">닉네임</label>
|
||||
<label className="mb-1 block text-sm font-medium text-[var(--color-text-muted)]">닉네임</label>
|
||||
<input
|
||||
{...register('nickname', { required: '닉네임을 입력해주세요.' })}
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-3 text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
|
||||
placeholder="개발자"
|
||||
/>
|
||||
{errors.nickname && <p className="text-red-500 text-xs mt-1">{errors.nickname.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">비밀번호</label>
|
||||
<label className="mb-1 block text-sm font-medium text-[var(--color-text-muted)]">비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
{...register('password', { required: '비밀번호를 입력해주세요.', minLength: { value: 6, message: '6자 이상 입력해주세요.' } })}
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-3 text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
{errors.password && <p className="text-red-500 text-xs mt-1">{errors.password.message}</p>}
|
||||
@@ -123,7 +122,7 @@ export default function SignupPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-lg transition-colors disabled:bg-gray-400"
|
||||
className="w-full rounded-lg bg-[var(--color-accent)] py-3 font-bold text-white transition-colors hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? '처리 중...' : '인증 메일 받기'}
|
||||
</button>
|
||||
@@ -134,13 +133,13 @@ export default function SignupPage() {
|
||||
{step === 'VERIFY' && (
|
||||
<form onSubmit={onVerifySubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">인증 코드 (6자리)</label>
|
||||
<label className="mb-1 block text-sm font-medium text-[var(--color-text-muted)]">인증 코드 (6자리)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value)}
|
||||
maxLength={6}
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 outline-none text-center text-2xl tracking-widest"
|
||||
className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-3 text-center text-2xl tracking-normal text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
@@ -148,7 +147,7 @@ export default function SignupPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-3 rounded-lg transition-colors disabled:bg-gray-400"
|
||||
className="w-full rounded-lg bg-[var(--color-accent)] py-3 font-bold text-white transition-colors hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? '처리 중...' : '인증 완료'}
|
||||
</button>
|
||||
@@ -156,7 +155,7 @@ export default function SignupPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('FORM')}
|
||||
className="w-full text-sm text-gray-500 hover:underline"
|
||||
className="w-full text-sm text-[var(--color-text-muted)] hover:underline"
|
||||
>
|
||||
이메일 다시 입력하기
|
||||
</button>
|
||||
@@ -164,9 +163,9 @@ export default function SignupPage() {
|
||||
)}
|
||||
|
||||
{/* 하단 링크 */}
|
||||
<div className="mt-6 text-center text-sm text-gray-500">
|
||||
<div className="mt-6 text-center text-sm text-[var(--color-text-muted)]">
|
||||
이미 계정이 있으신가요?{' '}
|
||||
<Link href="/login" className="text-blue-600 font-semibold hover:underline">
|
||||
<Link href="/login" className="font-semibold text-[var(--color-accent)] hover:underline">
|
||||
로그인하기
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -15,14 +15,14 @@ function LegacyWriteRedirect() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={36} />
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={36} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WritePage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex min-h-[60vh] items-center justify-center"><Loader2 className="animate-spin text-blue-500" /></div>}>
|
||||
<Suspense fallback={<div className="flex min-h-[60vh] items-center justify-center"><Loader2 className="animate-spin text-[var(--color-accent)]" /></div>}>
|
||||
<LegacyWriteRedirect />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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)]"
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
125
src/components/theme/ThemeProvider.tsx
Normal file
125
src/components/theme/ThemeProvider.tsx
Normal 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;
|
||||
};
|
||||
53
src/components/theme/ThemeToggle.tsx
Normal file
53
src/components/theme/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/components/theme/theme.ts
Normal file
30
src/components/theme/theme.ts
Normal 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';
|
||||
}
|
||||
})();
|
||||
`;
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user