@potenlab/ui 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +361 -0
  2. package/dist/cli.js +756 -0
  3. package/package.json +8 -5
  4. package/template/admin/README.md +36 -0
  5. package/template/admin/_gitignore +41 -0
  6. package/template/admin/components.json +23 -0
  7. package/template/admin/docs/changes.json +295 -0
  8. package/template/admin/docs/dev-plan.md +822 -0
  9. package/template/admin/docs/frontend-plan.md +874 -0
  10. package/template/admin/docs/prd.md +408 -0
  11. package/template/admin/docs/progress.json +777 -0
  12. package/template/admin/docs/test-plan.md +790 -0
  13. package/template/admin/docs/ui-ux-plan.md +1664 -0
  14. package/template/admin/eslint.config.mjs +18 -0
  15. package/template/admin/next.config.ts +7 -0
  16. package/template/admin/package.json +43 -0
  17. package/template/admin/postcss.config.mjs +7 -0
  18. package/template/admin/public/avatars/user1.svg +4 -0
  19. package/template/admin/public/avatars/user2.svg +4 -0
  20. package/template/admin/public/avatars/user3.svg +4 -0
  21. package/template/admin/public/avatars/user4.svg +4 -0
  22. package/template/admin/public/avatars/user5.svg +4 -0
  23. package/template/admin/public/file.svg +1 -0
  24. package/template/admin/public/globe.svg +1 -0
  25. package/template/admin/public/next.svg +1 -0
  26. package/template/admin/public/profile/img1.svg +7 -0
  27. package/template/admin/public/profile/img2.svg +7 -0
  28. package/template/admin/public/profile/img3.svg +7 -0
  29. package/template/admin/public/vercel.svg +1 -0
  30. package/template/admin/public/window.svg +1 -0
  31. package/template/admin/src/app/favicon.ico +0 -0
  32. package/template/admin/src/app/layout.tsx +38 -0
  33. package/template/admin/src/app/page.tsx +5 -0
  34. package/template/admin/src/app/users/[id]/page.tsx +10 -0
  35. package/template/admin/src/components/layouts/app-sidebar.tsx +152 -0
  36. package/template/admin/src/components/user-management/profile-images.tsx +69 -0
  37. package/template/admin/src/components/user-management/user-detail-form.tsx +143 -0
  38. package/template/admin/src/features/user-management/components/user-columns.tsx +101 -0
  39. package/template/admin/src/features/user-management/components/user-detail.tsx +79 -0
  40. package/template/admin/src/features/user-management/components/user-list.tsx +74 -0
  41. package/template/admin/src/features/user-management/types/index.ts +113 -0
  42. package/template/admin/src/features/user-management/utils/format.ts +2 -0
  43. package/template/admin/src/lib/mock-data.ts +131 -0
  44. package/template/admin/src/styles/globals.css +26 -0
  45. package/template/admin/tsconfig.json +34 -0
@@ -0,0 +1,18 @@
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
@@ -0,0 +1,7 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ /* config options here */
5
+ };
6
+
7
+ export default nextConfig;
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "template",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "eslint"
10
+ },
11
+ "dependencies": {
12
+ "@potenlab/ui": "^0.1.0",
13
+ "@tanstack/react-table": "^8.21.3",
14
+ "lucide-react": "^0.576.0",
15
+ "next": "16.1.6",
16
+ "next-themes": "^0.4.6",
17
+ "radix-ui": "^1.4.3",
18
+ "react": "19.2.3",
19
+ "react-dom": "19.2.3",
20
+ "react-hook-form": "^7.71.2",
21
+ "sonner": "^2.0.7"
22
+ },
23
+ "devDependencies": {
24
+ "@tailwindcss/postcss": "^4",
25
+ "@types/node": "^20",
26
+ "@types/react": "^19",
27
+ "@types/react-dom": "^19",
28
+ "eslint": "^9",
29
+ "eslint-config-next": "16.1.6",
30
+ "shadcn": "^3.8.5",
31
+ "tailwindcss": "^4",
32
+ "tw-animate-css": "^1.4.0",
33
+ "typescript": "^5"
34
+ },
35
+ "ignoreScripts": [
36
+ "sharp",
37
+ "unrs-resolver"
38
+ ],
39
+ "trustedDependencies": [
40
+ "sharp",
41
+ "unrs-resolver"
42
+ ]
43
+ }
@@ -0,0 +1,7 @@
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 44 44">
2
+ <rect width="44" height="44" rx="22" fill="#EEF2F6"/>
3
+ <text x="22" y="22" dominant-baseline="central" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="600" fill="#5A5E6A">U1</text>
4
+ </svg>
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 44 44">
2
+ <rect width="44" height="44" rx="22" fill="#E8EDF5"/>
3
+ <text x="22" y="22" dominant-baseline="central" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="600" fill="#4A5568">U2</text>
4
+ </svg>
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 44 44">
2
+ <rect width="44" height="44" rx="22" fill="#FEF3E2"/>
3
+ <text x="22" y="22" dominant-baseline="central" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="600" fill="#9C6B20">U3</text>
4
+ </svg>
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 44 44">
2
+ <rect width="44" height="44" rx="22" fill="#E6F6F0"/>
3
+ <text x="22" y="22" dominant-baseline="central" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="600" fill="#2D6A4F">U4</text>
4
+ </svg>
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 44 44">
2
+ <rect width="44" height="44" rx="22" fill="#F3E8F9"/>
3
+ <text x="22" y="22" dominant-baseline="central" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="600" fill="#6B3FA0">U5</text>
4
+ </svg>
@@ -0,0 +1 @@
1
+ <svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
@@ -0,0 +1 @@
1
+ <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="232" height="232" viewBox="0 0 232 232">
2
+ <rect width="232" height="232" rx="12" fill="#EEF2F6"/>
3
+ <rect x="76" y="60" width="80" height="80" rx="40" fill="#D1D5DB"/>
4
+ <text x="116" y="105" dominant-baseline="central" text-anchor="middle" font-family="Arial, sans-serif" font-size="28" font-weight="600" fill="#6B7280">1</text>
5
+ <rect x="56" y="156" width="120" height="12" rx="6" fill="#D1D5DB"/>
6
+ <rect x="76" y="176" width="80" height="8" rx="4" fill="#E5E7EB"/>
7
+ </svg>
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="232" height="232" viewBox="0 0 232 232">
2
+ <rect width="232" height="232" rx="12" fill="#E8EDF5"/>
3
+ <rect x="76" y="60" width="80" height="80" rx="40" fill="#C7CED9"/>
4
+ <text x="116" y="105" dominant-baseline="central" text-anchor="middle" font-family="Arial, sans-serif" font-size="28" font-weight="600" fill="#6B7280">2</text>
5
+ <rect x="56" y="156" width="120" height="12" rx="6" fill="#C7CED9"/>
6
+ <rect x="76" y="176" width="80" height="8" rx="4" fill="#DDE3ED"/>
7
+ </svg>
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="232" height="232" viewBox="0 0 232 232">
2
+ <rect width="232" height="232" rx="12" fill="#FEF3E2"/>
3
+ <rect x="76" y="60" width="80" height="80" rx="40" fill="#F5D9A8"/>
4
+ <text x="116" y="105" dominant-baseline="central" text-anchor="middle" font-family="Arial, sans-serif" font-size="28" font-weight="600" fill="#9C6B20">3</text>
5
+ <rect x="56" y="156" width="120" height="12" rx="6" fill="#F5D9A8"/>
6
+ <rect x="76" y="176" width="80" height="8" rx="4" fill="#FBE8C8"/>
7
+ </svg>
@@ -0,0 +1 @@
1
+ <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
@@ -0,0 +1 @@
1
+ <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
@@ -0,0 +1,38 @@
1
+ import type { Metadata } from "next";
2
+ import { Inter } from "next/font/google";
3
+ import "@/styles/globals.css";
4
+ import { DashboardLayout } from "@potenlab/ui";
5
+ import { AppSidebar } from "@/components/layouts/app-sidebar";
6
+
7
+ const inter = Inter({
8
+ subsets: ["latin"],
9
+ variable: "--font-inter-var",
10
+ display: "swap",
11
+ });
12
+
13
+ export const metadata: Metadata = {
14
+ title: "Potenlab Admin",
15
+ description: "Potenlab Admin User Management",
16
+ };
17
+
18
+ export default function RootLayout({
19
+ children,
20
+ }: Readonly<{
21
+ children: React.ReactNode;
22
+ }>) {
23
+ return (
24
+ <html lang="ko" className={inter.variable}>
25
+ <head>
26
+ <link
27
+ rel="stylesheet"
28
+ as="style"
29
+ crossOrigin="anonymous"
30
+ href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
31
+ />
32
+ </head>
33
+ <body className="antialiased" suppressHydrationWarning>
34
+ <DashboardLayout sidebar={<AppSidebar />}>{children}</DashboardLayout>
35
+ </body>
36
+ </html>
37
+ );
38
+ }
@@ -0,0 +1,5 @@
1
+ import { UserList } from "@/features/user-management/components/user-list";
2
+
3
+ export default function Dashboard() {
4
+ return <UserList />;
5
+ }
@@ -0,0 +1,10 @@
1
+ import { UserDetail } from "@/features/user-management/components/user-detail";
2
+
3
+ export default async function UserDetailPage({
4
+ params,
5
+ }: {
6
+ params: Promise<{ id: string }>;
7
+ }) {
8
+ const { id } = await params;
9
+ return <UserDetail userId={id} />;
10
+ }
@@ -0,0 +1,152 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { usePathname } from "next/navigation";
5
+ import { Home, UserRound, LayoutGrid, Megaphone, ShieldAlert, ScrollText, LogOut } from "lucide-react";
6
+
7
+ import {
8
+ Accordion,
9
+ AccordionItem,
10
+ AccordionTrigger,
11
+ AccordionContent,
12
+ cn,
13
+ type SidebarNavItem,
14
+ type SidebarSubItem,
15
+ } from "@potenlab/ui";
16
+
17
+ const NAV_ITEMS: SidebarNavItem[] = [
18
+ {
19
+ label: "홈",
20
+ icon: Home,
21
+ href: "/",
22
+ isAccordion: false,
23
+ },
24
+ {
25
+ label: "사용자",
26
+ isAccordion: true,
27
+ children: [{ label: "사용자관리", href: "/", icon: UserRound }],
28
+ },
29
+ {
30
+ label: "매치",
31
+ isAccordion: true,
32
+ children: [{ label: "매치 관리", href: "/matches", icon: LayoutGrid }],
33
+ },
34
+ {
35
+ label: "관리자",
36
+ isAccordion: true,
37
+ children: [
38
+ { label: "공지사항 관리", href: "/notices", icon: Megaphone },
39
+ { label: "신고 관리", href: "/reports", icon: ShieldAlert },
40
+ { label: "약관 관리", href: "/terms", icon: ScrollText },
41
+ ],
42
+ },
43
+ ];
44
+
45
+ export function AppSidebar() {
46
+ const pathname = usePathname();
47
+
48
+ // Determine which accordion sections should be open based on current path
49
+ const defaultOpenSections = NAV_ITEMS.filter(
50
+ (item) =>
51
+ item.isAccordion &&
52
+ item.children?.some((child) => child.href === pathname)
53
+ ).map((item) => item.label);
54
+
55
+ return (
56
+ <aside className="fixed left-0 top-0 z-40 flex h-screen w-[300px] flex-col bg-white border-r border-border">
57
+ {/* Header */}
58
+ <div className="border-b border-[#E2E8F0] py-[20px] px-[16px]">
59
+ <div className="flex flex-col gap-[6px]">
60
+ <h2 className="font-pretendard text-[24px] font-semibold">
61
+ ADMIN
62
+ </h2>
63
+ <p className="text-[16px] font-normal text-subtitle">
64
+ 관리자아이디
65
+ </p>
66
+ </div>
67
+ </div>
68
+
69
+ {/* Navigation */}
70
+ <nav aria-label="Main navigation" className="flex-1 overflow-y-auto px-[16px] py-[10px]">
71
+ <Accordion
72
+ type="multiple"
73
+ defaultValue={defaultOpenSections}
74
+ className="w-full"
75
+ >
76
+ {NAV_ITEMS.map((item) => {
77
+ const Icon = item.icon;
78
+
79
+ if (!item.isAccordion) {
80
+ return (
81
+ <Link
82
+ key={item.label}
83
+ href={item.href ?? "/"}
84
+ className={cn(
85
+ "flex h-[44px] items-center gap-[10px] rounded-md px-[14px] py-[8px] font-pretendard text-[16px] text-[#5A5E6A] transition-colors duration-150 hover:bg-table-header focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2",
86
+ pathname === item.href &&
87
+ !NAV_ITEMS.some(
88
+ (nav) =>
89
+ nav.isAccordion &&
90
+ nav.children?.some((c) => c.href === pathname)
91
+ ) &&
92
+ "bg-[#EEF2F6] text-black"
93
+ )}
94
+ >
95
+ {Icon && <Icon className="size-5 shrink-0" />}
96
+ <span>{item.label}</span>
97
+ </Link>
98
+ );
99
+ }
100
+
101
+ return (
102
+ <AccordionItem
103
+ key={item.label}
104
+ value={item.label}
105
+ className="border-b-0"
106
+ >
107
+ <AccordionTrigger className="h-[44px] items-center gap-[10px] rounded-md px-[14px] py-0 hover:bg-table-header hover:no-underline">
108
+ <span className="font-pretendard text-[16px] text-[#5A5E6A]">{item.label}</span>
109
+ </AccordionTrigger>
110
+ <AccordionContent className="pb-0 pt-0">
111
+ <div className="flex flex-col px-2">
112
+ {item.children?.map((child: SidebarSubItem) => {
113
+ const isActive = pathname === child.href;
114
+ const ChildIcon = child.icon;
115
+ return (
116
+ <Link
117
+ key={child.label}
118
+ href={child.href}
119
+ className={cn(
120
+ "flex h-[44px] items-center gap-[10px] rounded-[4px] px-[14px] py-[8px] font-pretendard text-[16px] text-[#5A5E6A] transition-colors duration-150 hover:bg-[#EEF2F6] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2",
121
+ isActive && "bg-[#EEF2F6] text-black"
122
+ )}
123
+ >
124
+ {ChildIcon && <ChildIcon className="size-4 shrink-0" />}
125
+ <span>{child.label}</span>
126
+ </Link>
127
+ );
128
+ })}
129
+ </div>
130
+ </AccordionContent>
131
+ </AccordionItem>
132
+ );
133
+ })}
134
+ </Accordion>
135
+ </nav>
136
+
137
+ {/* Footer */}
138
+ <div className="border-t border-[#E2E8F0] py-[20px]">
139
+ <button
140
+ type="button"
141
+ className="flex w-full items-center justify-center gap-3 font-pretendard text-[16px] text-[#5A5E6A] transition-colors duration-150 hover:text-red-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
142
+ onClick={() => {
143
+ // UI only - no actual logout logic
144
+ }}
145
+ >
146
+ <LogOut className="size-5" />
147
+ <span>로그아웃</span>
148
+ </button>
149
+ </div>
150
+ </aside>
151
+ );
152
+ }
@@ -0,0 +1,69 @@
1
+ "use client";
2
+
3
+ import { useRef } from "react";
4
+ import Image from "next/image";
5
+ import { ImagePlus } from "lucide-react";
6
+
7
+ export interface ProfileImagesProps {
8
+ images: string[];
9
+ maxImages?: number;
10
+ editable?: boolean;
11
+ onImageUpload?: (index: number, file: File) => void;
12
+ }
13
+
14
+ export function ProfileImages({
15
+ images,
16
+ maxImages = 3,
17
+ editable = false,
18
+ onImageUpload,
19
+ }: ProfileImagesProps) {
20
+ const fileInputRefs = useRef<(HTMLInputElement | null)[]>([]);
21
+
22
+ const emptySlotCount =
23
+ editable && images.length < maxImages ? maxImages - images.length : 0;
24
+
25
+ const handleFileChange = (slotIndex: number, file: File | undefined) => {
26
+ if (file && onImageUpload) {
27
+ onImageUpload(slotIndex, file);
28
+ }
29
+ };
30
+
31
+ return (
32
+ <div className="flex flex-row gap-4">
33
+ {images.map((src, index) => (
34
+ <Image
35
+ key={index}
36
+ src={src}
37
+ alt={`Profile image ${index + 1}`}
38
+ width={116}
39
+ height={116}
40
+ className="w-[116px] h-[116px] rounded-lg object-cover"
41
+ />
42
+ ))}
43
+ {Array.from({ length: emptySlotCount }).map((_, i) => {
44
+ const slotIndex = images.length + i;
45
+ return (
46
+ <button
47
+ key={`empty-${slotIndex}`}
48
+ type="button"
49
+ className="flex w-[116px] h-[116px] items-center justify-center rounded-lg bg-table-header border-2 border-dashed border-border cursor-pointer"
50
+ onClick={() => fileInputRefs.current[i]?.click()}
51
+ >
52
+ <ImagePlus className="w-6 h-6 text-placeholder" />
53
+ <input
54
+ ref={(el) => {
55
+ fileInputRefs.current[i] = el;
56
+ }}
57
+ type="file"
58
+ accept="image/*"
59
+ className="hidden"
60
+ onChange={(e) =>
61
+ handleFileChange(slotIndex, e.target.files?.[0])
62
+ }
63
+ />
64
+ </button>
65
+ );
66
+ })}
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,143 @@
1
+ "use client";
2
+
3
+ import { Controller } from "react-hook-form";
4
+ import type { UseFormReturn } from "react-hook-form";
5
+ import type { User, FormField as FormFieldType, UserDetailFormValues } from "@/features/user-management/types";
6
+ import {
7
+ BASIC_INFO_ROW_1,
8
+ BASIC_INFO_ROW_2,
9
+ BASIC_INFO_ROW_3,
10
+ } from "@/features/user-management/types";
11
+ import { ProfileImages } from "@/components/user-management/profile-images";
12
+ import { FormField, LabeledSwitch, Separator } from "@potenlab/ui";
13
+
14
+ export interface UserDetailFormProps {
15
+ user: User;
16
+ form: UseFormReturn<UserDetailFormValues>;
17
+ profileImages?: string[];
18
+ onImageUpload?: (index: number, file: File) => void;
19
+ }
20
+
21
+ export function UserDetailForm({
22
+ user,
23
+ form,
24
+ profileImages,
25
+ onImageUpload,
26
+ }: UserDetailFormProps) {
27
+ const renderField = (field: FormFieldType) => (
28
+ <Controller
29
+ key={field.key}
30
+ name={field.key as keyof UserDetailFormValues}
31
+ control={form.control}
32
+ render={({ field: controllerField }) => (
33
+ <FormField
34
+ id={`field-${field.key}`}
35
+ label={field.label}
36
+ type={field.type}
37
+ value={controllerField.value as string}
38
+ options={field.options}
39
+ onChange={controllerField.onChange}
40
+ />
41
+ )}
42
+ />
43
+ );
44
+
45
+ return (
46
+ <div className="flex flex-col gap-6">
47
+ {/* Basic Info Section */}
48
+ <div className="flex flex-col gap-6">
49
+ <h2 className="font-pretendard text-[20px] font-bold text-placeholder">
50
+ 기본 정보
51
+ </h2>
52
+
53
+ {/* Profile Images */}
54
+ <div className="mb-[24px]">
55
+ <ProfileImages
56
+ images={profileImages ?? user.profileImages}
57
+ editable={!!onImageUpload}
58
+ onImageUpload={onImageUpload}
59
+ />
60
+ </div>
61
+
62
+ {/* One-Line Intro */}
63
+ <Controller
64
+ name="intro"
65
+ control={form.control}
66
+ render={({ field: controllerField }) => (
67
+ <FormField
68
+ id="field-intro"
69
+ label="한줄 소개"
70
+ type="input"
71
+ value={controllerField.value}
72
+ onChange={controllerField.onChange}
73
+ />
74
+ )}
75
+ />
76
+
77
+ {/* Row 1: Role, Nickname, Phone, Age */}
78
+ <div className="grid grid-cols-4 gap-6">
79
+ {BASIC_INFO_ROW_1.map((field) => renderField(field))}
80
+ </div>
81
+
82
+ {/* Row 2: Gender, Exercise Style, Gym Relocation, Region */}
83
+ <div className="grid grid-cols-4 gap-6">
84
+ {BASIC_INFO_ROW_2.map((field) => renderField(field))}
85
+ </div>
86
+
87
+ {/* Row 3: Bench, Deadlift, Squat */}
88
+ <div className="grid grid-cols-3 gap-6">
89
+ {BASIC_INFO_ROW_3.map((field) => renderField(field))}
90
+ </div>
91
+ </div>
92
+
93
+ {/* Separator */}
94
+ <Separator />
95
+
96
+ {/* Other Settings Section */}
97
+ <div className="flex flex-col gap-6">
98
+ <h2 className="font-pretendard text-[20px] font-bold text-placeholder">
99
+ 기타 설정
100
+ </h2>
101
+
102
+ <div className="flex flex-row gap-[100px]">
103
+ <Controller
104
+ name="profilePublic"
105
+ control={form.control}
106
+ render={({ field: controllerField }) => (
107
+ <LabeledSwitch
108
+ id="toggle-profilePublic"
109
+ label="프로필 공개"
110
+ checked={controllerField.value}
111
+ onCheckedChange={controllerField.onChange}
112
+ />
113
+ )}
114
+ />
115
+ <Controller
116
+ name="matchChatNotification"
117
+ control={form.control}
118
+ render={({ field: controllerField }) => (
119
+ <LabeledSwitch
120
+ id="toggle-matchChatNotification"
121
+ label="매치 & 채팅 알림"
122
+ checked={controllerField.value}
123
+ onCheckedChange={controllerField.onChange}
124
+ />
125
+ )}
126
+ />
127
+ <Controller
128
+ name="marketingNotification"
129
+ control={form.control}
130
+ render={({ field: controllerField }) => (
131
+ <LabeledSwitch
132
+ id="toggle-marketingNotification"
133
+ label="마케팅 알림"
134
+ checked={controllerField.value}
135
+ onCheckedChange={controllerField.onChange}
136
+ />
137
+ )}
138
+ />
139
+ </div>
140
+ </div>
141
+ </div>
142
+ );
143
+ }