From ff45d1a87524c5e21f78da7137625d439f829d05 Mon Sep 17 00:00:00 2001 From: jcchoi Date: Fri, 2 Oct 2026 21:51:56 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8A=A4=ED=82=B5=20=EB=84=A4=EB=B9=84?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(with-base-layout)/(post)/layout.tsx | 77 ++++++++++++++++++- .../common/components/src/ui/header/index.tsx | 6 +- .../common/components/src/ui/layout/base.tsx | 6 +- .../components/src/ui/layout/nofooter.tsx | 6 +- .../common/components/src/ui/layout/nolnb.tsx | 6 +- .../src/ui/layout/skip-navigation.tsx | 59 ++++++++++++++ .../common/components/src/ui/layout/vbase.tsx | 10 ++- .../common/components/src/ui/lnb/index.tsx | 5 +- 8 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 packages/common/components/src/ui/layout/skip-navigation.tsx diff --git a/apps/common/features/app/(with-base-layout)/(post)/layout.tsx b/apps/common/features/app/(with-base-layout)/(post)/layout.tsx index 7f4b1e26b..719190e46 100644 --- a/apps/common/features/app/(with-base-layout)/(post)/layout.tsx +++ b/apps/common/features/app/(with-base-layout)/(post)/layout.tsx @@ -1,8 +1,11 @@ "use client"; +import { useEffect, useRef } from "react"; +import { usePathname, useRouter } from "next/navigation"; import { MenuApi } from "@/entities/menu"; +import { MenuAuthorityApi } from "@/entities/menu-authority/@x/menu"; import { ENDPOINT } from "@/shared/config"; -import { useMenuStore } from "@/shared/lib"; +import { useAlertStore, useMenuStore } from "@/shared/lib"; import { PageLayout } from "@/shared/ui/page-layout"; import { useQuery } from "@tanstack/react-query"; @@ -11,6 +14,9 @@ export default function PostLayout({ }: { children: React.ReactNode; }) { + const router = useRouter(); + const pathname = usePathname(); + const alert = useAlertStore((state) => state.setMessage); const { bbsInfo, currentMenuInfo } = useMenuStore((state) => state); const { data } = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.MENUS, currentMenuInfo?.menuCd], @@ -18,11 +24,78 @@ export default function PostLayout({ MenuApi.getMenuInfo(currentMenuInfo?.menuCd.toString() ?? ""), enabled: !!currentMenuInfo, }); + + const isRegisterPage = + pathname?.endsWith("/register") || pathname?.endsWith("/answer"); + const isModifyPage = pathname?.endsWith("/modify"); + const requiredAuthority = isRegisterPage + ? "regAuthrtYn" + : isModifyPage + ? "mdfcnAuthrtYn" + : undefined; + const { + data: pageAuthorities, + isError: isAuthorityError, + isLoading: isAuthorityLoading, + } = useQuery({ + queryKey: ["page-menu-authority", currentMenuInfo?.menuCd], + queryFn: () => + MenuAuthorityApi.getPageMenuAuthority(currentMenuInfo!.menuCd), + enabled: Boolean(requiredAuthority && currentMenuInfo?.menuCd), + }); + const hasRequiredAuthority = + !requiredAuthority || + pageAuthorities?.[0]?.[requiredAuthority] === "Y"; + const canRenderChildren = + !requiredAuthority || (hasRequiredAuthority && !isAuthorityLoading); + const handledDeniedRoute = useRef(""); + + useEffect(() => { + if (!requiredAuthority) { + handledDeniedRoute.current = ""; + return; + } + + if ( + !pathname || + !currentMenuInfo?.menuCd || + isAuthorityLoading || + (!isAuthorityError && hasRequiredAuthority) + ) { + return; + } + + const deniedRouteKey = `${pathname}:${currentMenuInfo.menuCd}:${requiredAuthority}`; + if (handledDeniedRoute.current === deniedRouteKey) return; + handledDeniedRoute.current = deniedRouteKey; + + const listPath = pathname.replace( + /\/(?:register|[^/]+\/(?:modify|answer))\/?$/, + "", + ); + const message = isAuthorityError + ? "권한 정보를 확인할 수 없습니다." + : requiredAuthority === "regAuthrtYn" + ? "등록 권한이 없습니다." + : "수정 권한이 없습니다."; + + alert(message, () => router.replace(listPath)); + }, [ + alert, + currentMenuInfo?.menuCd, + hasRequiredAuthority, + isAuthorityError, + isAuthorityLoading, + pathname, + requiredAuthority, + router, + ]); + const menuPaths = data?.menuPath?.split(">").map((path) => path.trim()); return ( - {children} + {canRenderChildren && children} ); } diff --git a/packages/common/components/src/ui/header/index.tsx b/packages/common/components/src/ui/header/index.tsx index 2d3209aa2..a1b56a3fb 100644 --- a/packages/common/components/src/ui/header/index.tsx +++ b/packages/common/components/src/ui/header/index.tsx @@ -270,7 +270,11 @@ export function Header({ }, []); return ( -
+
diff --git a/packages/common/components/src/ui/layout/base.tsx b/packages/common/components/src/ui/layout/base.tsx index aa6e9dfdb..fd10d559e 100644 --- a/packages/common/components/src/ui/layout/base.tsx +++ b/packages/common/components/src/ui/layout/base.tsx @@ -7,6 +7,7 @@ import { SidebarProvider } from "../sidebar"; import { LNB } from "../lnb"; import { Header } from "../header"; import { Footer } from "../footer"; +import { SkipNavigation } from "./skip-navigation"; import "./common.css"; export function BaseLayout({ @@ -30,6 +31,7 @@ export function BaseLayout({
+
-
{children}
+
+ {children} +
diff --git a/packages/common/components/src/ui/layout/nofooter.tsx b/packages/common/components/src/ui/layout/nofooter.tsx index 6d7ce1109..13dd8888f 100644 --- a/packages/common/components/src/ui/layout/nofooter.tsx +++ b/packages/common/components/src/ui/layout/nofooter.tsx @@ -5,6 +5,7 @@ import type { MenuItem } from "../../api"; import { SidebarProvider } from "../sidebar"; import { LNB } from "../lnb"; import { Header } from "../header"; +import { SkipNavigation } from "./skip-navigation"; import "./common.css"; import type { AuthService } from "@common/auth"; @@ -26,6 +27,7 @@ export function NoFooterLayout({
+
-
{children}
+
+ {children} +
diff --git a/packages/common/components/src/ui/layout/nolnb.tsx b/packages/common/components/src/ui/layout/nolnb.tsx index 68e42b175..d9ff9db1a 100644 --- a/packages/common/components/src/ui/layout/nolnb.tsx +++ b/packages/common/components/src/ui/layout/nolnb.tsx @@ -6,6 +6,7 @@ import type { MenuItem } from "../../api"; import { SidebarProvider } from "../sidebar"; import { Header } from "../header"; import { Footer } from "../footer"; +import { SkipNavigation } from "./skip-navigation"; import "./common.css"; export function NoLNBLayout({ @@ -24,10 +25,13 @@ export function NoLNBLayout({
+
-
{children}
+
+ {children} +
diff --git a/packages/common/components/src/ui/layout/skip-navigation.tsx b/packages/common/components/src/ui/layout/skip-navigation.tsx new file mode 100644 index 000000000..9313b1d8f --- /dev/null +++ b/packages/common/components/src/ui/layout/skip-navigation.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useSidebar } from "../sidebar"; + +type SkipNavigationProps = { + includeLnb?: boolean; +}; + +const linkClassName = + "sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[100] focus:rounded-sm focus:bg-background focus:px-4 focus:py-3 focus:text-foreground focus:shadow-lg focus:outline focus:outline-2 focus:outline-primary"; + +export function SkipNavigation({ + includeLnb = true, +}: SkipNavigationProps) { + const { isMobile, open, openMobile, setOpen, setOpenMobile } = useSidebar(); + const shouldFocusLnb = useRef(false); + + useEffect(() => { + const isLnbOpen = isMobile ? openMobile : open; + if (!shouldFocusLnb.current || !isLnbOpen) return; + + document.getElementById("lnb")?.focus(); + shouldFocusLnb.current = false; + }, [isMobile, open, openMobile]); + + const handleLnbClick = (event: React.MouseEvent) => { + event.preventDefault(); + const isLnbOpen = isMobile ? openMobile : open; + + if (isLnbOpen) { + document.getElementById("lnb")?.focus(); + return; + } + + shouldFocusLnb.current = true; + if (isMobile) { + setOpenMobile(true); + } else { + setOpen(true); + } + }; + + return ( + + ); +} diff --git a/packages/common/components/src/ui/layout/vbase.tsx b/packages/common/components/src/ui/layout/vbase.tsx index 7868a412f..d855d0cd9 100644 --- a/packages/common/components/src/ui/layout/vbase.tsx +++ b/packages/common/components/src/ui/layout/vbase.tsx @@ -3,6 +3,7 @@ import { SidebarProvider } from "../sidebar"; import { LNB } from "../lnb"; import { Header } from "../header"; import { Footer } from "../footer"; +import { SkipNavigation } from "./skip-navigation"; import "./common.css"; import type { QueryClient } from "@tanstack/react-query"; @@ -18,13 +19,18 @@ export function VBaseLayout({
+
-
+
{children} -
+
diff --git a/packages/common/components/src/ui/lnb/index.tsx b/packages/common/components/src/ui/lnb/index.tsx index 99c7402d7..cdfb51f32 100644 --- a/packages/common/components/src/ui/lnb/index.tsx +++ b/packages/common/components/src/ui/lnb/index.tsx @@ -187,7 +187,6 @@ export function LNB({ }) { const { open: isSidebarOpen, - setOpen, isMobile: isMobileView, setOpenMobile: closeMobileSidebar, } = useSidebar(); @@ -348,6 +347,10 @@ export function LNB({ )} >