Feat: 대시보드 추가
app/(auth)/forgot-password/page.tsx - 비밀번호 초기화 페이지 레이아웃 정리 및 카드 스타일 개선 app/(auth)/login/page.tsx - 로그인 페이지 레이아웃 경량화 및 메시지 표시 유지 app/(auth)/reset-password/page.tsx - 리셋 비밀번호 페이지 레이아웃 정리 app/(auth)/signup/page.tsx - 회원가입 페이지 레이아웃 정리 및 링크 배치 개선 app/(auth)/layout.tsx - 인증 관련 공통 배경 레이아웃 추가 (그라디언트/블러 효과 분리) app/(main)/layout.tsx - 메인 레이아웃 추가 (헤더, 사이드바 포함) app/(main)/page.tsx - 대시보드 기본 페이지 추가 (위젯/플레이스홀더) app/page.tsx - 기존 메인 페이지 제거 (대시보드로 대체) components/ui/avatar.tsx - 아바타 UI 컴포넌트 추가 components/ui/dropdown-menu.tsx - 드롭다운 메뉴 UI 컴포넌트 추가 (Radix 기반) features/layout/components/header.tsx - 헤더 컴포넌트 추가 (사용자 상태 표시 및 메뉴 연결) features/layout/components/sidebar.tsx - 사이드바 네비게이션 컴포넌트 추가 features/layout/components/user-menu.tsx - 사용자 드롭다운 메뉴 추가 (로그아웃 등) features/layout/types/index.ts - 레이아웃 관련 타입 정의 추가 package.json - Radix 드롭다운, Framer Motion 등 UI 관련 의존성 추가 package-lock.json - 패키지 잠금파일 갱신 및 교체 - 인증 및 메인 영역 구조를 분리하고 공통 레이아웃과 재사용 가능한 UI 컴포넌트를 도입하여 향후 페이지 확장 및 유지보수성을 개선합니다
This commit is contained in:
85
app/(auth)/forgot-password/page.tsx
Normal file
85
app/(auth)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import FormMessage from "@/components/form-message";
|
||||||
|
import { requestPasswordReset } from "@/features/auth/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [비밀번호 찾기 페이지]
|
||||||
|
*
|
||||||
|
* 사용자가 비밀번호를 잊어버렸을 때 재설정 링크를 요청하는 페이지입니다.
|
||||||
|
* - 이메일 입력 폼 제공
|
||||||
|
* - 서버 액션(requestPasswordReset)과 연동
|
||||||
|
*/
|
||||||
|
export default async function ForgotPasswordPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ message?: string }>;
|
||||||
|
}) {
|
||||||
|
const { message } = await searchParams;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
{message && <FormMessage message={message} />}
|
||||||
|
|
||||||
|
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
||||||
|
<CardHeader className="space-y-3 text-center">
|
||||||
|
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
||||||
|
<span className="text-sm font-semibold">MAIL</span>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||||
|
비밀번호 재설정
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
가입한 이메일 주소를 입력하시면 비밀번호 재설정 링크를 보내드립니다.
|
||||||
|
<br />
|
||||||
|
메일을 받지 못하셨다면 스팸함을 확인해 주세요.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<form className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email" className="text-sm font-medium">
|
||||||
|
이메일
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="name@example.com"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
className="h-11 transition-all duration-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
formAction={requestPasswordReset}
|
||||||
|
className="h-11 w-full bg-linear-to-r from-gray-900 to-black font-semibold text-white shadow-lg transition-all hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white"
|
||||||
|
>
|
||||||
|
재설정 링크 보내기
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-sm font-medium text-gray-700 hover:text-black dark:text-gray-300 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
로그인 페이지로 돌아가기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
app/(auth)/layout.tsx
Normal file
20
app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export default function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-linear-to-br from-gray-50 via-white to-gray-100 px-4 py-12 dark:from-black dark:via-gray-950 dark:to-gray-900">
|
||||||
|
{/* ========== 배경 그라디언트 레이어 ========== */}
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-gray-200/30 via-gray-100/15 to-transparent dark:from-gray-800/30 dark:via-gray-900/20" />
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,var(--tw-gradient-stops))] from-gray-300/30 via-gray-200/15 to-transparent dark:from-gray-700/30 dark:via-gray-800/20" />
|
||||||
|
|
||||||
|
{/* ========== 애니메이션 블러 효과 ========== */}
|
||||||
|
<div className="absolute left-1/4 top-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-300/20 blur-3xl dark:bg-gray-700/20" />
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-400/20 blur-3xl delay-700 dark:bg-gray-600/20" />
|
||||||
|
|
||||||
|
{/* ========== 메인 콘텐츠 영역 ========== */}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
app/(auth)/login/page.tsx
Normal file
62
app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import FormMessage from "@/components/form-message";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import LoginForm from "@/features/auth/components/login-form";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [로그인 페이지 컴포넌트]
|
||||||
|
*
|
||||||
|
* Modern UI with glassmorphism effect (유리 형태 디자인)
|
||||||
|
* - 투명 배경 + 블러 효과로 깊이감 표현
|
||||||
|
* - 그라디언트 배경으로 생동감 추가
|
||||||
|
* - shadcn/ui 컴포넌트로 일관된 디자인 시스템 유지
|
||||||
|
*
|
||||||
|
* @param searchParams - URL 쿼리 파라미터 (에러 메시지 전달용)
|
||||||
|
*/
|
||||||
|
export default async function LoginPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ message: string }>;
|
||||||
|
}) {
|
||||||
|
// URL에서 메시지 파라미터 추출 (로그인 실패 시 에러 메시지 표시)
|
||||||
|
const { message } = await searchParams;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
{/* 에러/성공 메시지 표시 영역 */}
|
||||||
|
{/* URL 파라미터에 message가 있으면 표시됨 */}
|
||||||
|
<FormMessage message={message} />
|
||||||
|
|
||||||
|
{/* ========== 로그인 카드 (Glassmorphism) ========== */}
|
||||||
|
{/* bg-white/70: 70% 투명도의 흰색 배경 */}
|
||||||
|
{/* backdrop-blur-xl: 배경 블러 효과 (유리 느낌) */}
|
||||||
|
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
||||||
|
{/* ========== 카드 헤더 영역 ========== */}
|
||||||
|
<CardHeader className="space-y-3 text-center">
|
||||||
|
{/* 아이콘 배경: 그라디언트 원형 */}
|
||||||
|
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
||||||
|
<span className="text-4xl">👋</span>
|
||||||
|
</div>
|
||||||
|
{/* 페이지 제목 */}
|
||||||
|
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||||
|
환영합니다!
|
||||||
|
</CardTitle>
|
||||||
|
{/* 페이지 설명 */}
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
서비스 이용을 위해 로그인해 주세요.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{/* ========== 카드 콘텐츠 영역 (폼) ========== */}
|
||||||
|
<CardContent>
|
||||||
|
<LoginForm />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
app/(auth)/reset-password/page.tsx
Normal file
64
app/(auth)/reset-password/page.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import FormMessage from "@/components/form-message";
|
||||||
|
import ResetPasswordForm from "@/features/auth/components/reset-password-form";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [비밀번호 재설정 페이지]
|
||||||
|
*
|
||||||
|
* 이메일 링크를 타고 들어온 사용자가 새 비밀번호를 설정하는 페이지입니다.
|
||||||
|
* - URL에 포함된 토큰 검증은 Middleware 및 Auth Confirm Route에서 선행됩니다.
|
||||||
|
* - 유효한 세션(Recovery Mode)이 없으면 로그인 페이지로 리다이렉트됩니다.
|
||||||
|
*/
|
||||||
|
export default async function ResetPasswordPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ message?: string }>;
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const message = encodeURIComponent(
|
||||||
|
"유효하지 않은 재설정 링크이거나 만료되었습니다. 다시 시도해 주세요.",
|
||||||
|
);
|
||||||
|
redirect(`/login?message=${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { message } = params;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
{message && <FormMessage message={message} />}
|
||||||
|
|
||||||
|
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
||||||
|
<CardHeader className="space-y-3 text-center">
|
||||||
|
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
||||||
|
<span className="text-sm font-semibold">PW</span>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||||
|
비밀번호 재설정
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
새 비밀번호를 입력해 주세요.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<ResetPasswordForm />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
app/(auth)/signup/page.tsx
Normal file
55
app/(auth)/signup/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import FormMessage from "@/components/form-message";
|
||||||
|
import SignupForm from "@/features/auth/components/signup-form";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default async function SignupPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ message: string }>;
|
||||||
|
}) {
|
||||||
|
const { message } = await searchParams;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
{/* 메시지 알림 */}
|
||||||
|
<FormMessage message={message} />
|
||||||
|
|
||||||
|
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
||||||
|
<CardHeader className="space-y-3 text-center">
|
||||||
|
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
||||||
|
<span className="text-4xl">🚀</span>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||||
|
회원가입
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
몇 가지 정보만 입력하면 바로 시작할 수 있습니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{/* ========== 폼 영역 ========== */}
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<SignupForm />
|
||||||
|
|
||||||
|
{/* ========== 로그인 링크 ========== */}
|
||||||
|
<p className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
이미 계정이 있으신가요?{" "}
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="font-semibold text-gray-900 transition-colors hover:text-black dark:text-gray-100 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
로그인 하러 가기
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
app/(main)/layout.tsx
Normal file
18
app/(main)/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Header } from "@/features/layout/components/header";
|
||||||
|
import { Sidebar } from "@/features/layout/components/sidebar";
|
||||||
|
|
||||||
|
export default function MainLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-zinc-50 dark:bg-black">
|
||||||
|
<Header />
|
||||||
|
<div className="flex flex-1">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 w-full p-6 md:p-8 lg:p-10">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
app/(main)/page.tsx
Normal file
63
app/(main)/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
export default function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">대시보드</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
자동매매 시스템 현황을 한눈에 확인하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{/* 예시 카드들 */}
|
||||||
|
<div className="rounded-xl border bg-card text-card-foreground shadow p-6">
|
||||||
|
<div className="flex flex-col space-y-1.5 p-6 pt-0 px-0">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
총 자산
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-bold">₩ 0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border bg-card text-card-foreground shadow p-6">
|
||||||
|
<div className="flex flex-col space-y-1.5 p-6 pt-0 px-0">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
평가 손익
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-bold text-green-500">+0%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border bg-card text-card-foreground shadow p-6">
|
||||||
|
<div className="flex flex-col space-y-1.5 p-6 pt-0 px-0">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
실현 손익
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-bold">₩ 0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border bg-card text-card-foreground shadow p-6">
|
||||||
|
<div className="flex flex-col space-y-1.5 p-6 pt-0 px-0">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
가동 봇
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-bold">0개</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 차트나 로그 영역 예시 */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||||
|
<div className="col-span-4 rounded-xl border bg-card text-card-foreground shadow min-h-[300px] p-6">
|
||||||
|
<div className="text-lg font-semibold mb-4">매매 추입</div>
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
차트 영역 준비중...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3 rounded-xl border bg-card text-card-foreground shadow min-h-[300px] p-6">
|
||||||
|
<div className="text-lg font-semibold mb-4">최근 활동</div>
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
로그 영역 준비중...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import FormMessage from "@/components/form-message";
|
|
||||||
import { requestPasswordReset } from "@/features/auth/actions";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [비밀번호 찾기 페이지]
|
|
||||||
*
|
|
||||||
* 사용자가 비밀번호를 잊어버렸을 때 재설정 링크를 요청하는 페이지입니다.
|
|
||||||
* - 이메일 입력 폼 제공
|
|
||||||
* - 서버 액션(requestPasswordReset)과 연동
|
|
||||||
*/
|
|
||||||
export default async function ForgotPasswordPage({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ message?: string }>;
|
|
||||||
}) {
|
|
||||||
const { message } = await searchParams;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-linear-to-br from-gray-50 via-white to-gray-100 px-4 py-12 dark:from-black dark:via-gray-950 dark:to-gray-900">
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-gray-200/30 via-gray-100/15 to-transparent dark:from-gray-800/30 dark:via-gray-900/20" />
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,var(--tw-gradient-stops))] from-gray-300/30 via-gray-200/15 to-transparent dark:from-gray-700/30 dark:via-gray-800/20" />
|
|
||||||
|
|
||||||
<div className="absolute left-1/4 top-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-300/20 blur-3xl dark:bg-gray-700/20" />
|
|
||||||
<div className="absolute bottom-1/4 right-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-400/20 blur-3xl delay-700 dark:bg-gray-600/20" />
|
|
||||||
|
|
||||||
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
|
||||||
{message && <FormMessage message={message} />}
|
|
||||||
|
|
||||||
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
|
||||||
<CardHeader className="space-y-3 text-center">
|
|
||||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
|
||||||
<span className="text-sm font-semibold">MAIL</span>
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
|
||||||
비밀번호 재설정
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-base">
|
|
||||||
가입한 이메일 주소를 입력하시면 비밀번호 재설정 링크를
|
|
||||||
보내드립니다.
|
|
||||||
<br />
|
|
||||||
메일을 받지 못하셨다면 스팸함을 확인해 주세요.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<form className="space-y-5">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email" className="text-sm font-medium">
|
|
||||||
이메일
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="name@example.com"
|
|
||||||
autoComplete="email"
|
|
||||||
required
|
|
||||||
className="h-11 transition-all duration-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
formAction={requestPasswordReset}
|
|
||||||
className="h-11 w-full bg-linear-to-r from-gray-900 to-black font-semibold text-white shadow-lg transition-all hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white"
|
|
||||||
>
|
|
||||||
재설정 링크 보내기
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<Link
|
|
||||||
href="/login"
|
|
||||||
className="text-sm font-medium text-gray-700 hover:text-black dark:text-gray-300 dark:hover:text-white"
|
|
||||||
>
|
|
||||||
로그인 페이지로 돌아가기
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import FormMessage from "@/components/form-message";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import LoginForm from "@/features/auth/components/login-form";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [로그인 페이지 컴포넌트]
|
|
||||||
*
|
|
||||||
* Modern UI with glassmorphism effect (유리 형태 디자인)
|
|
||||||
* - 투명 배경 + 블러 효과로 깊이감 표현
|
|
||||||
* - 그라디언트 배경으로 생동감 추가
|
|
||||||
* - shadcn/ui 컴포넌트로 일관된 디자인 시스템 유지
|
|
||||||
*
|
|
||||||
* @param searchParams - URL 쿼리 파라미터 (에러 메시지 전달용)
|
|
||||||
*/
|
|
||||||
export default async function LoginPage({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ message: string }>;
|
|
||||||
}) {
|
|
||||||
// URL에서 메시지 파라미터 추출 (로그인 실패 시 에러 메시지 표시)
|
|
||||||
const { message } = await searchParams;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-linear-to-br from-gray-50 via-white to-gray-100 px-4 py-12 dark:from-black dark:via-gray-950 dark:to-gray-900">
|
|
||||||
{/* ========== 배경 그라디언트 레이어 ========== */}
|
|
||||||
{/* 웹 페이지 전체 배경을 그라디언트로 채웁니다 */}
|
|
||||||
{/* 라이트 모드: 부드러운 그레이 톤 (gray → white → gray) */}
|
|
||||||
{/* 다크 모드: 깊은 블랙 톤으로 고급스러운 느낌 */}
|
|
||||||
|
|
||||||
{/* 추가 그라디언트 효과 1: 우상단에서 시작하는 원형 그라디언트 */}
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-gray-200/30 via-gray-100/15 to-transparent dark:from-gray-800/30 dark:via-gray-900/20" />
|
|
||||||
|
|
||||||
{/* 추가 그라디언트 효과 2: 좌하단에서 시작하는 원형 그라디언트 */}
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,var(--tw-gradient-stops))] from-gray-300/30 via-gray-200/15 to-transparent dark:from-gray-700/30 dark:via-gray-800/20" />
|
|
||||||
|
|
||||||
{/* ========== 애니메이션 블러 효과 ========== */}
|
|
||||||
{/* 부드럽게 깜빡이는 원형 블러로 생동감 표현 */}
|
|
||||||
{/* animate-pulse: 1.5초 주기로 opacity 변화 */}
|
|
||||||
<div className="absolute left-1/4 top-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-300/20 blur-3xl dark:bg-gray-700/20" />
|
|
||||||
{/* delay-700: 700ms 지연으로 교차 애니메이션 효과 */}
|
|
||||||
<div className="absolute bottom-1/4 right-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-400/20 blur-3xl delay-700 dark:bg-gray-600/20" />
|
|
||||||
|
|
||||||
{/* ========== 메인 콘텐츠 영역 ========== */}
|
|
||||||
{/* z-10: 배경보다 위에 표시 */}
|
|
||||||
{/* animate-in: 페이지 로드 시 fade-in + slide-up 애니메이션 */}
|
|
||||||
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
|
||||||
{/* 에러/성공 메시지 표시 영역 */}
|
|
||||||
{/* URL 파라미터에 message가 있으면 표시됨 */}
|
|
||||||
<FormMessage message={message} />
|
|
||||||
|
|
||||||
{/* ========== 로그인 카드 (Glassmorphism) ========== */}
|
|
||||||
{/* bg-white/70: 70% 투명도의 흰색 배경 */}
|
|
||||||
{/* backdrop-blur-xl: 배경 블러 효과 (유리 느낌) */}
|
|
||||||
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
|
||||||
{/* ========== 카드 헤더 영역 ========== */}
|
|
||||||
<CardHeader className="space-y-3 text-center">
|
|
||||||
{/* 아이콘 배경: 그라디언트 원형 */}
|
|
||||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
|
||||||
<span className="text-4xl">👋</span>
|
|
||||||
</div>
|
|
||||||
{/* 페이지 제목 */}
|
|
||||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
|
||||||
환영합니다!
|
|
||||||
</CardTitle>
|
|
||||||
{/* 페이지 설명 */}
|
|
||||||
<CardDescription className="text-base">
|
|
||||||
서비스 이용을 위해 로그인해 주세요.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{/* ========== 카드 콘텐츠 영역 (폼) ========== */}
|
|
||||||
<CardContent>
|
|
||||||
<LoginForm />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
114
app/page.tsx
114
app/page.tsx
@@ -1,114 +0,0 @@
|
|||||||
import Image from "next/image";
|
|
||||||
import { signout } from "@/features/auth/actions";
|
|
||||||
import { createClient } from "@/utils/supabase/server";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [메인 페이지 컴포넌트]
|
|
||||||
*
|
|
||||||
* 로그인한 사용자만 접근 가능 (Middleware에서 보호)
|
|
||||||
* - 사용자 정보 표시 (이메일, 프로필 아바타)
|
|
||||||
* - 로그아웃 버튼 제공
|
|
||||||
*/
|
|
||||||
export default async function Home() {
|
|
||||||
// 현재 로그인한 사용자 정보 가져오기
|
|
||||||
// Middleware에서 이미 인증을 확인했으므로 여기서는 user가 항상 존재함
|
|
||||||
const supabase = await createClient();
|
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
|
||||||
{/* ========== 헤더: 로그인 정보 및 로그아웃 버튼 ========== */}
|
|
||||||
<div className="flex w-full items-center justify-between">
|
|
||||||
{/* 사용자 프로필 표시 */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* 프로필 아바타: 이메일 첫 글자 표시 */}
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 text-white font-semibold">
|
|
||||||
{user?.email?.charAt(0).toUpperCase() || "U"}
|
|
||||||
</div>
|
|
||||||
{/* 이메일 및 로그인 상태 텍스트 */}
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
|
||||||
{user?.email || "사용자"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
로그인됨
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* 로그아웃 폼 */}
|
|
||||||
{/* formAction: 서버 액션(signout)을 호출하여 로그아웃 처리 */}
|
|
||||||
<form>
|
|
||||||
<Button
|
|
||||||
formAction={signout}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
로그아웃
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import FormMessage from "@/components/form-message";
|
|
||||||
import ResetPasswordForm from "@/features/auth/components/reset-password-form";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { createClient } from "@/utils/supabase/server";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [비밀번호 재설정 페이지]
|
|
||||||
*
|
|
||||||
* 이메일 링크를 타고 들어온 사용자가 새 비밀번호를 설정하는 페이지입니다.
|
|
||||||
* - URL에 포함된 토큰 검증은 Middleware 및 Auth Confirm Route에서 선행됩니다.
|
|
||||||
* - 유효한 세션(Recovery Mode)이 없으면 로그인 페이지로 리다이렉트됩니다.
|
|
||||||
*/
|
|
||||||
export default async function ResetPasswordPage({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ message?: string }>;
|
|
||||||
}) {
|
|
||||||
const params = await searchParams;
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
const message = encodeURIComponent(
|
|
||||||
"유효하지 않은 재설정 링크이거나 만료되었습니다. 다시 시도해 주세요.",
|
|
||||||
);
|
|
||||||
redirect(`/login?message=${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { message } = params;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-linear-to-br from-gray-50 via-white to-gray-100 px-4 py-12 dark:from-black dark:via-gray-950 dark:to-gray-900">
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-gray-200/30 via-gray-100/15 to-transparent dark:from-gray-800/30 dark:via-gray-900/20" />
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,var(--tw-gradient-stops))] from-gray-300/30 via-gray-200/15 to-transparent dark:from-gray-700/30 dark:via-gray-800/20" />
|
|
||||||
|
|
||||||
<div className="absolute left-1/4 top-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-300/20 blur-3xl dark:bg-gray-700/20" />
|
|
||||||
<div className="absolute bottom-1/4 right-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-400/20 blur-3xl delay-700 dark:bg-gray-600/20" />
|
|
||||||
|
|
||||||
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
|
||||||
{message && <FormMessage message={message} />}
|
|
||||||
|
|
||||||
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
|
||||||
<CardHeader className="space-y-3 text-center">
|
|
||||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
|
||||||
<span className="text-sm font-semibold">PW</span>
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
|
||||||
비밀번호 재설정
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-base">
|
|
||||||
새 비밀번호를 입력해 주세요.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<ResetPasswordForm />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import FormMessage from "@/components/form-message";
|
|
||||||
import SignupForm from "@/features/auth/components/signup-form";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
|
|
||||||
export default async function SignupPage({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ message: string }>;
|
|
||||||
}) {
|
|
||||||
const { message } = await searchParams;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-linear-to-br from-gray-50 via-white to-gray-100 px-4 py-12 dark:from-black dark:via-gray-950 dark:to-gray-900">
|
|
||||||
{/* 배경 그라데이션 효과 */}
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-gray-200/30 via-gray-100/15 to-transparent dark:from-gray-800/30 dark:via-gray-900/20" />
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,var(--tw-gradient-stops))] from-gray-300/30 via-gray-200/15 to-transparent dark:from-gray-700/30 dark:via-gray-800/20" />
|
|
||||||
|
|
||||||
{/* 애니메이션 블러 효과 */}
|
|
||||||
<div className="absolute left-1/4 top-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-300/20 blur-3xl dark:bg-gray-700/20" />
|
|
||||||
<div className="absolute bottom-1/4 right-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-400/20 blur-3xl delay-700 dark:bg-gray-600/20" />
|
|
||||||
|
|
||||||
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
|
||||||
{/* 메시지 알림 */}
|
|
||||||
<FormMessage message={message} />
|
|
||||||
|
|
||||||
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
|
||||||
<CardHeader className="space-y-3 text-center">
|
|
||||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
|
||||||
<span className="text-4xl">🚀</span>
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
|
||||||
회원가입
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-base">
|
|
||||||
몇 가지 정보만 입력하면 바로 시작할 수 있습니다.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{/* ========== 폼 영역 ========== */}
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<SignupForm />
|
|
||||||
|
|
||||||
{/* ========== 로그인 링크 ========== */}
|
|
||||||
<p className="text-center text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
이미 계정이 있으신가요?{" "}
|
|
||||||
<Link
|
|
||||||
href="/login"
|
|
||||||
className="font-semibold text-gray-900 transition-colors hover:text-black dark:text-gray-100 dark:hover:text-white"
|
|
||||||
>
|
|
||||||
로그인 하러 가기
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
109
components/ui/avatar.tsx
Normal file
109
components/ui/avatar.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||||
|
size?: "default" | "sm" | "lg"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn("aspect-square size-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar-badge"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full ring-2 select-none",
|
||||||
|
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||||
|
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||||
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group"
|
||||||
|
className={cn(
|
||||||
|
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroupCount({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarBadge,
|
||||||
|
AvatarGroup,
|
||||||
|
AvatarGroupCount,
|
||||||
|
}
|
||||||
257
components/ui/dropdown-menu.tsx
Normal file
257
components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
34
features/layout/components/header.tsx
Normal file
34
features/layout/components/header.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { UserMenu } from "./user-menu";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export async function Header() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-40 flex h-14 w-full items-center justify-between border-b border-zinc-200 bg-white/75 px-6 backdrop-blur-md transition-all dark:border-zinc-800 dark:bg-black/75">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
<div className="h-6 w-6 rounded-md bg-zinc-900 dark:bg-zinc-50" />
|
||||||
|
<span className="text-lg font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
|
AutoTrade
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{user ? (
|
||||||
|
<UserMenu user={user} />
|
||||||
|
) : (
|
||||||
|
<Button asChild variant="default" size="sm">
|
||||||
|
<Link href="/login">로그인</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
features/layout/components/sidebar.tsx
Normal file
80
features/layout/components/sidebar.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { BarChart2, Home, Settings, User, Wallet } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { MenuItem } from "../types";
|
||||||
|
|
||||||
|
const MENU_ITEMS: MenuItem[] = [
|
||||||
|
{
|
||||||
|
title: "대시보드",
|
||||||
|
href: "/",
|
||||||
|
icon: Home,
|
||||||
|
variant: "default",
|
||||||
|
matchExact: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "자동매매",
|
||||||
|
href: "/trade",
|
||||||
|
icon: BarChart2,
|
||||||
|
variant: "ghost",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "자산현황",
|
||||||
|
href: "/assets",
|
||||||
|
icon: Wallet,
|
||||||
|
variant: "ghost",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "프로필",
|
||||||
|
href: "/profile",
|
||||||
|
icon: User,
|
||||||
|
variant: "ghost",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "설정",
|
||||||
|
href: "/settings",
|
||||||
|
icon: Settings,
|
||||||
|
variant: "ghost",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="fixed left-0 top-14 z-30 hidden h-[calc(100vh-3.5rem)] w-full shrink-0 overflow-y-auto border-r border-zinc-200 bg-white py-6 pl-2 pr-6 dark:border-zinc-800 dark:bg-black md:sticky md:block md:w-64 lg:w-72">
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
{MENU_ITEMS.map((item) => {
|
||||||
|
const isActive = item.matchExact
|
||||||
|
? pathname === item.href
|
||||||
|
: pathname.startsWith(item.href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50"
|
||||||
|
: "text-zinc-500 dark:text-zinc-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon
|
||||||
|
className={cn(
|
||||||
|
"mr-3 h-5 w-5 shrink-0 transition-colors",
|
||||||
|
isActive
|
||||||
|
? "text-zinc-900 dark:text-zinc-50"
|
||||||
|
: "text-zinc-400 group-hover:text-zinc-900 dark:text-zinc-500 dark:group-hover:text-zinc-50",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
features/layout/components/user-menu.tsx
Normal file
70
features/layout/components/user-menu.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { signout } from "@/features/auth/actions";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { User } from "@supabase/supabase-js";
|
||||||
|
import { LogOut, Settings, User as UserIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface UserMenuProps {
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserMenu({ user }: UserMenuProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button className="flex items-center gap-2 outline-none">
|
||||||
|
<Avatar className="h-8 w-8 transition-opacity hover:opacity-80">
|
||||||
|
<AvatarImage src={user.user_metadata?.avatar_url} />
|
||||||
|
<AvatarFallback className="bg-linear-to-br from-indigo-500 to-purple-600 text-white text-xs font-bold">
|
||||||
|
{user.email?.charAt(0).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuLabel className="font-normal">
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">
|
||||||
|
{user.user_metadata?.name || "사용자"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs leading-none text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => router.push("/profile")}>
|
||||||
|
<UserIcon className="mr-2 h-4 w-4" />
|
||||||
|
<span>프로필</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
<span>설정</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<form action={signout}>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<button className="w-full text-red-600 dark:text-red-400">
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
<span>로그아웃</span>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</form>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
features/layout/types/index.ts
Normal file
9
features/layout/types/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export interface MenuItem {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
variant: "default" | "ghost";
|
||||||
|
matchExact?: boolean;
|
||||||
|
}
|
||||||
3153
package-lock.json
generated
3153
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
@@ -20,8 +21,10 @@
|
|||||||
"@tanstack/react-query-devtools": "^5.91.3",
|
"@tanstack/react-query-devtools": "^5.91.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.31.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user