diff --git a/README.md b/README.md index b4e78b538..ba078fb13 100644 --- a/README.md +++ b/README.md @@ -67,10 +67,10 @@ npx create-next-app@latest ./apps/as --typescript --eslint --tailwind --app ```json { // 기타 설정 ... + "baseUrl": "./src", "paths": { - "@common/components/*": ["../../../packages/common/components/src/*"], - "@common/components": ["../../../packages/common/components/src"], - "@common/network/*": ["../../../packages/common/network/*"], + "@common/components": ["../../../packages/common/components/dist/"], + "@common/config": ["../../../packages/common/config/dist/"], "@common/network": ["../../../packages/common/network/"] } } @@ -95,51 +95,34 @@ export default { ### 5. next.config.ts 수정 ```json import type { NextConfig } from "next"; -import webpack from "webpack"; -const serverApiUrl = - process.env.NEXT_PUBLIC_SERVER_API_URL || "http://localhost:3000"; +const subDomain = + process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000"; const nextConfig: NextConfig = { - webpack: (config) => { - config.plugins.push( - // common/components의 vite 환경 변수를 next 환경 변수로 정의 - new webpack.DefinePlugin({ - "import.meta.env.VITE_PORT": JSON.stringify( - process.env.NEXT_PUBLIC_PORT, - ), - "import.meta.env.VITE_PROXY_HOST": JSON.stringify( - process.env.NEXT_PUBLIC_PROXY_HOST, - ), - "import.meta.env.VITE_SERVER_API_URL": JSON.stringify( - process.env.NEXT_PUBLIC_SERVER_API_URL, - ), - "import.meta.env.VITE_SITE_ID": JSON.stringify(process.env.SITE_ID), - "import.meta.env.VITE_CLAIM_NAME": JSON.stringify( - process.env.NEXT_PUBLIC_CLAIM_NAME, - ), - "import.meta.env.VITE_ACCESS_TOKEN": JSON.stringify( - process.env.NEXT_PUBLIC_ACCESS_TOKEN, - ), - "import.meta.env.VITE_REFRESH_TOKEN": JSON.stringify( - process.env.NEXT_PUBLIC_REFRESH_TOKEN, - ), - "import.meta.env.VITE_AUTH_USER_ID": JSON.stringify( - process.env.NEXT_PUBLIC_AUTH_USER_ID, - ), - "process.env.SITE_ID": JSON.stringify(process.env.NEXT_PUBLIC_SITE_ID), - }), - ); - - return config; - }, async rewrites() { - return [ - { - source: "/server/:path*", - destination: `${serverApiUrl}/:path*`, - }, - ]; + return { + beforeFiles: [ + { + source: "/_next/static/:path*", + destination: `${subDomain}/_next/static/:path*`, + has: [ + { + type: "header", + key: "referer", + value: "(.*)/pst(.*)", + }, + ], + }, + ], + afterFiles: [ + { + source: "/pst/:path*", + destination: `${subDomain}/pst/:path*`, + }, + ], + fallback: [], + }; }, }; @@ -151,7 +134,7 @@ export default nextConfig; ```css @import "@common/assets/styles/base.css"; @import "@common/assets/styles/diveicon.css"; -@import "@common/assets/styles/components.css"; +@import "@common/components/index.css"; @tailwind base; @tailwind components; diff --git a/apps/common/features/app/(with-base-layout)/layout.tsx b/apps/common/features/app/(with-base-layout)/layout.tsx index bb1b7c0ba..970ccce5e 100644 --- a/apps/common/features/app/(with-base-layout)/layout.tsx +++ b/apps/common/features/app/(with-base-layout)/layout.tsx @@ -15,7 +15,7 @@ export default function WithBaseLayout({ const pathname = usePathname(); const queryClient = useQueryClient(); - const { setCurrentMenuInfo } = useMenuStore((state) => state); + const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo); const handleMenuClick = (item: MenuItem) => { setCurrentMenuInfo(item); diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/[pstNo]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/[pstNo]/page.tsx new file mode 100644 index 000000000..9d3f6c0e3 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/[pstNo]/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage } from "@/pages/(pst)/faq"; + +export default async function PstBbsFaqPostModifyPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/page.tsx new file mode 100644 index 000000000..34dd522bf --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/page.tsx @@ -0,0 +1,10 @@ +import { FaqPage } from "@/pages/(pst)/faq"; + +export default async function PstBbsFaqPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/register/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/register/page.tsx new file mode 100644 index 000000000..6a044f876 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/register/page.tsx @@ -0,0 +1,10 @@ +import { CreatePage } from "@/pages/(pst)/faq"; + +export default async function PstBbsFaqRegisterPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/[pstNo]/modify/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/[pstNo]/modify/page.tsx new file mode 100644 index 000000000..d7601ffd1 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/[pstNo]/modify/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage } from "@/pages/(pst)/image"; + +export default async function PstBbsImagePostModifyPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/[pstNo]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/[pstNo]/page.tsx new file mode 100644 index 000000000..9ca687f78 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/[pstNo]/page.tsx @@ -0,0 +1,10 @@ +import { ImagePost } from "@/pages/(pst)/image"; + +export default async function PstBbsImagePostPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/page.tsx new file mode 100644 index 000000000..520cfc83a --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/page.tsx @@ -0,0 +1,10 @@ +import { ImagePage } from "@/pages/(pst)/image"; + +export default async function PstBbsImagePage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/register/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/register/page.tsx new file mode 100644 index 000000000..c5e3e68c0 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/register/page.tsx @@ -0,0 +1,10 @@ +import { CreatePage } from "@/pages/(pst)/image"; + +export default async function PstBbsImageRegisterPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/[pstNo]/modify/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/[pstNo]/modify/page.tsx new file mode 100644 index 000000000..76f2d06c3 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/[pstNo]/modify/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage } from "@/pages/(pst)/movie"; + +export default async function PstBbsMoviePostModifyPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/[pstNo]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/[pstNo]/page.tsx new file mode 100644 index 000000000..e0983bd61 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/[pstNo]/page.tsx @@ -0,0 +1,10 @@ +import { MoviePost } from "@/pages/(pst)/movie"; + +export default async function PstBbsMoviePostPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/page.tsx new file mode 100644 index 000000000..bbd6b2619 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/page.tsx @@ -0,0 +1,10 @@ +import { MoviePage } from "@/pages/(pst)/movie"; + +export default async function PstBbsMoviePage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/register/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/register/page.tsx new file mode 100644 index 000000000..5cfffb031 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/register/page.tsx @@ -0,0 +1,10 @@ +import { CreatePage } from "@/pages/(pst)/movie"; + +export default async function PstBbsMovieRegisterPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/answer/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/answer/page.tsx new file mode 100644 index 000000000..bf13cb3e7 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/answer/page.tsx @@ -0,0 +1,10 @@ +import { CreatePage } from "@/pages/(pst)/normal"; + +export default async function PstBbsNormalAnswerPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/modify/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/modify/page.tsx new file mode 100644 index 000000000..d5ab3ee62 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/modify/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage } from "@/pages/(pst)/normal"; + +export default async function PstBbsNormalPostModifyPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/page.tsx new file mode 100644 index 000000000..aa2120e7c --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/page.tsx @@ -0,0 +1,10 @@ +import { NormalPost } from "@/pages/(pst)/normal"; + +export default async function PstBbsNormalPostPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/page.tsx new file mode 100644 index 000000000..eecbc7c33 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/page.tsx @@ -0,0 +1,10 @@ +import { NormalPage } from "@/pages/(pst)/normal"; + +export default async function PstBbsNormalPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/register/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/register/page.tsx new file mode 100644 index 000000000..122c87dc4 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/register/page.tsx @@ -0,0 +1,10 @@ +import { CreatePage } from "@/pages/(pst)/normal"; + +export default async function PstBbsNormalRegisterPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx new file mode 100644 index 000000000..bfe86c491 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage } from "@/pages/(pst)/notice"; + +export default async function PstBbsNoticePostModifyPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/page.tsx index 0f59903d4..f5b6f13fd 100644 --- a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/page.tsx +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/page.tsx @@ -1,4 +1,4 @@ -import { NoticePost } from "@/pages/notice"; +import { NoticePost } from "@/pages/(pst)/notice"; export default async function PstBbsNoticePostPage({ params, diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/page.tsx index df9ae763f..a94302706 100644 --- a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/page.tsx +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/page.tsx @@ -1,4 +1,4 @@ -import { NoticePage } from "@/pages/notice"; +import { NoticePage } from "@/pages/(pst)/notice"; export default async function PstBbsNoticePage({ params, diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/register/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/register/page.tsx new file mode 100644 index 000000000..859274306 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/register/page.tsx @@ -0,0 +1,10 @@ +import { CreatePage } from "@/pages/(pst)/notice"; + +export default async function PstBbsNoticeRegisterPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/layout.tsx b/apps/common/features/app/(with-base-layout)/pst/layout.tsx new file mode 100644 index 000000000..3f700d076 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/layout.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useMenuStore } from "@/shared/lib"; +import { PageLayout } from "@/shared/ui/page-layout"; + +export default function PostLayout({ + children, +}: { + children: React.ReactNode; +}) { + const bbsInfo = useMenuStore((state) => state.bbsInfo); + + return ( + + {children} + + ); +} diff --git a/apps/common/features/package.json b/apps/common/features/package.json index 8423560be..0bd642b8e 100644 --- a/apps/common/features/package.json +++ b/apps/common/features/package.json @@ -1,6 +1,6 @@ { "name": "features", - "version": "0.0.0", + "version": "0.1.0", "private": true, "scripts": { "dev": "next dev -p 13000", @@ -13,12 +13,15 @@ "@common/components": "workspace:*", "@common/config": "workspace:*", "@common/network": "workspace:*", + "@fontsource-variable/noto-sans-kr": "^5.2.5", "@frontend-opensource/use-react-hooks": "^1.1.4", "@hookform/resolvers": "^4.1.3", "@minoru/react-dnd-treeview": "^3.5.0", + "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-slot": "^1.1.2", "@tanstack/react-query": "^5.67.1", + "@tanstack/react-query-devtools": "^5.75.5", "@tanstack/react-table": "^8.21.2", "@tinymce/tinymce-react": "^6.1.0", "axios": "^1.8.1", diff --git a/apps/common/features/src/app/index.tsx b/apps/common/features/src/app/index.tsx index 5a73f2fdd..692b55ef0 100644 --- a/apps/common/features/src/app/index.tsx +++ b/apps/common/features/src/app/index.tsx @@ -1,5 +1,7 @@ import "./styles/base.css"; import "./styles/components.css"; +import "@fontsource-variable/noto-sans-kr"; + import { AppProvider } from "./providers"; import { cookies } from "next/headers"; diff --git a/apps/common/features/src/app/providers/app-provider.tsx b/apps/common/features/src/app/providers/app-provider.tsx index eaf0463a3..721f04bb6 100644 --- a/apps/common/features/src/app/providers/app-provider.tsx +++ b/apps/common/features/src/app/providers/app-provider.tsx @@ -1,27 +1,34 @@ "use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ConfirmProvider } from "@frontend-opensource/use-react-hooks"; +import { AuthWrapper } from "@/entities/auth"; import { MenuWrapper } from "@/entities/menu"; -import { StoreProvider } from "@/shared/providers"; +import { StoreProvider, PromptProvider } 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 { initializeNextClientConfig } from "@common/config"; const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { const queryClient = new QueryClient(); - - initializeNextClientConfig(); - return ( - - {children} - - - + + + + {children} + + + + + + +
+ +
); }; diff --git a/apps/common/features/src/app/styles/base.css b/apps/common/features/src/app/styles/base.css index a4f54680f..9025bec7e 100644 --- a/apps/common/features/src/app/styles/base.css +++ b/apps/common/features/src/app/styles/base.css @@ -5,8 +5,16 @@ @tailwind base; @tailwind utilities; -html, -body { - font-family: "Noto Sans KR", sans-serif; - font-size: 10px; +@layer base { + :root { + --secondary-background: rgba(0, 0, 0, 0.04); + } + [data-theme="black"] { + --secondary-background: rgba(255, 255, 255, 0.09); + } + html, + body { + font-family: "Noto Sans KR", sans-serif; + font-size: 10px; + } } diff --git a/apps/common/features/src/entities/attachment/api/attachment-service.ts b/apps/common/features/src/entities/attachment/api/attachment-service.ts index f956f6580..fc2dcab05 100644 --- a/apps/common/features/src/entities/attachment/api/attachment-service.ts +++ b/apps/common/features/src/entities/attachment/api/attachment-service.ts @@ -1,3 +1,4 @@ +import type { AxiosResponse } from "axios"; import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; import type { @@ -25,7 +26,7 @@ export class AttachmentApiService implements AttachmentService { return response.data; } - public async deleteAttachment(atflId: string): Promise { + public async deleteAttachmentByAtflId(atflId: string): Promise { await client.delete(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflId}`); } @@ -35,7 +36,7 @@ export class AttachmentApiService implements AttachmentService { ); } - public async getImageUrl(atflId: string): Promise { + public async getImageUrlByAtflId(atflId: string): Promise { if (!atflId) return ""; const response = await fetch(`${ENDPOINT.ATTACH_SERVICE.IMAGES}/${atflId}`); const blob = await response.blob(); @@ -50,6 +51,40 @@ export class AttachmentApiService implements AttachmentService { const blob = await response.blob(); return URL.createObjectURL(blob); } + + public async getStreamVideoUrlByAtflId(atflId: string): Promise { + const response = await fetch( + `${ENDPOINT.ATTACH_SERVICE.STREAM_VIDEO}/${atflId}`, + ); + const blob = await response.blob(); + return URL.createObjectURL(blob); + } + + public async getMovieThumbnailUrlByAtflId(atflId: string): Promise { + const response = await fetch( + `${ENDPOINT.ATTACH_SERVICE.MOVIE_THUMBNAIL}/${atflId}`, + ); + const blob = await response.blob(); + return URL.createObjectURL(blob); + } + + public async getMovieThumbnailUrlByAtflCd(atflCd: string): Promise { + const response = await fetch( + `${ENDPOINT.ATTACH_SERVICE.MOVIE_THUMBNAIL}/atflCd/${atflCd}`, + ); + const blob = await response.blob(); + return URL.createObjectURL(blob); + } + + public async downloadAttachmentByAtflCd( + atflCd: string, + ): Promise { + const response = await client.get( + `${ENDPOINT.ATTACH_SERVICE.DOWNLOAD}/${atflCd}`, + { responseType: "blob" }, + ); + return response; + } } export const AttachmentApi = new AttachmentApiService(); diff --git a/apps/common/features/src/entities/attachment/model/attachment-interface.ts b/apps/common/features/src/entities/attachment/model/attachment-interface.ts index 5133e4a72..0f1b5d1ee 100644 --- a/apps/common/features/src/entities/attachment/model/attachment-interface.ts +++ b/apps/common/features/src/entities/attachment/model/attachment-interface.ts @@ -1,3 +1,4 @@ +import type { AxiosResponse } from "axios"; import type { PageableList, PageableParams } from "@/shared/model"; export interface AttachmentParams extends PageableParams { @@ -35,8 +36,12 @@ export type AttachmentList = PageableList; export interface AttachmentService { getAttachmentsList: (params: AttachmentParams) => Promise; getAttachmentsInfo: (atflCd: string) => Promise; - deleteAttachment: (atflId: string) => Promise; + deleteAttachmentByAtflId: (atflId: string) => Promise; deleteAttachmentByAtflCd: (atflCd: string) => Promise; - getImageUrl: (atflId: string) => Promise; + getImageUrlByAtflId: (atflId: string) => Promise; getImageUrlByAtflCd: (atflCd: string) => Promise; + getStreamVideoUrlByAtflId: (atflId: string) => Promise; + getMovieThumbnailUrlByAtflId: (atflId: string) => Promise; + getMovieThumbnailUrlByAtflCd: (atflCd: string) => Promise; + downloadAttachmentByAtflCd: (atflCd: string) => Promise; } diff --git a/apps/common/features/src/entities/auth/index.ts b/apps/common/features/src/entities/auth/index.ts index 8c7981ac9..97e142225 100644 --- a/apps/common/features/src/entities/auth/index.ts +++ b/apps/common/features/src/entities/auth/index.ts @@ -1 +1,2 @@ export { LoginApi } from "./api/login-service"; +export { AuthWrapper } from "./lib/auth-wrapper"; diff --git a/apps/common/features/src/entities/auth/lib/auth-wrapper.tsx b/apps/common/features/src/entities/auth/lib/auth-wrapper.tsx new file mode 100644 index 000000000..e518f938a --- /dev/null +++ b/apps/common/features/src/entities/auth/lib/auth-wrapper.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect } from "react"; +import { client } from "@/shared/api"; +import type { IUserInfo } from "@/shared/store"; +import { getCookie, useUserStore } from "@/shared/lib"; +import { ENDPOINT, AUTH_USER_ID } from "@/shared/config"; +import { LoginApi } from "../api/login-service"; + +const AuthWrapper = ({ children }: { children: React.ReactNode }) => { + const { userInfo, setUserInfo } = useUserStore((state) => state); + + async function authCheck() { + const userId = getCookie(AUTH_USER_ID); + if (!userId) { + throw new Error("Missing required authentication tokens"); + } + + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/info/me`, + ); + if (!userInfo) setUserInfo(response.data); + } + + useEffect(() => { + (async () => { + try { + await authCheck(); + } catch (error) { + console.error(error); + await LoginApi.silentRefresh(); + await authCheck(); + } + })(); + }, []); + + return <>{children}; +}; + +export { AuthWrapper }; diff --git a/apps/common/features/src/entities/board/index.ts b/apps/common/features/src/entities/board/index.ts index f9ef1b610..0b63103e7 100644 --- a/apps/common/features/src/entities/board/index.ts +++ b/apps/common/features/src/entities/board/index.ts @@ -1,8 +1,10 @@ export { BoardApi } from "./api/board-service"; export { boardFormSchema, + BOARD_POSTING_CODE, type Board, type BoardFormData, type BoardParams, type BoardPopup, + type BoardPostingCode, } from "./model/board-interface"; diff --git a/apps/common/features/src/entities/board/model/board-interface.ts b/apps/common/features/src/entities/board/model/board-interface.ts index 1ed8f1d9f..5abd002bc 100644 --- a/apps/common/features/src/entities/board/model/board-interface.ts +++ b/apps/common/features/src/entities/board/model/board-interface.ts @@ -1,14 +1,15 @@ import { z } from "zod"; import type { PageableList, PageableParams } from "@/shared/model"; +import type { BbsType } from "@/shared/store/menu-store"; -export const BOARD_TYPE = { - BBSNORMAL: "BBSNORMAL", - BBSFAQ: "BBSFAQ", - BBSIMAGE: "BBSIMAGE", - BBSMOVIE: "BBSMOVIE", +export const BOARD_POSTING_CODE = { + 팝업게시: "01", + 상단게시: "02", + 기간게시: "03", } as const; -export type BoardType = (typeof BOARD_TYPE)[keyof typeof BOARD_TYPE]; +export type BoardPostingCode = + (typeof BOARD_POSTING_CODE)[keyof typeof BOARD_POSTING_CODE]; export interface BoardParams extends PageableParams { keywordType?: string; @@ -37,7 +38,7 @@ export interface BoardPopupParams extends PageableParams { export interface Board { bbsId: number; - bbsTypeCd: string; + bbsTypeCd: BbsType; bbsTypeNm: string; bbsNm: string; bbsExpln: string; diff --git a/apps/common/features/src/entities/code/ui/code-select.tsx b/apps/common/features/src/entities/code/ui/code-select.tsx index bd175a8a9..441f334e3 100644 --- a/apps/common/features/src/entities/code/ui/code-select.tsx +++ b/apps/common/features/src/entities/code/ui/code-select.tsx @@ -11,9 +11,15 @@ import { CodeApi } from "../api/code-service"; type CodeSelectProps = { upCd: string; + placeholder?: string; } & React.ComponentPropsWithoutRef; -function CodeSelect({ upCd, defaultValue, ...props }: CodeSelectProps) { +function CodeSelect({ + upCd, + defaultValue, + placeholder = "선택", + ...props +}: CodeSelectProps) { const { data: code } = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.CODE, upCd], queryFn: () => CodeApi.getCode(upCd), @@ -21,7 +27,7 @@ function CodeSelect({ upCd, defaultValue, ...props }: CodeSelectProps) { }); const defaultValueText = - code?.find((item) => item.lwpoCd === defaultValue)?.lwpoCdNm ?? "선택"; + code?.find((item) => item.lwpoCd === defaultValue)?.lwpoCdNm ?? placeholder; return ( + + + + + {sites?.content.map((item) => ( + + {item.siteNm} + + ))} + + + ); +} + +export { SiteSelect }; diff --git a/apps/common/features/src/entities/tus/index.ts b/apps/common/features/src/entities/tus/index.ts new file mode 100644 index 000000000..002cb2eda --- /dev/null +++ b/apps/common/features/src/entities/tus/index.ts @@ -0,0 +1 @@ +export { handleTusUpload } from "./lib/utils"; diff --git a/apps/common/features/src/entities/tus/lib/utils.ts b/apps/common/features/src/entities/tus/lib/utils.ts new file mode 100644 index 000000000..22747c26a --- /dev/null +++ b/apps/common/features/src/entities/tus/lib/utils.ts @@ -0,0 +1,60 @@ +import { Upload, type HttpResponse } from "tus-js-client"; +import { getCookie, setCookie } from "@/shared/lib"; +import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config"; + +type UploadOptions = { + handleUploadStart?: (upload: Upload) => void; + handleProgress?: (percentage: string) => void; + handleSuccess?: (upload: Upload) => void; + handleResponse?: (res: HttpResponse, upload: Upload) => void; +}; + +export const handleTusUpload = (file: File, options?: UploadOptions) => { + let metadata = {}; + + if (!file) return; + metadata = { + filename: file.name, + filetype: file.type, + }; + + const atflCd = getCookie("atfl-cd-key"); + if (atflCd) { + metadata = { + ...metadata, + atflCd, + }; + } + + const upload = new Upload(file, { + endpoint: `${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`, + chunkSize: 10 * 1024 * 1024, + headers: { + [CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "", + [AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "", + }, + metadata, + onError(error) { + console.error(`Failed because: ${error}`); + }, + onProgress(bytesUploaded, bytesTotal) { + const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2); + options?.handleProgress?.(percentage); + }, + onAfterResponse(req, res) { + const fileInfo = res.getHeader("X-File-Info"); + if (fileInfo === undefined || fileInfo === null) return; + + setCookie("atfl-cd-key", JSON.parse(fileInfo)?.atflCd ?? ""); + options?.handleResponse?.(res, upload); + }, + onSuccess() { + options?.handleSuccess?.(upload); + }, + }); + + upload.start(); + options?.handleUploadStart?.(upload); + + console.log("upload start >>>>>", upload); +}; diff --git a/apps/common/features/src/pages/(pst)/faq/index.ts b/apps/common/features/src/pages/(pst)/faq/index.ts new file mode 100644 index 000000000..350df198c --- /dev/null +++ b/apps/common/features/src/pages/(pst)/faq/index.ts @@ -0,0 +1,3 @@ +export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; +export { FaqPage } from "./ui/faq-page"; diff --git a/apps/common/features/src/pages/(pst)/faq/ui/create-page.tsx b/apps/common/features/src/pages/(pst)/faq/ui/create-page.tsx new file mode 100644 index 000000000..2d436c3c8 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/faq/ui/create-page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function CreatePage({ bbsId }: { bbsId: string }) { + const router = useRouter(); + const userInfo = useUserStore((state) => state.userInfo); + const alert = useAlertStore((state) => state.setMessage); + + const { mutateAsync: createPost } = useMutation({ + mutationFn: (data: PostFormData) => PostApi.createPost(data), + onSuccess: () => { + alert("등록되었습니다.", router.back); + }, + }); + + const form = useForm({ + resolver: zodResolver(postFormSchema), + values: { + bbsId: parseInt(bbsId), + pstTtl: "", + wrtrNm: userInfo?.userName ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( +
+
+

FAQ 게시글 등록

+
+ +
+ ); +} + +export { CreatePage }; diff --git a/apps/common/features/src/pages/(pst)/faq/ui/faq-page.tsx b/apps/common/features/src/pages/(pst)/faq/ui/faq-page.tsx new file mode 100644 index 000000000..aa67a17c5 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/faq/ui/faq-page.tsx @@ -0,0 +1,316 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + Checkbox, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Skeleton, +} from "@common/components"; +import { AttachmentApi } from "@/entities/attachment"; +import { PostApi, type PostParams } from "@/entities/post"; +import { useAlertStore, useMenuStore, useUserStore } from "@/shared/lib"; +import { ENDPOINT } from "@/shared/config"; +import dayjs from "dayjs"; +import React from "react"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; + +function FaqPage({ bbsId }: { bbsId: string }) { + const router = useRouter(); + const pathname = usePathname(); + const { confirm } = useConfirm(); + const userInfo = useUserStore((state) => state.userInfo); + const alert = useAlertStore((state) => state.setMessage); + const { bbsInfo, pageMenuAuthority } = useMenuStore((state) => state); + + const [postParams, setPostParams] = useState({ + keywordType: "title", + keyword: "", + }); + const [selectedPostNumbers, setSelectedPostNumbers] = useState([]); + + const { + data: postList, + isFetched, + refetch, + } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsInfo?.bbsTypeCd], + queryFn: () => + PostApi.getPostList(parseInt(bbsId), { + ...postParams, + bbsTypeCd: bbsInfo?.bbsTypeCd, + }), + enabled: !!bbsInfo?.bbsTypeCd, + }); + + const { mutate: deletePost } = useMutation({ + mutationFn: ({ pstNo, pstPswd }: { pstNo: number; pstPswd?: string }) => + PostApi.deletePost(parseInt(bbsId), pstNo, pstPswd), + onSuccess: () => { + alert("삭제되었습니다."); + refetch(); + }, + }); + + const handleDeletePost = async (pstNo: number) => { + if (await confirm("삭제하시겠습니까?")) { + deletePost({ pstNo, pstPswd: userInfo?.userUnqId }); + } + }; + + const onSelectedPostDelete = async () => { + if (await confirm("삭제하시겠습니까?")) { + await Promise.all( + selectedPostNumbers.map((pstNo) => + PostApi.deletePost(parseInt(bbsId), pstNo, userInfo?.userUnqId), + ), + ); + refetch(); + setSelectedPostNumbers([]); + alert("삭제되었습니다."); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + refetch(); + }; + + const handleCheckboxChange = (pstNo: number, checked: boolean | string) => { + setSelectedPostNumbers( + checked === true + ? [...selectedPostNumbers, pstNo] + : selectedPostNumbers.filter((num) => num !== pstNo), + ); + }; + + const handleAllCheckboxChange = (checked: boolean | string) => { + setSelectedPostNumbers( + checked === true ? postList?.content.map((item) => item.pstNo) || [] : [], + ); + }; + + return ( +
+
+
+ + + + setPostParams({ ...postParams, keyword: e.target.value }) + } + className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]" + /> + +
+
+
+
+
+

콘텐츠 목록

+
+ {pageMenuAuthority?.regAuthrtYn === "Y" && ( + + )} + {pageMenuAuthority?.delAuthrtYn === "Y" && ( + + )} +
+
+
+
+
+ e.stopPropagation()} + onCheckedChange={handleAllCheckboxChange} + /> + +
+ {isFetched ? ( + + {postList?.content.map((item) => ( +
+ e.stopPropagation()} + onCheckedChange={(checked) => + handleCheckboxChange(item.pstNo, checked) + } + className="mx-2 my-6" + /> + + +
+
+ Q + {item.pstTtl} +
+ + 등록일: {dayjs(item.regDt).format("YYYY-MM-DD")} + +
+
+ +
+ + A + +
+
+                            {item.pstCn}
+                          
+ +
+
+ + +
+
+
+
+
+ ))} +
+ ) : ( + + )} +
+
+
+ ); +} + +function Attachment({ atflCd }: { atflCd: string }) { + const [filename, setFilename] = useState(""); + + const { data: downloadResponse, isFetching } = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.DOWNLOAD, atflCd], + queryFn: () => AttachmentApi.downloadAttachmentByAtflCd(atflCd), + enabled: !!atflCd, + }); + + const handleDownload = async () => { + if (!downloadResponse) return; + try { + const blob = new Blob([downloadResponse.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = filename || "download"; + document.body.appendChild(link); + link.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(link); + } catch (error) { + console.error(error); + } + }; + + useEffect(() => { + if (!downloadResponse) return; + try { + let filename = ""; + const contentDisposition = + downloadResponse.headers["content-disposition"]; + if (contentDisposition) { + const filenameMatch = contentDisposition.match( + /filename\*=UTF-8''([^;]+)/, + ); + if (filenameMatch && filenameMatch[1]) { + filename = decodeURIComponent(filenameMatch[1]); + } + } + setFilename(filename); + } catch { + setFilename(""); + } + }, [downloadResponse]); + + return isFetching ? ( +
+ +
+ ) : ( + filename && ( +
+ 첨부파일 + +
+ ) + ); +} + +export { FaqPage }; diff --git a/apps/common/features/src/pages/(pst)/faq/ui/modify-page.tsx b/apps/common/features/src/pages/(pst)/faq/ui/modify-page.tsx new file mode 100644 index 000000000..6987cb0bc --- /dev/null +++ b/apps/common/features/src/pages/(pst)/faq/ui/modify-page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { + PostApi, + postUpdateFormSchema, + type PostUpdateFormData, +} from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { + const router = useRouter(); + const alert = useAlertStore((state) => state.setMessage); + const userInfo = useUserStore((state) => state.userInfo); + + const { data: postInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), + }); + + const { mutateAsync: updatePost } = useMutation({ + mutationFn: (data: PostUpdateFormData) => + PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), + onSuccess: () => { + alert("수정되었습니다.", router.back); + }, + }); + + const form = useForm({ + resolver: zodResolver(postUpdateFormSchema), + values: { + ...postInfo, + pstTtl: postInfo?.pstTtl ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( +
+
+

FAQ 게시글 수정

+
+ +
+ ); +} + +export { ModifyPage }; diff --git a/apps/common/features/src/pages/(pst)/image/index.ts b/apps/common/features/src/pages/(pst)/image/index.ts new file mode 100644 index 000000000..0bc16c1ad --- /dev/null +++ b/apps/common/features/src/pages/(pst)/image/index.ts @@ -0,0 +1,4 @@ +export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; +export { ImagePage } from "./ui/image-page"; +export { ImagePost } from "./ui/image-post"; diff --git a/apps/common/features/src/pages/(pst)/image/ui/create-page.tsx b/apps/common/features/src/pages/(pst)/image/ui/create-page.tsx new file mode 100644 index 000000000..dc5e4f894 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/image/ui/create-page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function CreatePage({ bbsId }: { bbsId: string }) { + const router = useRouter(); + const userInfo = useUserStore((state) => state.userInfo); + const alert = useAlertStore((state) => state.setMessage); + + const { mutateAsync: createPost } = useMutation({ + mutationFn: (data: PostFormData) => PostApi.createPost(data), + onSuccess: () => { + alert("등록되었습니다.", router.back); + }, + }); + + const form = useForm({ + resolver: zodResolver(postFormSchema), + values: { + bbsId: parseInt(bbsId), + pstTtl: "", + wrtrNm: userInfo?.userName ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( +
+
+

이미지 게시글 등록

+
+ +
+ ); +} + +export { CreatePage }; diff --git a/apps/common/features/src/pages/(pst)/image/ui/image-page.tsx b/apps/common/features/src/pages/(pst)/image/ui/image-page.tsx new file mode 100644 index 000000000..613d8bb5e --- /dev/null +++ b/apps/common/features/src/pages/(pst)/image/ui/image-page.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { PostPage as ImagePage } from "@/widgets/post"; diff --git a/apps/common/features/src/pages/(pst)/image/ui/image-post.tsx b/apps/common/features/src/pages/(pst)/image/ui/image-post.tsx new file mode 100644 index 000000000..77eb96bc1 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/image/ui/image-post.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { Post as ImagePost } from "@/widgets/post"; diff --git a/apps/common/features/src/pages/(pst)/image/ui/modify-page.tsx b/apps/common/features/src/pages/(pst)/image/ui/modify-page.tsx new file mode 100644 index 000000000..d292fea42 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/image/ui/modify-page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { + PostApi, + postUpdateFormSchema, + type PostUpdateFormData, +} from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { + const router = useRouter(); + const alert = useAlertStore((state) => state.setMessage); + const userInfo = useUserStore((state) => state.userInfo); + + const { data: postInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), + }); + + const { mutateAsync: updatePost } = useMutation({ + mutationFn: (data: PostUpdateFormData) => + PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), + onSuccess: () => { + alert("수정되었습니다.", router.back); + }, + }); + + const form = useForm({ + resolver: zodResolver(postUpdateFormSchema), + values: { + ...postInfo, + pstTtl: postInfo?.pstTtl ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( +
+
+

이미지 게시글 수정

+
+ +
+ ); +} + +export { ModifyPage }; diff --git a/apps/common/features/src/pages/(pst)/movie/index.ts b/apps/common/features/src/pages/(pst)/movie/index.ts new file mode 100644 index 000000000..c746cff6c --- /dev/null +++ b/apps/common/features/src/pages/(pst)/movie/index.ts @@ -0,0 +1,4 @@ +export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; +export { MoviePage } from "./ui/movie-page"; +export { MoviePost } from "./ui/movie-post"; diff --git a/apps/common/features/src/pages/(pst)/movie/ui/create-page.tsx b/apps/common/features/src/pages/(pst)/movie/ui/create-page.tsx new file mode 100644 index 000000000..b5e9af8a8 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/movie/ui/create-page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function CreatePage({ bbsId }: { bbsId: string }) { + const router = useRouter(); + const userInfo = useUserStore((state) => state.userInfo); + const alert = useAlertStore((state) => state.setMessage); + + const { mutateAsync: createPost } = useMutation({ + mutationFn: (data: PostFormData) => PostApi.createPost(data), + onSuccess: () => { + alert("등록되었습니다.", router.back); + }, + }); + + const form = useForm({ + resolver: zodResolver(postFormSchema), + values: { + bbsId: parseInt(bbsId), + pstTtl: "", + wrtrNm: userInfo?.userName ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( +
+
+

동영상 게시글 등록

+
+ +
+ ); +} + +export { CreatePage }; diff --git a/apps/common/features/src/pages/(pst)/movie/ui/modify-page.tsx b/apps/common/features/src/pages/(pst)/movie/ui/modify-page.tsx new file mode 100644 index 000000000..f9fc11f3d --- /dev/null +++ b/apps/common/features/src/pages/(pst)/movie/ui/modify-page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { + PostApi, + postUpdateFormSchema, + type PostUpdateFormData, +} from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { + const router = useRouter(); + const alert = useAlertStore((state) => state.setMessage); + const userInfo = useUserStore((state) => state.userInfo); + + const { data: postInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), + }); + + const { mutateAsync: updatePost } = useMutation({ + mutationFn: (data: PostUpdateFormData) => + PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), + onSuccess: () => { + alert("수정되었습니다.", router.back); + }, + }); + + const form = useForm({ + resolver: zodResolver(postUpdateFormSchema), + values: { + ...postInfo, + pstTtl: postInfo?.pstTtl ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( +
+
+

동영상 게시글 수정

+
+ +
+ ); +} + +export { ModifyPage }; diff --git a/apps/common/features/src/pages/(pst)/movie/ui/movie-page.tsx b/apps/common/features/src/pages/(pst)/movie/ui/movie-page.tsx new file mode 100644 index 000000000..5ba24e94b --- /dev/null +++ b/apps/common/features/src/pages/(pst)/movie/ui/movie-page.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { PostPage as MoviePage } from "@/widgets/post"; diff --git a/apps/common/features/src/pages/(pst)/movie/ui/movie-post.tsx b/apps/common/features/src/pages/(pst)/movie/ui/movie-post.tsx new file mode 100644 index 000000000..8a5bf6996 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/movie/ui/movie-post.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { Post as MoviePost } from "@/widgets/post"; diff --git a/apps/common/features/src/pages/(pst)/normal/index.ts b/apps/common/features/src/pages/(pst)/normal/index.ts new file mode 100644 index 000000000..bda06e816 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/normal/index.ts @@ -0,0 +1,4 @@ +export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; +export { NormalPage } from "./ui/normal-page"; +export { NormalPost } from "./ui/normal-post"; diff --git a/apps/common/features/src/pages/(pst)/normal/ui/create-page.tsx b/apps/common/features/src/pages/(pst)/normal/ui/create-page.tsx new file mode 100644 index 000000000..df2ab2a70 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/normal/ui/create-page.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function CreatePage({ bbsId, pstNo }: { bbsId: string; pstNo?: string }) { + const answerRegisterMode = !!pstNo; + + const router = useRouter(); + const userInfo = useUserStore((state) => state.userInfo); + const alert = useAlertStore((state) => state.setMessage); + + const { mutateAsync: createPost } = useMutation({ + mutationFn: (data: PostFormData) => PostApi.createPost(data), + onSuccess: () => { + alert("등록되었습니다.", router.back); + }, + }); + + const form = useForm({ + resolver: zodResolver(postFormSchema), + values: { + bbsId: parseInt(bbsId), + pstTtl: "", + wrtrNm: userInfo?.userName ?? "", + pstPswd: userInfo?.userUnqId ?? "", + popupYn: "N", + upndFixYn: "N", + prdPstgYn: "N", + parentPstNo: pstNo ? parseInt(pstNo) : null, + }, + }); + + return ( +
+
+

+ {answerRegisterMode ? "답글 등록" : "게시글 등록"} +

+
+ +
+ ); +} + +export { CreatePage }; diff --git a/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx b/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx new file mode 100644 index 000000000..c47b1999c --- /dev/null +++ b/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { + PostApi, + postUpdateFormSchema, + type PostUpdateFormData, +} from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { + const router = useRouter(); + const alert = useAlertStore((state) => state.setMessage); + const userInfo = useUserStore((state) => state.userInfo); + + const { data: postInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), + }); + + const { mutateAsync: updatePost } = useMutation({ + mutationFn: (data: PostUpdateFormData) => + PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), + onSuccess: () => { + alert("수정되었습니다.", router.back); + }, + }); + + const form = useForm({ + resolver: zodResolver(postUpdateFormSchema), + values: { + ...postInfo, + pstTtl: postInfo?.pstTtl ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( +
+
+

게시글 수정

+
+ +
+ ); +} + +export { ModifyPage }; diff --git a/apps/common/features/src/pages/(pst)/normal/ui/normal-page.tsx b/apps/common/features/src/pages/(pst)/normal/ui/normal-page.tsx new file mode 100644 index 000000000..22343dc93 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/normal/ui/normal-page.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { PostPage as NormalPage } from "@/widgets/post"; diff --git a/apps/common/features/src/pages/(pst)/normal/ui/normal-post.tsx b/apps/common/features/src/pages/(pst)/normal/ui/normal-post.tsx new file mode 100644 index 000000000..3643fdc36 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/normal/ui/normal-post.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { Post as NormalPost } from "@/widgets/post"; diff --git a/apps/common/features/src/pages/notice/index.ts b/apps/common/features/src/pages/(pst)/notice/index.ts similarity index 50% rename from apps/common/features/src/pages/notice/index.ts rename to apps/common/features/src/pages/(pst)/notice/index.ts index c75abbdc8..2de5491f8 100644 --- a/apps/common/features/src/pages/notice/index.ts +++ b/apps/common/features/src/pages/(pst)/notice/index.ts @@ -1,2 +1,4 @@ +export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; export { NoticePage } from "./ui/notice-page"; export { NoticePost } from "./ui/notice-post"; diff --git a/apps/common/features/src/pages/notice/model/notice-table-columns.tsx b/apps/common/features/src/pages/(pst)/notice/model/notice-table-columns.tsx similarity index 55% rename from apps/common/features/src/pages/notice/model/notice-table-columns.tsx rename to apps/common/features/src/pages/(pst)/notice/model/notice-table-columns.tsx index f6036f050..0926a2d9d 100644 --- a/apps/common/features/src/pages/notice/model/notice-table-columns.tsx +++ b/apps/common/features/src/pages/(pst)/notice/model/notice-table-columns.tsx @@ -1,13 +1,13 @@ import dayjs from "dayjs"; import type { ColumnDef } from "@tanstack/react-table"; -import type { Post } from "@/entities/post"; import { Badge, Button } from "@common/components"; +import { AttachmentApi } from "@/entities/attachment"; +import type { Post } from "@/entities/post"; export const columns: ColumnDef[] = [ { - id: "no", + accessorKey: "rowNo", header: "NO", - cell: ({ row }) => row.index + 1, }, { accessorKey: "pstTtl", @@ -51,11 +51,40 @@ export const columns: ColumnDef[] = [ ]; const AttachmentButton = ({ atflCd }: { atflCd: string }) => { - if (!atflCd) return null; + const handleDownload = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + let filename = ""; + const response = await AttachmentApi.downloadAttachmentByAtflCd(atflCd); + const contentDisposition = response.headers["content-disposition"]; + if (contentDisposition) { + const filenameMatch = contentDisposition.match( + /filename\*=UTF-8''([^;]+)/, + ); + if (filenameMatch) { + filename = decodeURIComponent(filenameMatch[1]); + } + } + + const blob = new Blob([response.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(link); + } catch (error) { + console.error(error); + } + }; + return ( - ); }; diff --git a/apps/common/features/src/pages/(pst)/notice/ui/create-page.tsx b/apps/common/features/src/pages/(pst)/notice/ui/create-page.tsx new file mode 100644 index 000000000..5033bff60 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/notice/ui/create-page.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function CreatePage({ bbsId }: { bbsId: string }) { + const router = useRouter(); + const userInfo = useUserStore((state) => state.userInfo); + const alert = useAlertStore((state) => state.setMessage); + + const { mutateAsync: createPost } = useMutation({ + mutationFn: (data: PostFormData) => PostApi.createPost(data), + onSuccess: () => { + alert("등록되었습니다.", router.back); + }, + }); + + const form = useForm({ + resolver: zodResolver(postFormSchema), + values: { + bbsId: parseInt(bbsId), + pstTtl: "", + wrtrNm: userInfo?.userName ?? "", + pstPswd: userInfo?.userUnqId ?? "", + popupYn: "N", + upndFixYn: "N", + prdPstgYn: "N", + }, + }); + + return ( +
+
+

공지 게시글 등록

+
+ +
+ ); +} + +export { CreatePage }; diff --git a/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx b/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx new file mode 100644 index 000000000..7a46a7d6b --- /dev/null +++ b/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { + PostApi, + postUpdateFormSchema, + type PostUpdateFormData, +} from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { + const router = useRouter(); + const alert = useAlertStore((state) => state.setMessage); + const userInfo = useUserStore((state) => state.userInfo); + + const { data: postInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), + }); + + const { mutateAsync: updatePost } = useMutation({ + mutationFn: (data: PostUpdateFormData) => + PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), + onSuccess: () => { + alert("수정되었습니다.", router.back); + }, + }); + + const form = useForm({ + resolver: zodResolver(postUpdateFormSchema), + values: { + ...postInfo, + pstTtl: postInfo?.pstTtl ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( +
+
+

공지 게시글 수정

+
+ +
+ ); +} + +export { ModifyPage }; diff --git a/apps/common/features/src/pages/(pst)/notice/ui/notice-page.tsx b/apps/common/features/src/pages/(pst)/notice/ui/notice-page.tsx new file mode 100644 index 000000000..4128d6107 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/notice/ui/notice-page.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { PostPage as NoticePage } from "@/widgets/post"; diff --git a/apps/common/features/src/pages/(pst)/notice/ui/notice-post.tsx b/apps/common/features/src/pages/(pst)/notice/ui/notice-post.tsx new file mode 100644 index 000000000..a774212ef --- /dev/null +++ b/apps/common/features/src/pages/(pst)/notice/ui/notice-post.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { Post as NoticePost } from "@/widgets/post"; diff --git a/apps/common/features/src/pages/notice/ui/notice-layout.tsx b/apps/common/features/src/pages/notice/ui/notice-layout.tsx deleted file mode 100644 index b4b5e37fb..000000000 --- a/apps/common/features/src/pages/notice/ui/notice-layout.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import type { Board } from "@/entities/board"; -import { PageLayout } from "@/shared/ui/page-layout"; -import { useLocalStorage } from "@frontend-opensource/use-react-hooks"; - -function NoticeLayout({ children }: { children: React.ReactNode }) { - const [bbsInfo, setBbsInfo] = useState(null); - const [storedBbsInfo] = useLocalStorage("bbs-info", ""); - - useEffect(() => { - if (storedBbsInfo) setBbsInfo(JSON.parse(storedBbsInfo)); - }, [storedBbsInfo]); - - return ( - - {children} - - ); -} - -export { NoticeLayout }; diff --git a/apps/common/features/src/pages/notice/ui/notice-post.tsx b/apps/common/features/src/pages/notice/ui/notice-post.tsx deleted file mode 100644 index 964e16a91..000000000 --- a/apps/common/features/src/pages/notice/ui/notice-post.tsx +++ /dev/null @@ -1,14 +0,0 @@ -"use client"; - -import { Post } from "@/widgets/post"; -import { NoticeLayout } from "./notice-layout"; - -function NoticePost({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { - return ( - - - - ); -} - -export { NoticePost }; diff --git a/apps/common/features/src/shared/config/api.ts b/apps/common/features/src/shared/config/api.ts index b82a8eb23..b6191979e 100644 --- a/apps/common/features/src/shared/config/api.ts +++ b/apps/common/features/src/shared/config/api.ts @@ -7,7 +7,10 @@ export const ENDPOINT = { TUS_UPLOAD: "/attach-service/api/v1/tus/upload", EDITOR_UPLOAD: "/attach-service/api/v1/upload/editor", IMAGES: "/attach-service/api/v1/images", + MOVIE_THUMBNAIL: "/attach-service/api/v1/movie/thumbnail", ATTACHMENTS: "/attach-service/api/v1/atfl/attachments", + DOWNLOAD: "/attach-service/api/v1/atflcd/download", + STREAM_VIDEO: "/attach-service/api/v1/stream/video", }, USER_SERVICE: { LOGIN: "/user-service/login", @@ -36,7 +39,8 @@ export const ENDPOINT = { }, BOARD_SERVICE: { BBS: "/board-service/api/v1/bbs", - POSTS: "/board-service/api/v1/posts", + POSTS_USER: "/board-service/api/v1/posts/user", + POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts", }, }; diff --git a/apps/common/features/src/shared/lib/index.ts b/apps/common/features/src/shared/lib/index.ts index bfbe51702..42ec7d509 100644 --- a/apps/common/features/src/shared/lib/index.ts +++ b/apps/common/features/src/shared/lib/index.ts @@ -1,3 +1,5 @@ export { getCookie, setCookie, removeCookie } from "./cookie"; +export { useAlertStore } from "./use-alert-store"; export { useMenuStore } from "./use-menu-store"; export { useUserStore } from "./use-user-store"; +export { usePrompt } from "./use-prompt"; diff --git a/apps/common/features/src/shared/lib/use-prompt.tsx b/apps/common/features/src/shared/lib/use-prompt.tsx new file mode 100644 index 000000000..f36858eb0 --- /dev/null +++ b/apps/common/features/src/shared/lib/use-prompt.tsx @@ -0,0 +1,26 @@ +import { useContext } from "react"; +import { PromptContext } from "../providers/prompt-provider"; + +export const usePrompt = () => { + const { message, resolve, setMessage, setResolve } = + useContext(PromptContext); + + const prompt = (message: string) => { + setMessage(message); + return new Promise<{ resolved: boolean; value?: string }>((resolve) => { + setResolve(() => resolve); + }); + }; + + const onSubmit = (value: string) => { + resolve?.({ resolved: true, value }); + setMessage(""); + }; + + const onCancel = () => { + resolve?.({ resolved: false }); + setMessage(""); + }; + + return { message, isOpen: !!message, prompt, onSubmit, onCancel }; +}; diff --git a/apps/common/features/src/shared/lib/use-tus.ts b/apps/common/features/src/shared/lib/use-tus.ts index fdaeb1f65..30976c23b 100644 --- a/apps/common/features/src/shared/lib/use-tus.ts +++ b/apps/common/features/src/shared/lib/use-tus.ts @@ -34,7 +34,7 @@ export const useTus = () => { }, onProgress(bytesUploaded, bytesTotal) { const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2); - setProgress(Number(percentage)); + setProgress(parseInt(percentage)); }, onAfterResponse(req, res) { const fileInfo = res.getHeader("X-File-Info"); diff --git a/apps/common/features/src/shared/providers/index.ts b/apps/common/features/src/shared/providers/index.ts index f2e092170..fa8320fe7 100644 --- a/apps/common/features/src/shared/providers/index.ts +++ b/apps/common/features/src/shared/providers/index.ts @@ -1 +1,2 @@ export { StoreProvider } from "./store-provider"; +export { PromptProvider } from "./prompt-provider"; diff --git a/apps/common/features/src/shared/providers/prompt-provider.tsx b/apps/common/features/src/shared/providers/prompt-provider.tsx new file mode 100644 index 000000000..10338c338 --- /dev/null +++ b/apps/common/features/src/shared/providers/prompt-provider.tsx @@ -0,0 +1,37 @@ +import { createContext, useState } from "react"; + +interface PromptContext { + message?: string; + resolve?: ({ + resolved, + value, + }: { + resolved: boolean; + value?: string; + }) => void; + setMessage: (message: string) => void; + setResolve: (resolve: () => void) => void; +} + +export const PromptContext = createContext({ + message: "", + resolve() {}, + setMessage() {}, + setResolve() {}, +}); + +export const PromptProvider = ({ children }: { children: React.ReactNode }) => { + const [message, setMessage] = useState(); + const [resolve, setResolve] = + useState< + ({ resolved, value }: { resolved: boolean; value?: string }) => void + >(); + + return ( + + {children} + + ); +}; diff --git a/apps/common/features/src/shared/store/menu-store.ts b/apps/common/features/src/shared/store/menu-store.ts index 9aec06603..8536011ee 100644 --- a/apps/common/features/src/shared/store/menu-store.ts +++ b/apps/common/features/src/shared/store/menu-store.ts @@ -1,23 +1,93 @@ import { createStore } from "zustand/vanilla"; -import type { MenuItem } from "@/entities/menu"; + +export const BBS_TYPE = { + BBSNOTICE: "BBSNOTICE", + BBSNORMAL: "BBSNORMAL", + BBSFAQ: "BBSFAQ", + BBSIMAGE: "BBSIMAGE", + BBSMOVIE: "BBSMOVIE", +} as const; + +export type BbsType = (typeof BBS_TYPE)[keyof typeof BBS_TYPE]; + +export interface IBbs { + bbsId: number; + bbsTypeCd: BbsType; + bbsTypeNm: string; + bbsNm: string; + bbsExpln: string; + rfrncUseYn: string; + atflUseYn: string; + atflMaxCnt: number; + atflMaxFileSz: number; + ansUseYn: string; + ansUseAuthrt: string; + cmntUseYn: string; + dgstfnEvlUseYn: string; + smsUseYn: string; + upndFixUseYn: string; + pstgPrdUseYn: string; + popupUseYn: string; + delYn: string; + useYn: string; + mdfcnDt: string; + mdfrNm: string; +} + +interface IMenu { + menuCd: number; + menuKornNm: string; + menuEngNm: string; + parentId: number | null; + sortSeq: number; + iconNm?: string; + children: IMenu[] | null; + levelNo: number; + urlPath: string; + menuLnkgTypeCd: string; + menuLnkgSn: number; + menuPath: string; + qrcd: string; + useYn: string; + otptYn: string; + newWndwYn: string; + requiredUrlPath: boolean; +} + +interface IAuthority { + mngAuthrtYn: string; + inqAuthrtYn: string; + regAuthrtYn: string; + mdfcnAuthrtYn: string; + delAuthrtYn: string; +} export type MenuState = { - currentMenuInfo: MenuItem | null; + bbsInfo: IBbs | null; + currentMenuInfo: IMenu | null; + pageMenuAuthority: IAuthority | null; }; export type MenuActions = { - setCurrentMenuInfo: (menu: MenuItem) => void; + setBbsInfo: (bbs: IBbs) => void; + setCurrentMenuInfo: (menu: IMenu) => void; + setPageMenuAuthority: (authority: IAuthority) => void; }; export type MenuStore = MenuState & MenuActions; export const defaultInitState: MenuState = { currentMenuInfo: null, + bbsInfo: null, + pageMenuAuthority: null, }; export const createMenuStore = (initState: MenuState = defaultInitState) => { return createStore()((set) => ({ ...initState, - setCurrentMenuInfo: (menu: MenuItem) => set({ currentMenuInfo: menu }), + setBbsInfo: (bbs: IBbs) => set({ bbsInfo: bbs }), + setCurrentMenuInfo: (menu: IMenu) => set({ currentMenuInfo: menu }), + setPageMenuAuthority: (authority: IAuthority) => + set({ pageMenuAuthority: authority }), })); }; diff --git a/apps/common/features/src/shared/store/user-store.ts b/apps/common/features/src/shared/store/user-store.ts index 00379fb1f..65d897cf1 100644 --- a/apps/common/features/src/shared/store/user-store.ts +++ b/apps/common/features/src/shared/store/user-store.ts @@ -5,6 +5,7 @@ export interface IUserInfo { mdfcnDt: string; rgtr: string; mdfr: string; + adminRoleYn: string; userUnqId: string; userId: string; userName: string; diff --git a/apps/common/features/src/shared/ui/alert-dialog.tsx b/apps/common/features/src/shared/ui/alert-dialog.tsx index 012d4ab86..3bd4fece8 100644 --- a/apps/common/features/src/shared/ui/alert-dialog.tsx +++ b/apps/common/features/src/shared/ui/alert-dialog.tsx @@ -6,7 +6,6 @@ import { Button, } from "@common/components"; import { useAlertStore } from "../lib/use-alert-store"; - function AlertDialog() { const { message, callback, setMessage } = useAlertStore((state) => state); const handleOpenChange = () => { diff --git a/apps/common/features/src/widgets/form-wrapper/ui/form-wrapper.tsx b/apps/common/features/src/shared/ui/form-wrapper.tsx similarity index 97% rename from apps/common/features/src/widgets/form-wrapper/ui/form-wrapper.tsx rename to apps/common/features/src/shared/ui/form-wrapper.tsx index caba0b595..2ad859f44 100644 --- a/apps/common/features/src/widgets/form-wrapper/ui/form-wrapper.tsx +++ b/apps/common/features/src/shared/ui/form-wrapper.tsx @@ -2,7 +2,7 @@ import { useRouter } from "next/navigation"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { type FieldValues, type UseFormReturn } from "react-hook-form"; import { Button } from "@common/components"; -import { Form } from "@/shared/ui/form"; +import { Form } from "./form"; type FormWrapperProps = { form: UseFormReturn; diff --git a/apps/common/features/src/shared/ui/grid-view-data-table.tsx b/apps/common/features/src/shared/ui/grid-view-data-table.tsx new file mode 100644 index 000000000..1005d4951 --- /dev/null +++ b/apps/common/features/src/shared/ui/grid-view-data-table.tsx @@ -0,0 +1,158 @@ +import type { HTMLAttributes } from "react"; +import { + flexRender, + type RowData, + type Table as TableInstance, +} from "@tanstack/react-table"; +import { + ChevronLeftIcon, + ChevronRightIcon, + DoubleArrowLeftIcon, + DoubleArrowRightIcon, +} from "@radix-ui/react-icons"; +import { Button, Checkbox, Label, cn } from "@common/components"; + +declare module "@tanstack/react-table" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + align?: "left" | "center" | "right"; + rowSpan?: number; + } +} + +type GridViewDataTableProps = { + table: TableInstance; + variant?: "default" | "secondary" | "line"; + className?: HTMLAttributes["className"]; + viewPagination?: boolean; + onRowClick?: (row: T) => void; +}; + +function GridViewDataTable({ + table, + className, + viewPagination, + onRowClick, +}: GridViewDataTableProps) { + return ( +
+
+ e.stopPropagation()} + onCheckedChange={(checked) => + table.toggleAllPageRowsSelected(!!checked) + } + /> + +
+
+
+ {table.getRowModel().rows.map((row) => ( +
onRowClick?.(row.original)} + className={cn( + "overflow-hidden rounded-md border border-border bg-background p-4 shadow-md", + onRowClick && "cursor-pointer hover:bg-accent", + )} + > + {row.getVisibleCells().map((cell) => { + const { meta } = cell.column.columnDef; + return ( +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ); + })} +
+ ))} +
+
+ {viewPagination && } +
+ ); +} + +function Pagination({ table }: { table: TableInstance }) { + const pageCount = table.getPageCount(); + const currentPage = table.getState().pagination.pageIndex; + const maxVisiblePages = 10; // 한 번에 보여줄 최대 페이지 수 + + let startPage = Math.max(0, currentPage - maxVisiblePages / 2); // 시작 페이지 + const endPage = Math.min(pageCount, startPage + maxVisiblePages); // 끝 페이지 + + if (endPage - startPage < maxVisiblePages) { + startPage = Math.max(0, endPage - maxVisiblePages); + } + + const pageNumbers = Array(endPage - startPage) + .fill(0) + .map((_, i) => startPage + i); + + return ( +
+ + +
+ {pageNumbers.map((pageNumber) => ( + + ))} +
+ + +
+ ); +} + +export { GridViewDataTable }; diff --git a/apps/common/features/src/shared/ui/prompt-dialog.tsx b/apps/common/features/src/shared/ui/prompt-dialog.tsx new file mode 100644 index 000000000..cfbcf2987 --- /dev/null +++ b/apps/common/features/src/shared/ui/prompt-dialog.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogClose, + Input, +} from "@common/components"; +import { usePrompt } from "@/shared/lib/use-prompt"; + +function PromptDialog() { + const { message, isOpen, onSubmit, onCancel } = usePrompt(); + const [value, setValue] = useState(""); + + return ( + + +
+

+ {message} +

+ setValue(e.target.value)} + /> +
+ + + + + + + + +
+
+ ); +} + +export { PromptDialog }; diff --git a/apps/common/features/src/widgets/form-wrapper/index.ts b/apps/common/features/src/widgets/form-wrapper/index.ts deleted file mode 100644 index aa5addaf9..000000000 --- a/apps/common/features/src/widgets/form-wrapper/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FormWrapper } from "./ui/form-wrapper"; diff --git a/apps/common/features/src/widgets/post/index.ts b/apps/common/features/src/widgets/post/index.ts index 9c833e568..7d43c205d 100644 --- a/apps/common/features/src/widgets/post/index.ts +++ b/apps/common/features/src/widgets/post/index.ts @@ -1 +1,3 @@ export { Post } from "./ui/post"; +export { PostForm } from "./ui/post-form"; +export { PostPage } from "./ui/post-page"; diff --git a/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx b/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx new file mode 100644 index 000000000..34f2fc237 --- /dev/null +++ b/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx @@ -0,0 +1,178 @@ +import dayjs from "dayjs"; +import Image from "next/image"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Badge, Checkbox, Skeleton, cn } from "@common/components"; +import { AttachmentApi } from "@/entities/attachment"; +import type { Post } from "@/entities/post"; +import { useQuery } from "@tanstack/react-query"; +import { ENDPOINT } from "@/shared/config"; +import type { BbsType } from "@/shared/store/menu-store"; + +declare module "@tanstack/react-table" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface TableMeta { + bbsTypeCd?: BbsType; + } +} + +export const createColumns = ( + thumbRefs: React.MutableRefObject>, +): ColumnDef[] => [ + { + id: "select", + header: ({ table }) => ( + e.stopPropagation()} + onCheckedChange={(checked) => + table.toggleAllPageRowsSelected(!!checked) + } + className="h-4 w-4 rounded border-gray-300" + /> + ), + cell: ({ row, table }) => { + const { atflCd, pstNo, pstTtl, inqNocs, rgtrNm, regDt } = row.original; + const bbsTypeCd = table.options.meta?.bbsTypeCd; + + if (bbsTypeCd === "BBSIMAGE") { + return ( +
+
+ e.stopPropagation()} + onCheckedChange={(checked) => row.toggleSelected(!!checked)} + className="h-4 w-4 rounded border-gray-300" + /> + {inqNocs >= 100 && HOT} + {dayjs().diff(dayjs(row.original.regDt), "hour") < 24 && ( + NEW + )} + + {pstTtl} + +
+ +
+ + {rgtrNm} + + + {dayjs(regDt).format("YYYY-MM-DD")} + +
+
+ ); + } + + if (bbsTypeCd === "BBSMOVIE") { + return ( +
+ +
+
+
+ e.stopPropagation()} + onCheckedChange={(checked) => row.toggleSelected(!!checked)} + className="h-4 w-4 rounded border-gray-300" + /> + {inqNocs >= 100 && HOT} + {dayjs().diff(dayjs(regDt), "hour") < 24 && ( + NEW + )} + + {pstTtl} + +
+
+ + {rgtrNm} + + + {dayjs(regDt).format("YYYY-MM-DD")} + +
+
+
+
+ ); + } + }, + }, +]; + +type ThumbImageProps = { + atflCd: string; + pstNo: number; + thumbRefs: React.MutableRefObject>; + bbsTypeCd?: string; + classes?: { + image?: string; + }; +}; + +const ThumbImage = ({ + atflCd, + pstNo, + thumbRefs, + bbsTypeCd, + classes, +}: ThumbImageProps) => { + const prevImage = thumbRefs.current.get(pstNo); + + const { data: imageUrl, isLoading } = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, atflCd], + queryFn: () => { + if (bbsTypeCd === "BBSIMAGE") { + return AttachmentApi.getImageUrlByAtflCd(atflCd); + } else if (bbsTypeCd === "BBSMOVIE") { + return AttachmentApi.getMovieThumbnailUrlByAtflCd(atflCd); + } + }, + select: (url) => { + if (url) thumbRefs.current.set(pstNo, url); + return url; + }, + enabled: !prevImage && !!atflCd, + }); + + if (isLoading) + return ( +
+ +
+ ); + + if (imageUrl) + return ( +
+ 썸네일 이미지 +
+ ); + + return ( +
+ +
+ ); +}; diff --git a/apps/common/features/src/widgets/post/model/post-table-columns.tsx b/apps/common/features/src/widgets/post/model/post-table-columns.tsx new file mode 100644 index 000000000..973ce9315 --- /dev/null +++ b/apps/common/features/src/widgets/post/model/post-table-columns.tsx @@ -0,0 +1,141 @@ +import dayjs from "dayjs"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Badge, Button, Checkbox } from "@common/components"; +import { AttachmentApi } from "@/entities/attachment"; +import type { Post } from "@/entities/post"; + +export const columns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + e.stopPropagation()} + onCheckedChange={(checked) => + table.toggleAllPageRowsSelected(!!checked) + } + className="h-4 w-4 rounded border-gray-300" + /> + ), + cell: ({ row }) => ( + e.stopPropagation()} + onCheckedChange={(checked) => row.toggleSelected(!!checked)} + className="h-4 w-4 rounded border-gray-300" + /> + ), + }, + { + accessorKey: "rowNo", + header: "NO", + cell: ({ row }) => { + if (row.original.isUpndFixPost) { + return ( + + + + ); + } + return row.original.rowNo; + }, + }, + { + accessorKey: "pstTtl", + header: "제목", + cell: ({ row }) => { + const { ansPstn, pstTtl, inqNocs, cmntCnt } = row.original; + + return ( +
+ {ansPstn > 0 && ( +
+
+ +
+ 답변 +
+ )} + {inqNocs >= 100 && HOT} + {dayjs().diff(dayjs(row.original.regDt), "hour") < 24 && ( + NEW + )} + {pstTtl} + {cmntCnt > 0 && ( +
+ + {cmntCnt} +
+ )} +
+ ); + }, + }, + { + accessorKey: "inqNocs", + header: "조회수", + }, + { + accessorKey: "atflCd", + header: "첨부파일", + cell: ({ row }) => { + const atflCd = row.original.atflCd; + if (!atflCd) return null; + return ; + }, + }, + { + accessorKey: "rgtrNm", + header: "작성자", + }, + { + accessorKey: "regDt", + header: "등록일", + cell: ({ row }) => dayjs(row.original.regDt).format("YYYY-MM-DD"), + }, +]; + +const AttachmentButton = ({ atflCd }: { atflCd: string }) => { + const handleDownload = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + let filename = ""; + const response = await AttachmentApi.downloadAttachmentByAtflCd(atflCd); + const contentDisposition = response.headers["content-disposition"]; + if (contentDisposition) { + const filenameMatch = contentDisposition.match( + /filename\*=UTF-8''([^;]+)/, + ); + if (filenameMatch) { + filename = decodeURIComponent(filenameMatch[1]); + } + } + + const blob = new Blob([response.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(link); + } catch (error) { + console.error(error); + } + }; + + return ( + + ); +}; diff --git a/apps/common/features/src/widgets/post/ui/general-file-uploader.tsx b/apps/common/features/src/widgets/post/ui/general-file-uploader.tsx new file mode 100644 index 000000000..2012201da --- /dev/null +++ b/apps/common/features/src/widgets/post/ui/general-file-uploader.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from "react"; +import type { FieldPath, UseFormReturn } from "react-hook-form"; +import type { HttpResponse, Upload } from "tus-js-client"; +import { Button, FileUploader } from "@common/components"; +import { handleTusUpload } from "@/entities/tus"; +import { type Attachment } from "@/entities/attachment"; +import { setCookie, useAlertStore, useMenuStore } from "@/shared/lib"; + +function GeneralFileUploader({ + form, + attachmentData, + deleteAttachmentIds, + setDeleteAttachmentIds, +}: { + form: UseFormReturn; + attachmentData?: Attachment[]; + deleteAttachmentIds: string[]; + setDeleteAttachmentIds: (deleteAttachmentIds: string[]) => void; +}) { + const alert = useAlertStore((state) => state.setMessage); + const bbsInfo = useMenuStore((state) => state.bbsInfo); + + const [attachments, setAttachments] = useState>( + {}, + ); + + const handleTusResponse = (res: HttpResponse, upload: Upload) => { + const fileInfo = res.getHeader("X-File-Info"); + if ( + typeof fileInfo !== "string" || + fileInfo === undefined || + fileInfo === null + ) + return; + + const { atflCd, atflId } = JSON.parse(fileInfo) ?? {}; + if (atflCd) form.setValue("atflCd" as FieldPath, atflCd); + if (atflId) { + setAttachments((prev) => ({ + ...prev, + [atflId]: { + atflCd, + atflId, + orgnlFileNm: (upload.file as File).name, + atflFlieSz: (upload.file as File).size, + atflType: (upload.file as File).type, + }, + })); + } + }; + + const onUpload = (e: React.ChangeEvent) => { + const atflCd = form.getValues("atflCd" as FieldPath); + if (atflCd) setCookie("atfl-cd-key", atflCd as string); + + const files = e.target.files; + if (!files) return; + if (bbsInfo?.atflMaxCnt && files.length > bbsInfo.atflMaxCnt) { + alert("첨부파일 최대 개수를 초과했습니다."); + return; + } + + for (const file of files) { + const fileSize = Math.round(file.size / 1024); // Byte -> KB + if (bbsInfo?.atflMaxFileSz && fileSize > bbsInfo.atflMaxFileSz) { + alert("첨부파일 최대 크기를 초과했습니다."); + return; + } + handleTusUpload(file, { + handleResponse: handleTusResponse, + }); + } + }; + + useEffect(() => { + const mapper = (data: Attachment[]) => + data.reduce( + (acc, curr) => { + acc[curr.atflId] = curr; + return acc; + }, + {} as Record, + ); + if (attachmentData) setAttachments(mapper(attachmentData)); + }, [attachmentData]); + + return ( + + {Object.entries(attachments).map(([atflId, attachment]) => { + if (deleteAttachmentIds.includes(atflId)) return null; + return ( +
+ {attachment.orgnlFileNm} + +
+ ); + })} + + } + /> + ); +} + +export { GeneralFileUploader }; diff --git a/apps/common/features/src/widgets/post/ui/post-action.tsx b/apps/common/features/src/widgets/post/ui/post-action.tsx index 3756fd1aa..4eaa960cb 100644 --- a/apps/common/features/src/widgets/post/ui/post-action.tsx +++ b/apps/common/features/src/widgets/post/ui/post-action.tsx @@ -1,32 +1,41 @@ -import { useRouter } from "next/navigation"; -import { useMutation } from "@tanstack/react-query"; +import { useRouter, usePathname } from "next/navigation"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { Button } from "@common/components"; -import { PostApi } from "@/entities/post"; +import { useMenuStore, useUserStore } from "@/shared/lib"; type PostActionProps = { bbsId: string; pstNo: string; children: React.ReactNode; + handleDelete: () => Promise; }; -function PostAction({ children, bbsId, pstNo }: PostActionProps) { +function PostAction({ children, handleDelete }: PostActionProps) { const router = useRouter(); + const pathname = usePathname(); const { confirm } = useConfirm(); - - const { mutate: deletePost } = useMutation({ - mutationFn: () => PostApi.deletePost(bbsId, pstNo), - onSuccess: () => { - router.back(); - }, - }); + const bbsInfo = useMenuStore((state) => state.bbsInfo); + const userInfo = useUserStore((state) => state.userInfo); const onDelete = async () => { - if (await confirm("삭제하시겠습니까?")) deletePost(); + if (await confirm("삭제하시겠습니까?")) handleDelete(); + }; + + const checkAnsUseAuthrt = () => { + // 관리자 + if (bbsInfo?.ansUseAuthrt === "A") { + return userInfo?.adminRoleYn === "Y"; + } + // 회원 + if (bbsInfo?.ansUseAuthrt === "M") { + return !!userInfo; + } + // 비회원 + return true; }; return ( -
+ <> {children}
- + {bbsInfo?.ansUseYn === "Y" && ( + + )}
-
+ ); } diff --git a/apps/common/features/src/widgets/post/ui/post-comment.tsx b/apps/common/features/src/widgets/post/ui/post-comment.tsx new file mode 100644 index 000000000..df05afb9b --- /dev/null +++ b/apps/common/features/src/widgets/post/ui/post-comment.tsx @@ -0,0 +1,260 @@ +import dayjs from "dayjs"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Button, Input, Textarea } from "@common/components"; +import { + CommentApi, + commentFormSchema, + type CommentFormData, + type CommentUpdateFormData, +} from "@/entities/comment"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, usePrompt, useUserStore } from "@/shared/lib"; +import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form"; + +function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) { + const { prompt } = usePrompt(); + const { confirm } = useConfirm(); + const queryClient = useQueryClient(); + const userInfo = useUserStore((state) => state.userInfo); + const alert = useAlertStore((state) => state.setMessage); + const [editingCmntNo, setEditingCmntNo] = useState(null); + const [editValue, setEditValue] = useState(""); + + 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({ + 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 }); + }; + + return ( +
+
+ + ( + + +