diff --git a/apps/dm/app/(with-layout)/contents/page.tsx b/apps/dm/app/(with-layout)/contents/page.tsx new file mode 100644 index 000000000..27d0974be --- /dev/null +++ b/apps/dm/app/(with-layout)/contents/page.tsx @@ -0,0 +1,9 @@ +export const dynamic = "force-dynamic"; +import { ContentsPage } from "@/components/ui/contents"; + +export default async function Contents() { + const contsSn = 28; + const menuCd = "24"; + + return ; +} diff --git a/apps/dm/app/(with-layout)/example/page.tsx b/apps/dm/app/(with-layout)/example/page.tsx index b32b3346e..94e908d81 100644 --- a/apps/dm/app/(with-layout)/example/page.tsx +++ b/apps/dm/app/(with-layout)/example/page.tsx @@ -1,3 +1,6 @@ +/** + * 물 재이용 사례 + */ import SubPageHeader from "@/components/SubPageHeader"; import BusinessSubNav, { type BusinessSubNavMenu } from "@/components/BusinessSubNav"; diff --git a/apps/dm/app/(with-layout)/status/_components/SewageStatusSection.tsx b/apps/dm/app/(with-layout)/status/_components/SewageStatusSection.tsx index cd660a929..f7eb49a84 100644 --- a/apps/dm/app/(with-layout)/status/_components/SewageStatusSection.tsx +++ b/apps/dm/app/(with-layout)/status/_components/SewageStatusSection.tsx @@ -287,7 +287,7 @@ export default function SewageStatusSection() { const isSubItem = type === "red-item" || type === "blue-item"; if (index === 0) { - console.log(header.id, value); + // console.log(header.id, value); return ( {isSubItem ? ( diff --git a/apps/dm/app/(with-layout)/status/page.tsx b/apps/dm/app/(with-layout)/status/page.tsx index b18662c06..8bed3d04f 100644 --- a/apps/dm/app/(with-layout)/status/page.tsx +++ b/apps/dm/app/(with-layout)/status/page.tsx @@ -58,7 +58,7 @@ const menus: BusinessSubNavMenu[] = [ }, { id: "status-treated-usage", - label: "연도별 하수처리수 재이용 현황", + label: "용도별 하수처리수 재이용 현황", }, { id: "status-treated-facility", diff --git a/apps/dm/app/layout.tsx b/apps/dm/app/layout.tsx index 1fd9ea1cc..49c6c357c 100644 --- a/apps/dm/app/layout.tsx +++ b/apps/dm/app/layout.tsx @@ -16,7 +16,7 @@ export default function RootLayout({ 물재이용 대민 -
+
{children}
diff --git a/apps/dm/app/main/layout.tsx b/apps/dm/app/main/layout.tsx index b1304a66a..873024784 100644 --- a/apps/dm/app/main/layout.tsx +++ b/apps/dm/app/main/layout.tsx @@ -1,12 +1,10 @@ // app/main/layout.tsx import "../reuse.module.css"; -import TopNav from "@/components/TopNav"; import { StoreProvider } from "../store"; export default function ReuseLandingLayout({ children }: { children: React.ReactNode }) { return ( -
{children}
{/* TopNav 높이만큼 */}
); diff --git a/apps/dm/app/store/store.tsx b/apps/dm/app/store/store.tsx index c1d4fcdfd..3b7b2c24d 100644 --- a/apps/dm/app/store/store.tsx +++ b/apps/dm/app/store/store.tsx @@ -33,7 +33,7 @@ export const StoreZus = ( return createStore()((set) => { if (typeof window !== "undefined") { tokenManager.setToken("site-id-key", process.env.NEXT_PUBLIC_SITE_ID||"DM"); - tokenManager.setToken("site-theme-key", "green"); + tokenManager.setToken("site-theme-key", "white"); } return { diff --git a/apps/dm/components/BusinessSubNav.tsx b/apps/dm/components/BusinessSubNav.tsx index 6b4246412..d8753b5dd 100644 --- a/apps/dm/components/BusinessSubNav.tsx +++ b/apps/dm/components/BusinessSubNav.tsx @@ -1,3 +1,6 @@ +/** + * 우측 메뉴 + */ "use client"; import Link from "next/link"; @@ -47,44 +50,67 @@ export default function BusinessSubNav({ setOpenMenus(initialOpenState); }, [menus]); - useEffect(() => { - if (!allSections.length) return; +useEffect(() => { + if (!allSections.length) return; - const observer = new IntersectionObserver( - (entries) => { - const visible = entries - .filter((entry) => entry.isIntersecting) - .sort((a, b) => b.intersectionRatio - a.intersectionRatio); + const targets = allSections + .map((section) => document.getElementById(section.id)) + .filter(Boolean) as HTMLElement[]; - if (visible[0]) { - setActiveId(visible[0].target.id); - } - }, - { - rootMargin: `-${offsetTop}px 0px -55% 0px`, - threshold: [0.2, 0.35, 0.5, 0.7], + if (!targets.length) return; + + let rafId = 0; + + const updateActiveSection = () => { + const baseLine = offsetTop + 500; + let nextActiveId = targets[0]?.id ?? ""; + + for (const el of targets) { + const { top } = el.getBoundingClientRect(); + if (top <= baseLine) { + nextActiveId = el.id; + continue; } - ); - const targets = allSections - .map((section) => document.getElementById(section.id)) - .filter(Boolean) as HTMLElement[]; + break; + } - targets.forEach((el) => observer.observe(el)); + setActiveId((prev) => (prev === nextActiveId ? prev : nextActiveId)); + }; - const hash = window.location.hash.replace("#", ""); - const initialActive = - allSections.find((section) => section.id === hash)?.id ?? - allSections[0]?.id ?? - ""; + const requestUpdate = () => { + if (rafId) return; - setActiveId(initialActive); + rafId = window.requestAnimationFrame(() => { // 예약번호 반환 + updateActiveSection(); + rafId = 0; + }); + }; - return () => observer.disconnect(); - }, [allSections, offsetTop]); + const hash = window.location.hash.replace("#", ""); + const initialActive = + allSections.find((section) => section.id === hash)?.id ?? + allSections[0]?.id ?? + ""; - useEffect(() => { - if (!activeId) return; + setActiveId(initialActive); + updateActiveSection(); + + window.addEventListener("scroll", requestUpdate, { passive: true }); + window.addEventListener("resize", requestUpdate); + + return () => { + window.removeEventListener("scroll", requestUpdate); + window.removeEventListener("resize", requestUpdate); + + if (rafId) { + window.cancelAnimationFrame(rafId); + } + }; +}, [allSections, offsetTop]); + + useEffect(() => { + if (!activeId) return; const parentMenu = menus.find((menu) => (menu.sections ?? []).some((section) => section.id === activeId) @@ -93,13 +119,13 @@ export default function BusinessSubNav({ if (!parentMenu) return; setOpenMenus(() => { - const nextOpenState: Record = {}; + const nextOpenState: Record = {}; - menus.forEach((menu) => { + menus.forEach((menu) => { nextOpenState[menu.key] = menu.key === parentMenu.key; - }); + }); - return nextOpenState; + return nextOpenState; }); }, [activeId, menus]); diff --git a/apps/dm/components/ui/contents/contents-api.ts b/apps/dm/components/ui/contents/contents-api.ts new file mode 100644 index 000000000..3a7c64639 --- /dev/null +++ b/apps/dm/components/ui/contents/contents-api.ts @@ -0,0 +1,26 @@ +// apps/dm/src/entities/content/ +// contents-api.ts +import { unstable_cache } from "next/cache"; +import { ENDPOINT } from "@/src/shared/config"; + + + +export const getCachedContentDetail = unstable_cache( + async (contsSn: number, menuCd: string) => { + const baseUrl = process.env.NEXT_PUBLIC_PROXY_HOST || "http://localhost:3015"; + const res = await fetch( + `${baseUrl}${ENDPOINT.CMS_SERVICE.CONTENTS}/${contsSn}?menuCd=${menuCd}`, + { + next: { revalidate: 3600, tags: [`content:${contsSn}`] }, + } + ); + + if (!res.ok) { + console.log(res); + throw new Error("Failed to fetch content"); + } + + return res.json(); + }, + ["dm-content-detail"], +); diff --git a/apps/dm/components/ui/contents/contentsPage.tsx b/apps/dm/components/ui/contents/contentsPage.tsx new file mode 100644 index 000000000..c823acd42 --- /dev/null +++ b/apps/dm/components/ui/contents/contentsPage.tsx @@ -0,0 +1,18 @@ +/** + * 컨텐츠 서버 페이지 + */ +import { getCachedContentDetail } from "./contents-api"; +import { ContentsPageClient } from "./contentsPageClient"; + +type ContentsPageProps = { + contsSn: number; + menuCd: string; +}; + +async function ContentsPage({ contsSn, menuCd }: ContentsPageProps) { + const initialContent = await getCachedContentDetail(contsSn, menuCd); + + return ; +} + +export { ContentsPage }; diff --git a/apps/dm/components/ui/contents/contentsPageClient.tsx b/apps/dm/components/ui/contents/contentsPageClient.tsx new file mode 100644 index 000000000..97c62bb83 --- /dev/null +++ b/apps/dm/components/ui/contents/contentsPageClient.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import type { ContentsDetail } from "@/src/entities/content/model/contents-interface"; + +type ContentsPageClientProps = { + initialContent: ContentsDetail; +}; + +function ContentsPageClient({ initialContent }: ContentsPageClientProps) { + const shadowRootRef = useRef(null); + + useEffect(() => { + if (!shadowRootRef.current || !initialContent?.contsCn) return; + + const shadowRoot = + shadowRootRef.current.shadowRoot ?? + shadowRootRef.current.attachShadow({ mode: "open" }); + shadowRoot.innerHTML = ""; + + const styleElement = document.createElement("style"); + styleElement.textContent = `div { font-size: 16px; line-height: 1.5; }`; + shadowRoot.appendChild(styleElement); + + const contentElement = document.createElement("div"); + contentElement.innerHTML = initialContent.contsCn; + shadowRoot.appendChild(contentElement); + }, [initialContent]); + + return ( +
+
+
+ ); +} + +export { ContentsPageClient }; diff --git a/apps/dm/components/ui/contents/index.ts b/apps/dm/components/ui/contents/index.ts new file mode 100644 index 000000000..82a5dad4f --- /dev/null +++ b/apps/dm/components/ui/contents/index.ts @@ -0,0 +1 @@ +export { ContentsPage } from "./contentsPage"; diff --git a/apps/dm/middleware.ts b/apps/dm/middleware.ts index f4f145217..c2a228931 100644 --- a/apps/dm/middleware.ts +++ b/apps/dm/middleware.ts @@ -60,7 +60,7 @@ export function middleware(req: NextRequest) { if (pathname.startsWith("/user-service") || pathname.startsWith("/cms-service")) { - let queryParams = searchParams.toString(); + const queryParams = searchParams.toString(); const requestHeaders = new Headers(req.headers); const newUrl = `${serverApiUrl}${pathname}${queryParams ? `?${queryParams}` : ""}`; diff --git a/apps/dm/next.config.ts b/apps/dm/next.config.ts index 5b5bf85da..bbe67b0ce 100644 --- a/apps/dm/next.config.ts +++ b/apps/dm/next.config.ts @@ -48,6 +48,10 @@ const nextConfig: NextConfig = { source: "/operation-service/:path*", destination: `${subDomain}/operation-service/:path*`, }, + { + source: "/cms-service/:path*", + destination: `${subDomain}/cms-service/:path*`, + }, ], fallback: [], }; diff --git a/apps/dm/src/entities/content/api/contents-service.ts b/apps/dm/src/entities/content/api/contents-service.ts new file mode 100644 index 000000000..292d9b795 --- /dev/null +++ b/apps/dm/src/entities/content/api/contents-service.ts @@ -0,0 +1,101 @@ +import { client } from "@/src/shared/api"; +import { ENDPOINT } from "@/src/shared/config"; +import type { + ContentsService, + ContentsParams, + ContentsList, + ContentsFormData, + ContentsVersionParams, + ContentsVersionList, + ContentsDetail, +} from "../model/contents-interface"; + +export class ContentsApiService implements ContentsService { + public async getContentsList(params: ContentsParams): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CONTENTS}`, + { params }, + ); + return response.data; + } + + public async getLatestApprovedContentsInfo( + contsSn: number, + menuCd: string, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CONTENTS}/${contsSn}`, + { params: { menuCd } }, + ); + return response.data; + } + + public async getContentsVersionList( + params: ContentsVersionParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}`, + { params }, + ); + return response.data; + } + + public async getLatestContentsInfo( + contsSn: number, + contsVer: number, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`, + ); + return response.data; + } + + public async createContents(data: ContentsFormData): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}`, + data, + ); + return response.data; + } + + public async updateContents( + contsSn: number, + contsVer: number, + data: ContentsFormData, + ): Promise { + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`, + data, + ); + return response.data; + } + + public async deleteContents( + contsSn: number, + contsVer: number, + ): Promise { + await client.delete( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`, + ); + } + + public async requestApprove( + contsSn: number, + contsVer: number, + ): Promise { + await client.put( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}/approveRequest`, + ); + } + + public async approveContents( + contsSn: number, + contsVer: number, + ): Promise { + await client.put( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}/approve`, + ); + } +} + +export const ContentsApi = new ContentsApiService(); diff --git a/apps/dm/src/entities/content/index.ts b/apps/dm/src/entities/content/index.ts new file mode 100644 index 000000000..2446dd716 --- /dev/null +++ b/apps/dm/src/entities/content/index.ts @@ -0,0 +1,8 @@ +export { ContentsApi } from "./api/contents-service"; +export { + CONTENTS_APPROVE_STATUS, + contentsFormSchema, + type Contents, + type ContentsParams, + type ContentsFormData, +} from "./model/contents-interface"; diff --git a/apps/dm/src/entities/content/model/api-interface.ts b/apps/dm/src/entities/content/model/api-interface.ts new file mode 100644 index 000000000..8218bfb0d --- /dev/null +++ b/apps/dm/src/entities/content/model/api-interface.ts @@ -0,0 +1,34 @@ +interface SortConfig { + empty: boolean; + sorted: boolean; + unsorted: boolean; +} + +interface PageableConfig { + sort: SortConfig; + offset: number; + pageNumber: number; + pageSize: number; + paged: boolean; + unpaged: boolean; +} + +export interface PageableList { + content: T[]; + pageable: PageableConfig; + sort: SortConfig; + totalPages: number; + totalElements: number; + number: number; + numberOfElements: number; + size: number; + first: boolean; + last: boolean; + empty: boolean; +} + +export interface PageableParams { + page?: number; + size?: number; + sort?: string; +} diff --git a/apps/dm/src/entities/content/model/contents-interface.ts b/apps/dm/src/entities/content/model/contents-interface.ts new file mode 100644 index 000000000..474dfe97c --- /dev/null +++ b/apps/dm/src/entities/content/model/contents-interface.ts @@ -0,0 +1,105 @@ +import { z } from "zod"; +import type { PageableList, PageableParams } from "./api-interface"; + +export const CONTENTS_APPROVE_STATUS = { + DRAFT: "01", + REQUESTED: "02", + APPROVED: "99", +} as const; + +export type ContentsApproveStatus = + (typeof CONTENTS_APPROVE_STATUS)[keyof typeof CONTENTS_APPROVE_STATUS]; + +export interface ContentsParams extends PageableParams { + contsTtl?: string; + aprvContsVer?: number; + useYn?: string; +} + +export interface ContentsVersionParams extends PageableParams { + contsSn?: number; + contsCn?: string; + aprvCd?: ContentsApproveStatus; + cclCd?: string; + koglCd?: string; + tag?: string; + useYn?: string; +} + +export interface ContentsVersion { + contsSn: number; + contsVer: number; + contsCn: string; + useYn: string; +} + +export const contentsSchema = z.object({ + contsSn: z.number(), + contsTtl: z.string(), + aprvContsVer: z.number(), + useYn: z.string(), + mdfcnDt: z.string(), + mdfr: z.string(), + mdfrNm: z.string(), + aprvDt: z.string(), + autzrNm: z.string(), + workAprvCd: z.string(), + workAprvNm: z.string(), + workContsVer: z.number(), +}); + +export const contentsDetailSchema = z.object({ + contsSn: z.number(), + contsVer: z.number(), + contsTtl: z.string(), + contsCn: z.string(), + contsRmrk: z.string().nullish(), + aprvCd: z.enum([ + CONTENTS_APPROVE_STATUS.DRAFT, + CONTENTS_APPROVE_STATUS.REQUESTED, + CONTENTS_APPROVE_STATUS.APPROVED, + ]), + cclCd: z.string().nullish(), + koglCd: z.string().nullish(), + tag: z.string().nullish(), + aprvDt: z.string(), + autzr: z.string(), + useYn: z.string(), + menuPicUserUnqIdNm: z.string().nullish(), +}); + +export const contentsFormSchema = contentsDetailSchema.omit({ + contsVer: true, + aprvDt: true, + autzr: true, +}); + +export type Contents = z.infer; +export type ContentsDetail = z.infer; +export type ContentsFormData = z.infer; +export type ContentsList = PageableList; +export type ContentsVersionList = PageableList; + +export interface ContentsService { + getContentsList(params: ContentsParams): Promise; + getLatestApprovedContentsInfo( + contsSn: number, + menuCd: string, + ): Promise; + getContentsVersionList( + params: ContentsVersionParams, + ): Promise; + getLatestContentsInfo( + contsSn: number, + contsVer: number, + ): Promise; + createContents(data: ContentsFormData): Promise; + updateContents( + contsSn: number, + contsVer: number, + data: ContentsFormData, + ): Promise; + deleteContents(contsSn: number, contsVer: number): Promise; + requestApprove(contsSn: number, contsVer: number): Promise; + approveContents(contsSn: number, contsVer: number): Promise; +}