header 메뉴 추가 및 CM용 layout 추가

This commit is contained in:
2025-09-02 13:25:50 +09:00
parent 5ddf9fe6e0
commit 1119f968b0
10 changed files with 252 additions and 28 deletions
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { NoLNBLayout } from "@common/components";
import type { MenuItem } from "@/entities/menu";
import { useMenuStore } from "@/shared/lib";
import { PageLayout } from "@/shared/ui/page-layout";
export default function WithBaseLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const queryClient = useQueryClient();
const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo);
const handleMenuClick = (item: MenuItem) => {
setCurrentMenuInfo(item);
router.push(item.urlPath);
};
return (
<NoLNBLayout
queryClient={queryClient}
currentPath={pathname ?? ""}
handleMenuClick={handleMenuClick}
>
<PageLayout>{children}</PageLayout>
</NoLNBLayout>
);
}
@@ -0,0 +1,2 @@
import { SkeletonPage } from "@/shared/ui/skeleton-page";
export default SkeletonPage;
@@ -1,3 +1,4 @@
import { getApiClient } from "./client";
import { getConfig } from "@common/config"; import { getConfig } from "@common/config";
import { ENDPOINT } from "../config"; import { ENDPOINT } from "../config";
@@ -9,6 +10,7 @@ export interface SiteInfo {
siteKindNm: string; siteKindNm: string;
siteAddr: string; siteAddr: string;
faxNumber: string; faxNumber: string;
dmnAddr: string;
telNo: string; telNo: string;
lwndCn: string; lwndCn: string;
basicSiteYn: string; basicSiteYn: string;
@@ -23,6 +25,35 @@ export interface SiteInfo {
mdfrNm: string; mdfrNm: string;
} }
export interface SiteListResponse {
content: SiteInfo[];
pageable: {
sort: {
empty: boolean;
sorted: boolean;
unsorted: boolean;
};
offset: number;
pageNumber: number;
pageSize: number;
paged: boolean;
unpaged: boolean;
};
last: boolean;
totalElements: number;
totalPages: number;
size: number;
number: number;
first: boolean;
sort: {
empty: boolean;
sorted: boolean;
unsorted: boolean;
};
numberOfElements: number;
empty: boolean;
}
const { PROXY_HOST } = getConfig(); const { PROXY_HOST } = getConfig();
const BASE_URL = `${PROXY_HOST}/server`; const BASE_URL = `${PROXY_HOST}/server`;
@@ -38,3 +69,18 @@ export const getSiteInfo = async (siteId: string) => {
); );
return response.json() as Promise<SiteInfo>; return response.json() as Promise<SiteInfo>;
}; };
const client = getApiClient();
export const getSiteList = async () => {
const response = await client.get<SiteListResponse>(
`${BASE_URL}/${ENDPOINT.CMS_SERVICE.SITE_LIST}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
},
);
return response.data;
};
@@ -10,6 +10,7 @@ export const ENDPOINT = {
NOTICE: "/cms-service/api/v1/noti", NOTICE: "/cms-service/api/v1/noti",
NOTICE_USER: "/cms-service/api/v1/noti/user", NOTICE_USER: "/cms-service/api/v1/noti/user",
BANNERS: "/cms-service/api/v1/bnrs/user", BANNERS: "/cms-service/api/v1/bnrs/user",
SITE_LIST: "/cms-service/api/v1/sites/users/me",
}, },
USER_SERVICE: { USER_SERVICE: {
USERS: "/user-service/api/v1/users", USERS: "/user-service/api/v1/users",
@@ -28,4 +28,24 @@
color: #fff; color: #fff;
} }
} }
/* 상단 메뉴 호버 시 하단 라인 */
.header-nav-link {
position: relative;
}
.header-nav-link::after {
content: '';
position: absolute;
bottom: -4px;
left: 0;
width: 0;
height: 2px;
background-color: #063A74;
transition: width 0.3s ease;
}
.header-nav-link:hover::after {
width: 100%;
}
} }
@@ -1,6 +1,6 @@
import "./header.css"; import "./header.css";
import { useEffect, useCallback, useState } from "react"; import { useEffect, useCallback, useState, useRef } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { images } from "@common/assets"; import { images } from "@common/assets";
import { getConfig } from "@common/config"; import { getConfig } from "@common/config";
@@ -12,6 +12,8 @@ import {
getSiteInfo, getSiteInfo,
getUserInfo, getUserInfo,
type Popup, type Popup,
getSiteList,
type SiteListResponse,
} from "../../api"; } from "../../api";
import { Button } from "../button"; import { Button } from "../button";
import { Skeleton } from "../skeleton"; import { Skeleton } from "../skeleton";
@@ -27,6 +29,8 @@ const isLoggedIn = !!userId;
export function Header({ viewOnly }: { viewOnly?: boolean }) { export function Header({ viewOnly }: { viewOnly?: boolean }) {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [logoWidth, setLogoWidth] = useState(0);
const logoRef = useRef<HTMLImageElement>(null);
const { data: userInfo } = useQuery({ const { data: userInfo } = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.USERS], queryKey: [ENDPOINT.USER_SERVICE.USERS],
@@ -54,6 +58,12 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) {
enabled: isLoggedIn && !viewOnly, enabled: isLoggedIn && !viewOnly,
}); });
const { data: userSiteList } = useQuery<SiteListResponse>({
queryKey: [ENDPOINT.CMS_SERVICE.SITE_LIST],
queryFn: () => getSiteList(),
enabled: isLoggedIn && !viewOnly,
});
const logout = async () => { const logout = async () => {
try { try {
const response = await fetch("/api/auth/logout", { const response = await fetch("/api/auth/logout", {
@@ -118,20 +128,56 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) {
}, []); }, []);
return ( return (
<header className="sticky top-0 z-30 box-border flex h-[5rem] w-full items-center justify-between border-b border-solid border-border bg-background"> <header className="sticky top-0 z-30 box-border flex h-[6rem] w-full items-center justify-between border-b border-solid border-border bg-background">
<div className="flex items-center px-6"> <div className="flex items-center px-6">
<SidebarTrigger className="size-12 !bg-transparent p-2 hover:!bg-accent [&>svg]:!size-full [&>svg]:text-foreground" /> <SidebarTrigger className="size-12 !bg-transparent p-2 hover:!bg-accent [&>svg]:!size-full [&>svg]:text-foreground" />
<h1 className="flex items-center gap-4"> <h1 className="flex flex-col items-center gap-2">
{mounted ? ( {mounted ? (
<img src={getLogoImage()} className="h-[3rem]" alt="logo" /> <img
) : ( ref={logoRef}
src={getLogoImage()}
className="h-[3rem]"
alt="logo"
onLoad={() => {
if (logoRef.current) {
setLogoWidth(logoRef.current.offsetWidth);
}
}}
/>
) : (
<Skeleton className="h-[3rem] w-[15rem]" /> <Skeleton className="h-[3rem] w-[15rem]" />
)} )}
<span className="text-[1.8rem] font-semibold text-foreground"> <span
{siteInfo?.siteNm} className="text-[1.2rem] font-semibold text-foreground"
</span> style={{
letterSpacing: logoWidth > 0 ? `${(logoWidth - 120) / (siteInfo?.siteNm?.length || 1)}px` : '0.05em'
}}
>
{siteInfo?.siteNm}
</span>
</h1> </h1>
</div> </div>
<div className="flex items-center">
{mounted && userSiteList?.content && userSiteList.content.length > 0 && (
<nav className="flex items-center gap-6">
{userSiteList.content.map((site, index) => (
<div key={site.siteId} className="flex items-center">
<a
href={site.dmnAddr}
className="text-2xl font-medium text-foreground hover:text-primary transition-colors header-nav-link"
target="_blank"
rel="noopener noreferrer"
>
{site.siteNm}
</a>
{index < userSiteList.content.length - 1 && (
<div className="w-px h-6 bg-border ml-6"></div>
)}
</div>
))}
</nav>
)}
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<SearchForm /> <SearchForm />
{mounted ? ( {mounted ? (
@@ -1,32 +1,63 @@
import { useState } from "react";
import { Button } from "../button"; import { Button } from "../button";
import { Input } from "../input"; import { Input } from "../input";
export function SearchForm() { export function SearchForm() {
const [isExpanded, setIsExpanded] = useState(false);
const [keyword, setKeyword] = useState("");
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const keyword = formData.get("keyword") as string;
if (keyword && keyword.trim().length > 0) { if (keyword && keyword.trim().length > 0) {
window.location.href = `/cms/search?keyword=${encodeURIComponent(keyword)}`; window.location.href = `/cms/search?keyword=${encodeURIComponent(keyword)}`;
} }
}; };
const handleButtonClick = () => {
if (isExpanded) {
// 입력 박스가 열려있을 때는 검색 실행
if (keyword && keyword.trim().length > 0) {
window.location.href = `/cms/search?keyword=${encodeURIComponent(keyword)}`;
}
} else {
// 입력 박스가 닫혀있을 때는 입력 박스 열기
setIsExpanded(true);
}
};
const handleInputBlur = () => {
// 입력 박스에서 포커스가 벗어나면 닫기 (약간의 지연을 두어 버튼 클릭이 먼저 처리되도록)
setTimeout(() => {
if (!keyword.trim()) {
setIsExpanded(false);
}
}, 200);
};
return ( return (
<form onSubmit={handleSubmit} className="relative flex items-center gap-2"> <div className="w-96">
<Input <form onSubmit={handleSubmit} className="relative flex items-center justify-end">
type="text" {isExpanded && (
name="keyword" <Input
className="h-[3.6rem] w-96 rounded-[3.6rem] pl-4 pr-12" type="text"
placeholder="검색어를 입력해주세요." value={keyword}
/> onChange={(e) => setKeyword(e.target.value)}
<Button onBlur={handleInputBlur}
type="submit" className="h-[3.6rem] w-96 rounded-[3.6rem] pl-4 pr-12 absolute left-0"
size="icon" placeholder="검색어를 입력해주세요."
className="absolute right-[0.4rem] top-[0.4rem] size-[2.8rem] rounded-full p-2" autoFocus
> />
<i className="diveicon di-search" /> )}
</Button> <Button
</form> type="button"
size="icon"
onClick={handleButtonClick}
className="size-[2.8rem] rounded-full p-2 relative z-10"
>
<i className="diveicon di-search" />
</Button>
</form>
</div>
); );
} }
@@ -1,3 +1,4 @@
export { BaseLayout } from "./base"; export { BaseLayout } from "./base";
export { VBaseLayout } from "./vbase"; export { VBaseLayout } from "./vbase";
export { NoFooterLayout } from "./nofooter"; export { NoFooterLayout } from "./nofooter";
export { NoLNBLayout } from "./nolnb";
@@ -0,0 +1,42 @@
import type { QueryClient } from "@tanstack/react-query";
import { QueryProvider } from "../../provider/query-provider";
import { PopupProvider } from "../../provider/popup-provider";
import { ClientIpGuard } from "../../lib/client-ip-guard";
import type { MenuItem } from "../../api";
import { SidebarProvider } from "../sidebar";
import { LNB } from "../lnb";
import { Header } from "../header";
import { Footer } from "../footer";
import "./common.css";
export function NoLNBLayout({
children,
queryClient,
currentPath,
handleMenuClick,
}: {
children: React.ReactNode;
queryClient?: QueryClient;
currentPath?: string;
handleMenuClick?: (item: MenuItem) => void;
}) {
return (
<QueryProvider queryClient={queryClient}>
<ClientIpGuard currentPath={currentPath}>
<PopupProvider>
<SidebarProvider>
<div className="flex-1">
<Header />
<div className="flex">
<div className="flex min-h-[calc(100vh-50px)] flex-1 flex-col overflow-auto">
<div className="flex-1">{children}</div>
<Footer />
</div>
</div>
</div>
</SidebarProvider>
</PopupProvider>
</ClientIpGuard>
</QueryProvider>
);
}