header 메뉴 추가 및 CM용 layout 추가
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user