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

@ -18,4 +18,4 @@
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
}

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

105
web/package-lock.json generated

@ -34,6 +34,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.0",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.7",
"typescript": "^5"
@ -5881,6 +5882,110 @@
"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": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",

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

@ -1,5 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
plugins: ['@tailwindcss/postcss'],
};
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 "tw-animate-css";
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));

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

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

@ -1,8 +1,8 @@
"use client";
'use client';
import Guard from "@/components/guard";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useMe } from "@/hooks/useMe";
import Guard from '@/components/guard';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useMe } from '@/hooks/useMe';
export default function MePage() {
const { data, isLoading, error } = useMe();
@ -13,15 +13,15 @@ export default function MePage() {
{error && <p className="text-red-600"> </p>}
{!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">
<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]}
</div>
<div>
<CardTitle>{data.name ?? "이름 없음"}</CardTitle>
<div className="text-gray-400 text-xs">ID: {data.id}</div>
<CardTitle>{data.name ?? '이름 없음'}</CardTitle>
<div className="text-xs text-gray-400">ID: {data.id}</div>
</div>
</CardHeader>

@ -1,9 +1,9 @@
import Image from "next/image";
import Image from 'next/image';
export default function Home() {
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">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<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="row-start-2 flex flex-col items-center gap-[32px] sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
@ -12,22 +12,20 @@ export default function Home() {
height={38}
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]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
Get started by editing{' '}
<code className="rounded bg-black/[.05] px-1 py-0.5 font-mono font-semibold dark:bg-white/[.06]">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
<li className="tracking-[-.01em]">Save and see your changes instantly.</li>
</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
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"
target="_blank"
rel="noopener noreferrer"
@ -42,7 +40,7 @@ export default function Home() {
Deploy now
</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"
target="_blank"
rel="noopener noreferrer"
@ -51,20 +49,14 @@ export default function Home() {
</a>
</div>
</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
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"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
<Image aria-hidden src="/file.svg" alt="File icon" width={16} height={16} />
Learn
</a>
<a
@ -73,13 +65,7 @@ export default function Home() {
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
<Image aria-hidden src="/window.svg" alt="Window icon" width={16} height={16} />
Examples
</a>
<a
@ -88,13 +74,7 @@ export default function Home() {
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
<Image aria-hidden src="/globe.svg" alt="Globe icon" width={16} height={16} />
Go to nextjs.org
</a>
</footer>

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

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

@ -1,16 +1,16 @@
"use client";
'use client';
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { Separator } from "./ui/separator";
import { Avatar, AvatarFallback } from "./ui/avatar";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { hasToken, setToken } from "@/lib/token";
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { Separator } from './ui/separator';
import { Avatar, AvatarFallback } from './ui/avatar';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { hasToken, setToken } from '@/lib/token';
const nav = [
{ href: "/", label: "홈" },
{ href: "/me", label: "내 정보" },
{ href: '/', label: '홈' },
{ href: '/me', label: '내 정보' },
];
export default function AppHeader() {
@ -25,13 +25,13 @@ export default function AppHeader() {
const onLogout = () => {
setToken(null);
queryClient.removeQueries({ queryKey: ["me"], exact: true });
router.push("/login");
queryClient.removeQueries({ queryKey: ['me'], exact: true });
router.push('/login');
};
return (
<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">
Web
</Link>
@ -41,9 +41,7 @@ export default function AppHeader() {
<Link
key={n.href}
href={n.href}
className={`hover:underline ${
pathname === n.href ? "underline" : ""
}`}
className={`hover:underline ${pathname === n.href ? 'underline' : ''}`}
>
{n.label}
</Link>
@ -53,7 +51,7 @@ export default function AppHeader() {
{authed ? (
<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}
>
@ -61,13 +59,13 @@ export default function AppHeader() {
) : (
<div className="flex items-center gap-2">
<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"
>
</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"
>

@ -1,15 +1,15 @@
"use client";
'use client';
import { hasToken } from "@/lib/token";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { hasToken } 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(() => {
if (!hasToken()) r.replace("/login");
if (!hasToken()) r.replace('/login');
else setReady(true);
}, [r]);

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

@ -1,39 +1,36 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
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"
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",
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",
'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",
'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",
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",
variant: 'default',
size: 'default',
},
}
)
},
);
function Button({
className,
@ -41,11 +38,11 @@ function Button({
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : 'button';
return (
<Comp
@ -53,7 +50,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...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 (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
'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">) {
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
'@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">) {
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
className={cn('leading-none font-semibold', className)}
{...props}
/>
)
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
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
)}
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 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">) {
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)}
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
)
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };

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

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

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

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

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

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

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

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

Loading…
Cancel
Save