Peace 4 weeks ago
parent ee300e2c89
commit 5975c35aaa
  1. 16
      web/eslint.config.mjs
  2. 2
      web/next.config.ts
  3. 105
      web/package-lock.json
  4. 1
      web/package.json
  5. 2
      web/postcss.config.mjs
  6. 10
      web/prettier.config.mjs
  7. 4
      web/src/app/globals.css
  8. 14
      web/src/app/layout.tsx
  9. 79
      web/src/app/login/page.tsx
  10. 16
      web/src/app/me/page.tsx
  11. 48
      web/src/app/page.tsx
  12. 12
      web/src/app/providers.tsx
  13. 89
      web/src/app/signup/page.tsx
  14. 36
      web/src/components/app-header.tsx
  15. 10
      web/src/components/guard.tsx
  16. 38
      web/src/components/ui/avatar.tsx
  17. 49
      web/src/components/ui/button.tsx
  18. 67
      web/src/components/ui/card.tsx
  19. 113
      web/src/components/ui/dropdown-menu.tsx
  20. 109
      web/src/components/ui/form.tsx
  21. 18
      web/src/components/ui/input.tsx
  22. 21
      web/src/components/ui/label.tsx
  23. 18
      web/src/components/ui/separator.tsx
  24. 8
      web/src/hooks/useMe.ts
  25. 19
      web/src/lib/api.ts
  26. 8
      web/src/lib/token.ts
  27. 6
      web/src/lib/utils.ts

@ -1,6 +1,6 @@
import { dirname } from "path"; import { dirname } from 'path';
import { fileURLToPath } from "url"; import { fileURLToPath } from 'url';
import { FlatCompat } from "@eslint/eslintrc"; import { FlatCompat } from '@eslint/eslintrc';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@ -10,15 +10,9 @@ const compat = new FlatCompat({
}); });
const eslintConfig = [ const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"), ...compat.extends('next/core-web-vitals', 'next/typescript'),
{ {
ignores: [ ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'],
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
}, },
]; ];

@ -1,4 +1,4 @@
import type { NextConfig } from "next"; import type { NextConfig } from 'next';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */

105
web/package-lock.json generated

@ -34,6 +34,7 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.0", "eslint-config-next": "15.5.0",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4", "tailwindcss": "^4",
"tw-animate-css": "^1.3.7", "tw-animate-css": "^1.3.7",
"typescript": "^5" "typescript": "^5"
@ -5881,6 +5882,110 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prettier": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-plugin-tailwindcss": {
"version": "0.6.14",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
"integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.21.3"
},
"peerDependencies": {
"@ianvs/prettier-plugin-sort-imports": "*",
"@prettier/plugin-hermes": "*",
"@prettier/plugin-oxc": "*",
"@prettier/plugin-pug": "*",
"@shopify/prettier-plugin-liquid": "*",
"@trivago/prettier-plugin-sort-imports": "*",
"@zackad/prettier-plugin-twig": "*",
"prettier": "^3.0",
"prettier-plugin-astro": "*",
"prettier-plugin-css-order": "*",
"prettier-plugin-import-sort": "*",
"prettier-plugin-jsdoc": "*",
"prettier-plugin-marko": "*",
"prettier-plugin-multiline-arrays": "*",
"prettier-plugin-organize-attributes": "*",
"prettier-plugin-organize-imports": "*",
"prettier-plugin-sort-imports": "*",
"prettier-plugin-style-order": "*",
"prettier-plugin-svelte": "*"
},
"peerDependenciesMeta": {
"@ianvs/prettier-plugin-sort-imports": {
"optional": true
},
"@prettier/plugin-hermes": {
"optional": true
},
"@prettier/plugin-oxc": {
"optional": true
},
"@prettier/plugin-pug": {
"optional": true
},
"@shopify/prettier-plugin-liquid": {
"optional": true
},
"@trivago/prettier-plugin-sort-imports": {
"optional": true
},
"@zackad/prettier-plugin-twig": {
"optional": true
},
"prettier-plugin-astro": {
"optional": true
},
"prettier-plugin-css-order": {
"optional": true
},
"prettier-plugin-import-sort": {
"optional": true
},
"prettier-plugin-jsdoc": {
"optional": true
},
"prettier-plugin-marko": {
"optional": true
},
"prettier-plugin-multiline-arrays": {
"optional": true
},
"prettier-plugin-organize-attributes": {
"optional": true
},
"prettier-plugin-organize-imports": {
"optional": true
},
"prettier-plugin-sort-imports": {
"optional": true
},
"prettier-plugin-style-order": {
"optional": true
},
"prettier-plugin-svelte": {
"optional": true
}
}
},
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",

@ -35,6 +35,7 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.0", "eslint-config-next": "15.5.0",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4", "tailwindcss": "^4",
"tw-animate-css": "^1.3.7", "tw-animate-css": "^1.3.7",
"typescript": "^5" "typescript": "^5"

@ -1,5 +1,5 @@
const config = { const config = {
plugins: ["@tailwindcss/postcss"], plugins: ['@tailwindcss/postcss'],
}; };
export default config; export default config;

@ -0,0 +1,10 @@
const prettierConfig = {
plugins: ['prettier-plugin-tailwindcss'],
semi: true,
singleQuote: true,
trailingComma: 'all',
printWidth: 100,
endOfLine: 'auto',
};
export default prettierConfig;

@ -1,5 +1,5 @@
@import "tailwindcss"; @import 'tailwindcss';
@import "tw-animate-css"; @import 'tw-animate-css';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));

@ -1,12 +1,12 @@
import type { Metadata } from "next"; import type { Metadata } from 'next';
import "./globals.css"; import './globals.css';
import Link from "next/link"; import Link from 'next/link';
import AppHeader from "@/components/app-header"; import AppHeader from '@/components/app-header';
import Providers from "./providers"; import Providers from './providers';
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Web", title: 'Web',
description: "SPA Boilereplate", description: 'SPA Boilereplate',
}; };
export default function RootLayout({ export default function RootLayout({

@ -1,38 +1,32 @@
"use client"; 'use client';
import { z } from "zod"; import { z } from 'zod';
import { useState } from "react"; import { useState } from 'react';
import { useForm } from "react-hook-form"; import { useForm } from 'react-hook-form';
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from '@hookform/resolvers/zod';
import { import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
Form, import { Input } from '@/components/ui/input';
FormControl, import { Button } from '@/components/ui/button';
FormField, import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
FormItem, import { setToken } from '@/lib/token';
FormMessage, import { api } from '@/lib/api';
} from "@/components/ui/form"; import { useRouter } from 'next/navigation';
import { Input } from "@/components/ui/input"; import axios from 'axios';
import { Button } from "@/components/ui/button"; import { useQueryClient } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { MeResponse } from '@/hooks/useMe';
import { setToken } from "@/lib/token"; import Link from 'next/link';
import { api } from "@/lib/api";
import { useRouter } from "next/navigation";
import axios from "axios";
import { useQueryClient } from "@tanstack/react-query";
import { MeResponse } from "@/hooks/useMe";
import Link from "next/link";
const LoginSchema = z.object({ const LoginSchema = z.object({
name: z.string().min(1, "이름을 입력하세요."), name: z.string().min(1, '이름을 입력하세요.'),
password: z.string().min(4, "비밀번호를 입력하세요."), password: z.string().min(4, '비밀번호를 입력하세요.'),
}); });
type LoginInput = z.infer<typeof LoginSchema>; type LoginInput = z.infer<typeof LoginSchema>;
export default function LoginPage() { export default function LoginPage() {
const form = useForm<LoginInput>({ const form = useForm<LoginInput>({
resolver: zodResolver(LoginSchema), resolver: zodResolver(LoginSchema),
defaultValues: { name: "", password: "" }, defaultValues: { name: '', password: '' },
mode: "onTouched", mode: 'onTouched',
}); });
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -44,27 +38,27 @@ export default function LoginPage() {
setError(null); setError(null);
setLoading(true); setLoading(true);
try { try {
const res = await api.post("/auth/login", values); const res = await api.post('/auth/login', values);
setToken(res.data.access_token); setToken(res.data.access_token);
// me 캐시 미리 채워넣기 // me 캐시 미리 채워넣기
await queryClient.prefetchQuery({ await queryClient.prefetchQuery({
queryKey: ["me"], queryKey: ['me'],
queryFn: async (): Promise<MeResponse> => { queryFn: async (): Promise<MeResponse> => {
const res = await api.get("/auth/me"); const res = await api.get('/auth/me');
return res.data; return res.data;
}, },
}); });
router.replace("/me"); router.replace('/me');
} catch (e: unknown) { } catch (e: unknown) {
console.log(e); console.log(e);
if (axios.isAxiosError(e)) { if (axios.isAxiosError(e)) {
setError(e?.message || "로그인 실패"); setError(e?.message || '로그인 실패');
} else if (e instanceof Error) { } else if (e instanceof Error) {
setError(e.message); setError(e.message);
} else { } else {
setError("로그인 실패"); setError('로그인 실패');
} }
} finally { } finally {
setLoading(false); setLoading(false);
@ -72,7 +66,7 @@ export default function LoginPage() {
}; };
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex min-h-screen items-center justify-center">
<div className="mx-auto my-auto w-full max-w-sm"> <div className="mx-auto my-auto w-full max-w-sm">
<Card className="shadow-lg"> <Card className="shadow-lg">
<CardHeader> <CardHeader>
@ -81,21 +75,14 @@ export default function LoginPage() {
<CardContent className="flex flex-col gap-y-2 pb-4"> <CardContent className="flex flex-col gap-y-2 pb-4">
{/* Name */} {/* Name */}
<Form {...form}> <Form {...form}>
<form <form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
className="space-y-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input <Input placeholder="아이디" autoComplete="username" {...field} />
placeholder="아이디"
autoComplete="username"
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -121,15 +108,13 @@ export default function LoginPage() {
/> />
<Button type="submit" className="w-full" disabled={loading}> <Button type="submit" className="w-full" disabled={loading}>
{loading ? "로그인 중..." : "로그인"} {loading ? '로그인 중...' : '로그인'}
</Button> </Button>
{error && ( {error && <p className="text-center text-sm text-red-600">{error}</p>}
<p className="text-center text-sm text-red-600">{error}</p>
)}
<p className="text-center text-xs text-gray-500"> <p className="text-center text-xs text-gray-500">
?{" "} ?{' '}
<Link href="/signup" className="underline"> <Link href="/signup" className="underline">
</Link> </Link>

@ -1,8 +1,8 @@
"use client"; 'use client';
import Guard from "@/components/guard"; import Guard from '@/components/guard';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useMe } from "@/hooks/useMe"; import { useMe } from '@/hooks/useMe';
export default function MePage() { export default function MePage() {
const { data, isLoading, error } = useMe(); const { data, isLoading, error } = useMe();
@ -13,15 +13,15 @@ export default function MePage() {
{error && <p className="text-red-600"> </p>} {error && <p className="text-red-600"> </p>}
{!isLoading && !error && data && ( {!isLoading && !error && data && (
<div className="flex items-center justify-center min-h-screen"> <div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm shadow-lg"> <Card className="w-full max-w-sm shadow-lg">
<CardHeader className="flex flex-row items-center gap-4"> <CardHeader className="flex flex-row items-center gap-4">
<div className="w-12 h-12 rounded-full bg-gray-300 flex items-center justify-center font-bold text-xl text-gray-500"> <div className="flex h-12 w-12 items-center justify-center rounded-full bg-gray-300 text-xl font-bold text-gray-500">
{data.name?.[0]} {data.name?.[0]}
</div> </div>
<div> <div>
<CardTitle>{data.name ?? "이름 없음"}</CardTitle> <CardTitle>{data.name ?? '이름 없음'}</CardTitle>
<div className="text-gray-400 text-xs">ID: {data.id}</div> <div className="text-xs text-gray-400">ID: {data.id}</div>
</div> </div>
</CardHeader> </CardHeader>

@ -1,9 +1,9 @@
import Image from "next/image"; import Image from 'next/image';
export default function Home() { export default function Home() {
return ( return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20"> <div className="grid min-h-screen grid-rows-[20px_1fr_20px] items-center justify-items-center gap-16 p-8 pb-20 font-sans sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start"> <main className="row-start-2 flex flex-col items-center gap-[32px] sm:items-start">
<Image <Image
className="dark:invert" className="dark:invert"
src="/next.svg" src="/next.svg"
@ -12,22 +12,20 @@ export default function Home() {
height={38} height={38}
priority priority
/> />
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left"> <ol className="list-inside list-decimal text-center font-mono text-sm/6 sm:text-left">
<li className="mb-2 tracking-[-.01em]"> <li className="mb-2 tracking-[-.01em]">
Get started by editing{" "} Get started by editing{' '}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded"> <code className="rounded bg-black/[.05] px-1 py-0.5 font-mono font-semibold dark:bg-white/[.06]">
src/app/page.tsx src/app/page.tsx
</code> </code>
. .
</li> </li>
<li className="tracking-[-.01em]"> <li className="tracking-[-.01em]">Save and see your changes instantly.</li>
Save and see your changes instantly.
</li>
</ol> </ol>
<div className="flex gap-4 items-center flex-col sm:flex-row"> <div className="flex flex-col items-center gap-4 sm:flex-row">
<a <a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto" className="bg-foreground text-background flex h-10 items-center justify-center gap-2 rounded-full border border-solid border-transparent px-4 text-sm font-medium transition-colors hover:bg-[#383838] sm:h-12 sm:w-auto sm:px-5 sm:text-base dark:hover:bg-[#ccc]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@ -42,7 +40,7 @@ export default function Home() {
Deploy now Deploy now
</a> </a>
<a <a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]" className="flex h-10 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-4 text-sm font-medium transition-colors hover:border-transparent hover:bg-[#f2f2f2] sm:h-12 sm:w-auto sm:px-5 sm:text-base md:w-[158px] dark:border-white/[.145] dark:hover:bg-[#1a1a1a]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@ -51,20 +49,14 @@ export default function Home() {
</a> </a>
</div> </div>
</main> </main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center"> <footer className="row-start-3 flex flex-wrap items-center justify-center gap-[24px]">
<a <a
className="flex items-center gap-2 hover:underline hover:underline-offset-4" className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<Image <Image aria-hidden src="/file.svg" alt="File icon" width={16} height={16} />
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn Learn
</a> </a>
<a <a
@ -73,13 +65,7 @@ export default function Home() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<Image <Image aria-hidden src="/window.svg" alt="Window icon" width={16} height={16} />
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples Examples
</a> </a>
<a <a
@ -88,13 +74,7 @@ export default function Home() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<Image <Image aria-hidden src="/globe.svg" alt="Globe icon" width={16} height={16} />
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org Go to nextjs.org
</a> </a>
</footer> </footer>

@ -1,8 +1,8 @@
"use client"; 'use client';
import { loadTokenFromStorage } from "@/lib/token"; import { loadTokenFromStorage } from '@/lib/token';
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
export default function Providers({ children }: { children: React.ReactNode }) { export default function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient()); const [queryClient] = useState(() => new QueryClient());
@ -11,7 +11,5 @@ export default function Providers({ children }: { children: React.ReactNode }) {
loadTokenFromStorage(); // 새로고침 토큰 복원 loadTokenFromStorage(); // 새로고침 토큰 복원
}, []); }, []);
return ( return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
} }

@ -1,34 +1,28 @@
"use client"; 'use client';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
Form, import { Input } from '@/components/ui/input';
FormControl, import { api } from '@/lib/api';
FormField, import { zodResolver } from '@hookform/resolvers/zod';
FormItem, import axios from 'axios';
FormMessage, import Link from 'next/link';
} from "@/components/ui/form"; import { useRouter } from 'next/navigation';
import { Input } from "@/components/ui/input"; import { useState } from 'react';
import { api } from "@/lib/api"; import { useForm } from 'react-hook-form';
import { zodResolver } from "@hookform/resolvers/zod"; import z from 'zod';
import axios from "axios";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import z from "zod";
const SignupSchema = z const SignupSchema = z
.object({ .object({
name: z.string().min(1, "아이디를 입력하세요."), name: z.string().min(1, '아이디를 입력하세요.'),
password: z.string().min(4, "비밀번호는 4자 이상이어야 합니다."), password: z.string().min(4, '비밀번호는 4자 이상이어야 합니다.'),
confirmPassword: z.string().min(1, "비밀번호 확인을 입력하세요."), confirmPassword: z.string().min(1, '비밀번호 확인을 입력하세요.'),
email: z.email("이메일 형식이 아닙니다.").optional(), email: z.email('이메일 형식이 아닙니다.').optional(),
}) })
.refine((v) => v.password === v.confirmPassword, { .refine((v) => v.password === v.confirmPassword, {
path: ["confirmPassword"], path: ['confirmPassword'],
message: "비밀번호가 일치하지 않습니다.", message: '비밀번호가 일치하지 않습니다.',
}); });
type SignupInput = z.infer<typeof SignupSchema>; type SignupInput = z.infer<typeof SignupSchema>;
@ -37,12 +31,12 @@ export default function SignupPage() {
const form = useForm<SignupInput>({ const form = useForm<SignupInput>({
resolver: zodResolver(SignupSchema), resolver: zodResolver(SignupSchema),
defaultValues: { defaultValues: {
name: "", name: '',
password: "", password: '',
confirmPassword: "", confirmPassword: '',
email: undefined, email: undefined,
}, },
mode: "onTouched", mode: 'onTouched',
}); });
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -62,18 +56,16 @@ export default function SignupPage() {
const emailTrim = values.email?.trim(); const emailTrim = values.email?.trim();
if (emailTrim) payload.email = emailTrim; if (emailTrim) payload.email = emailTrim;
await api.post("/auth/signup", payload); await api.post('/auth/signup', payload);
setOk("회원가입이 완료되어습니다. 로그인 페이지로 이동합니다."); setOk('회원가입이 완료되어습니다. 로그인 페이지로 이동합니다.');
setTimeout(() => router.replace("/login"), 800); setTimeout(() => router.replace('/login'), 800);
} catch (e: unknown) { } catch (e: unknown) {
if (axios.isAxiosError(e)) { if (axios.isAxiosError(e)) {
setError( setError(e.request?.data?.message ?? e.message ?? '회원가입에 실패했습니다.');
e.request?.data?.message ?? e.message ?? "회원가입에 실패했습니다."
);
} else if (e instanceof Error) { } else if (e instanceof Error) {
setError(e.message); setError(e.message);
} else { } else {
setError("회원가입에 실패했습니다."); setError('회원가입에 실패했습니다.');
} }
} finally { } finally {
setLoading(false); setLoading(false);
@ -81,7 +73,7 @@ export default function SignupPage() {
}; };
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex min-h-screen items-center justify-center">
<div className="mx-auto w-full max-w-sm"> <div className="mx-auto w-full max-w-sm">
<Card className="shadow-lg"> <Card className="shadow-lg">
<CardHeader> <CardHeader>
@ -89,10 +81,7 @@ export default function SignupPage() {
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-y-2 pb-4"> <CardContent className="flex flex-col gap-y-2 pb-4">
<Form {...form}> <Form {...form}>
<form <form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
className="space-y-4"
onSubmit={form.handleSubmit(onSubmit)}
>
{/* name */} {/* name */}
<FormField <FormField
control={form.control} control={form.control}
@ -100,11 +89,7 @@ export default function SignupPage() {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input <Input placeholder="아이디" autoComplete="username" {...field} />
placeholder="아이디"
autoComplete="username"
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -169,18 +154,14 @@ export default function SignupPage() {
/> />
<Button type="submit" className="w-full" disabled={loading}> <Button type="submit" className="w-full" disabled={loading}>
{loading ? "처리 중..." : "가입하기"} {loading ? '처리 중...' : '가입하기'}
</Button> </Button>
{error && ( {error && <p className="text-center text-sm text-red-600">{error}</p>}
<p className="text-center text-sm text-red-600">{error}</p> {ok && <p className="text-center text-sm text-green-600">{ok}</p>}
)}
{ok && (
<p className="text-center text-sm text-green-600">{ok}</p>
)}
<p className="text-center text-xs text-gray-500"> <p className="text-center text-xs text-gray-500">
?{" "} ?{' '}
<Link href="/login" className="underline"> <Link href="/login" className="underline">
</Link> </Link>

@ -1,16 +1,16 @@
"use client"; 'use client';
import Link from "next/link"; import Link from 'next/link';
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from 'next/navigation';
import { Separator } from "./ui/separator"; import { Separator } from './ui/separator';
import { Avatar, AvatarFallback } from "./ui/avatar"; import { Avatar, AvatarFallback } from './ui/avatar';
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
import { hasToken, setToken } from "@/lib/token"; import { hasToken, setToken } from '@/lib/token';
const nav = [ const nav = [
{ href: "/", label: "홈" }, { href: '/', label: '홈' },
{ href: "/me", label: "내 정보" }, { href: '/me', label: '내 정보' },
]; ];
export default function AppHeader() { export default function AppHeader() {
@ -25,13 +25,13 @@ export default function AppHeader() {
const onLogout = () => { const onLogout = () => {
setToken(null); setToken(null);
queryClient.removeQueries({ queryKey: ["me"], exact: true }); queryClient.removeQueries({ queryKey: ['me'], exact: true });
router.push("/login"); router.push('/login');
}; };
return ( return (
<header className="bg-gray-900 text-white"> <header className="bg-gray-900 text-white">
<div className="mx-auto max-w-full px-4 h-14 flex items-center justify-between"> <div className="mx-auto flex h-14 max-w-full items-center justify-between px-4">
<Link href="/" className="font-semibold"> <Link href="/" className="font-semibold">
Web Web
</Link> </Link>
@ -41,9 +41,7 @@ export default function AppHeader() {
<Link <Link
key={n.href} key={n.href}
href={n.href} href={n.href}
className={`hover:underline ${ className={`hover:underline ${pathname === n.href ? 'underline' : ''}`}
pathname === n.href ? "underline" : ""
}`}
> >
{n.label} {n.label}
</Link> </Link>
@ -53,7 +51,7 @@ export default function AppHeader() {
{authed ? ( {authed ? (
<button <button
className="rounded bg-white/10 px-3 py-1 hover:bg-white/20 transition" className="rounded bg-white/10 px-3 py-1 transition hover:bg-white/20"
onClick={onLogout} onClick={onLogout}
> >
@ -61,13 +59,13 @@ export default function AppHeader() {
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
className="rounded bg-blue-600 px-3 py-1 hover:bg-blue-700 transition" className="rounded bg-blue-600 px-3 py-1 transition hover:bg-blue-700"
href="/signup" href="/signup"
> >
</Link> </Link>
<Link <Link
className="rounded bg-gray-700 px-3 py-1 hover:bg-gray-600 transition" className="rounded bg-gray-700 px-3 py-1 transition hover:bg-gray-600"
href="/login" href="/login"
> >

@ -1,15 +1,15 @@
"use client"; 'use client';
import { hasToken } from "@/lib/token"; import { hasToken } from '@/lib/token';
import { useRouter } from "next/navigation"; import { useRouter } from 'next/navigation';
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
export default function Guard({ children }: { children: React.ReactNode }) { export default function Guard({ children }: { children: React.ReactNode }) {
const r = useRouter(); const r = useRouter();
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
useEffect(() => { useEffect(() => {
if (!hasToken()) r.replace("/login"); if (!hasToken()) r.replace('/login');
else setReady(true); else setReady(true);
}, [r]); }, [r]);

@ -1,37 +1,28 @@
"use client" 'use client';
import * as React from "react" import * as React from 'react';
import * as AvatarPrimitive from "@radix-ui/react-avatar" import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Avatar({ function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return ( return (
<AvatarPrimitive.Root <AvatarPrimitive.Root
data-slot="avatar" data-slot="avatar"
className={cn( className={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props} {...props}
/> />
) );
} }
function AvatarImage({ function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return ( return (
<AvatarPrimitive.Image <AvatarPrimitive.Image
data-slot="avatar-image" data-slot="avatar-image"
className={cn("aspect-square size-full", className)} className={cn('aspect-square size-full', className)}
{...props} {...props}
/> />
) );
} }
function AvatarFallback({ function AvatarFallback({
@ -41,13 +32,10 @@ function AvatarFallback({
return ( return (
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
data-slot="avatar-fallback" data-slot="avatar-fallback"
className={cn( className={cn('bg-muted flex size-full items-center justify-center rounded-full', className)}
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props} {...props}
/> />
) );
} }
export { Avatar, AvatarImage, AvatarFallback } export { Avatar, AvatarImage, AvatarFallback };

@ -1,39 +1,36 @@
import * as React from "react" import * as React from 'react';
import { Slot } from "@radix-ui/react-slot" import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const buttonVariants = cva( 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", "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: { variants: {
variant: { variant: {
default: default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive: 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", '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: 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", '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: secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
ghost: link: 'text-primary underline-offset-4 hover:underline',
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", 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", 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", lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: "size-9", icon: 'size-9',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default',
}, },
} },
) );
function Button({ function Button({
className, className,
@ -41,11 +38,11 @@ function Button({
size, size,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : 'button';
return ( return (
<Comp <Comp
@ -53,7 +50,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Button, buttonVariants } export { Button, buttonVariants };

@ -1,92 +1,75 @@
import * as React from "react" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<"div">) { function Card({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", 'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( 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", '@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 className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-title" data-slot="card-title"
className={cn("leading-none font-semibold", className)} className={cn('leading-none font-semibold', className)}
{...props} {...props}
/> />
) );
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-description" data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)} className={cn('text-muted-foreground text-sm', className)}
{...props} {...props}
/> />
) );
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-action" data-slot="card-action"
className={cn( className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props} {...props}
/> />
) );
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return ( return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-footer" data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)} className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props} {...props}
/> />
) );
} }
export { export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

@ -1,34 +1,25 @@
"use client" 'use client';
import * as React from "react" import * as React from 'react';
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function DropdownMenu({ function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
...props return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
} }
function DropdownMenuPortal({ function DropdownMenuPortal({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return ( return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
} }
function DropdownMenuTrigger({ function DropdownMenuTrigger({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return ( return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
} }
function DropdownMenuContent({ function DropdownMenuContent({
@ -42,31 +33,27 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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", '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 className,
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
) );
} }
function DropdownMenuGroup({ function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
...props return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
} }
function DropdownMenuItem({ function DropdownMenuItem({
className, className,
inset, inset,
variant = "default", variant = 'default',
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
variant?: "default" | "destructive" variant?: 'default' | 'destructive';
}) { }) {
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
@ -75,11 +62,11 @@ function DropdownMenuItem({
data-variant={variant} data-variant={variant}
className={cn( 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", "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 className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
@ -93,7 +80,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( 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", "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 className,
)} )}
checked={checked} checked={checked}
{...props} {...props}
@ -105,18 +92,13 @@ function DropdownMenuCheckboxItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
) );
} }
function DropdownMenuRadioGroup({ function DropdownMenuRadioGroup({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return ( return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
} }
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
@ -129,7 +111,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( 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", "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 className,
)} )}
{...props} {...props}
> >
@ -140,7 +122,7 @@ function DropdownMenuRadioItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
) );
} }
function DropdownMenuLabel({ function DropdownMenuLabel({
@ -148,19 +130,16 @@ function DropdownMenuLabel({
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label" data-slot="dropdown-menu-label"
data-inset={inset} data-inset={inset}
className={cn( className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({
@ -170,32 +149,24 @@ function DropdownMenuSeparator({
return ( return (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator" data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)} className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props} {...props}
/> />
) );
} }
function DropdownMenuShortcut({ function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
className,
...props
}: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
className={cn( className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSub({ function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
...props return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
} }
function DropdownMenuSubTrigger({ function DropdownMenuSubTrigger({
@ -204,22 +175,22 @@ function DropdownMenuSubTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger" data-slot="dropdown-menu-sub-trigger"
data-inset={inset} data-inset={inset}
className={cn( 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", '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 className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto size-4" /> <ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
) );
} }
function DropdownMenuSubContent({ function DropdownMenuSubContent({
@ -230,12 +201,12 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( 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", '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 className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@ -254,4 +225,4 @@ export {
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
} };

@ -1,8 +1,8 @@
"use client" 'use client';
import * as React from "react" import * as React from 'react';
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from "@radix-ui/react-slot" import { Slot } from '@radix-ui/react-slot';
import { import {
Controller, Controller,
FormProvider, FormProvider,
@ -11,23 +11,21 @@ import {
type ControllerProps, type ControllerProps,
type FieldPath, type FieldPath,
type FieldValues, type FieldValues,
} from "react-hook-form" } from 'react-hook-form';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { Label } from "@/components/ui/label" import { Label } from '@/components/ui/label';
const Form = FormProvider const Form = FormProvider;
type FormFieldContextValue< type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = { > = {
name: TName name: TName;
} };
const FormFieldContext = React.createContext<FormFieldContextValue>( const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
{} as FormFieldContextValue
)
const FormField = < const FormField = <
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
@ -39,21 +37,21 @@ const FormField = <
<FormFieldContext.Provider value={{ name: props.name }}> <FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} /> <Controller {...props} />
</FormFieldContext.Provider> </FormFieldContext.Provider>
) );
} };
const useFormField = () => { const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext) const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext) const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext() const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name }) const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState) const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) { if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>") throw new Error('useFormField should be used within <FormField>');
} }
const { id } = itemContext const { id } = itemContext;
return { return {
id, id,
@ -62,97 +60,84 @@ const useFormField = () => {
formDescriptionId: `${id}-form-item-description`, formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`, formMessageId: `${id}-form-item-message`,
...fieldState, ...fieldState,
} };
} };
type FormItemContextValue = { type FormItemContextValue = {
id: string id: string;
} };
const FormItemContext = React.createContext<FormItemContextValue>( const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) { function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId() const id = React.useId();
return ( return (
<FormItemContext.Provider value={{ id }}> <FormItemContext.Provider value={{ id }}>
<div <div data-slot="form-item" className={cn('grid gap-2', className)} {...props} />
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider> </FormItemContext.Provider>
) );
} }
function FormLabel({ function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
className, const { error, formItemId } = useFormField();
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return ( return (
<Label <Label
data-slot="form-label" data-slot="form-label"
data-error={!!error} data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)} className={cn('data-[error=true]:text-destructive', className)}
htmlFor={formItemId} htmlFor={formItemId}
{...props} {...props}
/> />
) );
} }
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) { function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField() const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return ( return (
<Slot <Slot
data-slot="form-control" data-slot="form-control"
id={formItemId} id={formItemId}
aria-describedby={ aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error} aria-invalid={!!error}
{...props} {...props}
/> />
) );
} }
function FormDescription({ className, ...props }: React.ComponentProps<"p">) { function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField() const { formDescriptionId } = useFormField();
return ( return (
<p <p
data-slot="form-description" data-slot="form-description"
id={formDescriptionId} id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)} className={cn('text-muted-foreground text-sm', className)}
{...props} {...props}
/> />
) );
} }
function FormMessage({ className, ...props }: React.ComponentProps<"p">) { function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField() const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children const body = error ? String(error?.message ?? '') : props.children;
if (!body) { if (!body) {
return null return null;
} }
return ( return (
<p <p
data-slot="form-message" data-slot="form-message"
id={formMessageId} id={formMessageId}
className={cn("text-destructive text-sm", className)} className={cn('text-destructive text-sm', className)}
{...props} {...props}
> >
{body} {body}
</p> </p>
) );
} }
export { export {
@ -164,4 +149,4 @@ export {
FormDescription, FormDescription,
FormMessage, FormMessage,
FormField, FormField,
} };

@ -1,21 +1,21 @@
import * as React from "react" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return ( return (
<input <input
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( 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", '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]", '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", 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Input } export { Input };

@ -1,24 +1,21 @@
"use client" 'use client';
import * as React from "react" import * as React from 'react';
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Label({ function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return ( return (
<LabelPrimitive.Root <LabelPrimitive.Root
data-slot="label" data-slot="label"
className={cn( 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", '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 className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Label } export { Label };

@ -1,13 +1,13 @@
"use client" 'use client';
import * as React from "react" import * as React from 'react';
import * as SeparatorPrimitive from "@radix-ui/react-separator" import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Separator({ function Separator({
className, className,
orientation = "horizontal", orientation = 'horizontal',
decorative = true, decorative = true,
...props ...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) { }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
@ -17,12 +17,12 @@ function Separator({
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( 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", '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 className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Separator } export { Separator };

@ -1,5 +1,5 @@
import { api } from "@/lib/api"; import { api } from '@/lib/api';
import { useQuery } from "@tanstack/react-query"; import { useQuery } from '@tanstack/react-query';
export type MeResponse = { export type MeResponse = {
id: number; id: number;
@ -10,9 +10,9 @@ export type MeResponse = {
export function useMe() { export function useMe() {
return useQuery({ return useQuery({
queryKey: ["me"], queryKey: ['me'],
queryFn: async (): Promise<MeResponse> => { queryFn: async (): Promise<MeResponse> => {
const res = await api.get("/auth/me"); const res = await api.get('/auth/me');
return res.data; return res.data;
}, },
}); });

@ -1,5 +1,5 @@
import axios from "axios"; import axios from 'axios';
import { getToken, setToken } from "./token"; import { getToken, setToken } from './token';
export const api = axios.create({ export const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
@ -14,7 +14,7 @@ api.interceptors.request.use((config) => {
return config; return config;
} }
if (config.headers && "Authorization" in config.headers) { if (config.headers && 'Authorization' in config.headers) {
delete config.headers.Authorization; delete config.headers.Authorization;
} }
@ -25,17 +25,12 @@ api.interceptors.response.use(
(res) => res, (res) => res,
(error) => { (error) => {
const url: string | undefined = error?.config?.url; const url: string | undefined = error?.config?.url;
const isAuthRoute = const isAuthRoute = url?.includes('/auth/login') || url?.includes('/auth/signup');
url?.includes("/auth/login") || url?.includes("/auth/signup"); if (error?.response?.status === 401 && typeof window !== 'undefined' && !isAuthRoute) {
if (
error?.response?.status === 401 &&
typeof window !== "undefined" &&
!isAuthRoute
) {
setToken(null); // 토큰 파기 setToken(null); // 토큰 파기
window.location.href = "/login"; // 전역 리다이렉트 window.location.href = '/login'; // 전역 리다이렉트
} }
return Promise.reject(error); return Promise.reject(error);
} },
); );

@ -1,8 +1,8 @@
const KEY = "access_token"; const KEY = 'access_token';
let token: string | null | undefined; let token: string | null | undefined;
export const getToken = (): string | null => { export const getToken = (): string | null => {
if (typeof window === "undefined") return null; if (typeof window === 'undefined') return null;
if (token === undefined) { if (token === undefined) {
token = localStorage.getItem(KEY) ?? null; token = localStorage.getItem(KEY) ?? null;
} }
@ -14,13 +14,13 @@ export const hasToken = (): boolean => !!getToken();
export const setToken = (t: string | null) => { export const setToken = (t: string | null) => {
token = t; token = t;
if (typeof window !== "undefined") { if (typeof window !== 'undefined') {
if (t) localStorage.setItem(KEY, t); if (t) localStorage.setItem(KEY, t);
else localStorage.removeItem(KEY); else localStorage.removeItem(KEY);
} }
}; };
export const loadTokenFromStorage = () => { export const loadTokenFromStorage = () => {
if (typeof window === "undefined") return; if (typeof window === 'undefined') return;
token = localStorage.getItem(KEY); token = localStorage.getItem(KEY);
}; };

@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from 'clsx';
import { twMerge } from "tailwind-merge" import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }

Loading…
Cancel
Save