@potenlab/ui 0.1.2 → 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.
- package/dist/cli.js +756 -0
- package/package.json +8 -3
- package/template/admin/README.md +36 -0
- package/template/admin/_gitignore +41 -0
- package/template/admin/components.json +23 -0
- package/template/admin/docs/changes.json +295 -0
- package/template/admin/docs/dev-plan.md +822 -0
- package/template/admin/docs/frontend-plan.md +874 -0
- package/template/admin/docs/prd.md +408 -0
- package/template/admin/docs/progress.json +777 -0
- package/template/admin/docs/test-plan.md +790 -0
- package/template/admin/docs/ui-ux-plan.md +1664 -0
- package/template/admin/eslint.config.mjs +18 -0
- package/template/admin/next.config.ts +7 -0
- package/template/admin/package.json +43 -0
- package/template/admin/postcss.config.mjs +7 -0
- package/template/admin/public/avatars/user1.svg +4 -0
- package/template/admin/public/avatars/user2.svg +4 -0
- package/template/admin/public/avatars/user3.svg +4 -0
- package/template/admin/public/avatars/user4.svg +4 -0
- package/template/admin/public/avatars/user5.svg +4 -0
- package/template/admin/public/file.svg +1 -0
- package/template/admin/public/globe.svg +1 -0
- package/template/admin/public/next.svg +1 -0
- package/template/admin/public/profile/img1.svg +7 -0
- package/template/admin/public/profile/img2.svg +7 -0
- package/template/admin/public/profile/img3.svg +7 -0
- package/template/admin/public/vercel.svg +1 -0
- package/template/admin/public/window.svg +1 -0
- package/template/admin/src/app/favicon.ico +0 -0
- package/template/admin/src/app/layout.tsx +38 -0
- package/template/admin/src/app/page.tsx +5 -0
- package/template/admin/src/app/users/[id]/page.tsx +10 -0
- package/template/admin/src/components/layouts/app-sidebar.tsx +152 -0
- package/template/admin/src/components/user-management/profile-images.tsx +69 -0
- package/template/admin/src/components/user-management/user-detail-form.tsx +143 -0
- package/template/admin/src/features/user-management/components/user-columns.tsx +101 -0
- package/template/admin/src/features/user-management/components/user-detail.tsx +79 -0
- package/template/admin/src/features/user-management/components/user-list.tsx +74 -0
- package/template/admin/src/features/user-management/types/index.ts +113 -0
- package/template/admin/src/features/user-management/utils/format.ts +2 -0
- package/template/admin/src/lib/mock-data.ts +131 -0
- package/template/admin/src/styles/globals.css +26 -0
- 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,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,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>
|
|
Binary file
|
|
@@ -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,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
|
+
}
|