parent
122d639cfb
commit
75e05dc5ab
File diff suppressed because one or more lines are too long
@ -0,0 +1,21 @@ |
|||||||
|
{ |
||||||
|
"$schema": "https://ui.shadcn.com/schema.json", |
||||||
|
"style": "new-york", |
||||||
|
"rsc": true, |
||||||
|
"tsx": true, |
||||||
|
"tailwind": { |
||||||
|
"config": "", |
||||||
|
"css": "src/app/globals.css", |
||||||
|
"baseColor": "neutral", |
||||||
|
"cssVariables": true, |
||||||
|
"prefix": "" |
||||||
|
}, |
||||||
|
"aliases": { |
||||||
|
"components": "@/components", |
||||||
|
"utils": "@/lib/utils", |
||||||
|
"ui": "@/components/ui", |
||||||
|
"lib": "@/lib", |
||||||
|
"hooks": "@/hooks" |
||||||
|
}, |
||||||
|
"iconLibrary": "lucide" |
||||||
|
} |
File diff suppressed because it is too large
Load Diff
@ -1,26 +1,129 @@ |
|||||||
@import "tailwindcss"; |
@import "tailwindcss"; |
||||||
|
@import "tw-animate-css"; |
||||||
|
|
||||||
:root { |
@custom-variant dark (&:is(.dark *)); |
||||||
--background: #ffffff; |
|
||||||
--foreground: #171717; |
|
||||||
} |
|
||||||
|
|
||||||
@theme inline { |
@theme inline { |
||||||
--color-background: var(--background); |
--color-background: var(--background); |
||||||
--color-foreground: var(--foreground); |
--color-foreground: var(--foreground); |
||||||
--font-sans: var(--font-geist-sans); |
--font-sans: var(--font-geist-sans); |
||||||
--font-mono: var(--font-geist-mono); |
--font-mono: var(--font-geist-mono); |
||||||
|
--color-sidebar-ring: var(--sidebar-ring); |
||||||
|
--color-sidebar-border: var(--sidebar-border); |
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); |
||||||
|
--color-sidebar-accent: var(--sidebar-accent); |
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); |
||||||
|
--color-sidebar-primary: var(--sidebar-primary); |
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground); |
||||||
|
--color-sidebar: var(--sidebar); |
||||||
|
--color-chart-5: var(--chart-5); |
||||||
|
--color-chart-4: var(--chart-4); |
||||||
|
--color-chart-3: var(--chart-3); |
||||||
|
--color-chart-2: var(--chart-2); |
||||||
|
--color-chart-1: var(--chart-1); |
||||||
|
--color-ring: var(--ring); |
||||||
|
--color-input: var(--input); |
||||||
|
--color-border: var(--border); |
||||||
|
--color-destructive: var(--destructive); |
||||||
|
--color-accent-foreground: var(--accent-foreground); |
||||||
|
--color-accent: var(--accent); |
||||||
|
--color-muted-foreground: var(--muted-foreground); |
||||||
|
--color-muted: var(--muted); |
||||||
|
--color-secondary-foreground: var(--secondary-foreground); |
||||||
|
--color-secondary: var(--secondary); |
||||||
|
--color-primary-foreground: var(--primary-foreground); |
||||||
|
--color-primary: var(--primary); |
||||||
|
--color-popover-foreground: var(--popover-foreground); |
||||||
|
--color-popover: var(--popover); |
||||||
|
--color-card-foreground: var(--card-foreground); |
||||||
|
--color-card: var(--card); |
||||||
|
--radius-sm: calc(var(--radius) - 4px); |
||||||
|
--radius-md: calc(var(--radius) - 2px); |
||||||
|
--radius-lg: var(--radius); |
||||||
|
--radius-xl: calc(var(--radius) + 4px); |
||||||
} |
} |
||||||
|
|
||||||
@media (prefers-color-scheme: dark) { |
@media (prefers-color-scheme: light) { |
||||||
:root { |
:root { |
||||||
--background: #0a0a0a; |
--background: #ededed; |
||||||
--foreground: #ededed; |
--foreground: #0a0a0a; |
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
body { |
:root { |
||||||
background: var(--background); |
--radius: 0.625rem; |
||||||
color: var(--foreground); |
--background: oklch(1 0 0); |
||||||
font-family: Arial, Helvetica, sans-serif; |
--foreground: oklch(0.145 0 0); |
||||||
|
--card: oklch(1 0 0); |
||||||
|
--card-foreground: oklch(0.145 0 0); |
||||||
|
--popover: oklch(1 0 0); |
||||||
|
--popover-foreground: oklch(0.145 0 0); |
||||||
|
--primary: oklch(0.205 0 0); |
||||||
|
--primary-foreground: oklch(0.985 0 0); |
||||||
|
--secondary: oklch(0.97 0 0); |
||||||
|
--secondary-foreground: oklch(0.205 0 0); |
||||||
|
--muted: oklch(0.97 0 0); |
||||||
|
--muted-foreground: oklch(0.556 0 0); |
||||||
|
--accent: oklch(0.97 0 0); |
||||||
|
--accent-foreground: oklch(0.205 0 0); |
||||||
|
--destructive: oklch(0.577 0.245 27.325); |
||||||
|
--border: oklch(0.922 0 0); |
||||||
|
--input: oklch(0.922 0 0); |
||||||
|
--ring: oklch(0.708 0 0); |
||||||
|
--chart-1: oklch(0.646 0.222 41.116); |
||||||
|
--chart-2: oklch(0.6 0.118 184.704); |
||||||
|
--chart-3: oklch(0.398 0.07 227.392); |
||||||
|
--chart-4: oklch(0.828 0.189 84.429); |
||||||
|
--chart-5: oklch(0.769 0.188 70.08); |
||||||
|
--sidebar: oklch(0.985 0 0); |
||||||
|
--sidebar-foreground: oklch(0.145 0 0); |
||||||
|
--sidebar-primary: oklch(0.205 0 0); |
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0); |
||||||
|
--sidebar-accent: oklch(0.97 0 0); |
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0); |
||||||
|
--sidebar-border: oklch(0.922 0 0); |
||||||
|
--sidebar-ring: oklch(0.708 0 0); |
||||||
|
} |
||||||
|
|
||||||
|
.dark { |
||||||
|
--background: oklch(0.145 0 0); |
||||||
|
--foreground: oklch(0.985 0 0); |
||||||
|
--card: oklch(0.205 0 0); |
||||||
|
--card-foreground: oklch(0.985 0 0); |
||||||
|
--popover: oklch(0.205 0 0); |
||||||
|
--popover-foreground: oklch(0.985 0 0); |
||||||
|
--primary: oklch(0.922 0 0); |
||||||
|
--primary-foreground: oklch(0.205 0 0); |
||||||
|
--secondary: oklch(0.269 0 0); |
||||||
|
--secondary-foreground: oklch(0.985 0 0); |
||||||
|
--muted: oklch(0.269 0 0); |
||||||
|
--muted-foreground: oklch(0.708 0 0); |
||||||
|
--accent: oklch(0.269 0 0); |
||||||
|
--accent-foreground: oklch(0.985 0 0); |
||||||
|
--destructive: oklch(0.704 0.191 22.216); |
||||||
|
--border: oklch(1 0 0 / 10%); |
||||||
|
--input: oklch(1 0 0 / 15%); |
||||||
|
--ring: oklch(0.556 0 0); |
||||||
|
--chart-1: oklch(0.488 0.243 264.376); |
||||||
|
--chart-2: oklch(0.696 0.17 162.48); |
||||||
|
--chart-3: oklch(0.769 0.188 70.08); |
||||||
|
--chart-4: oklch(0.627 0.265 303.9); |
||||||
|
--chart-5: oklch(0.645 0.246 16.439); |
||||||
|
--sidebar: oklch(0.205 0 0); |
||||||
|
--sidebar-foreground: oklch(0.985 0 0); |
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376); |
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0); |
||||||
|
--sidebar-accent: oklch(0.269 0 0); |
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0); |
||||||
|
--sidebar-border: oklch(1 0 0 / 10%); |
||||||
|
--sidebar-ring: oklch(0.556 0 0); |
||||||
|
} |
||||||
|
|
||||||
|
@layer base { |
||||||
|
* { |
||||||
|
@apply border-border outline-ring/50; |
||||||
|
} |
||||||
|
body { |
||||||
|
@apply bg-background text-foreground; |
||||||
|
} |
||||||
} |
} |
||||||
|
@ -0,0 +1,123 @@ |
|||||||
|
"use client"; |
||||||
|
|
||||||
|
import { z } from "zod"; |
||||||
|
import { useState } from "react"; |
||||||
|
import { useForm } from "react-hook-form"; |
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"; |
||||||
|
import { |
||||||
|
Form, |
||||||
|
FormControl, |
||||||
|
FormField, |
||||||
|
FormItem, |
||||||
|
FormMessage, |
||||||
|
} from "@/components/ui/form"; |
||||||
|
import { Input } from "@/components/ui/input"; |
||||||
|
import { Button } from "@/components/ui/button"; |
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
||||||
|
import { setToken } from "@/lib/token"; |
||||||
|
import { api } from "@/lib/api"; |
||||||
|
import { useRouter } from "next/navigation"; |
||||||
|
import axios from "axios"; |
||||||
|
|
||||||
|
const LoginSchema = z.object({ |
||||||
|
name: z.string().min(1, "이름을 입력하세요."), |
||||||
|
password: z.string().min(1, "비밀번호를 입력하세요."), |
||||||
|
}); |
||||||
|
type LoginInput = z.infer<typeof LoginSchema>; |
||||||
|
|
||||||
|
export default function LoginPage() { |
||||||
|
const form = useForm<LoginInput>({ |
||||||
|
resolver: zodResolver(LoginSchema), |
||||||
|
defaultValues: { name: "", password: "" }, |
||||||
|
mode: "onTouched", |
||||||
|
}); |
||||||
|
|
||||||
|
const [error, setError] = useState<string | null>(null); |
||||||
|
const [loading, setLoading] = useState(false); |
||||||
|
const router = useRouter(); |
||||||
|
|
||||||
|
const onSubmit = async (values: LoginInput) => { |
||||||
|
setError(null); |
||||||
|
setLoading(true); |
||||||
|
try { |
||||||
|
const res = await api.post("/auth/login", values); |
||||||
|
setToken(res.data.access_token); |
||||||
|
router.push("/me"); |
||||||
|
} catch (e: unknown) { |
||||||
|
console.log(e); |
||||||
|
if (axios.isAxiosError(e)) { |
||||||
|
setError(e?.message || "로그인 실패"); |
||||||
|
} else if (e instanceof Error) { |
||||||
|
setError(e.message); |
||||||
|
} else { |
||||||
|
setError("로그인 실패"); |
||||||
|
} |
||||||
|
} finally { |
||||||
|
setLoading(false); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="flex items-center justify-center min-h-screen"> |
||||||
|
<div className="mx-auto my-auto w-full max-w-sm"> |
||||||
|
<Card> |
||||||
|
<CardHeader> |
||||||
|
<CardTitle>로그인</CardTitle> |
||||||
|
</CardHeader> |
||||||
|
<CardContent className="flex flex-col gap-y-2 pb-4"> |
||||||
|
{/* Name */} |
||||||
|
<Form {...form}> |
||||||
|
<form |
||||||
|
className="space-y-4" |
||||||
|
onSubmit={form.handleSubmit(onSubmit)} |
||||||
|
> |
||||||
|
<FormField |
||||||
|
control={form.control} |
||||||
|
name="name" |
||||||
|
render={({ field }) => ( |
||||||
|
<FormItem> |
||||||
|
<FormControl> |
||||||
|
<Input |
||||||
|
placeholder="아이디" |
||||||
|
autoComplete="username" |
||||||
|
{...field} |
||||||
|
/> |
||||||
|
</FormControl> |
||||||
|
<FormMessage /> |
||||||
|
</FormItem> |
||||||
|
)} |
||||||
|
/> |
||||||
|
|
||||||
|
{/* Password */} |
||||||
|
<FormField |
||||||
|
control={form.control} |
||||||
|
name="password" |
||||||
|
render={({ field }) => ( |
||||||
|
<FormItem> |
||||||
|
<FormControl> |
||||||
|
<Input |
||||||
|
type="password" |
||||||
|
placeholder="비밀번호" |
||||||
|
autoComplete="current-password" |
||||||
|
{...field} |
||||||
|
/> |
||||||
|
</FormControl> |
||||||
|
</FormItem> |
||||||
|
)} |
||||||
|
/> |
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}> |
||||||
|
{loading ? "로그인 중..." : "로그인"} |
||||||
|
</Button> |
||||||
|
|
||||||
|
{error && ( |
||||||
|
<p className="text-center text-sm text-red-600">{error}</p> |
||||||
|
)} |
||||||
|
</form> |
||||||
|
</Form> |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,25 @@ |
|||||||
|
"use client"; |
||||||
|
|
||||||
|
import Guard from "@/components/guard"; |
||||||
|
import { useMe } from "@/hooks/useMe"; |
||||||
|
|
||||||
|
export default function MePage() { |
||||||
|
const { data, isLoading, error } = useMe(); |
||||||
|
|
||||||
|
return ( |
||||||
|
<Guard> |
||||||
|
<div className="space-y-4"> |
||||||
|
<h1 className="text-xl font-semibold">내 정보</h1> |
||||||
|
|
||||||
|
{isLoading && <p>불러오는 중…</p>} |
||||||
|
{error && <p className="text-red-600">조회 실패</p>} |
||||||
|
|
||||||
|
{!isLoading && !error && data && ( |
||||||
|
<pre className="rounded bg-gray-900 p-4 text-sm text-white"> |
||||||
|
{JSON.stringify(data, null, 2)} |
||||||
|
</pre> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</Guard> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,17 @@ |
|||||||
|
"use client"; |
||||||
|
|
||||||
|
import { loadTokenFromStorage } from "@/lib/token"; |
||||||
|
import { useEffect } from "react"; |
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
||||||
|
|
||||||
|
const queryClient = new QueryClient(); |
||||||
|
|
||||||
|
export default function Providers({ children }: { children: React.ReactNode }) { |
||||||
|
useEffect(() => { |
||||||
|
loadTokenFromStorage(); // 새로고침 토큰 복원
|
||||||
|
}, []); |
||||||
|
|
||||||
|
return ( |
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,46 @@ |
|||||||
|
"use client"; |
||||||
|
|
||||||
|
import Link from "next/link"; |
||||||
|
import { usePathname } from "next/navigation"; |
||||||
|
import { Separator } from "./ui/separator"; |
||||||
|
import { Avatar, AvatarFallback } from "./ui/avatar"; |
||||||
|
|
||||||
|
const nav = [ |
||||||
|
{ href: "/", label: "홈" }, |
||||||
|
{ href: "/me", label: "내 정보" }, |
||||||
|
{ href: "/login", label: "로그인" }, |
||||||
|
]; |
||||||
|
|
||||||
|
export default function AppHeader() { |
||||||
|
const pathname = usePathname(); |
||||||
|
|
||||||
|
return ( |
||||||
|
<header className="bg-gray-900 text-white"> |
||||||
|
<div className="mx-auto max-w-full px-4 h-14 flex items-center justify-between"> |
||||||
|
<Link href="/" className="font-semibold"> |
||||||
|
Web |
||||||
|
</Link> |
||||||
|
|
||||||
|
<nav className="flex items-center gap-4 text-sm"> |
||||||
|
{nav.map((n) => ( |
||||||
|
<Link |
||||||
|
key={n.href} |
||||||
|
href={n.href} |
||||||
|
className={`hover:underline ${ |
||||||
|
pathname === n.href ? "underline" : "" |
||||||
|
}`}
|
||||||
|
> |
||||||
|
{n.label} |
||||||
|
</Link> |
||||||
|
))} |
||||||
|
|
||||||
|
<Separator orientation="vertical" className="h-5 bg-white/30" /> |
||||||
|
|
||||||
|
<Avatar className="h-8 w-8"> |
||||||
|
<AvatarFallback>U</AvatarFallback> |
||||||
|
</Avatar> |
||||||
|
</nav> |
||||||
|
</div> |
||||||
|
</header> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,19 @@ |
|||||||
|
"use client"; |
||||||
|
|
||||||
|
import { getToken } from "@/lib/token"; |
||||||
|
import { useRouter } from "next/navigation"; |
||||||
|
import { useEffect, useState } from "react"; |
||||||
|
|
||||||
|
export default function Guard({ children }: { children: React.ReactNode }) { |
||||||
|
const r = useRouter(); |
||||||
|
const [ready, setReady] = useState(false); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const t = getToken(); |
||||||
|
if (!t) r.replace("/login"); |
||||||
|
else setReady(true); |
||||||
|
}, [r]); |
||||||
|
|
||||||
|
if (!ready) return null; |
||||||
|
return <>{children}</>; |
||||||
|
} |
@ -0,0 +1,53 @@ |
|||||||
|
"use client" |
||||||
|
|
||||||
|
import * as React from "react" |
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar" |
||||||
|
|
||||||
|
import { cn } from "@/lib/utils" |
||||||
|
|
||||||
|
function Avatar({ |
||||||
|
className, |
||||||
|
...props |
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root>) { |
||||||
|
return ( |
||||||
|
<AvatarPrimitive.Root |
||||||
|
data-slot="avatar" |
||||||
|
className={cn( |
||||||
|
"relative flex size-8 shrink-0 overflow-hidden rounded-full", |
||||||
|
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 flex size-full items-center justify-center rounded-full", |
||||||
|
className |
||||||
|
)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback } |
@ -0,0 +1,59 @@ |
|||||||
|
import * as React from "react" |
||||||
|
import { Slot } from "@radix-ui/react-slot" |
||||||
|
import { cva, type VariantProps } from "class-variance-authority" |
||||||
|
|
||||||
|
import { cn } from "@/lib/utils" |
||||||
|
|
||||||
|
const buttonVariants = cva( |
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", |
||||||
|
{ |
||||||
|
variants: { |
||||||
|
variant: { |
||||||
|
default: |
||||||
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", |
||||||
|
destructive: |
||||||
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", |
||||||
|
outline: |
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", |
||||||
|
secondary: |
||||||
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", |
||||||
|
ghost: |
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", |
||||||
|
link: "text-primary underline-offset-4 hover:underline", |
||||||
|
}, |
||||||
|
size: { |
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3", |
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", |
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4", |
||||||
|
icon: "size-9", |
||||||
|
}, |
||||||
|
}, |
||||||
|
defaultVariants: { |
||||||
|
variant: "default", |
||||||
|
size: "default", |
||||||
|
}, |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
function Button({ |
||||||
|
className, |
||||||
|
variant, |
||||||
|
size, |
||||||
|
asChild = false, |
||||||
|
...props |
||||||
|
}: React.ComponentProps<"button"> & |
||||||
|
VariantProps<typeof buttonVariants> & { |
||||||
|
asChild?: boolean |
||||||
|
}) { |
||||||
|
const Comp = asChild ? Slot : "button" |
||||||
|
|
||||||
|
return ( |
||||||
|
<Comp |
||||||
|
data-slot="button" |
||||||
|
className={cn(buttonVariants({ variant, size, className }))} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export { Button, buttonVariants } |
@ -0,0 +1,92 @@ |
|||||||
|
import * as React from "react" |
||||||
|
|
||||||
|
import { cn } from "@/lib/utils" |
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
data-slot="card" |
||||||
|
className={cn( |
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", |
||||||
|
className |
||||||
|
)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
data-slot="card-header" |
||||||
|
className={cn( |
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", |
||||||
|
className |
||||||
|
)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
data-slot="card-title" |
||||||
|
className={cn("leading-none font-semibold", className)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
data-slot="card-description" |
||||||
|
className={cn("text-muted-foreground text-sm", className)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
data-slot="card-action" |
||||||
|
className={cn( |
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end", |
||||||
|
className |
||||||
|
)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
data-slot="card-content" |
||||||
|
className={cn("px-6", className)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
data-slot="card-footer" |
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export { |
||||||
|
Card, |
||||||
|
CardHeader, |
||||||
|
CardFooter, |
||||||
|
CardTitle, |
||||||
|
CardAction, |
||||||
|
CardDescription, |
||||||
|
CardContent, |
||||||
|
} |
@ -0,0 +1,257 @@ |
|||||||
|
"use client" |
||||||
|
|
||||||
|
import * as React from "react" |
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" |
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" |
||||||
|
|
||||||
|
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 flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8", |
||||||
|
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, |
||||||
|
} |
@ -0,0 +1,167 @@ |
|||||||
|
"use client" |
||||||
|
|
||||||
|
import * as React from "react" |
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label" |
||||||
|
import { Slot } from "@radix-ui/react-slot" |
||||||
|
import { |
||||||
|
Controller, |
||||||
|
FormProvider, |
||||||
|
useFormContext, |
||||||
|
useFormState, |
||||||
|
type ControllerProps, |
||||||
|
type FieldPath, |
||||||
|
type FieldValues, |
||||||
|
} from "react-hook-form" |
||||||
|
|
||||||
|
import { cn } from "@/lib/utils" |
||||||
|
import { Label } from "@/components/ui/label" |
||||||
|
|
||||||
|
const Form = FormProvider |
||||||
|
|
||||||
|
type FormFieldContextValue< |
||||||
|
TFieldValues extends FieldValues = FieldValues, |
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, |
||||||
|
> = { |
||||||
|
name: TName |
||||||
|
} |
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>( |
||||||
|
{} as FormFieldContextValue |
||||||
|
) |
||||||
|
|
||||||
|
const FormField = < |
||||||
|
TFieldValues extends FieldValues = FieldValues, |
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, |
||||||
|
>({ |
||||||
|
...props |
||||||
|
}: ControllerProps<TFieldValues, TName>) => { |
||||||
|
return ( |
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}> |
||||||
|
<Controller {...props} /> |
||||||
|
</FormFieldContext.Provider> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
const useFormField = () => { |
||||||
|
const fieldContext = React.useContext(FormFieldContext) |
||||||
|
const itemContext = React.useContext(FormItemContext) |
||||||
|
const { getFieldState } = useFormContext() |
||||||
|
const formState = useFormState({ name: fieldContext.name }) |
||||||
|
const fieldState = getFieldState(fieldContext.name, formState) |
||||||
|
|
||||||
|
if (!fieldContext) { |
||||||
|
throw new Error("useFormField should be used within <FormField>") |
||||||
|
} |
||||||
|
|
||||||
|
const { id } = itemContext |
||||||
|
|
||||||
|
return { |
||||||
|
id, |
||||||
|
name: fieldContext.name, |
||||||
|
formItemId: `${id}-form-item`, |
||||||
|
formDescriptionId: `${id}-form-item-description`, |
||||||
|
formMessageId: `${id}-form-item-message`, |
||||||
|
...fieldState, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
type FormItemContextValue = { |
||||||
|
id: string |
||||||
|
} |
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>( |
||||||
|
{} as FormItemContextValue |
||||||
|
) |
||||||
|
|
||||||
|
function FormItem({ className, ...props }: React.ComponentProps<"div">) { |
||||||
|
const id = React.useId() |
||||||
|
|
||||||
|
return ( |
||||||
|
<FormItemContext.Provider value={{ id }}> |
||||||
|
<div |
||||||
|
data-slot="form-item" |
||||||
|
className={cn("grid gap-2", className)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
</FormItemContext.Provider> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function FormLabel({ |
||||||
|
className, |
||||||
|
...props |
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) { |
||||||
|
const { error, formItemId } = useFormField() |
||||||
|
|
||||||
|
return ( |
||||||
|
<Label |
||||||
|
data-slot="form-label" |
||||||
|
data-error={!!error} |
||||||
|
className={cn("data-[error=true]:text-destructive", className)} |
||||||
|
htmlFor={formItemId} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) { |
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField() |
||||||
|
|
||||||
|
return ( |
||||||
|
<Slot |
||||||
|
data-slot="form-control" |
||||||
|
id={formItemId} |
||||||
|
aria-describedby={ |
||||||
|
!error |
||||||
|
? `${formDescriptionId}` |
||||||
|
: `${formDescriptionId} ${formMessageId}` |
||||||
|
} |
||||||
|
aria-invalid={!!error} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function FormDescription({ className, ...props }: React.ComponentProps<"p">) { |
||||||
|
const { formDescriptionId } = useFormField() |
||||||
|
|
||||||
|
return ( |
||||||
|
<p |
||||||
|
data-slot="form-description" |
||||||
|
id={formDescriptionId} |
||||||
|
className={cn("text-muted-foreground text-sm", className)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function FormMessage({ className, ...props }: React.ComponentProps<"p">) { |
||||||
|
const { error, formMessageId } = useFormField() |
||||||
|
const body = error ? String(error?.message ?? "") : props.children |
||||||
|
|
||||||
|
if (!body) { |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<p |
||||||
|
data-slot="form-message" |
||||||
|
id={formMessageId} |
||||||
|
className={cn("text-destructive text-sm", className)} |
||||||
|
{...props} |
||||||
|
> |
||||||
|
{body} |
||||||
|
</p> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export { |
||||||
|
useFormField, |
||||||
|
Form, |
||||||
|
FormItem, |
||||||
|
FormLabel, |
||||||
|
FormControl, |
||||||
|
FormDescription, |
||||||
|
FormMessage, |
||||||
|
FormField, |
||||||
|
} |
@ -0,0 +1,21 @@ |
|||||||
|
import * as React from "react" |
||||||
|
|
||||||
|
import { cn } from "@/lib/utils" |
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) { |
||||||
|
return ( |
||||||
|
<input |
||||||
|
type={type} |
||||||
|
data-slot="input" |
||||||
|
className={cn( |
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", |
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", |
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", |
||||||
|
className |
||||||
|
)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export { Input } |
@ -0,0 +1,24 @@ |
|||||||
|
"use client" |
||||||
|
|
||||||
|
import * as React from "react" |
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label" |
||||||
|
|
||||||
|
import { cn } from "@/lib/utils" |
||||||
|
|
||||||
|
function Label({ |
||||||
|
className, |
||||||
|
...props |
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) { |
||||||
|
return ( |
||||||
|
<LabelPrimitive.Root |
||||||
|
data-slot="label" |
||||||
|
className={cn( |
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", |
||||||
|
className |
||||||
|
)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export { Label } |
@ -0,0 +1,28 @@ |
|||||||
|
"use client" |
||||||
|
|
||||||
|
import * as React from "react" |
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator" |
||||||
|
|
||||||
|
import { cn } from "@/lib/utils" |
||||||
|
|
||||||
|
function Separator({ |
||||||
|
className, |
||||||
|
orientation = "horizontal", |
||||||
|
decorative = true, |
||||||
|
...props |
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) { |
||||||
|
return ( |
||||||
|
<SeparatorPrimitive.Root |
||||||
|
data-slot="separator" |
||||||
|
decorative={decorative} |
||||||
|
orientation={orientation} |
||||||
|
className={cn( |
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", |
||||||
|
className |
||||||
|
)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export { Separator } |
@ -0,0 +1,19 @@ |
|||||||
|
import { api } from "@/lib/api"; |
||||||
|
import { useQuery } from "@tanstack/react-query"; |
||||||
|
|
||||||
|
export type MeResponse = { |
||||||
|
id: number; |
||||||
|
name: string; |
||||||
|
email?: string | null; |
||||||
|
profile?: { bio?: string | null; avatarUrl?: string | null }; |
||||||
|
}; |
||||||
|
|
||||||
|
export function useMe() { |
||||||
|
return useQuery({ |
||||||
|
queryKey: ["me"], |
||||||
|
queryFn: async (): Promise<MeResponse> => { |
||||||
|
const res = await api.get("/auth/me"); |
||||||
|
return res.data; |
||||||
|
}, |
||||||
|
}); |
||||||
|
} |
@ -0,0 +1,28 @@ |
|||||||
|
import axios from "axios"; |
||||||
|
import { getToken, setToken } from "./token"; |
||||||
|
|
||||||
|
export const api = axios.create({ |
||||||
|
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, |
||||||
|
}); |
||||||
|
|
||||||
|
api.interceptors.request.use((config) => { |
||||||
|
const t = getToken(); |
||||||
|
if (t) { |
||||||
|
config.headers = config.headers ?? {}; |
||||||
|
config.headers.Authorization = `Bearer ${t}`; |
||||||
|
} |
||||||
|
|
||||||
|
return config; |
||||||
|
}); |
||||||
|
|
||||||
|
api.interceptors.response.use( |
||||||
|
(res) => res, |
||||||
|
(error) => { |
||||||
|
if (error?.response?.status === 401 && typeof window !== "undefined") { |
||||||
|
setToken(null); // 토큰 파기
|
||||||
|
window.location.href = "/login"; // 전역 리다이렉트
|
||||||
|
} |
||||||
|
|
||||||
|
return Promise.reject(error); |
||||||
|
} |
||||||
|
); |
@ -0,0 +1,6 @@ |
|||||||
|
import { clsx, type ClassValue } from "clsx" |
||||||
|
import { twMerge } from "tailwind-merge" |
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) { |
||||||
|
return twMerge(clsx(inputs)) |
||||||
|
} |
Loading…
Reference in new issue