Merge branch 'iipbs_cs' of https://www.onasys.co.kr:3000/ONAANDALL/frontend
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.next
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.env*
|
||||
apps/**/node_modules
|
||||
packages/**/node_modules
|
||||
apps/**/.next
|
||||
**/.next/cache
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
# ---------------------- #
|
||||
# STEP 1: Base 설치 환경 #
|
||||
# ---------------------- #
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
WORKDIR /repo
|
||||
|
||||
# pnpm 설치 (corepack)
|
||||
RUN corepack enable && corepack prepare pnpm@10.12.1 --activate
|
||||
|
||||
# 필요한 시스템 패키지 설치 (선택)
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
# ---------------------- #
|
||||
# STEP 2: 의존성 설치용 캐시 레이어 #
|
||||
# ---------------------- #
|
||||
FROM base AS deps
|
||||
|
||||
# workspace 정의 복사
|
||||
COPY pnpm-workspace.yaml ./
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY apps/cs/package.json ./apps/cs/
|
||||
COPY packages ./packages
|
||||
|
||||
# 종속성 설치 (only production dependencies)
|
||||
RUN rm -rf node_modules .next
|
||||
RUN pnpm install --frozen-lockfile --node-linker=node-modules
|
||||
#RUN pnpm install --frozen-lockfile --node-linker=node-modules
|
||||
RUN ls -al .
|
||||
#RUN pnpm install --prod
|
||||
|
||||
# ---------------------- #
|
||||
# STEP 3: 빌드 단계 #
|
||||
# ---------------------- #
|
||||
FROM base AS builder
|
||||
|
||||
COPY --from=deps /repo /repo
|
||||
WORKDIR /repo
|
||||
|
||||
# 전체 소스 복사
|
||||
COPY . .
|
||||
|
||||
RUN ls -al /repo/apps/cs/.next || true
|
||||
RUN rm -rf /repo/apps/cs/.next
|
||||
RUN ls -al /repo/apps/cs/
|
||||
# Next.js 빌드
|
||||
RUN cat /repo/apps/cs/next.config.ts || true
|
||||
RUN pnpm --filter ./apps/cs... build
|
||||
|
||||
#RUN ls -al .next/standalone
|
||||
# 👉 실행에 필요한 모듈만 유지
|
||||
#RUN pnpm prune --prod
|
||||
|
||||
# Next.js 빌드 내용 보기
|
||||
RUN ls -al ./apps/cs/
|
||||
RUN ls -al /repo/apps/cs/.next
|
||||
RUN ls -al /repo/apps/cs/.next/chunks || true
|
||||
|
||||
# web browser request zip format
|
||||
#RUN find /repo/apps/cs/.next/static -type f -name '*.js' -exec gzip -k {} \;
|
||||
|
||||
#RUN find ./apps/cs/.next -type f
|
||||
|
||||
# ---------------------- #
|
||||
# STEP 4: 최종 러너 단계 (최소 런타임) #
|
||||
# ---------------------- #
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# corepack 사용 설정
|
||||
#RUN corepack enable && corepack prepare pnpm@8.15.5 --activate
|
||||
|
||||
# 환경변수 설정
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
# pnpm은 런타임에 필요 없음 → corepack 설치 생략 가능
|
||||
|
||||
# 빌드 결과만 복사
|
||||
RUN rm -rf .next
|
||||
COPY --from=builder /repo/apps/cs/.next/standalone ./
|
||||
COPY --from=builder /repo/apps/cs/public ./public
|
||||
COPY --from=builder /repo/apps/cs/.next/static ./apps/cs/.next/static
|
||||
COPY --from=builder /repo/apps/cs/.env ./apps/cs/.env
|
||||
COPY --from=builder /repo/apps/cs/package.json ./apps/cs/package.json
|
||||
COPY --from=builder /repo/apps/cs/next.config.ts ./apps/cs/next.config.ts
|
||||
# 헬스체크 등 필요 시 추가 가능
|
||||
#HEALTHCHECK --interval=30s --timeout=3s CMD wget --spider -q http://localhost:3000 || exit 1
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "apps/cs/server.js"]
|
||||
#CMD ["pnpm", "start"]
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
CLAIM_NAME,
|
||||
REFRESH_TOKEN,
|
||||
SERVER_API_URL,
|
||||
SITE_ID,
|
||||
} from "@/shared/config";
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
@@ -15,9 +14,10 @@ async function handler(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
) {
|
||||
const cookieStore = await cookies();
|
||||
const siteId = cookieStore.get("site-id-key")?.value ?? "";
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Site-Id": SITE_ID,
|
||||
"X-Site-Id": siteId,
|
||||
};
|
||||
let body = {};
|
||||
const isRefreshHandler = params.url.includes("refresh");
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
const nextConfig: NextConfig = {
|
||||
async rewrites() {
|
||||
return {
|
||||
afterFiles: [
|
||||
{
|
||||
source: "/cms/:path*",
|
||||
destination: "/:path*",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@faker-js/faker": "^9.5.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/webpack": "^5.28.5",
|
||||
|
||||
@@ -4,10 +4,11 @@ import { useEffect } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { MenuAuthorityApi } from "@/entities/menu-authority/@x/menu";
|
||||
import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib";
|
||||
import { SITE_ID } from "@/shared/config";
|
||||
import { MenuApi } from "../api/menu-service";
|
||||
import { getCurrentMenu } from "./utils";
|
||||
|
||||
const siteId = getCookie("site-id-key") ?? "";
|
||||
|
||||
const MenuWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -17,7 +18,7 @@ const MenuWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
useMenuStore((state) => state);
|
||||
|
||||
const getCurrentMenuInfo = async () => {
|
||||
const menuRoles = await MenuApi.getMenuRoles(SITE_ID);
|
||||
const menuRoles = await MenuApi.getMenuRoles(siteId);
|
||||
if (!menuRoles) return;
|
||||
return getCurrentMenu(menuRoles, pathname ?? "");
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Client } from "@common/network";
|
||||
import { BASE_URL } from "../config/api";
|
||||
import { SITE_ID } from "../config/env";
|
||||
import { getCookie } from "../lib";
|
||||
|
||||
const siteId = getCookie("site-id-key") ?? "";
|
||||
|
||||
export const client = Client.getInstance({
|
||||
baseURL: BASE_URL,
|
||||
siteId: SITE_ID,
|
||||
siteId,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ export const PORT = process.env.NEXT_PUBLIC_PORT || "13000";
|
||||
export const PROXY_HOST =
|
||||
process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
|
||||
export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
|
||||
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "CS";
|
||||
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
|
||||
export const ACCESS_TOKEN =
|
||||
process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
|
||||
|
||||
@@ -77,7 +77,7 @@ function PostPage({ bbsId }: { bbsId: string }) {
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId],
|
||||
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pagination],
|
||||
queryFn: () =>
|
||||
PostApi.getPostList(parseInt(bbsId), {
|
||||
...postParams,
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
import { LoginlogPage } from "@/pages/loginlog";
|
||||
export default LoginlogPage;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
AUTH_USER_ID,
|
||||
CLAIM_NAME,
|
||||
REFRESH_TOKEN,
|
||||
} from "@/shared/config";
|
||||
|
||||
export async function POST() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(ACCESS_TOKEN);
|
||||
cookieStore.delete(REFRESH_TOKEN);
|
||||
cookieStore.delete(AUTH_USER_ID);
|
||||
cookieStore.delete(CLAIM_NAME);
|
||||
return NextResponse.json({ message: "Logged out" }, { status: 200 });
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const ipAddress =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0] || "unknown";
|
||||
|
||||
const ipv4Regex = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/;
|
||||
const match = ipAddress.match(ipv4Regex);
|
||||
|
||||
const normalizedIp = match ? match[1] : ipAddress;
|
||||
|
||||
return NextResponse.json({
|
||||
originalIp: ipAddress,
|
||||
normalizedIp: normalizedIp,
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ const subDomain =
|
||||
process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
async rewrites() {
|
||||
return {
|
||||
beforeFiles: [
|
||||
@@ -14,15 +15,15 @@ const nextConfig: NextConfig = {
|
||||
{
|
||||
type: "header",
|
||||
key: "referer",
|
||||
value: "(.*)/pst(.*)",
|
||||
value: "(.*)/cms(.*)",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
afterFiles: [
|
||||
{
|
||||
source: "/pst/:path*",
|
||||
destination: `${subDomain}/pst/:path*`,
|
||||
source: "/cms/:path*",
|
||||
destination: `${subDomain}/cms/:path*`,
|
||||
},
|
||||
],
|
||||
fallback: [],
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "15.3.0",
|
||||
"next-qrcode": "^2.5.1",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-hook-form": "^7.54.2",
|
||||
@@ -44,7 +45,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@faker-js/faker": "^9.5.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/webpack": "^5.28.5",
|
||||
|
||||
@@ -5,42 +5,41 @@ import { MultiBackend, getBackendOptions } from "@minoru/react-dnd-treeview";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
|
||||
import { ClientIpGuard } from "@/entities/ip";
|
||||
import { AuthWrapper } from "@/entities/auth";
|
||||
import { MenuWrapper } from "@/entities/menu";
|
||||
import { PromptProvider, StoreProvider } from "@/shared/providers";
|
||||
import { AlertDialog } from "@/shared/ui/alert-dialog";
|
||||
import { PromptDialog } from "@/shared/ui/prompt-dialog";
|
||||
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
|
||||
import { SelectPromptDialog } from "@/shared/ui/select-prompt-dialog";
|
||||
import { PromptProvider, StoreProvider } from "@/shared/providers";
|
||||
|
||||
const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
const queryClient = new QueryClient();
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ClientIpGuard>
|
||||
<StoreProvider>
|
||||
<PromptProvider>
|
||||
<ConfirmProvider>
|
||||
<AuthWrapper>
|
||||
<MenuWrapper>
|
||||
<DndProvider
|
||||
backend={MultiBackend}
|
||||
options={getBackendOptions()}
|
||||
>
|
||||
{children}
|
||||
</DndProvider>
|
||||
</MenuWrapper>
|
||||
<AlertDialog />
|
||||
<PromptDialog />
|
||||
<ConfirmDialog />
|
||||
</AuthWrapper>
|
||||
</ConfirmProvider>
|
||||
</PromptProvider>
|
||||
</StoreProvider>
|
||||
<div className="text-2xl">
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</div>
|
||||
</ClientIpGuard>
|
||||
<StoreProvider>
|
||||
<PromptProvider>
|
||||
<ConfirmProvider>
|
||||
<AuthWrapper>
|
||||
<MenuWrapper>
|
||||
<DndProvider
|
||||
backend={MultiBackend}
|
||||
options={getBackendOptions()}
|
||||
>
|
||||
{children}
|
||||
</DndProvider>
|
||||
</MenuWrapper>
|
||||
<AlertDialog />
|
||||
<PromptDialog />
|
||||
<ConfirmDialog />
|
||||
<SelectPromptDialog />
|
||||
</AuthWrapper>
|
||||
</ConfirmProvider>
|
||||
</PromptProvider>
|
||||
</StoreProvider>
|
||||
<div className="text-2xl">
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface GroupUserListResponseDto {
|
||||
mode: string;
|
||||
groupId: string;
|
||||
userUnqId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
ogdpSeCd: string;
|
||||
ogdpSeNm: string;
|
||||
|
||||
@@ -7,4 +7,3 @@ export {
|
||||
type IpList,
|
||||
type IP,
|
||||
} from "./model/ip-interface";
|
||||
export { ClientIpGuard } from "./lib/client-ip-guard";
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { SiteApi } from "@/entities/site/@x/ip";
|
||||
import { ENDPOINT, SITE_ID } from "@/shared/config";
|
||||
import { IpApi } from "../api/ip-service";
|
||||
|
||||
type ClientIpResponse = {
|
||||
originalIp: string;
|
||||
normalizedIp: string;
|
||||
};
|
||||
|
||||
const ClientIpGuard = ({ children }: { children: React.ReactNode }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [isAllowed, setIsAllowed] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const response = await fetch("/api/getClientIp");
|
||||
const { normalizedIp } = (await response.json()) as ClientIpResponse;
|
||||
|
||||
if (!normalizedIp || !SITE_ID) return;
|
||||
|
||||
const siteInfo = await queryClient.ensureQueryData({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.SITES, SITE_ID],
|
||||
queryFn: () => SiteApi.siteDetail(SITE_ID),
|
||||
});
|
||||
|
||||
if (siteInfo.siteKndCd === "O") return;
|
||||
|
||||
const { content: ipList } = await IpApi.getIpList({
|
||||
siteId: SITE_ID,
|
||||
prmYn: "Y",
|
||||
});
|
||||
|
||||
const allowedClientIp = ipList.some((ip) => ip.ipAddr === normalizedIp);
|
||||
setIsAllowed(allowedClientIp);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (!isAllowed) {
|
||||
alert("허용되지 않은 IP로 접근하였습니다.");
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export { ClientIpGuard };
|
||||
@@ -13,4 +13,5 @@ export {
|
||||
type PostUpdateFormData,
|
||||
type PostService,
|
||||
} from "./model/post-interface";
|
||||
export { deleteReasonMessages } from "./model/delete-reason-messages";
|
||||
export { PostPageGuard } from "./lib/post-page-guard";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const deleteReasonMessages = {
|
||||
"저작권 침해":
|
||||
"타인의 저작물을 허락 없이 게시하는 경우 저작권 침해로 간주되어 삭제될 수 있습니다.",
|
||||
명예훼손: "타인의 명예를 훼손하는 내용의 게시물은 삭제될 수 있습니다.",
|
||||
"개인정보 노출": "타인의 개인정보를 노출하는 게시물도 삭제될 수 있습니다.",
|
||||
"불법 정보 유통":
|
||||
"불법적인 상품이나 서비스를 판매하거나 홍보하는 게시물은 삭제될 수 있습니다.",
|
||||
"욕설 및 비방":
|
||||
"심한 욕설이나 비방, 모욕적인 내용이 포함된 게시물은 삭제될 수 있습니다.",
|
||||
"광고성 정보": "지나치게 광고성이 짙은 게시물은 삭제될 수 있습니다.",
|
||||
};
|
||||
@@ -21,20 +21,28 @@ import {
|
||||
Skeleton,
|
||||
} from "@common/components";
|
||||
import { AttachmentApi } from "@/entities/attachment";
|
||||
import { PostApi, type PostParams } from "@/entities/post";
|
||||
import {
|
||||
PostApi,
|
||||
deleteReasonMessages,
|
||||
type PostParams,
|
||||
} from "@/entities/post";
|
||||
import { AuthorityButton } from "@/entities/menu-authority";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import {
|
||||
useAlertStore,
|
||||
useMenuStore,
|
||||
usePrompt,
|
||||
useSelectPrompt,
|
||||
useMutationWrapper,
|
||||
} from "@/shared/lib";
|
||||
|
||||
const selectData = Object.entries(deleteReasonMessages).map(
|
||||
([label, value]) => ({ label, value }),
|
||||
);
|
||||
|
||||
function FaqPage({ bbsId }: { bbsId: string }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { prompt } = usePrompt();
|
||||
const { prompt } = useSelectPrompt(selectData);
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const bbsInfo = useMenuStore((state) => state.bbsInfo);
|
||||
|
||||
@@ -75,11 +83,13 @@ function FaqPage({ bbsId }: { bbsId: string }) {
|
||||
|
||||
const handleDeletePost = async (pstNo: number) => {
|
||||
const delRsn = await getPromptValue("삭제 사유를 입력해주세요.");
|
||||
if (!delRsn) return;
|
||||
deletePost({ pstNo, delRsn });
|
||||
};
|
||||
|
||||
const onSelectedPostDelete = async () => {
|
||||
const delRsn = await getPromptValue("삭제 사유를 입력해주세요.");
|
||||
if (!delRsn) return;
|
||||
await Promise.all(
|
||||
selectedPostNumbers.map((pstNo) =>
|
||||
PostApi.deletePost(parseInt(bbsId), pstNo, delRsn),
|
||||
|
||||
@@ -26,7 +26,7 @@ export const columns: ColumnDef<GroupUserListResponseDto>[] = [
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "groupId",
|
||||
accessorKey: "userId",
|
||||
header: "사용자ID",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -57,7 +57,6 @@ function GroupUsersSection({ groupId }: GroupUsersSectionProps) {
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getRowId: (row) => row.userUnqId,
|
||||
},
|
||||
dataTableOptions: {
|
||||
viewPagination: true,
|
||||
@@ -65,15 +64,18 @@ function GroupUsersSection({ groupId }: GroupUsersSectionProps) {
|
||||
});
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
const selectedRows = Object.keys(rowSelection);
|
||||
const selectedRows = table.getSelectedRowModel().rows.map((row) => ({
|
||||
groupId: row.original.groupId,
|
||||
userUnqId: row.original.userUnqId,
|
||||
}));
|
||||
if (selectedRows.length === 0 || !groupId) return;
|
||||
if (!(await confirm("사용자를 삭제하시겠습니까?"))) return;
|
||||
|
||||
try {
|
||||
const usersToDelete = selectedRows.map((userUnqId) => ({
|
||||
const usersToDelete = selectedRows.map((row) => ({
|
||||
mode: "D",
|
||||
groupId,
|
||||
userUnqId,
|
||||
groupId: row.groupId,
|
||||
userUnqId: row.userUnqId,
|
||||
}));
|
||||
|
||||
await GroupUsersApi.saveGroupUser(usersToDelete);
|
||||
@@ -90,6 +92,7 @@ function GroupUsersSection({ groupId }: GroupUsersSectionProps) {
|
||||
|
||||
useEffect(() => {
|
||||
setPagination({ ...pagination, pageIndex: 0 });
|
||||
setRowSelection({});
|
||||
}, [groupId]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { LoginlogPage } from "./ui/loginlog-page";
|
||||
@@ -75,6 +75,9 @@ function MenuPage() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -91,6 +94,9 @@ function MenuPage() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -101,6 +107,9 @@ function MenuPage() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -118,8 +127,10 @@ function MenuPage() {
|
||||
menuLnkgTypeCd: menuInfo?.menuLnkgTypeCd ?? MENU_TYPE.EMPTY,
|
||||
iconNm: menuInfo?.iconName,
|
||||
otptYn: menuInfo?.otptYn ?? "Y",
|
||||
evlUseYn: menuInfo?.evlUseYn ?? "Y",
|
||||
evlUseYn: menuInfo?.evlUseYn ?? "N",
|
||||
qrcd: menuInfo?.qrcd ?? "N",
|
||||
useYn: menuInfo?.useYn ?? "Y",
|
||||
newWndwYn: menuInfo?.newWndwYn ?? "N",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useQRCode } from "next-qrcode";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@common/components";
|
||||
import { useClipboard } from "@frontend-opensource/use-react-hooks";
|
||||
|
||||
function QRCodeDialog({ url }: { url: string }) {
|
||||
const { Canvas } = useQRCode();
|
||||
const { copied: isCopied, copyText } = useClipboard();
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [originUrl, setOriginUrl] = useState("");
|
||||
const qrCodeUrl = originUrl + url;
|
||||
|
||||
const handleDownload = () => {
|
||||
const canvas = canvasRef.current?.querySelector("canvas");
|
||||
if (!canvas) return;
|
||||
const url = canvas.toDataURL("image/png");
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "qr-code.png";
|
||||
link.click();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
setOriginUrl(window.location.origin);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button disabled={!url}>QR 조회</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>QR 코드 스캔</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<div ref={canvasRef}>
|
||||
<Canvas
|
||||
text={url}
|
||||
options={{
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 1,
|
||||
width: 200,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-sm bg-accent p-2 text-[1.3rem] text-accent-foreground">
|
||||
<p className="break-all px-2">{qrCodeUrl}</p>
|
||||
<Tooltip open={isCopied}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => copyText(qrCodeUrl)}
|
||||
className="size-10 shrink-0 rounded-sm hover:bg-[rgba(0,0,0,0.1)]"
|
||||
>
|
||||
<i className="diveicon di-clipboard" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span className="text-base">복사완료</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Button onClick={handleDownload}>QR 코드 다운로드</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { QRCodeDialog };
|
||||
@@ -1,5 +1,5 @@
|
||||
import dayjs from "dayjs";
|
||||
import { type UseFormReturn } from "react-hook-form";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
DatePicker,
|
||||
IconFinder,
|
||||
@@ -21,6 +21,7 @@ import type { MenuFormData, MenuType } from "../model/menu-interface";
|
||||
import { OrganizationSearchDialog } from "./organization-search-dialog";
|
||||
import { ContentSearchDialog } from "./content-search-dialog";
|
||||
import { BoardSearchDialog } from "./board-search-dialog";
|
||||
import { QRCodeDialog } from "./qrcode-dialog";
|
||||
|
||||
type TypeMenuFormProps = {
|
||||
form: UseFormReturn<MenuFormData>;
|
||||
@@ -33,6 +34,7 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
|
||||
};
|
||||
|
||||
const handleContentSelect = (content: Contents) => {
|
||||
form.setValue("urlPath", `/cms/cntns/${content.contsSn}`);
|
||||
form.setValue("menuLnkgSn", content.contsSn);
|
||||
form.setValue("menuLnkgSnNm", content.contsTtl);
|
||||
};
|
||||
@@ -43,6 +45,10 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
|
||||
};
|
||||
|
||||
const handleBoardSelect = (board: BoardPopup) => {
|
||||
form.setValue(
|
||||
"urlPath",
|
||||
`/cms/${board.bbsTypeCd.toLowerCase()}/${board.bbsId}`,
|
||||
);
|
||||
form.setValue("menuLnkgSn", board.bbsId);
|
||||
form.setValue("menuLnkgSnNm", board.bbsNm);
|
||||
};
|
||||
@@ -52,6 +58,14 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
|
||||
form.setValue("menuPicDsgnYmd", dayjs(date).format("YYYYMMDD"));
|
||||
};
|
||||
|
||||
const getUrlPathPlaceholder = () => {
|
||||
if (menuType === "CONTENTS") return "콘텐츠 선택 시 자동으로 입력됩니다.";
|
||||
if (menuType === "BOARD") return "게시판 선택 시 자동으로 입력됩니다.";
|
||||
if (menuType === "INSIDE")
|
||||
return "메뉴에 연결된 페이지의 경로를 입력하십시오.";
|
||||
return "'http://' 또는 'https://'를 포함하여 입력하십시오.";
|
||||
};
|
||||
|
||||
return (
|
||||
<Table variant="secondary" className="-mt-[0.1rem]">
|
||||
<colgroup>
|
||||
@@ -91,7 +105,10 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{(menuType === "INSIDE" || menuType === "OUTSIDE") && (
|
||||
{(menuType === "CONTENTS" ||
|
||||
menuType === "BOARD" ||
|
||||
menuType === "INSIDE" ||
|
||||
menuType === "OUTSIDE") && (
|
||||
<TableRow>
|
||||
<TableHead>연결 URL</TableHead>
|
||||
<TableCell className="border">
|
||||
@@ -105,10 +122,9 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="!text-[1.3rem]"
|
||||
placeholder={
|
||||
menuType === "INSIDE"
|
||||
? "메뉴에 연결된 페이지의 경로를 입력하십시오."
|
||||
: "'http://' 또는 'https://'를 포함하여 입력하십시오."
|
||||
placeholder={getUrlPathPlaceholder()}
|
||||
disabled={
|
||||
menuType === "CONTENTS" || menuType === "BOARD"
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -293,25 +309,30 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
|
||||
control={form.control}
|
||||
name="qrcd"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
value={field.value ?? ""}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="Y" id="qrcdY" />
|
||||
<Label htmlFor="qrcdY">사용</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
value={field.value ?? ""}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="Y" id="qrcdY" />
|
||||
<Label htmlFor="qrcdY">사용</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="N" id="qrcdN" />
|
||||
<Label htmlFor="qrcdN">사용안함</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="N" id="qrcdN" />
|
||||
<Label htmlFor="qrcdN">사용안함</Label>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
{field.value === "Y" && (
|
||||
<QRCodeDialog url={form.watch("urlPath") ?? ""} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
@@ -23,13 +23,14 @@ import {
|
||||
} from "@common/components";
|
||||
import {
|
||||
PostApi,
|
||||
deleteReasonMessages,
|
||||
type DeletePost,
|
||||
type RecentPost,
|
||||
type RecentPostParams,
|
||||
} from "@/entities/post";
|
||||
import { BoardApi } from "@/entities/board";
|
||||
import { AuthorityButton } from "@/entities/menu-authority";
|
||||
import { useAlertStore, usePrompt, useDataTable } from "@/shared/lib";
|
||||
import { useAlertStore, useSelectPrompt, useDataTable } from "@/shared/lib";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { columns } from "../model/table-columns";
|
||||
import { MovePostDialog } from "./move-post-dialog";
|
||||
@@ -39,9 +40,13 @@ type DateRange = {
|
||||
to?: Date | undefined;
|
||||
};
|
||||
|
||||
const selectData = Object.entries(deleteReasonMessages).map(
|
||||
([label, value]) => ({ label, value }),
|
||||
);
|
||||
|
||||
function RecentPage() {
|
||||
const router = useRouter();
|
||||
const { prompt } = usePrompt();
|
||||
const { prompt } = useSelectPrompt(selectData);
|
||||
const { confirm } = useConfirm();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [dateRange, setDateRange] = useState<DateRange>();
|
||||
|
||||
@@ -18,12 +18,8 @@ function ModifyPage({ siteId }: { siteId: string }) {
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.SITES, siteId],
|
||||
queryFn: () => SiteApi.siteDetail(siteId),
|
||||
select: (data) => {
|
||||
const keys = Object.keys(siteFormSchema.shape);
|
||||
const filteredData = Object.fromEntries(
|
||||
Object.entries(data).filter(([key]) => keys.includes(key)),
|
||||
);
|
||||
return {
|
||||
...filteredData,
|
||||
...data,
|
||||
faxNo: data.faxNumber,
|
||||
bscSiteYn: data.basicSiteYn,
|
||||
};
|
||||
@@ -50,7 +46,14 @@ function ModifyPage({ siteId }: { siteId: string }) {
|
||||
|
||||
const form = useForm<SiteFormData>({
|
||||
resolver: zodResolver(siteFormSchema),
|
||||
values: siteDetail as SiteFormData,
|
||||
values: {
|
||||
...siteDetail,
|
||||
siteId: siteDetail?.siteId ?? "",
|
||||
siteNm: siteDetail?.siteNm ?? "",
|
||||
siteKndCd: siteDetail?.siteKndCd ?? "",
|
||||
bscSiteYn: siteDetail?.bscSiteYn ?? "",
|
||||
useYn: siteDetail?.useYn ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -82,6 +82,7 @@ function SiteForm({ form, siteId, ...props }: SiteFormProps) {
|
||||
<CodeSelect
|
||||
{...field}
|
||||
upCd="SITEKNDCD"
|
||||
value={field.value ?? ""}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Client } from "@common/network";
|
||||
import { BASE_URL } from "../config/api";
|
||||
import { SITE_ID } from "../config/env";
|
||||
import { setCookie } from "../lib";
|
||||
|
||||
setCookie("site-id-key", SITE_ID);
|
||||
|
||||
export const client = Client.getInstance({
|
||||
baseURL: BASE_URL,
|
||||
|
||||
@@ -58,6 +58,7 @@ export const ENDPOINT = {
|
||||
BBS_TEMPLATES: "/board-service/api/v1/bbsTmplt",
|
||||
POSTS: "/board-service/api/v1/posts",
|
||||
POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts",
|
||||
POST_EVALUATION: "/board-service/api/v1/pstEvl",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -5,3 +5,4 @@ export { useDataTable } from "./use-data-table";
|
||||
export { useMenuStore } from "./use-menu-store";
|
||||
export { useUserStore } from "./use-user-store";
|
||||
export { usePrompt } from "./use-prompt";
|
||||
export { useSelectPrompt } from "./use-select-prompt";
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useContext, useEffect } from "react";
|
||||
import { PromptContext } from "../providers/prompt-provider";
|
||||
|
||||
type SelectData = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const useSelectPrompt = (data?: SelectData[]) => {
|
||||
const {
|
||||
selectData,
|
||||
selectionGuide,
|
||||
resolve,
|
||||
setResolve,
|
||||
setSelectData,
|
||||
setSelectionGuide,
|
||||
} = useContext(PromptContext);
|
||||
|
||||
const prompt = (message: string) => {
|
||||
setSelectionGuide(message);
|
||||
return new Promise<{ resolved: boolean; value?: string }>((resolve) => {
|
||||
setResolve(() => resolve);
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = (value: string) => {
|
||||
resolve?.({ resolved: true, value });
|
||||
setSelectionGuide("");
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
resolve?.({ resolved: false });
|
||||
setSelectionGuide("");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setSelectData(data);
|
||||
}, [data]);
|
||||
|
||||
return {
|
||||
selectData,
|
||||
selectionGuide,
|
||||
isOpen: !!selectionGuide,
|
||||
prompt,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,14 @@
|
||||
import { createContext, useState } from "react";
|
||||
|
||||
type SelectData = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
interface PromptContext {
|
||||
message?: string;
|
||||
selectData?: SelectData[];
|
||||
selectionGuide?: string;
|
||||
resolve?: ({
|
||||
resolved,
|
||||
value,
|
||||
@@ -10,18 +17,26 @@ interface PromptContext {
|
||||
value?: string;
|
||||
}) => void;
|
||||
setMessage: (message: string) => void;
|
||||
setSelectData: (selectData: SelectData[]) => void;
|
||||
setSelectionGuide: (message: string) => void;
|
||||
setResolve: (resolve: () => void) => void;
|
||||
}
|
||||
|
||||
export const PromptContext = createContext<PromptContext>({
|
||||
message: "",
|
||||
selectData: [],
|
||||
selectionGuide: "",
|
||||
resolve() {},
|
||||
setMessage() {},
|
||||
setSelectData() {},
|
||||
setSelectionGuide() {},
|
||||
setResolve() {},
|
||||
});
|
||||
|
||||
export const PromptProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [message, setMessage] = useState<string>();
|
||||
const [selectData, setSelectData] = useState<SelectData[]>([]);
|
||||
const [selectionGuide, setSelectionGuide] = useState<string>();
|
||||
const [resolve, setResolve] =
|
||||
useState<
|
||||
({ resolved, value }: { resolved: boolean; value?: string }) => void
|
||||
@@ -29,7 +44,16 @@ export const PromptProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
return (
|
||||
<PromptContext.Provider
|
||||
value={{ message, resolve, setMessage, setResolve }}
|
||||
value={{
|
||||
message,
|
||||
selectData,
|
||||
selectionGuide,
|
||||
resolve,
|
||||
setMessage,
|
||||
setSelectData,
|
||||
setSelectionGuide,
|
||||
setResolve,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PromptContext.Provider>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
@@ -13,6 +13,10 @@ function PromptDialog() {
|
||||
const { message, isOpen, onSubmit, onCancel } = usePrompt();
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) setValue("");
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onCancel}>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
import { useSelectPrompt } from "../lib/use-select-prompt";
|
||||
|
||||
function SelectPromptDialog() {
|
||||
const { selectData, selectionGuide, isOpen, onSubmit, onCancel } =
|
||||
useSelectPrompt();
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) setValue("");
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onCancel}>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<div className="flex flex-col gap-6 pb-4 pt-8">
|
||||
<p className="whitespace-pre-line text-center text-[1.4rem] text-foreground">
|
||||
{selectionGuide}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select onValueChange={setValue}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectData?.map((data) => (
|
||||
<SelectItem key={data.value} value={data.value}>
|
||||
{data.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Textarea
|
||||
value={value}
|
||||
className="!text-[1.3rem] !leading-6"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="!justify-center">
|
||||
<DialogClose asChild>
|
||||
<Button onClick={() => onSubmit(value)}>확인</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button onClick={onCancel} color="white">
|
||||
취소
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { SelectPromptDialog };
|
||||
@@ -0,0 +1,14 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type {
|
||||
PostEvaluationPayload,
|
||||
PostEvaluationService,
|
||||
} from "../model/post-evaluation-interface";
|
||||
|
||||
export class PostEvaluationApiService implements PostEvaluationService {
|
||||
public async postEvaluation(payload: PostEvaluationPayload) {
|
||||
await client.post(ENDPOINT.BOARD_SERVICE.POST_EVALUATION, payload);
|
||||
}
|
||||
}
|
||||
|
||||
export const PostEvaluationApi = new PostEvaluationApiService();
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface PostEvaluationPayload {
|
||||
pstNo: number;
|
||||
evlIp?: string;
|
||||
evlDt?: string;
|
||||
evlScr: number;
|
||||
}
|
||||
|
||||
export interface PostEvaluationService {
|
||||
postEvaluation: (payload: PostEvaluationPayload) => Promise<void>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { Button } from "@common/components";
|
||||
import { useMenuStore, usePrompt, useUserStore } from "@/shared/lib";
|
||||
import { deleteReasonMessages } from "@/entities/post";
|
||||
import { useMenuStore, useSelectPrompt, useUserStore } from "@/shared/lib";
|
||||
|
||||
type PostActionProps = {
|
||||
bbsId: string;
|
||||
@@ -9,10 +10,14 @@ type PostActionProps = {
|
||||
handleDelete: (delRsn?: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const selectData = Object.entries(deleteReasonMessages).map(
|
||||
([label, value]) => ({ label, value }),
|
||||
);
|
||||
|
||||
function PostAction({ children, handleDelete }: PostActionProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { prompt } = usePrompt();
|
||||
const { prompt } = useSelectPrompt(selectData);
|
||||
const bbsInfo = useMenuStore((state) => state.bbsInfo);
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
|
||||
|
||||
@@ -25,19 +25,24 @@ import { AuthorityButton } from "@/entities/menu-authority";
|
||||
import {
|
||||
PostApi,
|
||||
PostPageGuard,
|
||||
deleteReasonMessages,
|
||||
type Post,
|
||||
type PostParams,
|
||||
} from "@/entities/post";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useMenuStore, usePrompt } from "@/shared/lib";
|
||||
import { useMenuStore, useSelectPrompt } from "@/shared/lib";
|
||||
import { GridViewDataTable } from "@/shared/ui/grid-view-data-table";
|
||||
import { columns as tableColumns } from "../model/post-table-columns";
|
||||
import { createColumns as createGridViewColumns } from "../model/grid-view-table-columns";
|
||||
|
||||
const selectData = Object.entries(deleteReasonMessages).map(
|
||||
([label, value]) => ({ label, value }),
|
||||
);
|
||||
|
||||
function PostPage({ bbsId }: { bbsId: string }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { prompt } = usePrompt();
|
||||
const { prompt } = useSelectPrompt(selectData);
|
||||
const bbsInfo = useMenuStore((state) => state.bbsInfo);
|
||||
const thumbRefs = useRef<Map<number, string>>(new Map());
|
||||
|
||||
|
||||
@@ -5,14 +5,21 @@ import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Skeleton } from "@common/components";
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Skeleton,
|
||||
} from "@common/components";
|
||||
import { AttachmentApi } from "@/entities/attachment";
|
||||
import { PostApi } from "@/entities/post";
|
||||
import { useAlertStore, useMutationWrapper, useMenuStore } from "@/shared/lib";
|
||||
import { BoardApi } from "@/entities/board";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore, useMutationWrapper, useMenuStore } from "@/shared/lib";
|
||||
import { PostAction } from "./post-action";
|
||||
import { PostComment } from "./post-comment";
|
||||
import { BoardApi } from "@/entities/board";
|
||||
import { PostEvaluationApi } from "../api/post-evaluation-service";
|
||||
|
||||
function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
|
||||
const router = useRouter();
|
||||
@@ -20,6 +27,7 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
|
||||
const { bbsInfo, setBbsInfo } = useMenuStore((state) => state);
|
||||
|
||||
const [filename, setFilename] = useState("");
|
||||
const [evlScr, setEvlScr] = useState(5);
|
||||
|
||||
const { data: boardInfo } = useQuery({
|
||||
queryKey: [ENDPOINT.BOARD_SERVICE.BBS, bbsId],
|
||||
@@ -72,6 +80,17 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: postEvaluation } = useMutationWrapper({
|
||||
mutationFn: (evlScr: number) =>
|
||||
PostEvaluationApi.postEvaluation({
|
||||
pstNo: parseInt(pstNo),
|
||||
evlScr,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
alert("게시물 평가가 완료되었습니다.");
|
||||
},
|
||||
});
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!downloadResponse) return;
|
||||
try {
|
||||
@@ -187,6 +206,53 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{bbsInfo?.dgstfnEvlUseYn === "Y" && (
|
||||
<>
|
||||
<hr />
|
||||
<div className="mt-8 flex items-center gap-2 px-4">
|
||||
<p>
|
||||
이 페이지에서 제공하는
|
||||
<br />
|
||||
정보에 대하여 만족하시나요?
|
||||
</p>
|
||||
<div className="ml-4 rounded-sm border border-border bg-accent px-8 py-4">
|
||||
<RadioGroup
|
||||
value={evlScr.toString()}
|
||||
onValueChange={(value) => setEvlScr(parseInt(value))}
|
||||
className="flex items-center space-x-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="5" id="evl5" />
|
||||
<Label htmlFor="evl5">매우 만족 😊</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="4" id="evl4" />
|
||||
<Label htmlFor="evl4">만족 🙂</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="3" id="evl3" />
|
||||
<Label htmlFor="evl3">보통 😐</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="2" id="evl2" />
|
||||
<Label htmlFor="evl2">불만족 🙁</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="1" id="evl1" />
|
||||
<Label htmlFor="evl1">매우 불만족 😞</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
color="white"
|
||||
onClick={() => postEvaluation(evlScr)}
|
||||
>
|
||||
평가하기
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PostAction>
|
||||
<PostComment bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} />
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Docker 이미지 빌드
|
||||
# 로그 포함 빌드
|
||||
docker build -t frontend --progress=plain .
|
||||
docker save -o frontend_2025.tar frontend
|
||||
@@ -0,0 +1,232 @@
|
||||
# Next.js App Router의 라우트 그룹(Route Groups) 구현 분석
|
||||
|
||||
## 개요
|
||||
|
||||
Next.js의 App Router에서는 괄호로 묶인 디렉토리(`(with-base-layout)`, `(without-base-layout)`)를 라우트 그룹으로 처리하여 URL 경로에 영향을 주지 않으면서 서로 다른 레이아웃을 적용할 수 있게 합니다. 이 문서에서는 프로젝트에서 라우트 그룹이 어떻게 구현되고 사용되는지 분석합니다.
|
||||
|
||||
## 라우트 그룹 구조 분석
|
||||
|
||||
### 1. 디렉토리 구조
|
||||
|
||||
프로젝트에서는 두 가지 주요 라우트 그룹이 사용되고 있습니다:
|
||||
|
||||
```
|
||||
/apps/cs/app/
|
||||
├── (with-base-layout)/
|
||||
│ ├── banner/
|
||||
│ ├── bbs/
|
||||
│ ├── code/
|
||||
│ ├── contents/
|
||||
│ ├── dashboard/
|
||||
│ ├── ...
|
||||
│ ├── layout.tsx
|
||||
│ └── loading.tsx
|
||||
├── (without-base-layout)/
|
||||
│ ├── user-survey-article/
|
||||
│ └── user-survey-result/
|
||||
└── layout.tsx
|
||||
```
|
||||
|
||||
### 2. 라우트 그룹별 레이아웃 구현
|
||||
|
||||
#### 루트 레이아웃 (`/apps/cs/app/layout.tsx`)
|
||||
|
||||
```tsx
|
||||
import type { Metadata } from "next";
|
||||
import { App } from "@/app";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "유역하수도 통합 정보플랫폼",
|
||||
};
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
#### App 컴포넌트 (`/apps/cs/src/app/index.tsx`)
|
||||
|
||||
```tsx
|
||||
import "./styles/base.css";
|
||||
import "./styles/components.css";
|
||||
import "@fontsource-variable/noto-sans-kr";
|
||||
|
||||
import { AppProvider } from "./providers";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const App = async ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
const theme = (await cookies()).get("site-theme-key");
|
||||
|
||||
return (
|
||||
<html lang="ko" data-theme={theme?.value}>
|
||||
<body>
|
||||
<AppProvider>{children}</AppProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
|
||||
export { App };
|
||||
```
|
||||
|
||||
#### (with-base-layout) 레이아웃 (`/apps/cs/app/(with-base-layout)/layout.tsx`)
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { BaseLayout } from "@common/components";
|
||||
import type { MenuItem } from "@/entities/menu";
|
||||
import { useMenuStore } from "@/shared/lib";
|
||||
import { PageLayout } from "@/shared/ui/page-layout";
|
||||
|
||||
export default function WithBaseLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo);
|
||||
|
||||
const handleMenuClick = (item: MenuItem) => {
|
||||
setCurrentMenuInfo(item);
|
||||
router.push(item.urlPath);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
queryClient={queryClient}
|
||||
currentPath={pathname ?? ""}
|
||||
handleMenuClick={handleMenuClick}
|
||||
>
|
||||
<PageLayout>{children}</PageLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### PageLayout 컴포넌트 (`/apps/cs/src/shared/ui/page-layout.tsx`)
|
||||
|
||||
```tsx
|
||||
import { PageTitle } from "@common/components";
|
||||
import { useMenuStore } from "../lib/use-menu-store";
|
||||
|
||||
function PageLayout({ children }: { children: React.ReactNode }) {
|
||||
const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo);
|
||||
|
||||
const menuPaths = currentMenuInfo?.menuPath
|
||||
?.split(">")
|
||||
.map((path) => path.trim());
|
||||
|
||||
return (
|
||||
<div className="h-auto min-h-full bg-container pb-2">
|
||||
<PageTitle
|
||||
title={currentMenuInfo?.menuKornNm ?? ""}
|
||||
menuPaths={menuPaths ?? []}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 라우트 그룹의 작동 원리
|
||||
|
||||
### 1. URL 경로에 영향을 주지 않는 그룹화
|
||||
|
||||
Next.js App Router에서 괄호로 묶인 디렉토리는 URL 경로에 포함되지 않습니다. 예를 들어:
|
||||
|
||||
- `/bbs/register` 경로는 `(with-base-layout)/bbs/register` 디렉토리에 매핑됩니다.
|
||||
- `/user-survey-article/1` 경로는 `(without-base-layout)/user-survey-article/[srvySn]` 디렉토리에 매핑됩니다.
|
||||
|
||||
### 2. 레이아웃 분리 및 재사용
|
||||
|
||||
라우트 그룹을 사용하면 서로 다른 레이아웃을 가진 페이지들을 효율적으로 구성할 수 있습니다:
|
||||
|
||||
- `(with-base-layout)` 그룹: 메뉴, 헤더, 사이드바 등을 포함하는 `BaseLayout`과 `PageLayout`을 사용합니다.
|
||||
- `(without-base-layout)` 그룹: 기본 레이아웃 없이 페이지 컴포넌트만 렌더링합니다.
|
||||
|
||||
### 3. 중첩 라우트 그룹
|
||||
|
||||
프로젝트에서는 중첩된 라우트 그룹도 사용되고 있습니다:
|
||||
|
||||
```
|
||||
(with-base-layout)/bbs/(post)/bbsnormal/
|
||||
```
|
||||
|
||||
여기서 `(post)`는 게시물 관련 페이지들을 그룹화하는 중첩된 라우트 그룹입니다.
|
||||
|
||||
## 라우트 그룹 사용 사례
|
||||
|
||||
### 1. 관리자 인터페이스 (`with-base-layout`)
|
||||
|
||||
관리자 인터페이스는 일관된 레이아웃과 네비게이션이 필요합니다:
|
||||
|
||||
```tsx
|
||||
// (with-base-layout)/layout.tsx
|
||||
return (
|
||||
<BaseLayout
|
||||
queryClient={queryClient}
|
||||
currentPath={pathname ?? ""}
|
||||
handleMenuClick={handleMenuClick}
|
||||
>
|
||||
<PageLayout>{children}</PageLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
```
|
||||
|
||||
이 레이아웃은 다음과 같은 기능을 제공합니다:
|
||||
- 메뉴 시스템을 통한 페이지 간 이동
|
||||
- 현재 페이지 경로 표시
|
||||
- 일관된 페이지 타이틀 및 구조
|
||||
|
||||
### 2. 독립적인 페이지 (`without-base-layout`)
|
||||
|
||||
설문 기사나 결과 페이지와 같은 독립적인 페이지는 기본 레이아웃 없이 콘텐츠에 집중합니다:
|
||||
|
||||
```tsx
|
||||
// (without-base-layout)/user-survey-article/[srvySn]/page.tsx
|
||||
export default async function UserSurveyArticlePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ srvySn: string }>;
|
||||
}) {
|
||||
const { srvySn } = await params;
|
||||
return <UserSurveyArticle srvySn={srvySn} />;
|
||||
}
|
||||
```
|
||||
|
||||
이러한 페이지는:
|
||||
- 외부 링크나 팝업으로 열리는 경우가 많음
|
||||
- 독립적인 기능을 제공(예: 설문 제출 후 창 닫기)
|
||||
- 메인 애플리케이션 UI와 분리된 경험 제공
|
||||
|
||||
## 두 라우트 그룹의 주요 차이점
|
||||
|
||||
### (with-base-layout)
|
||||
- `BaseLayout` 컴포넌트를 포함하는 레이아웃을 사용합니다.
|
||||
- 이 레이아웃은 메뉴, 헤더, 사이드바 등 기본 UI 요소를 포함합니다.
|
||||
- `PageLayout` 컴포넌트로 감싸져 있어 일관된 페이지 구조를 제공합니다.
|
||||
- 주로 관리자 페이지나 로그인 후 사용하는 내부 페이지에 사용됩니다.
|
||||
- 다양한 관리 기능을 포함하는 여러 하위 디렉토리가 있습니다.
|
||||
|
||||
### (without-base-layout)
|
||||
- 기본 레이아웃 없이 페이지 컴포넌트만 렌더링합니다.
|
||||
- 메뉴, 헤더, 사이드바 등의 UI 요소가 없는 독립적인 페이지입니다.
|
||||
- 주로 외부 사용자용 페이지나 팝업, 임베드 페이지에 사용됩니다.
|
||||
- 현재 두 가지 특수 목적 페이지만 포함하고 있습니다.
|
||||
|
||||
## 기술적 이점
|
||||
|
||||
1. **코드 구성 개선**: 관련 페이지와 레이아웃을 논리적으로 그룹화할 수 있습니다.
|
||||
2. **레이아웃 재사용**: 공통 레이아웃을 여러 페이지에서 재사용할 수 있습니다.
|
||||
3. **URL 구조 단순화**: 그룹 이름이 URL에 포함되지 않아 깔끔한 URL 구조를 유지할 수 있습니다.
|
||||
4. **유지보수성 향상**: 관련 기능을 함께 그룹화하여 코드 탐색과 유지보수가 용이합니다.
|
||||
|
||||
## 결론
|
||||
|
||||
Next.js App Router의 라우트 그룹 기능은 프로젝트에서 다양한 레이아웃 요구사항을 효율적으로 관리하는 데 중요한 역할을 합니다. `(with-base-layout)`과 `(without-base-layout)` 그룹을 통해 관리자 인터페이스와 독립적인 페이지를 명확하게 분리하면서도 코드 재사용성과 유지보수성을 높일 수 있습니다.
|
||||
|
||||
이 구조는 확장성이 뛰어나 새로운 페이지나 기능을 추가할 때 기존 패턴을 따르기 쉽고, 애플리케이션이 성장함에 따라 필요한 경우 추가 라우트 그룹을 도입할 수 있는 유연성을 제공합니다.
|
||||
@@ -0,0 +1,650 @@
|
||||
# 게시물 댓글(Comment) 관리 프로그램 아키텍처 문서
|
||||
|
||||
## 개요
|
||||
|
||||
이 문서는 게시물 댓글(Comment) 관리 프로그램의 아키텍처와 소스 흐름에 대한 상세 설명입니다. 댓글 관리 기능은 게시물에 대한 댓글을 등록, 조회, 수정, 삭제하는 기능을 제공하며, 로그인 및 비로그인 사용자 모두 이용할 수 있습니다.
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- **프레임워크**: Next.js (App Router)
|
||||
- **상태 관리**: React Query, Zustand(useUserStore, useAlertStore)
|
||||
- **UI 컴포넌트**: @common/components
|
||||
- **폼 관리**: React Hook Form, Zod
|
||||
- **API 통신**: Axios
|
||||
- **알림 처리**: useConfirm, usePrompt 커스텀 훅
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── apps/
|
||||
│ └── cs/
|
||||
│ ├── src/
|
||||
│ │ ├── entities/
|
||||
│ │ │ └── comment/
|
||||
│ │ │ ├── api/
|
||||
│ │ │ │ └── comment-service.ts # 댓글 API 호출 함수
|
||||
│ │ │ └── model/
|
||||
│ │ │ └── comment-interface.ts # 댓글 인터페이스 정의
|
||||
│ │ └── widgets/
|
||||
│ │ └── post/
|
||||
│ │ └── ui/
|
||||
│ │ └── post-comment.tsx # 댓글 UI 컴포넌트
|
||||
```
|
||||
|
||||
## 데이터 모델
|
||||
|
||||
### 댓글(Comment) 인터페이스
|
||||
|
||||
```typescript
|
||||
export interface Comment {
|
||||
cmntEvl: number; // 댓글 평가 점수
|
||||
pstNo: number; // 게시물 번호
|
||||
cmntNo: number; // 댓글 번호
|
||||
cmntCn: string; // 댓글 내용
|
||||
wrtrNm: string; // 작성자명
|
||||
regDt: string; // 등록일시
|
||||
rgtr: string; // 등록자
|
||||
mdfcnDt: string; // 수정일시
|
||||
mdfr: string; // 수정자
|
||||
delYn: string; // 삭제 여부
|
||||
}
|
||||
```
|
||||
|
||||
### 댓글 검색 파라미터(CommentParams) 인터페이스
|
||||
|
||||
```typescript
|
||||
export interface CommentParams extends PageableParams {
|
||||
keywordType?: string; // 검색 키워드 유형
|
||||
keyword?: string; // 검색 키워드
|
||||
pstNo: number; // 게시물 번호
|
||||
delYn?: string; // 삭제 여부
|
||||
}
|
||||
```
|
||||
|
||||
### 댓글 폼 데이터(CommentFormData) 인터페이스
|
||||
|
||||
Zod 스키마를 사용하여 폼 데이터의 유효성 검증을 수행합니다.
|
||||
|
||||
```typescript
|
||||
export const commentFormSchema = z.object({
|
||||
pstNo: z.number(), // 게시물 번호
|
||||
cmntCn: z.string(), // 댓글 내용
|
||||
wrtrNm: z.string().nullish(), // 작성자명 (선택적)
|
||||
cmntPswd: z.string(), // 댓글 비밀번호
|
||||
cmntEvl: z.number().nullish(), // 댓글 평가 점수 (선택적)
|
||||
});
|
||||
|
||||
export type CommentFormData = z.infer<typeof commentFormSchema>;
|
||||
```
|
||||
|
||||
### 댓글 수정 폼 데이터(CommentUpdateFormData) 인터페이스
|
||||
|
||||
```typescript
|
||||
export const commentUpdateFormSchema = commentFormSchema.omit({
|
||||
pstNo: true, // 수정 시에는 게시물 번호가 필요 없음
|
||||
});
|
||||
|
||||
export type CommentUpdateFormData = z.infer<typeof commentUpdateFormSchema>;
|
||||
```
|
||||
|
||||
## API 기능
|
||||
|
||||
댓글 관리 API는 다음과 같은 기능을 제공합니다:
|
||||
|
||||
### 1. 댓글 목록 조회
|
||||
|
||||
```typescript
|
||||
getComments: async (params: CommentParams): Promise<CommentList> => {
|
||||
const response = await client.get<CommentList>(
|
||||
`${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
- **설명**: 특정 게시물의 댓글 목록을 페이지네이션과 함께 조회합니다.
|
||||
- **파라미터**: `CommentParams` (게시물 번호, 검색 조건)
|
||||
- **반환값**: 페이지네이션된 댓글 목록 (`CommentList`)
|
||||
|
||||
### 2. 댓글 등록
|
||||
|
||||
```typescript
|
||||
createComment: async (data: CommentFormData): Promise<Comment> => {
|
||||
const validatedData = commentFormSchema.parse(data);
|
||||
const response = await client.post<Comment>(
|
||||
`${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}`,
|
||||
validatedData,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
- **설명**: 새로운 댓글을 등록합니다.
|
||||
- **파라미터**: `CommentFormData` (댓글 등록 데이터)
|
||||
- **반환값**: 등록된 댓글 정보 (`Comment`)
|
||||
|
||||
### 3. 댓글 수정
|
||||
|
||||
```typescript
|
||||
updateComment: async (
|
||||
bbsId: number,
|
||||
pstNo: number,
|
||||
cmntNo: number,
|
||||
data: CommentUpdateFormData,
|
||||
): Promise<Comment> => {
|
||||
const validatedData = commentUpdateFormSchema.parse(data);
|
||||
const response = await client.put<Comment>(
|
||||
`${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}/${bbsId}/${pstNo}/${cmntNo}`,
|
||||
validatedData,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
- **설명**: 기존 댓글 정보를 수정합니다.
|
||||
- **파라미터**: `bbsId` (게시판 ID), `pstNo` (게시물 번호), `cmntNo` (댓글 번호), `CommentUpdateFormData` (수정할 댓글 데이터)
|
||||
- **반환값**: 수정된 댓글 정보 (`Comment`)
|
||||
|
||||
### 4. 댓글 삭제
|
||||
|
||||
```typescript
|
||||
deleteComment: async (
|
||||
bbsId: number,
|
||||
pstNo: number,
|
||||
cmntNo: number,
|
||||
cmntPswd: string,
|
||||
): Promise<Comment> => {
|
||||
const response = await client.delete<Comment>(
|
||||
`${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}/${bbsId}/${pstNo}/${cmntNo}`,
|
||||
{ params: { cmntPswd } },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
- **설명**: 댓글을 삭제합니다.
|
||||
- **파라미터**: `bbsId` (게시판 ID), `pstNo` (게시물 번호), `cmntNo` (댓글 번호), `cmntPswd` (댓글 비밀번호)
|
||||
- **반환값**: 삭제된 댓글 정보 (`Comment`)
|
||||
|
||||
## 프로그램 흐름
|
||||
|
||||
### 1. 댓글 목록 조회 흐름
|
||||
|
||||
1. 게시물 상세 페이지에서 `PostComment` 컴포넌트 로드
|
||||
2. React Query를 사용하여 `getComments` API 호출
|
||||
```typescript
|
||||
const { data: comments } = useQuery({
|
||||
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS, pstNo],
|
||||
queryFn: () => CommentApi.getComments({ pstNo }),
|
||||
enabled: !!pstNo,
|
||||
});
|
||||
```
|
||||
3. 응답 데이터를 UI에 표시
|
||||
```tsx
|
||||
{comments?.content.map((comment) => {
|
||||
if (comment.delYn === "Y") return null;
|
||||
return (
|
||||
<div className="flex justify-between gap-2 border-t border-gray-200 py-8" key={comment.cmntNo}>
|
||||
{/* 댓글 내용 표시 */}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
```
|
||||
|
||||
### 2. 댓글 등록 흐름
|
||||
|
||||
1. 사용자가 댓글 내용 입력 및 등록 버튼 클릭
|
||||
2. React Hook Form을 통해 폼 데이터 유효성 검증
|
||||
```typescript
|
||||
const form = useForm<CommentFormData>({
|
||||
resolver: zodResolver(commentFormSchema),
|
||||
values: {
|
||||
pstNo,
|
||||
cmntCn: "",
|
||||
wrtrNm: userInfo?.userName ?? "",
|
||||
cmntPswd: userInfo?.userUnqId ?? "",
|
||||
},
|
||||
});
|
||||
```
|
||||
3. 유효성 검증 통과 시 `onSubmit` 함수 호출
|
||||
```typescript
|
||||
const onSubmit = (data: CommentFormData) => {
|
||||
createComment(data);
|
||||
};
|
||||
```
|
||||
4. React Query의 `useMutation` 훅을 통해 `createComment` API 호출
|
||||
```typescript
|
||||
const { mutate: createComment } = useMutation({
|
||||
mutationFn: (data: CommentFormData) => CommentApi.createComment(data),
|
||||
onSuccess: () => {
|
||||
alert("등록되었습니다.");
|
||||
form.setValue("cmntCn", "");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS],
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
5. 성공 시 알림 표시 및 댓글 목록 새로고침
|
||||
|
||||
### 3. 댓글 수정 흐름
|
||||
|
||||
1. 사용자가 댓글 수정 버튼 클릭
|
||||
2. 수정 모드 활성화 및 기존 댓글 내용 로드
|
||||
```typescript
|
||||
const handleEdit = (cmntNo: number, cmntCn: string) => {
|
||||
setEditingCmntNo(cmntNo);
|
||||
setEditValue(cmntCn);
|
||||
};
|
||||
```
|
||||
3. 사용자가 댓글 내용 수정 후 저장 버튼 클릭
|
||||
4. 확인 다이얼로그 표시 및 비밀번호 확인
|
||||
```typescript
|
||||
const handleUpdateComment = async (cmntNo: number, wrtrNm: string) => {
|
||||
if (!(await confirm("수정하시겠습니까?"))) return;
|
||||
const cmntPswd = await getCommentPassword();
|
||||
updateComment({
|
||||
cmntNo,
|
||||
data: {
|
||||
cmntCn: editValue,
|
||||
wrtrNm,
|
||||
cmntPswd,
|
||||
},
|
||||
});
|
||||
};
|
||||
```
|
||||
5. React Query의 `useMutation` 훅을 통해 `updateComment` API 호출
|
||||
6. 성공 시 알림 표시 및 댓글 목록 새로고침
|
||||
|
||||
### 4. 댓글 삭제 흐름
|
||||
|
||||
1. 사용자가 댓글 삭제 버튼 클릭
|
||||
2. 확인 다이얼로그 표시 및 비밀번호 확인
|
||||
```typescript
|
||||
const handleDeleteComment = async (cmntNo: number) => {
|
||||
if (!(await confirm("삭제하시겠습니까?"))) return;
|
||||
const cmntPswd = await getCommentPassword();
|
||||
deleteComment({ cmntNo, cmntPswd });
|
||||
};
|
||||
```
|
||||
3. React Query의 `useMutation` 훅을 통해 `deleteComment` API 호출
|
||||
4. 성공 시 알림 표시 및 댓글 목록 새로고침
|
||||
|
||||
## 주요 컴포넌트
|
||||
|
||||
### PostComment 컴포넌트
|
||||
|
||||
게시물 댓글을 표시하고 관리하는 컴포넌트입니다.
|
||||
|
||||
```tsx
|
||||
function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) {
|
||||
// 상태 및 훅 설정
|
||||
const { prompt } = usePrompt();
|
||||
const { confirm } = useConfirm();
|
||||
const queryClient = useQueryClient();
|
||||
const { userInfo } = useUserStore((state) => state);
|
||||
const { setMessage: alert } = useAlertStore((state) => state);
|
||||
const [editingCmntNo, setEditingCmntNo] = useState<number | null>(null);
|
||||
const [editValue, setEditValue] = useState<string>("");
|
||||
|
||||
// 댓글 목록 조회
|
||||
const { data: comments } = useQuery({
|
||||
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS, pstNo],
|
||||
queryFn: () => CommentApi.getComments({ pstNo }),
|
||||
enabled: !!pstNo,
|
||||
});
|
||||
|
||||
// 댓글 등록 뮤테이션
|
||||
const { mutate: createComment } = useMutation({
|
||||
mutationFn: (data: CommentFormData) => CommentApi.createComment(data),
|
||||
onSuccess: () => {
|
||||
alert("등록되었습니다.");
|
||||
form.setValue("cmntCn", "");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// 댓글 수정 뮤테이션
|
||||
const { mutate: updateComment } = useMutation({
|
||||
mutationFn: ({
|
||||
cmntNo,
|
||||
data,
|
||||
}: {
|
||||
cmntNo: number;
|
||||
data: CommentUpdateFormData;
|
||||
}) => CommentApi.updateComment(bbsId, pstNo, cmntNo, data),
|
||||
onSuccess: () => {
|
||||
alert("수정되었습니다.");
|
||||
setEditingCmntNo(null);
|
||||
setEditValue("");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// 댓글 삭제 뮤테이션
|
||||
const { mutate: deleteComment } = useMutation({
|
||||
mutationFn: ({ cmntNo, cmntPswd }: { cmntNo: number; cmntPswd: string }) =>
|
||||
CommentApi.deleteComment(bbsId, pstNo, cmntNo, cmntPswd),
|
||||
onSuccess: () => {
|
||||
alert("삭제되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// 폼 설정
|
||||
const form = useForm<CommentFormData>({
|
||||
resolver: zodResolver(commentFormSchema),
|
||||
values: {
|
||||
pstNo,
|
||||
cmntCn: "",
|
||||
wrtrNm: userInfo?.userName ?? "",
|
||||
cmntPswd: userInfo?.userUnqId ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
// 폼 제출 핸들러
|
||||
const onSubmit = (data: CommentFormData) => {
|
||||
createComment(data);
|
||||
};
|
||||
|
||||
// 비밀번호 획득 함수
|
||||
const getCommentPassword = async () => {
|
||||
let cmntPswd = "";
|
||||
if (userInfo?.userUnqId) {
|
||||
cmntPswd = userInfo?.userUnqId;
|
||||
} else {
|
||||
const { resolved, value } = await prompt("비밀번호를 입력해주세요.");
|
||||
if (resolved) cmntPswd = value ?? "";
|
||||
}
|
||||
return cmntPswd;
|
||||
};
|
||||
|
||||
// 댓글 수정 핸들러
|
||||
const handleUpdateComment = async (cmntNo: number, wrtrNm: string) => {
|
||||
if (!(await confirm("수정하시겠습니까?"))) return;
|
||||
const cmntPswd = await getCommentPassword();
|
||||
updateComment({
|
||||
cmntNo,
|
||||
data: {
|
||||
cmntCn: editValue,
|
||||
wrtrNm,
|
||||
cmntPswd,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 댓글 삭제 핸들러
|
||||
const handleDeleteComment = async (cmntNo: number) => {
|
||||
if (!(await confirm("삭제하시겠습니까?"))) return;
|
||||
const cmntPswd = await getCommentPassword();
|
||||
deleteComment({ cmntNo, cmntPswd });
|
||||
};
|
||||
|
||||
// UI 렌더링
|
||||
return (
|
||||
<div className="card card-border !p-8">
|
||||
{/* 댓글 등록 폼 */}
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
{/* 댓글 내용 입력 필드 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cmntCn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
maxLength={1000}
|
||||
className="h-[10rem] bg-background p-4 !text-[1.3rem] !leading-6"
|
||||
placeholder="댓글을 입력해주세요."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="flex gap-4">
|
||||
{/* 비로그인 사용자를 위한 작성자명 입력 필드 */}
|
||||
{!userInfo?.userName && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="wrtrNm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="w-[16rem] bg-background !text-[1.3rem]"
|
||||
placeholder="작성자"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{/* 비로그인 사용자를 위한 비밀번호 입력 필드 */}
|
||||
{!userInfo?.userUnqId && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cmntPswd"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
value={field.value ?? ""}
|
||||
className="w-[12rem] bg-background !text-[1.3rem]"
|
||||
placeholder="비밀번호"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit">등록</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{/* 댓글 목록 */}
|
||||
<div className="mt-8 flex flex-col">
|
||||
{comments?.content.map((comment) => {
|
||||
if (comment.delYn === "Y") return null;
|
||||
return (
|
||||
<div
|
||||
className="flex justify-between gap-2 border-t border-gray-200 py-8"
|
||||
key={comment.cmntNo}
|
||||
>
|
||||
{/* 댓글 정보 표시 */}
|
||||
<div className="flex w-full gap-10">
|
||||
<div className="flex w-[10rem] shrink-0 flex-col">
|
||||
<span className="text-[1.4rem] font-bold text-label">
|
||||
{comment.wrtrNm}
|
||||
</span>
|
||||
<span className="text-[1.2rem] text-label">
|
||||
{dayjs(comment.regDt).format("YYYY.MM.DD")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 수정 모드일 때 */}
|
||||
{editingCmntNo === comment.cmntNo ? (
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Textarea
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
className="h-[10rem] bg-background p-4 !text-[1.3rem] !leading-6"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingCmntNo(null);
|
||||
setEditValue("");
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleUpdateComment(comment.cmntNo, comment.wrtrNm)
|
||||
}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* 일반 모드일 때 */
|
||||
<div className="flex flex-1 flex-col">
|
||||
<p className="whitespace-pre-wrap text-[1.4rem] text-label">
|
||||
{comment.cmntCn}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 수정/삭제 버튼 */}
|
||||
{editingCmntNo !== comment.cmntNo && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleEdit(comment.cmntNo, comment.cmntCn)}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
onClick={() => handleDeleteComment(comment.cmntNo)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 로그인/비로그인 사용자 처리
|
||||
|
||||
### 로그인 사용자 처리
|
||||
|
||||
로그인 사용자의 경우 `useUserStore`에서 사용자 정보를 가져와 자동으로 설정합니다.
|
||||
|
||||
```typescript
|
||||
const { userInfo } = useUserStore((state) => state);
|
||||
|
||||
const form = useForm<CommentFormData>({
|
||||
resolver: zodResolver(commentFormSchema),
|
||||
values: {
|
||||
pstNo,
|
||||
cmntCn: "",
|
||||
wrtrNm: userInfo?.userName ?? "",
|
||||
cmntPswd: userInfo?.userUnqId ?? "",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 비로그인 사용자 처리
|
||||
|
||||
비로그인 사용자의 경우 작성자명과 비밀번호를 직접 입력받습니다.
|
||||
|
||||
```tsx
|
||||
{!userInfo?.userName && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="wrtrNm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="w-[16rem] bg-background !text-[1.3rem]"
|
||||
placeholder="작성자"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!userInfo?.userUnqId && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cmntPswd"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
value={field.value ?? ""}
|
||||
className="w-[12rem] bg-background !text-[1.3rem]"
|
||||
placeholder="비밀번호"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
## 비밀번호 검증 로직
|
||||
|
||||
댓글 수정/삭제 시 비밀번호 검증을 위한 로직이 구현되어 있습니다.
|
||||
|
||||
```typescript
|
||||
const getCommentPassword = async () => {
|
||||
let cmntPswd = "";
|
||||
if (userInfo?.userUnqId) {
|
||||
// 로그인 사용자의 경우 사용자 ID를 비밀번호로 사용
|
||||
cmntPswd = userInfo?.userUnqId;
|
||||
} else {
|
||||
// 비로그인 사용자의 경우 프롬프트를 통해 비밀번호 입력 요청
|
||||
const { resolved, value } = await prompt("비밀번호를 입력해주세요.");
|
||||
if (resolved) cmntPswd = value ?? "";
|
||||
}
|
||||
return cmntPswd;
|
||||
};
|
||||
```
|
||||
|
||||
## 아키텍처 특징
|
||||
|
||||
1. **계층 분리**: API, 모델, UI 컴포넌트가 명확히 분리되어 있습니다.
|
||||
2. **타입 안전성**: TypeScript 인터페이스와 Zod 스키마를 통한 타입 검증을 제공합니다.
|
||||
3. **상태 관리**: React Query를 통한 서버 상태 관리와 Zustand를 통한 클라이언트 상태 관리를 제공합니다.
|
||||
4. **유효성 검증**: Zod 스키마를 사용하여 폼 데이터의 유효성을 검증합니다.
|
||||
5. **사용자 경험**: 로그인/비로그인 사용자에 대한 차별화된 UI 제공합니다.
|
||||
6. **비동기 처리**: React Query의 `useMutation`을 통한 효율적인 비동기 작업 처리를 제공합니다.
|
||||
|
||||
## 확장 가능성
|
||||
|
||||
1. **댓글 계층 구조**: 대댓글 기능 추가
|
||||
2. **댓글 좋아요/싫어요**: 댓글 평가 기능 강화
|
||||
3. **댓글 신고**: 부적절한 댓글 신고 기능 추가
|
||||
4. **댓글 알림**: 댓글 등록 시 알림 기능 추가
|
||||
5. **댓글 정렬**: 최신순, 인기순 등 다양한 정렬 옵션 제공
|
||||
6. **댓글 검색**: 댓글 내용 검색 기능 추가
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. 비로그인 사용자의 댓글 비밀번호는 클라이언트 측에서만 검증되므로 보안에 주의가 필요합니다.
|
||||
2. 댓글 삭제 시 실제로 데이터베이스에서 삭제하지 않고 `delYn` 필드를 'Y'로 설정하는 소프트 삭제 방식을 사용합니다.
|
||||
3. 댓글 내용에 HTML 태그나 스크립트가 포함될 경우 XSS 공격 가능성이 있으므로 서버 측에서 적절한 필터링이 필요합니다.
|
||||
@@ -0,0 +1,480 @@
|
||||
# 게시물(Post) 관리 프로그램 아키텍처 문서
|
||||
|
||||
## 개요
|
||||
|
||||
이 문서는 게시물(Post) 관리 프로그램의 아키텍처와 소스 흐름에 대한 상세 설명입니다. 게시물 관리 기능은 게시판에 게시물을 등록, 조회, 수정, 삭제하는 기능을 제공하며, 다양한 유형의 게시물(일반, 이미지, 동영상, 공지사항 등)을 지원합니다.
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- **프레임워크**: Next.js (App Router)
|
||||
- **상태 관리**: React Query, Zustand(useMenuStore, useUserStore)
|
||||
- **UI 컴포넌트**: @common/components
|
||||
- **테이블 관리**: TanStack Table
|
||||
- **폼 관리**: React Hook Form, Zod
|
||||
- **API 통신**: Axios
|
||||
- **파일 업로드**: 커스텀 파일 업로더 컴포넌트
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── apps/
|
||||
│ └── cs/
|
||||
│ ├── app/
|
||||
│ │ └── (with-base-layout)/
|
||||
│ │ └── bbs/
|
||||
│ │ └── (post)/ # 게시물 관련 페이지
|
||||
│ ├── src/
|
||||
│ │ ├── entities/
|
||||
│ │ │ ├── post/
|
||||
│ │ │ │ ├── api/
|
||||
│ │ │ │ │ └── post-service.ts # 게시물 API 호출 함수
|
||||
│ │ │ │ └── model/
|
||||
│ │ │ │ └── post-interface.ts # 게시물 인터페이스 정의
|
||||
│ │ │ └── posting/
|
||||
│ │ │ ├── api/
|
||||
│ │ │ │ └── posting-service.ts # 게시 관련 API 호출 함수
|
||||
│ │ │ ├── model/
|
||||
│ │ │ │ └── posting-interface.ts # 게시 관련 인터페이스 정의
|
||||
│ │ │ └── ui/
|
||||
│ │ │ └── posting-dialog.tsx # 게시 관련 다이얼로그 컴포넌트
|
||||
│ │ ├── pages/
|
||||
│ │ │ └── (post)/
|
||||
│ │ │ ├── image/
|
||||
│ │ │ │ └── ui/
|
||||
│ │ │ │ └── image-post.tsx # 이미지 게시물 컴포넌트
|
||||
│ │ │ ├── movie/
|
||||
│ │ │ │ └── ui/
|
||||
│ │ │ │ └── movie-post.tsx # 동영상 게시물 컴포넌트
|
||||
│ │ │ ├── normal/
|
||||
│ │ │ │ └── ui/
|
||||
│ │ │ │ └── normal-post.tsx # 일반 게시물 컴포넌트
|
||||
│ │ │ └── notice/
|
||||
│ │ │ └── ui/
|
||||
│ │ │ └── notice-post.tsx # 공지사항 게시물 컴포넌트
|
||||
│ │ └── widgets/
|
||||
│ │ └── post/
|
||||
│ │ ├── model/
|
||||
│ │ │ ├── post-table-columns.tsx # 게시물 테이블 컬럼 정의
|
||||
│ │ │ └── grid-view-table-columns.tsx # 그리드 뷰 테이블 컬럼 정의
|
||||
│ │ └── ui/
|
||||
│ │ ├── post-action.tsx # 게시물 액션 컴포넌트
|
||||
│ │ ├── post-comment.tsx # 게시물 댓글 컴포넌트
|
||||
│ │ ├── post-form.tsx # 게시물 등록/수정 폼
|
||||
│ │ ├── post-page.tsx # 게시물 목록 페이지
|
||||
│ │ ├── post.tsx # 게시물 상세 컴포넌트
|
||||
│ │ └── posting-period-selector.tsx # 게시 기간 선택기
|
||||
```
|
||||
|
||||
## 데이터 모델
|
||||
|
||||
### 게시물(Post) 인터페이스
|
||||
|
||||
```typescript
|
||||
export interface Post {
|
||||
rowNo: number; // 행 번호
|
||||
bbsId: number; // 게시판 ID
|
||||
pstNo: number; // 게시물 번호
|
||||
pstTtl: string; // 게시물 제목
|
||||
pstCn: string; // 게시물 내용
|
||||
inqNocs: number; // 조회수
|
||||
rfrncUrl: string; // 참조 URL
|
||||
popupYn: string; // 팝업 여부
|
||||
upndFixYn: string; // 상단 고정 여부
|
||||
prdPstgYn: string; // 기간 게시 여부
|
||||
rootPath: string; // 루트 경로
|
||||
pstPath: string; // 게시물 경로
|
||||
ansPstn: number; // 답변 위치
|
||||
wrtrNm: string; // 작성자명
|
||||
wrtrCplc: string; // 작성자 소속
|
||||
atflCd: string; // 첨부파일 코드
|
||||
regDt: string; // 등록일시
|
||||
rgtrNm: string; // 등록자명
|
||||
cmntCnt: number; // 댓글 수
|
||||
}
|
||||
```
|
||||
|
||||
### 게시물 상세 정보(PostInfo) 인터페이스
|
||||
|
||||
```typescript
|
||||
export interface PostInfo extends Post {
|
||||
pstCn: string; // 게시물 내용
|
||||
rgtr: string; // 등록자
|
||||
mdfcnDt: string; // 수정일시
|
||||
mdfr: string; // 수정자
|
||||
}
|
||||
```
|
||||
|
||||
### 최근 게시물(RecentPost) 인터페이스
|
||||
|
||||
```typescript
|
||||
export interface RecentPost extends Post {
|
||||
bbsNm: string; // 게시판명
|
||||
bbsTypeCd: string; // 게시판 유형 코드
|
||||
}
|
||||
```
|
||||
|
||||
### 게시물 이동(MovePost) 인터페이스
|
||||
|
||||
```typescript
|
||||
export interface MovePost {
|
||||
fromBbsId: number; // 출발 게시판 ID
|
||||
toBbsId?: number; // 도착 게시판 ID
|
||||
pstNo: number; // 게시물 번호
|
||||
}
|
||||
```
|
||||
|
||||
### 게시물 삭제(DeletePost) 인터페이스
|
||||
|
||||
```typescript
|
||||
export interface DeletePost {
|
||||
bbsId: number; // 게시판 ID
|
||||
pstNo: number; // 게시물 번호
|
||||
delRsn?: string; // 삭제 사유
|
||||
}
|
||||
```
|
||||
|
||||
### 게시물 검색 파라미터(PostParams) 인터페이스
|
||||
|
||||
```typescript
|
||||
export interface PostParams extends PageableParams {
|
||||
keywordType?: string; // 검색 키워드 유형 (제목, 내용, 작성자 등)
|
||||
keyword?: string; // 검색 키워드
|
||||
popupYn?: string; // 팝업 여부
|
||||
upndFixYn?: string; // 상단 고정 여부
|
||||
prdPstgYn?: string; // 기간 게시 여부
|
||||
siteId?: string; // 사이트 ID
|
||||
bbsTypeCd?: string; // 게시판 유형 코드
|
||||
}
|
||||
```
|
||||
|
||||
### 최근 게시물 검색 파라미터(RecentPostParams) 인터페이스
|
||||
|
||||
```typescript
|
||||
export interface RecentPostParams extends PageableParams {
|
||||
keywordType?: string; // 검색 키워드 유형
|
||||
keyword?: string; // 검색 키워드
|
||||
bbsId?: number | null; // 게시판 ID
|
||||
delYn?: string; // 삭제 여부
|
||||
frDateYmd?: string; // 시작 날짜
|
||||
toDateYmd?: string; // 종료 날짜
|
||||
}
|
||||
```
|
||||
|
||||
### 게시물 폼 데이터(PostFormData) 인터페이스
|
||||
|
||||
Zod 스키마를 사용하여 폼 데이터의 유효성 검증을 수행합니다.
|
||||
|
||||
```typescript
|
||||
export const postFormSchema = z.object({
|
||||
bbsId: z.number(), // 게시판 ID
|
||||
pstTtl: z.string().nonempty(), // 게시물 제목
|
||||
pstCn: z.string().nullish(), // 게시물 내용
|
||||
rfrncUrl: z.string().nullish(), // 참조 URL
|
||||
popupYn: z.string().nullish(), // 팝업 여부
|
||||
popupYnEtc: z.array(postEtcSchema).nullish(), // 팝업 관련 추가 정보
|
||||
upndFixYn: z.string().nullish(), // 상단 고정 여부
|
||||
upndFixYnEtc: z.array(postEtcSchema).nullish(), // 상단 고정 관련 추가 정보
|
||||
prdPstgYn: z.string().nullish(), // 기간 게시 여부
|
||||
prdPstgYnEtc: z.array(postEtcSchema).nullish(), // 기간 게시 관련 추가 정보
|
||||
pstPswd: z.string(), // 게시물 비밀번호
|
||||
wrtrNm: z.string().nullish(), // 작성자명
|
||||
wrtrCplc: z.string().nullish(), // 작성자 소속
|
||||
atflCd: z.string().nullish(), // 첨부파일 코드
|
||||
parentPstNo: z.number().nullish(), // 부모 게시물 번호
|
||||
});
|
||||
```
|
||||
|
||||
## API 기능
|
||||
|
||||
게시물 관리 API는 다음과 같은 기능을 제공합니다:
|
||||
|
||||
### 1. 게시물 목록 조회
|
||||
|
||||
```typescript
|
||||
getPostList: async (bbsId: number, params: PostParams): Promise<PostList> => {
|
||||
const response = await client.get<PostList>(
|
||||
`${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
- **설명**: 특정 게시판의 게시물 목록을 페이지네이션과 함께 조회합니다.
|
||||
- **파라미터**: `bbsId` (게시판 ID), `PostParams` (검색 조건)
|
||||
- **반환값**: 페이지네이션된 게시물 목록 (`PostList`)
|
||||
|
||||
### 2. 최근 게시물 목록 조회
|
||||
|
||||
```typescript
|
||||
getRecentPostList: async (params?: RecentPostParams): Promise<RecentPostList> => {
|
||||
const response = await client.get<RecentPostList>(
|
||||
`${ENDPOINT.BOARD_SERVICE.POSTS}/recentPosts`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
- **설명**: 최근 게시물 목록을 조회합니다.
|
||||
- **파라미터**: `RecentPostParams` (검색 조건)
|
||||
- **반환값**: 페이지네이션된 최근 게시물 목록 (`RecentPostList`)
|
||||
|
||||
### 3. 게시물 상세 조회
|
||||
|
||||
```typescript
|
||||
getPostInfo: async (bbsId: number, pstNo: number): Promise<PostInfo> => {
|
||||
const response = await client.get<PostInfo>(
|
||||
`${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
- **설명**: 특정 게시물의 상세 정보를 조회합니다.
|
||||
- **파라미터**: `bbsId` (게시판 ID), `pstNo` (게시물 번호)
|
||||
- **반환값**: 게시물 상세 정보 (`PostInfo`)
|
||||
|
||||
### 4. 게시물 생성
|
||||
|
||||
```typescript
|
||||
createPost: async (data: PostFormData): Promise<Post> => {
|
||||
const validatedData = postFormSchema.parse(data);
|
||||
const response = await client.post<Post>(
|
||||
`${ENDPOINT.BOARD_SERVICE.POSTS}`,
|
||||
validatedData,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
- **설명**: 새로운 게시물을 생성합니다.
|
||||
- **파라미터**: `PostFormData` (게시물 생성 데이터)
|
||||
- **반환값**: 생성된 게시물 정보 (`Post`)
|
||||
|
||||
### 5. 게시물 수정
|
||||
|
||||
```typescript
|
||||
updatePost: async (bbsId: number, pstNo: number, data: PostUpdateFormData): Promise<Post> => {
|
||||
const validatedData = postUpdateFormSchema.parse(data);
|
||||
const response = await client.put<Post>(
|
||||
`${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`,
|
||||
validatedData,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
- **설명**: 기존 게시물 정보를 수정합니다.
|
||||
- **파라미터**: `bbsId` (게시판 ID), `pstNo` (게시물 번호), `PostUpdateFormData` (수정할 게시물 데이터)
|
||||
- **반환값**: 수정된 게시물 정보 (`Post`)
|
||||
|
||||
### 6. 게시물 삭제
|
||||
|
||||
```typescript
|
||||
deletePost: async (bbsId: number, pstNo: number, delRsn?: string): Promise<void> => {
|
||||
await client.delete(`${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`, {
|
||||
params: { delRsn },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- **설명**: 게시물을 삭제합니다.
|
||||
- **파라미터**: `bbsId` (게시판 ID), `pstNo` (게시물 번호), `delRsn` (삭제 사유, 선택적)
|
||||
- **반환값**: void
|
||||
|
||||
### 7. 게시물 소프트 삭제 (여러 게시물)
|
||||
|
||||
```typescript
|
||||
softDeletePost: async (data: DeletePost[]): Promise<void> => {
|
||||
await client.post(`${ENDPOINT.BOARD_SERVICE.POSTS}/softDeleteSels`, data);
|
||||
}
|
||||
```
|
||||
|
||||
- **설명**: 여러 게시물을 소프트 삭제합니다 (실제 삭제하지 않고 삭제 표시만 함).
|
||||
- **파라미터**: `DeletePost[]` (삭제할 게시물 목록)
|
||||
- **반환값**: void
|
||||
|
||||
### 8. 게시물 하드 삭제 (여러 게시물)
|
||||
|
||||
```typescript
|
||||
hardDeletePost: async (data: DeletePost[]): Promise<void> => {
|
||||
await client.post(`${ENDPOINT.BOARD_SERVICE.POSTS}/hardDeleteSels`, data);
|
||||
}
|
||||
```
|
||||
|
||||
- **설명**: 여러 게시물을 하드 삭제합니다 (실제로 데이터베이스에서 삭제).
|
||||
- **파라미터**: `DeletePost[]` (삭제할 게시물 목록)
|
||||
- **반환값**: void
|
||||
|
||||
### 9. 게시물 이동
|
||||
|
||||
```typescript
|
||||
movePost: async (data: MovePost[]): Promise<void> => {
|
||||
await client.post(`${ENDPOINT.BOARD_SERVICE.POSTS}/movePstByBbsId`, data);
|
||||
}
|
||||
```
|
||||
|
||||
- **설명**: 게시물을 다른 게시판으로 이동합니다.
|
||||
- **파라미터**: `MovePost[]` (이동할 게시물 정보 목록)
|
||||
- **반환값**: void
|
||||
|
||||
### 10. 게시물 복구
|
||||
|
||||
```typescript
|
||||
restorePost: async (data: DeletePost[]): Promise<void> => {
|
||||
await client.post(`${ENDPOINT.BOARD_SERVICE.POSTS}/restoreSels`, data);
|
||||
}
|
||||
```
|
||||
|
||||
- **설명**: 소프트 삭제된 게시물을 복구합니다.
|
||||
- **파라미터**: `DeletePost[]` (복구할 게시물 목록)
|
||||
- **반환값**: void
|
||||
|
||||
## 프로그램 흐름
|
||||
|
||||
### 1. 게시물 목록 조회 흐름
|
||||
|
||||
1. 사용자가 게시판 페이지에 접근
|
||||
2. `useMenuStore`를 통해 현재 게시판 정보 로드
|
||||
3. React Query를 사용하여 `getPostList` API 호출
|
||||
4. 상단 고정 게시물이 있는 경우 별도로 조회하여 목록 상단에 표시
|
||||
5. 검색 조건 변경 시 쿼리 파라미터 업데이트 및 API 재호출
|
||||
6. 응답 데이터를 TanStack Table을 사용하여 테이블 형태로 표시
|
||||
7. 게시판 유형에 따라 일반 테이블 또는 그리드 뷰로 표시
|
||||
|
||||
### 2. 게시물 상세 조회 흐름
|
||||
|
||||
1. 사용자가 게시물 목록에서 특정 게시물 클릭
|
||||
2. 해당 게시물의 상세 페이지로 이동 (`/bbs/{bbsId}/{pstNo}`)
|
||||
3. React Query를 사용하여 `getPostInfo` API 호출
|
||||
4. 게시물 상세 정보 및 첨부파일 정보 로드
|
||||
5. 게시판 유형에 따라 적절한 상세 보기 컴포넌트 렌더링
|
||||
- 일반 게시물: `normal-post.tsx`
|
||||
- 이미지 게시물: `image-post.tsx`
|
||||
- 동영상 게시물: `movie-post.tsx`
|
||||
- 공지사항: `notice-post.tsx`
|
||||
|
||||
### 3. 게시물 등록 흐름
|
||||
|
||||
1. 사용자가 게시물 등록 버튼 클릭
|
||||
2. 게시물 등록 페이지로 이동 (`/bbs/{bbsId}/register`)
|
||||
3. React Hook Form과 Zod를 사용하여 폼 데이터 관리 및 유효성 검증
|
||||
4. 첨부파일 업로드 시 `AttachmentApi`를 통해 파일 업로드 및 `atflCd` 획득
|
||||
5. 저장 버튼 클릭 시 확인 다이얼로그 표시 후 `createPost` API 호출
|
||||
6. 성공 시 게시물 목록 페이지로 리다이렉트
|
||||
|
||||
### 4. 게시물 수정 흐름
|
||||
|
||||
1. 사용자가 게시물 상세 페이지에서 수정 버튼 클릭
|
||||
2. 게시물 수정 페이지로 이동 (`/bbs/{bbsId}/{pstNo}/modify`)
|
||||
3. `getPostInfo` API를 호출하여 기존 게시물 데이터 로드
|
||||
4. 기존 첨부파일 정보 로드 및 표시
|
||||
5. 사용자가 데이터 수정 후 저장 버튼 클릭
|
||||
6. 확인 다이얼로그 표시 후 `updatePost` API 호출
|
||||
7. 성공 시 게시물 상세 페이지로 리다이렉트
|
||||
|
||||
### 5. 게시물 삭제 흐름
|
||||
|
||||
1. 사용자가 게시물 상세 페이지에서 삭제 버튼 클릭 또는 목록에서 여러 게시물 선택 후 삭제 버튼 클릭
|
||||
2. 삭제 사유 입력 다이얼로그 표시
|
||||
3. 사용자가 삭제 사유 입력 후 확인
|
||||
4. 단일 게시물 삭제 시 `deletePost` API 호출, 여러 게시물 삭제 시 `softDeletePost` 또는 `hardDeletePost` API 호출
|
||||
5. 성공 시 게시물 목록 페이지로 리다이렉트 또는 목록 새로고침
|
||||
|
||||
## 주요 컴포넌트
|
||||
|
||||
### 1. PostPage 컴포넌트
|
||||
|
||||
게시물 목록을 조회하고 표시하는 페이지 컴포넌트입니다.
|
||||
|
||||
- **기능**:
|
||||
- 게시물 검색 (제목, 내용, 작성자 등)
|
||||
- 페이지네이션
|
||||
- 페이지 크기 조정
|
||||
- 게시물 목록 표시 (테이블 또는 그리드 뷰)
|
||||
- 게시물 등록 페이지로 이동
|
||||
- 게시물 상세 페이지로 이동 (행 클릭)
|
||||
- 선택한 게시물 삭제
|
||||
|
||||
### 2. PostForm 컴포넌트
|
||||
|
||||
게시물 등록 및 수정에 사용되는 폼 컴포넌트입니다.
|
||||
|
||||
- **기능**:
|
||||
- 게시물 정보 입력/수정 (제목, 내용, 작성자 등)
|
||||
- 첨부파일 업로드 및 관리
|
||||
- 참조 URL 입력 (게시판 설정에 따라)
|
||||
- 팝업 게시 설정 (게시판 설정에 따라)
|
||||
- 상단 고정 설정 (게시판 설정에 따라)
|
||||
- 기간 게시 설정 (게시판 설정에 따라)
|
||||
- 저장 확인 다이얼로그
|
||||
- 취소 기능
|
||||
|
||||
### 3. Post 컴포넌트
|
||||
|
||||
게시물 상세 정보를 표시하는 컴포넌트입니다.
|
||||
|
||||
- **기능**:
|
||||
- 게시물 상세 정보 표시
|
||||
- 첨부파일 다운로드
|
||||
- 수정/삭제 버튼
|
||||
- 목록으로 돌아가기
|
||||
- 댓글 표시 및 관리 (게시판 설정에 따라)
|
||||
|
||||
### 4. PostComment 컴포넌트
|
||||
|
||||
게시물 댓글을 표시하고 관리하는 컴포넌트입니다.
|
||||
|
||||
- **기능**:
|
||||
- 댓글 목록 표시
|
||||
- 댓글 등록
|
||||
- 댓글 수정/삭제
|
||||
- 댓글 페이지네이션
|
||||
|
||||
### 5. PostingPeriodSelector 컴포넌트
|
||||
|
||||
게시 기간을 선택하는 컴포넌트입니다.
|
||||
|
||||
- **기능**:
|
||||
- 게시 기간 유형 선택
|
||||
- 시작일/종료일 선택
|
||||
- 게시 기간 설정
|
||||
|
||||
### 6. 게시물 유형별 컴포넌트
|
||||
|
||||
게시판 유형에 따라 다양한 게시물 컴포넌트를 제공합니다.
|
||||
|
||||
- **NormalPost**: 일반 텍스트 게시물 컴포넌트
|
||||
- **ImagePost**: 이미지 게시물 컴포넌트 (이미지 갤러리 형태)
|
||||
- **MoviePost**: 동영상 게시물 컴포넌트 (동영상 플레이어 포함)
|
||||
- **NoticePost**: 공지사항 게시물 컴포넌트
|
||||
|
||||
## 아키텍처 특징
|
||||
|
||||
1. **계층 분리**: API, 모델, UI 컴포넌트가 명확히 분리되어 있습니다.
|
||||
2. **타입 안전성**: TypeScript 인터페이스와 Zod 스키마를 통한 타입 검증을 제공합니다.
|
||||
3. **컴포넌트 재사용**: 공통 컴포넌트를 활용하여 코드 중복을 최소화합니다.
|
||||
4. **유효성 검증**: Zod 스키마를 사용하여 폼 데이터의 유효성을 검증합니다.
|
||||
5. **상태 관리**: React Query를 통한 서버 상태 관리와 Zustand를 통한 클라이언트 상태 관리를 제공합니다.
|
||||
6. **조건부 렌더링**: 게시판 설정에 따라 UI 요소를 조건부로 렌더링합니다.
|
||||
7. **다양한 게시물 유형 지원**: 일반, 이미지, 동영상, 공지사항 등 다양한 게시물 유형을 지원합니다.
|
||||
|
||||
## 확장 가능성
|
||||
|
||||
1. **다국어 지원**: 게시물 내용의 다국어 지원 기능 추가
|
||||
2. **게시물 버전 관리**: 게시물 수정 이력 관리 기능 추가
|
||||
3. **게시물 예약 발행**: 특정 시간에 자동으로 게시되는 기능 추가
|
||||
4. **게시물 통계**: 조회수, 좋아요 등 게시물 통계 기능 강화
|
||||
5. **게시물 공유**: 소셜 미디어 공유 기능 추가
|
||||
6. **게시물 알림**: 새 게시물 알림 기능 추가
|
||||
7. **게시물 태그**: 게시물 태그 및 카테고리 기능 추가
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. 게시물 삭제 시 연관된 첨부파일, 댓글 등의 처리에 주의가 필요합니다.
|
||||
2. 게시물 이동 시 게시판 유형 간 호환성 문제가 발생할 수 있습니다.
|
||||
3. 첨부파일 업로드 시 파일 크기 및 유형 제한을 준수해야 합니다.
|
||||
4. 게시 기간 설정 시 시간대(timezone) 처리에 주의가 필요합니다.
|
||||
5. 비로그인 사용자의 게시물 작성 시 비밀번호 관리에 주의가 필요합니다.
|
||||
@@ -0,0 +1,96 @@
|
||||
# 프론트엔드 디버깅 환경 구축 및 Windsurf 디버깅 가이드
|
||||
|
||||
이 문서는 본 프로젝트(Next.js 기반 프론트엔드)에서 Windsurf 및 브라우저/VSCode를 활용해 효율적으로 디버깅하는 방법을 안내합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 개발 환경 준비
|
||||
|
||||
### 1) Node.js 및 패키지 설치
|
||||
- Node.js 18.x 이상 필요
|
||||
- 루트 디렉토리에서:
|
||||
```bash
|
||||
npm install
|
||||
# 또는
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2) 환경 변수 파일 준비
|
||||
- `.env.local` 등 환경 변수 파일을 복사/생성합니다.
|
||||
- API 엔드포인트 등 민감 정보는 환경 변수로 관리하세요.
|
||||
|
||||
### 3) Next.js 개발 서버 실행
|
||||
- 아래 명령어로 개발 서버 실행:
|
||||
```bash
|
||||
npm run dev
|
||||
# 또는
|
||||
pnpm dev
|
||||
```
|
||||
- 기본 포트: `3000` (필요시 `.env` 또는 `package.json`에서 변경)
|
||||
|
||||
---
|
||||
|
||||
## 2. Windsurf 디버깅 환경 구축
|
||||
|
||||
### 1) 소스맵 활성화
|
||||
- Next.js는 개발 모드에서 자동으로 소스맵을 제공합니다.
|
||||
- 별도 설정 없이 브라우저에서 TypeScript/JSX 소스 디버깅 가능.
|
||||
|
||||
### 2) 브라우저 디버깅
|
||||
- Chrome/Edge 등에서 F12 → `Sources` 탭에서 실제 소스 파일에 브레이크포인트 설정.
|
||||
- step-by-step 실행, 변수 값 확인, 콜스택 추적 가능.
|
||||
|
||||
### 3) VSCode 디버깅 (선택)
|
||||
- `.vscode/launch.json` 파일 생성:
|
||||
```json
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"name": "Next.js: Chrome 브라우저에서 디버깅",
|
||||
"url": "http://localhost:3000",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
- VSCode에서 디버깅 세션 시작 → 브레이크포인트, 변수 추적 가능.
|
||||
|
||||
### 4) Windsurf 연동
|
||||
- Windsurf에서 “로컬 서버 연결” 기능으로 `http://localhost:3000` 접속.
|
||||
- 브라우저 프리뷰, 콘솔 로그, 네트워크 요청, 소스맵 기반 디버깅 가능.
|
||||
- "디버깅 세션 시작" 버튼 클릭 시 브라우저 개발자 도구와 동일하게 동작.
|
||||
|
||||
---
|
||||
|
||||
## 3. 디버깅 팁
|
||||
|
||||
- **핫 리로드**: 코드 수정 시 자동 반영.
|
||||
- **React DevTools**: 컴포넌트 상태, props, hooks 실시간 확인.
|
||||
- **네트워크 탭**: API 요청/응답 실시간 확인.
|
||||
- **에러 오버레이**: Next.js가 런타임/빌드 에러를 화면에 표시.
|
||||
|
||||
---
|
||||
|
||||
## 4. 문제 해결 체크리스트
|
||||
|
||||
- **빌드/런타임 에러**: 터미널과 브라우저 메시지 확인.
|
||||
- **API 통신 문제**: 네트워크 탭에서 요청/응답 확인, 환경 변수 및 서버 상태 점검.
|
||||
- **소스맵 미작동**: `next.config.js` 등에서 소스맵 관련 옵션 확인.
|
||||
|
||||
---
|
||||
|
||||
## 5. Windsurf 디버깅 절차 요약
|
||||
|
||||
1. 의존성 설치 및 개발 서버 실행 (`npm run dev`)
|
||||
2. Windsurf에서 “로컬 서버 연결”로 `http://localhost:3000` 접속
|
||||
3. 브라우저 프리뷰에서 UI/네트워크/콘솔/소스 디버깅
|
||||
4. 필요시 VSCode에서 브레이크포인트 디버깅 병행
|
||||
|
||||
---
|
||||
|
||||
## 참고
|
||||
- Next.js 공식문서: https://nextjs.org/docs
|
||||
- Windsurf 공식문서 및 지원 채널 참고
|
||||
@@ -0,0 +1,93 @@
|
||||
# ---------------------- #
|
||||
# STEP 1: Base 설치 환경 #
|
||||
# ---------------------- #
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
WORKDIR /repo
|
||||
|
||||
# pnpm 설치 (corepack)
|
||||
RUN corepack enable && corepack prepare pnpm@10.12.1 --activate
|
||||
|
||||
# 필요한 시스템 패키지 설치 (선택)
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
# ---------------------- #
|
||||
# STEP 2: 의존성 설치용 캐시 레이어 #
|
||||
# ---------------------- #
|
||||
FROM base AS deps
|
||||
|
||||
# workspace 정의 복사
|
||||
COPY pnpm-workspace.yaml ./
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY apps/cs/package.json ./apps/cs/
|
||||
COPY packages ./packages
|
||||
|
||||
# 종속성 설치 (only production dependencies)
|
||||
RUN rm -rf node_modules .next
|
||||
RUN pnpm install --frozen-lockfile --node-linker=node-modules
|
||||
#RUN pnpm install --frozen-lockfile --node-linker=node-modules
|
||||
RUN ls -al .
|
||||
#RUN pnpm install --prod
|
||||
|
||||
# ---------------------- #
|
||||
# STEP 3: 빌드 단계 #
|
||||
# ---------------------- #
|
||||
FROM base AS builder
|
||||
|
||||
COPY --from=deps /repo /repo
|
||||
WORKDIR /repo
|
||||
|
||||
# 전체 소스 복사
|
||||
COPY . .
|
||||
|
||||
RUN ls -al /repo/apps/cs/.next || true
|
||||
RUN rm -rf /repo/apps/cs/.next
|
||||
RUN ls -al /repo/apps/cs/
|
||||
# Next.js 빌드
|
||||
RUN cat /repo/apps/cs/next.config.ts || true
|
||||
RUN pnpm --filter ./apps/cs... build
|
||||
|
||||
#RUN ls -al .next/standalone
|
||||
# 👉 실행에 필요한 모듈만 유지
|
||||
#RUN pnpm prune --prod
|
||||
|
||||
# Next.js 빌드 내용 보기
|
||||
RUN ls -al ./apps/cs/
|
||||
RUN ls -al /repo/apps/cs/.next
|
||||
RUN ls -al /repo/apps/cs/.next/chunks || true
|
||||
|
||||
# web browser request zip format
|
||||
RUN find /repo/apps/cs/.next/static -type f -name '*.js' -exec gzip -k {} \;
|
||||
#RUN ls -al ./apps/cs/node-modules | wc -l
|
||||
#RUN find ./apps/cs/.next -type f
|
||||
|
||||
# ---------------------- #
|
||||
# STEP 4: 최종 러너 단계 (최소 런타임) #
|
||||
# ---------------------- #
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# corepack 사용 설정
|
||||
#RUN corepack enable && corepack prepare pnpm@8.15.5 --activate
|
||||
|
||||
# 환경변수 설정
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
# pnpm은 런타임에 필요 없음 → corepack 설치 생략 가능
|
||||
|
||||
# 빌드 결과만 복사
|
||||
RUN rm -rf .next
|
||||
COPY --from=builder /repo/apps/cs/.next/standalone ./
|
||||
COPY --from=builder /repo/apps/cs/public ./public
|
||||
COPY --from=builder /repo/apps/cs/.next/static ./apps/cs/.next/static
|
||||
COPY --from=builder /repo/apps/cs/.env ./apps/cs/.env
|
||||
COPY --from=builder /repo/apps/cs/package.json ./apps/cs/package.json
|
||||
COPY --from=builder /repo/apps/cs/next.config.ts ./apps/cs/next.config.ts
|
||||
# 헬스체크 등 필요 시 추가 가능
|
||||
#HEALTHCHECK --interval=30s --timeout=3s CMD wget --spider -q http://localhost:3000 || exit 1
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "apps/cs/server.js"]
|
||||
#CMD ["pnpm", "start"]
|
||||
@@ -0,0 +1,36 @@
|
||||
# Docker 이미지 빌드
|
||||
docker build -t frontend .
|
||||
|
||||
# 로그 포함 빌드
|
||||
docker build -t frontend --progress=plain .
|
||||
docker scout quickview
|
||||
# 실행
|
||||
docker run -p 3000:3000 frontend
|
||||
|
||||
docker run --name frontend -p 3000:3000 frontend
|
||||
|
||||
#
|
||||
docker run -it frontend sh
|
||||
|
||||
ls -al /app
|
||||
|
||||
# 실제 실행 방법
|
||||
node .next/standalone/apps/cs/server.js
|
||||
|
||||
|
||||
/app/.next # cp -r static /app/apps/cs/.next/static
|
||||
|
||||
|
||||
|
||||
• .env.production 파일을 .next/standalone/ 기준 또는 루트로 복사
|
||||
• 또는 process.env.xxx를 직접 주입 (예: docker run -e)
|
||||
일부 라이브러리는 process.cwd()를 기준으로 파일을 찾는데, 이 경로는 .next/standalone/... 안이므로 실패할 수 있습니다.
|
||||
해결:
|
||||
가능하면 __dirname 기반 경로를 사용하거나, 실행 전에 process.chdir(...) 수동 지정
|
||||
4. ✅ 의존성 누락
|
||||
|
||||
Next.js가 standalone에 필요한 모듈만 복사하지만, 일부는 누락될 수 있습니다.
|
||||
|
||||
예:
|
||||
• 동적 import (import('some-lib'))
|
||||
• optional chaining으로 쓰이는 모듈
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const subDomain =
|
||||
process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone', // docker image shrink
|
||||
compress: true, // 기본값 true지만 명시 web browser request zip format
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/_next/static/:path*.js',
|
||||
destination: '/_next/static/:path*.js.gz',
|
||||
has: [
|
||||
{
|
||||
type: 'header',
|
||||
key: 'accept-encoding',
|
||||
value: '.*gzip.*',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/_next/static/:path*.js.gz',
|
||||
headers: [
|
||||
{ key: 'Content-Encoding', value: 'gzip' },
|
||||
{ key: 'Content-Type', value: 'application/javascript' },
|
||||
{ key: 'Vary', value: 'Accept-Encoding' },
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
# Project Improvement Plan
|
||||
|
||||
This document outlines a detailed plan for improving the frontend project based on the requirements and goals specified in `requirements.md`.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
After analyzing the project requirements, we've identified several key areas for improvement to enhance the developer experience, code quality, performance, and user experience across all applications. This plan provides a roadmap for implementing these improvements in a phased approach, considering the constraints and future considerations outlined in the requirements.
|
||||
|
||||
## Architecture Improvements
|
||||
|
||||
### Component Library Enhancement
|
||||
|
||||
**Rationale**: The shared component library is central to achieving unified user experience and code reusability goals.
|
||||
|
||||
**Action Items**:
|
||||
1. Audit existing components in `packages/common/components` to identify gaps and inconsistencies
|
||||
2. Implement missing core components based on Shadcn UI patterns
|
||||
3. Enhance component documentation with usage examples and best practices
|
||||
4. Create a component development checklist to ensure all new components meet accessibility standards
|
||||
5. Implement a versioning strategy for components to manage breaking changes
|
||||
|
||||
### Monorepo Structure Optimization
|
||||
|
||||
**Rationale**: Optimizing the monorepo structure will improve build times and developer experience.
|
||||
|
||||
**Action Items**:
|
||||
1. Review and optimize Turborepo cache configuration
|
||||
2. Implement project references in TypeScript configuration for better type checking
|
||||
3. Create clear guidelines for code organization within each application
|
||||
4. Establish conventions for sharing code between applications
|
||||
5. Implement better dependency management to prevent version conflicts
|
||||
|
||||
### State Management Standardization
|
||||
|
||||
**Rationale**: Consistent state management patterns will improve maintainability and reduce bugs.
|
||||
|
||||
**Action Items**:
|
||||
1. Create standardized Zustand store patterns for common use cases
|
||||
2. Implement store persistence where appropriate
|
||||
3. Develop utilities for store debugging and testing
|
||||
4. Document best practices for state management
|
||||
5. Create examples of complex state management scenarios
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Bundle Size Reduction
|
||||
|
||||
**Rationale**: Meeting the 500KB compressed bundle size constraint requires careful optimization.
|
||||
|
||||
**Action Items**:
|
||||
1. Implement bundle analysis in the CI pipeline
|
||||
2. Set up code splitting for all applications
|
||||
3. Optimize image loading and processing
|
||||
4. Implement tree-shaking for all dependencies
|
||||
5. Create guidelines for importing third-party libraries
|
||||
|
||||
### Runtime Performance
|
||||
|
||||
**Rationale**: Achieving the FCP and TTI targets requires optimizing the critical rendering path.
|
||||
|
||||
**Action Items**:
|
||||
1. Implement performance monitoring in production
|
||||
2. Optimize component rendering with React.memo and useMemo
|
||||
3. Implement lazy loading for non-critical components
|
||||
4. Optimize CSS delivery and rendering
|
||||
5. Create performance testing scenarios for CI
|
||||
|
||||
## Developer Experience
|
||||
|
||||
### Documentation Improvements
|
||||
|
||||
**Rationale**: Better documentation will help teams with varying levels of expertise work effectively.
|
||||
|
||||
**Action Items**:
|
||||
1. Create a comprehensive developer onboarding guide
|
||||
2. Document common patterns and solutions
|
||||
3. Implement automated documentation generation for components
|
||||
4. Create interactive examples for complex components
|
||||
5. Establish a process for keeping documentation up-to-date
|
||||
|
||||
### Tooling Enhancements
|
||||
|
||||
**Rationale**: Better tooling will improve developer productivity and code quality.
|
||||
|
||||
**Action Items**:
|
||||
1. Enhance ESLint and Prettier configurations for stricter code quality checks
|
||||
2. Implement pre-commit hooks for code formatting and linting
|
||||
3. Create custom VS Code extensions for project-specific tasks
|
||||
4. Implement automated dependency updates with Renovate
|
||||
5. Create custom CLI tools for common development tasks
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Component Testing
|
||||
|
||||
**Rationale**: Comprehensive testing is essential for maintaining quality as the codebase grows.
|
||||
|
||||
**Action Items**:
|
||||
1. Establish minimum test coverage requirements for components
|
||||
2. Implement visual regression testing with Storybook
|
||||
3. Create testing utilities for common testing scenarios
|
||||
4. Implement accessibility testing in the CI pipeline
|
||||
5. Document testing best practices and patterns
|
||||
|
||||
### Integration Testing
|
||||
|
||||
**Rationale**: Integration testing ensures that components work together as expected.
|
||||
|
||||
**Action Items**:
|
||||
1. Implement end-to-end testing with Cypress
|
||||
2. Create testing environments for each application
|
||||
3. Implement API mocking for testing
|
||||
4. Create test data generators for common scenarios
|
||||
5. Implement performance testing in the CI pipeline
|
||||
|
||||
## Accessibility Improvements
|
||||
|
||||
### Accessibility Standards
|
||||
|
||||
**Rationale**: Meeting accessibility requirements is a core goal of the project.
|
||||
|
||||
**Action Items**:
|
||||
1. Conduct an accessibility audit of existing components
|
||||
2. Implement ARIA attributes and keyboard navigation for all components
|
||||
3. Create accessibility testing guidelines
|
||||
4. Implement automated accessibility testing in the CI pipeline
|
||||
5. Provide training on accessibility best practices
|
||||
|
||||
### Responsive Design
|
||||
|
||||
**Rationale**: Ensuring responsive design is essential for a good user experience on all devices.
|
||||
|
||||
**Action Items**:
|
||||
1. Establish responsive design guidelines
|
||||
2. Create responsive design testing scenarios
|
||||
3. Implement responsive design utilities
|
||||
4. Optimize for mobile devices
|
||||
5. Create responsive design documentation
|
||||
|
||||
## Technical Debt Reduction
|
||||
|
||||
### Legacy Code Migration
|
||||
|
||||
**Rationale**: Gradually migrating legacy code will improve maintainability and reduce technical debt.
|
||||
|
||||
**Action Items**:
|
||||
1. Identify and prioritize legacy code for migration
|
||||
2. Create a migration plan for each legacy component
|
||||
3. Implement automated tests for legacy code
|
||||
4. Create migration guidelines and best practices
|
||||
5. Establish metrics for tracking migration progress
|
||||
|
||||
### Code Quality Improvements
|
||||
|
||||
**Rationale**: Improving code quality will reduce bugs and improve maintainability.
|
||||
|
||||
**Action Items**:
|
||||
1. Implement stricter TypeScript configurations
|
||||
2. Create code quality metrics and dashboards
|
||||
3. Implement code reviews for all pull requests
|
||||
4. Create code quality guidelines
|
||||
5. Implement automated code quality checks in the CI pipeline
|
||||
|
||||
## Future-Proofing
|
||||
|
||||
### Internationalization Preparation
|
||||
|
||||
**Rationale**: Preparing for internationalization now will make it easier to implement in the future.
|
||||
|
||||
**Action Items**:
|
||||
1. Research and select an internationalization library
|
||||
2. Create guidelines for internationalization-friendly component design
|
||||
3. Implement a proof-of-concept for internationalization
|
||||
4. Document internationalization best practices
|
||||
5. Create a plan for implementing internationalization across all applications
|
||||
|
||||
### Theming Support
|
||||
|
||||
**Rationale**: Implementing theming support will enable white-labeling in the future.
|
||||
|
||||
**Action Items**:
|
||||
1. Research and select a theming approach
|
||||
2. Implement a proof-of-concept for theming
|
||||
3. Create theming guidelines for component development
|
||||
4. Document theming best practices
|
||||
5. Create a plan for implementing theming across all applications
|
||||
|
||||
## Implementation Timeline
|
||||
|
||||
### Phase 1: Foundation (Months 1-2)
|
||||
|
||||
Focus on architecture improvements, developer experience, and establishing standards.
|
||||
|
||||
### Phase 2: Quality and Performance (Months 3-4)
|
||||
|
||||
Focus on testing, performance optimization, and accessibility improvements.
|
||||
|
||||
### Phase 3: Technical Debt and Future-Proofing (Months 5-6)
|
||||
|
||||
Focus on technical debt reduction and preparing for future requirements.
|
||||
|
||||
## Success Metrics
|
||||
|
||||
To measure the success of this improvement plan, we will track the following metrics:
|
||||
|
||||
1. **Build Performance**: CI/CD pipeline completion time, local build time
|
||||
2. **Runtime Performance**: FCP, TTI, bundle size
|
||||
3. **Code Quality**: Test coverage, TypeScript strict mode compliance
|
||||
4. **Developer Experience**: Time to onboard new developers, developer satisfaction surveys
|
||||
5. **User Experience**: Accessibility compliance, user satisfaction surveys
|
||||
|
||||
## Conclusion
|
||||
|
||||
This improvement plan provides a comprehensive roadmap for enhancing the frontend project in line with the requirements and goals specified in `requirements.md`. By following this plan, we can create a more maintainable, performant, and user-friendly frontend while enabling team autonomy and code reusability.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Project Requirements
|
||||
|
||||
This document outlines the key requirements, goals, and constraints for the frontend project.
|
||||
|
||||
## Core Goals
|
||||
|
||||
1. **Unified User Experience**: Create a consistent user experience across all applications within the monorepo.
|
||||
2. **Code Reusability**: Maximize code reuse through shared components and utilities.
|
||||
3. **Team Autonomy**: Allow different teams to work independently on their applications while maintaining consistency.
|
||||
4. **Performance Optimization**: Ensure fast loading times and smooth user interactions across all applications.
|
||||
5. **Accessibility**: Make all applications accessible to users with disabilities.
|
||||
6. **Maintainability**: Ensure the codebase is easy to maintain and extend over time.
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
### Architecture
|
||||
|
||||
1. Maintain the monorepo structure using pnpm workspaces and Turborepo.
|
||||
2. Follow the established project structure:
|
||||
- `apps/`: Contains the applications
|
||||
- `common/`: Common features provided by the main company
|
||||
- `cs/`, `wr/`, `as/`: Applications developed by different teams
|
||||
- `packages/`: Contains shared packages
|
||||
- `common/`: Common packages provided by the main company
|
||||
- `assets/`: Shared assets (styles, images, etc.)
|
||||
- `components/`: Shared UI components
|
||||
- `business_components/`: Business-specific components
|
||||
- `config/`: Shared configuration
|
||||
- `network/`: Network-related utilities
|
||||
|
||||
### Development Standards
|
||||
|
||||
1. Use TypeScript for all code to ensure type safety.
|
||||
2. Follow ESLint and Prettier configurations for consistent code style.
|
||||
3. Implement comprehensive testing using Storybook for component development and testing.
|
||||
4. Use Zustand for state management across applications.
|
||||
5. Implement TanStack Query (React Query) for data fetching and caching.
|
||||
6. Use Axios for HTTP requests.
|
||||
|
||||
### UI/UX Requirements
|
||||
|
||||
1. Use Tailwind CSS for styling with consistent design patterns.
|
||||
2. Implement Radix UI for accessible UI primitives.
|
||||
3. Follow Shadcn UI patterns for component design.
|
||||
4. Use TanStack Table for data tables and Chart.js for charts.
|
||||
5. Ensure responsive design for all screen sizes.
|
||||
6. Maintain a consistent color scheme and typography across all applications.
|
||||
|
||||
## Constraints
|
||||
|
||||
1. **Performance Budgets**:
|
||||
- First Contentful Paint (FCP): < 1.8s
|
||||
- Time to Interactive (TTI): < 3.5s
|
||||
- Total Bundle Size: < 500KB (compressed)
|
||||
|
||||
2. **Browser Support**:
|
||||
- Support the last two versions of major browsers (Chrome, Firefox, Safari, Edge)
|
||||
- No support required for Internet Explorer
|
||||
|
||||
3. **Deployment Constraints**:
|
||||
- CI/CD pipeline must complete within 10 minutes
|
||||
- Zero-downtime deployments required
|
||||
|
||||
4. **Team Constraints**:
|
||||
- Multiple teams working on different applications
|
||||
- Need for clear boundaries and interfaces between teams
|
||||
- Varying levels of expertise with the technology stack
|
||||
|
||||
5. **Technical Debt**:
|
||||
- Some legacy code exists in certain applications
|
||||
- Need to gradually migrate to the new architecture
|
||||
|
||||
## Future Considerations
|
||||
|
||||
1. **Internationalization**: Prepare for multi-language support in the future.
|
||||
2. **Theming**: Consider implementing a theming system for white-labeling.
|
||||
3. **Mobile Applications**: Prepare for potential React Native integration.
|
||||
4. **Micro-frontends**: Evaluate the possibility of moving to a micro-frontend architecture.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Docker 이미지 빌드
|
||||
docker build -t frontend .
|
||||
|
||||
# 로그 포함 빌드
|
||||
docker build -t frontend --progress=plain .
|
||||
docker scout quickview
|
||||
# 실행
|
||||
docker run -p 3000:3000 frontend
|
||||
|
||||
docker run --name frontend -p 3000:3000 frontend
|
||||
|
||||
#
|
||||
docker run -it frontend sh
|
||||
|
||||
ls -al /app
|
||||
|
||||
# 실제 실행 방법
|
||||
node .next/standalone/apps/cs/server.js
|
||||
|
||||
|
||||
/app/.next # cp -r static /app/apps/cs/.next/static
|
||||
|
||||
|
||||
|
||||
• .env.production 파일을 .next/standalone/ 기준 또는 루트로 복사
|
||||
• 또는 process.env.xxx를 직접 주입 (예: docker run -e)
|
||||
일부 라이브러리는 process.cwd()를 기준으로 파일을 찾는데, 이 경로는 .next/standalone/... 안이므로 실패할 수 있습니다.
|
||||
해결:
|
||||
가능하면 __dirname 기반 경로를 사용하거나, 실행 전에 process.chdir(...) 수동 지정
|
||||
4. ✅ 의존성 누락
|
||||
|
||||
Next.js가 standalone에 필요한 모듈만 복사하지만, 일부는 누락될 수 있습니다.
|
||||
|
||||
예:
|
||||
• 동적 import (import('some-lib'))
|
||||
• optional chaining으로 쓰이는 모듈
|
||||
@@ -1,5 +1,6 @@
|
||||
export const images = {
|
||||
logo: require("./logo.png"),
|
||||
logoWhite: require("./logo_white.png"),
|
||||
menuBtn: require("./mob_menuBtn.svg"),
|
||||
menuBtnClose: require("./mob_menuBtn_close.svg"),
|
||||
iconFolderOff: require("./icon_folder_off.png"),
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -1,8 +1,6 @@
|
||||
|
||||
import React from 'react';
|
||||
import React from 'react';
|
||||
import BarChart from './BarChart';
|
||||
import DoughnutChart from './DoughnutChart';
|
||||
import { type ChartConfig } from './lib/chart';
|
||||
import LineChart from './LineChart';
|
||||
import LineBarchart from './LineBarChart';
|
||||
import DoubleBarChart from './DoubleBar';
|
||||
@@ -35,6 +33,16 @@ const CHART_FORM = {
|
||||
* @returns
|
||||
*/
|
||||
|
||||
// 각 차트에서 사용하는 ChartConfig 타입 정의
|
||||
type ChartConfigItem = {
|
||||
label: string;
|
||||
fill: string;
|
||||
};
|
||||
|
||||
type ChartConfig = {
|
||||
[key: string]: ChartConfigItem;
|
||||
};
|
||||
|
||||
type MainChartProps = {
|
||||
chartForm: string;
|
||||
chartConfig: ChartConfig;
|
||||
@@ -74,4 +82,3 @@ function MainChart(props: MainChartProps) {
|
||||
export { MainChart };
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { CHART_FORM };
|
||||
export type { ChartConfig };
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Client } from "@common/network";
|
||||
import { getConfig } from "@common/config";
|
||||
import { getCookie } from "../lib";
|
||||
|
||||
const siteId = getCookie("site-id-key") ?? "";
|
||||
|
||||
const getClientInstance = () => {
|
||||
const config = getConfig();
|
||||
const { PROXY_HOST, SITE_ID } = config;
|
||||
const { PROXY_HOST } = config;
|
||||
const BASE_URL = `${PROXY_HOST}/server`;
|
||||
|
||||
return Client.getInstance({
|
||||
baseURL: BASE_URL,
|
||||
siteId: SITE_ID ?? "",
|
||||
siteId,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { getApiClient } from "./client";
|
||||
import { ENDPOINT } from "../config";
|
||||
|
||||
interface PageableParams {
|
||||
page?: number;
|
||||
size?: number;
|
||||
sort?: string[];
|
||||
}
|
||||
|
||||
export interface IpParams extends PageableParams {
|
||||
siteId: string;
|
||||
ipAddr?: string;
|
||||
prmYn?: string;
|
||||
}
|
||||
|
||||
export interface IP {
|
||||
mngNo: number;
|
||||
siteId: string;
|
||||
ipAddr: string;
|
||||
prmYn: string;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
interface SortConfig {
|
||||
empty: boolean;
|
||||
sorted: boolean;
|
||||
unsorted: boolean;
|
||||
}
|
||||
|
||||
interface PageableConfig {
|
||||
sort: SortConfig;
|
||||
offset: number;
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
paged: boolean;
|
||||
unpaged: boolean;
|
||||
}
|
||||
|
||||
interface PageableList<T> {
|
||||
content: T[];
|
||||
pageable: PageableConfig;
|
||||
sort: SortConfig;
|
||||
totalPages: number;
|
||||
totalElements: number;
|
||||
number: number;
|
||||
numberOfElements: number;
|
||||
size: number;
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
empty: boolean;
|
||||
}
|
||||
|
||||
export type IpList = PageableList<IP>;
|
||||
|
||||
const client = getApiClient();
|
||||
|
||||
export const getIpList = async (params: IpParams) => {
|
||||
const response = await client.get<IpList>(`${ENDPOINT.CMS_SERVICE.IPS}`, {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
@@ -1,6 +1,22 @@
|
||||
import { getApiClient } from "./client";
|
||||
import { ENDPOINT } from "../config";
|
||||
|
||||
export interface Popup {
|
||||
popupSn: number;
|
||||
popupTypeCd: string;
|
||||
popupTypeNm: string;
|
||||
contsTypeCd: string;
|
||||
contsTypeNm: string;
|
||||
popupTtl: string;
|
||||
popupWdth: number;
|
||||
popupHgt: number;
|
||||
popupEndUseYn: string;
|
||||
useYn: string;
|
||||
mdfcnDt: string;
|
||||
mdfr: string;
|
||||
mdfrNm: string;
|
||||
}
|
||||
|
||||
export interface PopupInfo {
|
||||
popupSn: number;
|
||||
popupTypeCd: string;
|
||||
@@ -23,39 +39,10 @@ export interface PopupInfo {
|
||||
mdfrNm: string;
|
||||
}
|
||||
|
||||
interface SortConfig {
|
||||
empty: boolean;
|
||||
sorted: boolean;
|
||||
unsorted: boolean;
|
||||
}
|
||||
|
||||
interface PageableConfig {
|
||||
sort: SortConfig;
|
||||
offset: number;
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
paged: boolean;
|
||||
unpaged: boolean;
|
||||
}
|
||||
|
||||
interface PageableList<T> {
|
||||
content: T[];
|
||||
pageable: PageableConfig;
|
||||
sort: SortConfig;
|
||||
totalPages: number;
|
||||
totalElements: number;
|
||||
number: number;
|
||||
numberOfElements: number;
|
||||
size: number;
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
empty: boolean;
|
||||
}
|
||||
|
||||
const client = getApiClient();
|
||||
|
||||
export const getUserPopupList = async () => {
|
||||
const response = await client.get<PageableList<PopupInfo>>(
|
||||
const response = await client.get<Popup[]>(
|
||||
`${ENDPOINT.CMS_SERVICE.POPUPS}/user/list`,
|
||||
);
|
||||
return response.data;
|
||||
|
||||
@@ -3,6 +3,7 @@ export const ENDPOINT = {
|
||||
MENU_ROLES: "/cms-service/api/v1/menu-roles",
|
||||
SITES: "/cms-service/api/v1/sites",
|
||||
POPUPS: "/cms-service/api/v1/popups",
|
||||
IPS: "/cms-service/api/v1/site-ips",
|
||||
},
|
||||
USER_SERVICE: {
|
||||
USERS: "/user-service/api/v1/users",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getSiteInfo } from "../api/site";
|
||||
import { getIpList } from "../api/ip";
|
||||
import { ENDPOINT } from "../config";
|
||||
import { getCookie } from "../lib";
|
||||
|
||||
const siteId = getCookie("site-id-key") ?? "";
|
||||
|
||||
const ClientIpGuard = ({
|
||||
currentPath,
|
||||
children,
|
||||
}: {
|
||||
currentPath?: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [clientIp, setClientIp] = useState("");
|
||||
const [isAllowed, setIsAllowed] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const response = await fetch("https://api.ipify.org?format=json");
|
||||
const data = await response.json();
|
||||
if (!data?.ip) return;
|
||||
setClientIp(data.ip);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!siteId || !clientIp) return;
|
||||
|
||||
const siteInfo = await queryClient.ensureQueryData({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.SITES, siteId],
|
||||
queryFn: () => getSiteInfo(siteId),
|
||||
});
|
||||
|
||||
if (siteInfo.siteKndCd === "O") return;
|
||||
|
||||
const { content: ipList } = await getIpList({
|
||||
siteId,
|
||||
prmYn: "Y",
|
||||
});
|
||||
|
||||
const allowedClientIp = ipList.some((ip) => ip.ipAddr === clientIp);
|
||||
setIsAllowed(allowedClientIp);
|
||||
})();
|
||||
}, [queryClient, clientIp, currentPath]);
|
||||
|
||||
if (!isAllowed) {
|
||||
alert("허용되지 않은 IP로 접근하였습니다.");
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export { ClientIpGuard };
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const initHistoryPatch = (() => {
|
||||
let initialized = false;
|
||||
return () => {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const patch = (method: "pushState" | "replaceState") => {
|
||||
const original = history[method];
|
||||
history[method] = function (...args) {
|
||||
const result = original.apply(this, args);
|
||||
window.dispatchEvent(new Event("locationchange"));
|
||||
return result;
|
||||
};
|
||||
};
|
||||
|
||||
patch("pushState");
|
||||
patch("replaceState");
|
||||
};
|
||||
})();
|
||||
|
||||
export const usePathname = () => {
|
||||
const [pathname, setPathname] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
initHistoryPatch();
|
||||
|
||||
const update = () => {
|
||||
queueMicrotask(() => setPathname(window.location.pathname));
|
||||
};
|
||||
|
||||
window.addEventListener("popstate", update);
|
||||
window.addEventListener("locationchange", update);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("popstate", update);
|
||||
window.removeEventListener("locationchange", update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return pathname;
|
||||
};
|
||||
@@ -1,9 +1,8 @@
|
||||
import "./header.css";
|
||||
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { useEffect, useCallback, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { images } from "@common/assets";
|
||||
import { getConfig } from "@common/config";
|
||||
import { ENDPOINT } from "../../config";
|
||||
import { setCookie, getCookie } from "../../lib";
|
||||
import {
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
getUserPopupList,
|
||||
getSiteInfo,
|
||||
getUserInfo,
|
||||
type PopupInfo,
|
||||
type Popup,
|
||||
} from "../../api";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -21,10 +20,13 @@ import {
|
||||
} from "../dropdown-menu";
|
||||
import { Button } from "../button";
|
||||
import { SidebarTrigger } from "../sidebar";
|
||||
import { Skeleton } from "../skeleton";
|
||||
|
||||
const { SITE_ID } = getConfig();
|
||||
const siteId = getCookie("site-id-key") ?? "";
|
||||
|
||||
export function Header({ viewOnly }: { viewOnly?: boolean }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const { data: userInfo } = useQuery({
|
||||
queryKey: [ENDPOINT.USER_SERVICE.USERS],
|
||||
queryFn: () => getUserInfo(),
|
||||
@@ -32,8 +34,8 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) {
|
||||
});
|
||||
|
||||
const { data: siteInfo } = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.SITES, SITE_ID],
|
||||
queryFn: () => getSiteInfo(SITE_ID ?? ""),
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.SITES, siteId],
|
||||
queryFn: () => getSiteInfo(siteId),
|
||||
select: (data) => {
|
||||
if (data.siteSkn) {
|
||||
const siteSkin = data.siteSkn.toLowerCase();
|
||||
@@ -50,7 +52,19 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) {
|
||||
queryFn: () => getUserPopupList(),
|
||||
});
|
||||
|
||||
const openPopup = useCallback(async (popup: PopupInfo) => {
|
||||
const logout = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to logout");
|
||||
window.location.href = "/auth/login";
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const openPopup = useCallback(async (popup: Popup) => {
|
||||
try {
|
||||
const cookieName = `cs-popup-hide-for-day-${popup.popupSn}`;
|
||||
const hasCookie = getCookie(cookieName);
|
||||
@@ -78,11 +92,18 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getLogoImage = () => {
|
||||
const siteThemeKey = getCookie("site-theme-key");
|
||||
if (siteThemeKey === "blue" || siteThemeKey === "green")
|
||||
return images.logoWhite;
|
||||
return images.logo;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!userPopupList?.content?.length) return;
|
||||
if (!userPopupList?.length) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
userPopupList.content.forEach((popup) => {
|
||||
userPopupList.forEach((popup) => {
|
||||
openPopup(popup);
|
||||
});
|
||||
}, 500);
|
||||
@@ -90,12 +111,20 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) {
|
||||
return () => clearTimeout(timer);
|
||||
}, [userPopupList, openPopup]);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 box-border flex h-[5rem] w-full items-center justify-between border-b border-solid border-border bg-background">
|
||||
<div className="flex items-center px-6">
|
||||
<SidebarTrigger className="size-12 !bg-transparent p-2 hover:!bg-accent [&>svg]:!size-full [&>svg]:text-foreground" />
|
||||
<h1 className="flex items-center gap-4">
|
||||
<img src={images.logo} className="h-[3rem]" alt="logo" />
|
||||
{mounted ? (
|
||||
<img src={getLogoImage()} className="h-[3rem]" alt="logo" />
|
||||
) : (
|
||||
<Skeleton className="h-[3rem] w-[15rem]" />
|
||||
)}
|
||||
<span className="text-[1.8rem] font-semibold text-foreground">
|
||||
{siteInfo?.siteNm}
|
||||
</span>
|
||||
@@ -122,7 +151,7 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) {
|
||||
<p className="text-base text-label">접속 IP</p>
|
||||
<p></p>
|
||||
</div>
|
||||
<DropdownMenuItem>로그아웃</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={logout}>로그아웃</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</header>
|
||||
|
||||
@@ -28,3 +28,4 @@ export * from "./switch";
|
||||
export * from "./table";
|
||||
export * from "./tabs";
|
||||
export * from "./textarea";
|
||||
export * from "./tooltip";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { QueryProvider } from "../../provider/query-provider";
|
||||
import { PopupProvider } from "../../provider/popup-provider";
|
||||
import { ClientIpGuard } from "../../lib/client-ip-guard";
|
||||
import type { MenuItem } from "../../api";
|
||||
import { SidebarProvider } from "../sidebar";
|
||||
import { LNB } from "../lnb";
|
||||
@@ -21,25 +22,27 @@ export function BaseLayout({
|
||||
}) {
|
||||
return (
|
||||
<QueryProvider queryClient={queryClient}>
|
||||
<PopupProvider>
|
||||
<SidebarProvider>
|
||||
<div className="flex-1">
|
||||
<Header />
|
||||
<div className="flex h-[calc(100vh-50px)]">
|
||||
<LNB
|
||||
currentPath={currentPath}
|
||||
handleMenuClick={handleMenuClick}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="max-h-[calc(100%-50px)] flex-1 overflow-auto">
|
||||
{children}
|
||||
<ClientIpGuard currentPath={currentPath}>
|
||||
<PopupProvider>
|
||||
<SidebarProvider>
|
||||
<div className="flex-1">
|
||||
<Header />
|
||||
<div className="flex h-[calc(100vh-50px)]">
|
||||
<LNB
|
||||
currentPath={currentPath}
|
||||
handleMenuClick={handleMenuClick}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="max-h-[calc(100%-50px)] flex-1 overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</PopupProvider>
|
||||
</SidebarProvider>
|
||||
</PopupProvider>
|
||||
</ClientIpGuard>
|
||||
</QueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { getConfig } from "@common/config";
|
||||
import { ENDPOINT } from "../../config";
|
||||
import { cn, setCookie } from "../../lib";
|
||||
import { getMenuList, type MenuItem } from "../../api";
|
||||
@@ -24,8 +23,9 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from "../collapsible";
|
||||
import { getParentIds } from "./lib";
|
||||
import { getCookie } from "../../lib";
|
||||
|
||||
const { SITE_ID } = getConfig();
|
||||
const siteId = getCookie("site-id-key") ?? "";
|
||||
|
||||
export function LNB({
|
||||
viewOnly,
|
||||
@@ -38,7 +38,7 @@ export function LNB({
|
||||
}) {
|
||||
const { data: menuList } = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
|
||||
queryFn: () => getMenuList(SITE_ID ?? ""),
|
||||
queryFn: () => getMenuList(siteId),
|
||||
enabled: !viewOnly,
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
[data-sidebar="trigger"] > svg {
|
||||
color: #fff !important;
|
||||
}
|
||||
[data-radix-popper-content-wrapper] > * {
|
||||
color: #0a0a0a !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
[data-radix-popper-content-wrapper] > *:hover {
|
||||
color: #0a0a0a !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="red"] {
|
||||
@@ -78,5 +86,13 @@
|
||||
[data-sidebar="trigger"] > svg {
|
||||
color: #fff !important;
|
||||
}
|
||||
[data-radix-popper-content-wrapper] > * {
|
||||
color: #0a0a0a !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
[data-radix-popper-content-wrapper] > *:hover {
|
||||
color: #0a0a0a !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,8 +64,5 @@ export function getViteConfig(): EnvConfig {
|
||||
}
|
||||
|
||||
export function getConfig(): EnvConfig {
|
||||
if (typeof import.meta?.env !== "undefined") {
|
||||
return getViteConfig();
|
||||
}
|
||||
return getNextConfig();
|
||||
}
|
||||
|
||||
Generated
+1852
-1981
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user