From a90f3261bef1b8cb35fc6ab5b7c8964e762be416 Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 22 May 2026 17:22:29 +0900 Subject: [PATCH 1/8] =?UTF-8?q?chore:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20App?= =?UTF-8?q?=20Router=20=EC=A0=84=ED=99=98=20=EA=B8=B0=EB=B0=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/app/auth/login/page.tsx | 41 +++++++ apps/admin/src/app/layout.tsx | 18 +++ apps/admin/src/app/login/page.tsx | 15 +++ apps/admin/src/app/page.tsx | 21 ++++ apps/admin/src/app/providers.tsx | 18 +++ apps/admin/src/app/scores/page.tsx | 10 ++ .../features/auth/AdminLoginPage.tsx | 103 ++++++++++++++++++ .../features/auth/RequireAdminSession.tsx | 44 ++++++++ .../features/scores/ScoresPageContent.tsx | 64 +++++++++++ .../src/components/layout/AdminLayout.tsx | 5 +- .../src/components/layout/AdminSidebar.tsx | 5 +- apps/admin/src/lib/auth/session.ts | 17 --- .../admin/src/lib/auth/tanstackRouteGuards.ts | 18 +++ apps/admin/src/routes/auth/login.tsx | 101 +---------------- apps/admin/src/routes/bruno/index.tsx | 2 +- apps/admin/src/routes/chat-socket/index.tsx | 2 +- apps/admin/src/routes/scores/index.tsx | 67 +----------- 17 files changed, 365 insertions(+), 186 deletions(-) create mode 100644 apps/admin/src/app/auth/login/page.tsx create mode 100644 apps/admin/src/app/layout.tsx create mode 100644 apps/admin/src/app/login/page.tsx create mode 100644 apps/admin/src/app/page.tsx create mode 100644 apps/admin/src/app/providers.tsx create mode 100644 apps/admin/src/app/scores/page.tsx create mode 100644 apps/admin/src/components/features/auth/AdminLoginPage.tsx create mode 100644 apps/admin/src/components/features/auth/RequireAdminSession.tsx create mode 100644 apps/admin/src/components/features/scores/ScoresPageContent.tsx create mode 100644 apps/admin/src/lib/auth/tanstackRouteGuards.ts diff --git a/apps/admin/src/app/auth/login/page.tsx b/apps/admin/src/app/auth/login/page.tsx new file mode 100644 index 00000000..58445d90 --- /dev/null +++ b/apps/admin/src/app/auth/login/page.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { AdminLoginPage } from "@/components/features/auth/AdminLoginPage"; +import { ensureSessionToken } from "@/lib/auth/session"; + +export default function AuthLoginPage() { + const [canRenderLogin, setCanRenderLogin] = useState(false); + + useEffect(() => { + let isMounted = true; + + const redirectIfAuthenticated = async () => { + const token = await ensureSessionToken(); + if (!isMounted) return; + + if (token) { + window.location.replace("/scores"); + return; + } + + setCanRenderLogin(true); + }; + + void redirectIfAuthenticated(); + + return () => { + isMounted = false; + }; + }, []); + + if (!canRenderLogin) { + return ( +
+ 관리자 세션을 확인하는 중... +
+ ); + } + + return window.location.assign("/scores")} />; +} diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx new file mode 100644 index 00000000..95d655da --- /dev/null +++ b/apps/admin/src/app/layout.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; +import "../styles.css"; +import { Providers } from "./providers"; + +export const metadata = { + title: "Solid Connection Admin", + viewport: "width=device-width, initial-scale=1", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/apps/admin/src/app/login/page.tsx b/apps/admin/src/app/login/page.tsx new file mode 100644 index 00000000..ec86cc56 --- /dev/null +++ b/apps/admin/src/app/login/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { useEffect } from "react"; + +export default function LoginRedirectPage() { + useEffect(() => { + window.location.replace("/auth/login"); + }, []); + + return ( +
+ 로그인 페이지로 이동하는 중... +
+ ); +} diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx new file mode 100644 index 00000000..70664a70 --- /dev/null +++ b/apps/admin/src/app/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useEffect } from "react"; +import { ensureSessionToken } from "@/lib/auth/session"; + +export default function HomePage() { + useEffect(() => { + const redirectBySession = async () => { + const token = await ensureSessionToken(); + window.location.replace(token ? "/scores" : "/auth/login"); + }; + + void redirectBySession(); + }, []); + + return ( +
+ 관리자 페이지로 이동하는 중... +
+ ); +} diff --git a/apps/admin/src/app/providers.tsx b/apps/admin/src/app/providers.tsx new file mode 100644 index 00000000..0a903cdb --- /dev/null +++ b/apps/admin/src/app/providers.tsx @@ -0,0 +1,18 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Toaster } from "sonner"; +import { QueryProvider } from "@/components/providers/QueryProvider"; + +interface ProvidersProps { + children: ReactNode; +} + +export function Providers({ children }: ProvidersProps) { + return ( + + {children} + + + ); +} diff --git a/apps/admin/src/app/scores/page.tsx b/apps/admin/src/app/scores/page.tsx new file mode 100644 index 00000000..d7e40c86 --- /dev/null +++ b/apps/admin/src/app/scores/page.tsx @@ -0,0 +1,10 @@ +import { RequireAdminSession } from "@/components/features/auth/RequireAdminSession"; +import { ScoresPageContent } from "@/components/features/scores/ScoresPageContent"; + +export default function ScoresPage() { + return ( + + + + ); +} diff --git a/apps/admin/src/components/features/auth/AdminLoginPage.tsx b/apps/admin/src/components/features/auth/AdminLoginPage.tsx new file mode 100644 index 00000000..9bca8997 --- /dev/null +++ b/apps/admin/src/components/features/auth/AdminLoginPage.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { type FormEvent, useId, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { adminSignInApi } from "@/lib/api/auth"; +import { saveAccessToken } from "@/lib/utils/localStorage"; + +interface AdminLoginPageProps { + onLoginSuccess: () => void; +} + +export function AdminLoginPage({ onLoginSuccess }: AdminLoginPageProps) { + const emailInputId = useId(); + const passwordInputId = useId(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + const signInMutation = useMutation({ + mutationFn: ({ nextEmail, nextPassword }: { nextEmail: string; nextPassword: string }) => + adminSignInApi(nextEmail, nextPassword), + }); + + const isLoading = signInMutation.isPending; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + try { + const response = await signInMutation.mutateAsync({ nextEmail: email, nextPassword: password }); + const { accessToken } = response.data; + + saveAccessToken(accessToken); + + toast("로그인 성공", { + description: "관리자 페이지로 이동합니다.", + }); + + onLoginSuccess(); + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } }; + toast.error("로그인 실패", { + description: error.response?.data?.message || "로그인에 실패했습니다.", + }); + } + }; + + return ( +
+ + +
+ SC +
+ 관리자 로그인 + + 솔리드 커넥션 운영 콘솔에 접속합니다 + +
+ +
+
+ + setEmail(e.target.value)} + disabled={isLoading} + required + className="h-11 border-k-100 bg-bg-50 typo-regular-3 text-k-800 placeholder:text-k-400" + /> +
+
+ + setPassword(e.target.value)} + disabled={isLoading} + required + className="h-11 border-k-100 bg-bg-50 typo-regular-3 text-k-800" + /> +
+ +
+
+
+
+ ); +} diff --git a/apps/admin/src/components/features/auth/RequireAdminSession.tsx b/apps/admin/src/components/features/auth/RequireAdminSession.tsx new file mode 100644 index 00000000..911190b8 --- /dev/null +++ b/apps/admin/src/components/features/auth/RequireAdminSession.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { type ReactNode, useEffect, useState } from "react"; +import { ensureSessionToken } from "@/lib/auth/session"; + +interface RequireAdminSessionProps { + children: ReactNode; +} + +export function RequireAdminSession({ children }: RequireAdminSessionProps) { + const [isAuthorized, setIsAuthorized] = useState(false); + + useEffect(() => { + let isMounted = true; + + const checkSession = async () => { + const token = await ensureSessionToken(); + if (!isMounted) return; + + if (!token) { + window.location.replace("/auth/login"); + return; + } + + setIsAuthorized(true); + }; + + void checkSession(); + + return () => { + isMounted = false; + }; + }, []); + + if (!isAuthorized) { + return ( +
+ 관리자 세션을 확인하는 중... +
+ ); + } + + return children; +} diff --git a/apps/admin/src/components/features/scores/ScoresPageContent.tsx b/apps/admin/src/components/features/scores/ScoresPageContent.tsx new file mode 100644 index 00000000..9923c397 --- /dev/null +++ b/apps/admin/src/components/features/scores/ScoresPageContent.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useId, useState } from "react"; +import { AdminLayout } from "@/components/layout/AdminLayout"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { VerifyStatus } from "@/types/scores"; +import { GpaScoreTable } from "./GpaScoreTable"; +import { LanguageScoreTable } from "./LanguageScoreTable"; + +export function ScoresPageContent() { + const [verifyFilter, setVerifyFilter] = useState("PENDING"); + const verifyFilterId = useId(); + + return ( + +
+ + +
+ +
+ + + + GPA 성적 + + + 어학성적 + + + + + + + + + + + +
+
+ ); +} diff --git a/apps/admin/src/components/layout/AdminLayout.tsx b/apps/admin/src/components/layout/AdminLayout.tsx index 4b027072..fa09dadf 100644 --- a/apps/admin/src/components/layout/AdminLayout.tsx +++ b/apps/admin/src/components/layout/AdminLayout.tsx @@ -1,4 +1,3 @@ -import { useNavigate } from "@tanstack/react-router"; import { LogOut } from "lucide-react"; import { toast } from "sonner"; import { clearSession } from "@/lib/auth/session"; @@ -12,12 +11,10 @@ interface AdminLayoutProps { } export function AdminLayout({ children, activeMenu, title, description }: AdminLayoutProps) { - const navigate = useNavigate(); - const handleLogout = () => { clearSession(); toast.success("로그아웃되었습니다."); - void navigate({ to: "/auth/login" }); + window.location.assign("/auth/login"); }; return ( diff --git a/apps/admin/src/components/layout/AdminSidebar.tsx b/apps/admin/src/components/layout/AdminSidebar.tsx index aeab9a3a..40bc1f83 100644 --- a/apps/admin/src/components/layout/AdminSidebar.tsx +++ b/apps/admin/src/components/layout/AdminSidebar.tsx @@ -1,4 +1,3 @@ -import { Link } from "@tanstack/react-router"; import { FileText, FlaskConical, MessageSquare } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -36,10 +35,10 @@ export function AdminSidebar({ activeMenu }: AdminSidebarProps) { ); return ( - + {menu.label} - + ); })} diff --git a/apps/admin/src/lib/auth/session.ts b/apps/admin/src/lib/auth/session.ts index 93c50c43..528ee996 100644 --- a/apps/admin/src/lib/auth/session.ts +++ b/apps/admin/src/lib/auth/session.ts @@ -1,4 +1,3 @@ -import { redirect } from "@tanstack/react-router"; import { reissueAccessTokenApi } from "@/lib/api/auth"; import { isTokenExpired } from "@/lib/utils/jwtUtils"; import { loadAccessToken, removeAccessToken, saveAccessToken } from "@/lib/utils/localStorage"; @@ -59,19 +58,3 @@ export const ensureSessionToken = async (): Promise => { return reissueAccessTokenIfPossible(); }; - -export const requireAdminSession = async (): Promise => { - const token = await ensureSessionToken(); - if (!token) { - throw redirect({ to: "/auth/login" }); - } - - return token; -}; - -export const redirectIfAuthenticated = async () => { - const token = await ensureSessionToken(); - if (token) { - throw redirect({ to: "/scores" }); - } -}; diff --git a/apps/admin/src/lib/auth/tanstackRouteGuards.ts b/apps/admin/src/lib/auth/tanstackRouteGuards.ts new file mode 100644 index 00000000..2dfdc33c --- /dev/null +++ b/apps/admin/src/lib/auth/tanstackRouteGuards.ts @@ -0,0 +1,18 @@ +import { redirect } from "@tanstack/react-router"; +import { ensureSessionToken } from "./session"; + +export const requireAdminSession = async (): Promise => { + const token = await ensureSessionToken(); + if (!token) { + throw redirect({ to: "/auth/login" }); + } + + return token; +}; + +export const redirectIfAuthenticated = async () => { + const token = await ensureSessionToken(); + if (token) { + throw redirect({ to: "/scores" }); + } +}; diff --git a/apps/admin/src/routes/auth/login.tsx b/apps/admin/src/routes/auth/login.tsx index c714565e..e9bab3c1 100644 --- a/apps/admin/src/routes/auth/login.tsx +++ b/apps/admin/src/routes/auth/login.tsx @@ -1,107 +1,16 @@ -import { useMutation } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { type FormEvent, useId, useState } from "react"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { adminSignInApi } from "@/lib/api/auth"; -import { redirectIfAuthenticated } from "@/lib/auth/session"; -import { saveAccessToken } from "@/lib/utils/localStorage"; +import { AdminLoginPage } from "@/components/features/auth/AdminLoginPage"; +import { redirectIfAuthenticated } from "@/lib/auth/tanstackRouteGuards"; export const Route = createFileRoute("/auth/login")({ beforeLoad: async () => { await redirectIfAuthenticated(); }, - component: LoginPage, + component: LoginRoute, }); -function LoginPage() { +function LoginRoute() { const navigate = useNavigate(); - const emailInputId = useId(); - const passwordInputId = useId(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const signInMutation = useMutation({ - mutationFn: ({ nextEmail, nextPassword }: { nextEmail: string; nextPassword: string }) => - adminSignInApi(nextEmail, nextPassword), - }); - - const isLoading = signInMutation.isPending; - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - - try { - const response = await signInMutation.mutateAsync({ nextEmail: email, nextPassword: password }); - const { accessToken } = response.data; - - saveAccessToken(accessToken); - - toast("로그인 성공", { - description: "관리자 페이지로 이동합니다.", - }); - - navigate({ to: "/scores" }); - } catch (err: unknown) { - const error = err as { response?: { data?: { message?: string } } }; - toast.error("로그인 실패", { - description: error.response?.data?.message || "로그인에 실패했습니다.", - }); - } - }; - - return ( -
- - -
- SC -
- 관리자 로그인 - - 솔리드 커넥션 운영 콘솔에 접속합니다 - -
- -
-
- - setEmail(e.target.value)} - disabled={isLoading} - required - className="h-11 border-k-100 bg-bg-50 typo-regular-3 text-k-800 placeholder:text-k-400" - /> -
-
- - setPassword(e.target.value)} - disabled={isLoading} - required - className="h-11 border-k-100 bg-bg-50 typo-regular-3 text-k-800" - /> -
- -
-
-
-
- ); + return void navigate({ to: "/scores" })} />; } diff --git a/apps/admin/src/routes/bruno/index.tsx b/apps/admin/src/routes/bruno/index.tsx index b43faa1e..41bfddf5 100644 --- a/apps/admin/src/routes/bruno/index.tsx +++ b/apps/admin/src/routes/bruno/index.tsx @@ -10,7 +10,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { axiosInstance } from "@/lib/api/client"; -import { requireAdminSession } from "@/lib/auth/session"; +import { requireAdminSession } from "@/lib/auth/tanstackRouteGuards"; import { cn } from "@/lib/utils"; type DefinitionMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"; diff --git a/apps/admin/src/routes/chat-socket/index.tsx b/apps/admin/src/routes/chat-socket/index.tsx index fd92c6b6..5fc8af81 100644 --- a/apps/admin/src/routes/chat-socket/index.tsx +++ b/apps/admin/src/routes/chat-socket/index.tsx @@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { requireAdminSession } from "@/lib/auth/session"; +import { requireAdminSession } from "@/lib/auth/tanstackRouteGuards"; import { loadAccessToken } from "@/lib/utils/localStorage"; type ConnectionState = "DISCONNECTED" | "CONNECTING" | "CONNECTED" | "ERROR"; diff --git a/apps/admin/src/routes/scores/index.tsx b/apps/admin/src/routes/scores/index.tsx index db69cac0..c6334a17 100644 --- a/apps/admin/src/routes/scores/index.tsx +++ b/apps/admin/src/routes/scores/index.tsx @@ -1,71 +1,10 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useId, useState } from "react"; -import { GpaScoreTable } from "@/components/features/scores/GpaScoreTable"; -import { LanguageScoreTable } from "@/components/features/scores/LanguageScoreTable"; -import { AdminLayout } from "@/components/layout/AdminLayout"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { requireAdminSession } from "@/lib/auth/session"; -import type { VerifyStatus } from "@/types/scores"; +import { ScoresPageContent } from "@/components/features/scores/ScoresPageContent"; +import { requireAdminSession } from "@/lib/auth/tanstackRouteGuards"; export const Route = createFileRoute("/scores/")({ beforeLoad: async () => { await requireAdminSession(); }, - component: ScoresPage, + component: ScoresPageContent, }); - -function ScoresPage() { - const [verifyFilter, setVerifyFilter] = useState("PENDING"); - const verifyFilterId = useId(); - - return ( - -
- - -
- -
- - - - GPA 성적 - - - 어학성적 - - - - - - - - - - - -
-
- ); -} From 3d8901cfb6811e56738da16f066a4701aad1abb5 Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 22 May 2026 19:39:11 +0900 Subject: [PATCH 2/8] =?UTF-8?q?chore:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B0=20=ED=8E=98=EC=9D=B4=EC=A7=80=20App=20Router?= =?UTF-8?q?=20=EB=B3=91=EB=A0=AC=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/app/bruno/page.tsx | 10 + apps/admin/src/app/chat-socket/page.tsx | 10 + .../features/bruno/BrunoApiPageContent.tsx | 460 +++++++++++++++++ .../chat-socket/ChatSocketPageContent.tsx | 480 +++++++++++++++++ apps/admin/src/routes/bruno/index.tsx | 461 +---------------- apps/admin/src/routes/chat-socket/index.tsx | 481 +----------------- 6 files changed, 964 insertions(+), 938 deletions(-) create mode 100644 apps/admin/src/app/bruno/page.tsx create mode 100644 apps/admin/src/app/chat-socket/page.tsx create mode 100644 apps/admin/src/components/features/bruno/BrunoApiPageContent.tsx create mode 100644 apps/admin/src/components/features/chat-socket/ChatSocketPageContent.tsx diff --git a/apps/admin/src/app/bruno/page.tsx b/apps/admin/src/app/bruno/page.tsx new file mode 100644 index 00000000..7d2f22ba --- /dev/null +++ b/apps/admin/src/app/bruno/page.tsx @@ -0,0 +1,10 @@ +import { RequireAdminSession } from "@/components/features/auth/RequireAdminSession"; +import { BrunoApiPageContent } from "@/components/features/bruno/BrunoApiPageContent"; + +export default function BrunoPage() { + return ( + + + + ); +} diff --git a/apps/admin/src/app/chat-socket/page.tsx b/apps/admin/src/app/chat-socket/page.tsx new file mode 100644 index 00000000..7688d944 --- /dev/null +++ b/apps/admin/src/app/chat-socket/page.tsx @@ -0,0 +1,10 @@ +import { RequireAdminSession } from "@/components/features/auth/RequireAdminSession"; +import { ChatSocketPageContent } from "@/components/features/chat-socket/ChatSocketPageContent"; + +export default function ChatSocketPage() { + return ( + + + + ); +} diff --git a/apps/admin/src/components/features/bruno/BrunoApiPageContent.tsx b/apps/admin/src/components/features/bruno/BrunoApiPageContent.tsx new file mode 100644 index 00000000..fd1785e8 --- /dev/null +++ b/apps/admin/src/components/features/bruno/BrunoApiPageContent.tsx @@ -0,0 +1,460 @@ +"use client"; + +import { Copy, Play, RotateCcw } from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { AdminLayout } from "@/components/layout/AdminLayout"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { axiosInstance } from "@/lib/api/client"; +import { cn } from "@/lib/utils"; + +type DefinitionMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"; +type MethodFilter = "ALL" | DefinitionMethod; + +interface ApiDefinitionEntry { + method: DefinitionMethod; + path: string; + pathParams: Record; + queryParams: Record; + body: unknown; + response: unknown; +} + +interface EndpointItem { + domain: string; + name: string; + definition: ApiDefinitionEntry; +} + +interface RequestResult { + status: number; + durationMs: number; + headers: Record; + body: unknown; +} + +const definitionModules = import.meta.glob("../../../../../../packages/api-schema/src/apis/*/apiDefinitions.ts", { + eager: true, +}) as Record>; + +const normalizeTokenKey = (value: string) => value.replace(/[^a-zA-Z0-9]/g, "").toLowerCase(); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isDefinitionMethod = (value: unknown): value is DefinitionMethod => + value === "GET" || + value === "POST" || + value === "PUT" || + value === "PATCH" || + value === "DELETE" || + value === "HEAD" || + value === "OPTIONS"; + +const isApiDefinitionEntry = (value: unknown): value is ApiDefinitionEntry => { + if (!isRecord(value)) { + return false; + } + + return ( + isDefinitionMethod(value.method) && + typeof value.path === "string" && + isRecord(value.pathParams) && + isRecord(value.queryParams) && + "body" in value && + "response" in value + ); +}; + +const parseDefinitionRegistry = (): EndpointItem[] => { + const endpoints: EndpointItem[] = []; + + for (const [modulePath, moduleExports] of Object.entries(definitionModules)) { + const domainMatch = modulePath.match(/apis\/([^/]+)\/apiDefinitions\.ts$/); + if (!domainMatch) { + continue; + } + + const domain = domainMatch[1]; + + for (const exportedValue of Object.values(moduleExports)) { + if (!isRecord(exportedValue)) { + continue; + } + + for (const [endpointName, endpointDefinition] of Object.entries(exportedValue)) { + if (!isApiDefinitionEntry(endpointDefinition)) { + continue; + } + + endpoints.push({ + domain, + name: endpointName, + definition: endpointDefinition, + }); + } + } + } + + return endpoints.sort((a, b) => { + if (a.domain === b.domain) { + return a.name.localeCompare(b.name); + } + return a.domain.localeCompare(b.domain); + }); +}; + +const ALL_ENDPOINTS = parseDefinitionRegistry(); + +const toPrettyJson = (value: unknown) => JSON.stringify(value, null, 2); + +const parseJsonValue = (text: string, label: string): unknown => { + if (!text.trim()) { + return {}; + } + + try { + return JSON.parse(text) as unknown; + } catch { + throw new Error(`${label} JSON 형식이 올바르지 않습니다.`); + } +}; + +const parseJsonRecord = (text: string, label: string): Record => { + const parsed = parseJsonValue(text, label); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`${label}는 JSON 객체여야 합니다.`); + } + + return parsed as Record; +}; + +const toStringRecord = (value: Record): Record => { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, String(entry)])); +}; + +const resolvePath = (rawPath: string, pathParams: Record) => { + const withoutBaseToken = rawPath.replace("{{URL}}", ""); + + return withoutBaseToken.replace(/\{\{([^}]+)\}\}/g, (_full, tokenName: string) => { + const exact = pathParams[tokenName]; + if (exact !== undefined && exact !== null) { + return encodeURIComponent(String(exact)); + } + + const normalizedToken = normalizeTokenKey(tokenName); + const similarEntry = Object.entries(pathParams).find( + ([key, value]) => normalizeTokenKey(key) === normalizedToken && value !== undefined && value !== null, + ); + + if (similarEntry) { + return encodeURIComponent(String(similarEntry[1])); + } + + throw new Error(`경로 파라미터 '${tokenName}' 값이 필요합니다.`); + }); +}; + +const splitPathAndInlineQuery = (pathWithInlineQuery: string) => { + const questionMarkIndex = pathWithInlineQuery.indexOf("?"); + const path = questionMarkIndex >= 0 ? pathWithInlineQuery.slice(0, questionMarkIndex) : pathWithInlineQuery; + const queryString = questionMarkIndex >= 0 ? pathWithInlineQuery.slice(questionMarkIndex + 1) : ""; + const inlineQuery = Object.fromEntries(new URLSearchParams(queryString)); + return { path, inlineQuery }; +}; + +const METHOD_FILTERS: MethodFilter[] = ["ALL", "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]; + +export function BrunoApiPageContent() { + const [search, setSearch] = useState(""); + const [methodFilter, setMethodFilter] = useState("ALL"); + const [selectedKey, setSelectedKey] = useState( + ALL_ENDPOINTS[0] ? `${ALL_ENDPOINTS[0].domain}:${ALL_ENDPOINTS[0].name}` : "", + ); + const [pathParamsText, setPathParamsText] = useState("{}"); + const [queryParamsText, setQueryParamsText] = useState("{}"); + const [bodyText, setBodyText] = useState("{}"); + const [headersText, setHeadersText] = useState("{}"); + const [isSending, setIsSending] = useState(false); + const [requestResult, setRequestResult] = useState(null); + + const visibleEndpoints = useMemo(() => { + const normalized = search.trim().toLowerCase(); + return ALL_ENDPOINTS.filter((endpoint) => { + const matchesMethod = methodFilter === "ALL" || endpoint.definition.method === methodFilter; + if (!matchesMethod) { + return false; + } + if (!normalized) { + return true; + } + + const matchesSearch = + `${endpoint.domain} ${endpoint.name} ${endpoint.definition.method} ${endpoint.definition.path}` + .toLowerCase() + .includes(normalized); + return matchesSearch; + }); + }, [methodFilter, search]); + + const selectedEndpoint = useMemo(() => { + return ALL_ENDPOINTS.find((endpoint) => `${endpoint.domain}:${endpoint.name}` === selectedKey) ?? null; + }, [selectedKey]); + + const handleResetEditors = () => { + setPathParamsText("{}"); + setQueryParamsText("{}"); + setBodyText("{}"); + setHeadersText("{}"); + setRequestResult(null); + }; + + const handleCopyResponse = async () => { + if (!requestResult) { + return; + } + + await navigator.clipboard.writeText(toPrettyJson(requestResult.body)); + toast.success("응답 JSON을 복사했습니다."); + }; + + const handleSendRequest = async () => { + if (!selectedEndpoint) { + toast.error("호출할 API를 선택해주세요."); + return; + } + + try { + setIsSending(true); + + const pathParams = parseJsonRecord(pathParamsText, "Path Params"); + const queryParams = parseJsonRecord(queryParamsText, "Query Params"); + const headers = toStringRecord(parseJsonRecord(headersText, "Headers")); + const body = parseJsonValue(bodyText, "Body"); + + const resolvedPath = resolvePath(selectedEndpoint.definition.path, pathParams); + const { path, inlineQuery } = splitPathAndInlineQuery(resolvedPath); + const mergedQueryParams = { ...inlineQuery, ...queryParams }; + const startedAt = performance.now(); + + const response = await axiosInstance.request({ + url: path, + method: selectedEndpoint.definition.method, + params: mergedQueryParams, + data: + selectedEndpoint.definition.method === "GET" || selectedEndpoint.definition.method === "HEAD" + ? undefined + : body, + headers, + validateStatus: () => true, + }); + + const durationMs = Math.round(performance.now() - startedAt); + const responseHeaders = Object.fromEntries( + Object.entries(response.headers).map(([key, value]) => [ + key, + Array.isArray(value) ? value.join(", ") : String(value), + ]), + ); + + setRequestResult({ + status: response.status, + durationMs, + headers: responseHeaders, + body: response.data, + }); + toast.success("요청이 완료되었습니다."); + } catch (error) { + const message = error instanceof Error ? error.message : "요청 중 오류가 발생했습니다."; + toast.error(message); + } finally { + setIsSending(false); + } + }; + + return ( + +
+ + + Bruno API 목록 + setSearch(event.target.value)} + /> +
+ {METHOD_FILTERS.map((method) => { + const active = methodFilter === method; + return ( + + ); + })} +
+
+ +
+ {visibleEndpoints.map((endpoint) => { + const key = `${endpoint.domain}:${endpoint.name}`; + const active = key === selectedKey; + + return ( + + ); + })} +
+
+
+ +
+ + +
+ 요청 빌더 +
+ + +
+
+ {selectedEndpoint ? ( +
+

{selectedEndpoint.definition.method}

+

{selectedEndpoint.definition.path}

+
+ ) : ( +

왼쪽에서 API를 선택해주세요.

+ )} +
+ + + + Path Params + Query + Body + Headers + + + +