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 { ENDPOINT } from "../config";
|
||||
|
||||
@@ -9,6 +10,7 @@ export interface SiteInfo {
|
||||
siteKindNm: string;
|
||||
siteAddr: string;
|
||||
faxNumber: string;
|
||||
dmnAddr: string;
|
||||
telNo: string;
|
||||
lwndCn: string;
|
||||
basicSiteYn: string;
|
||||
@@ -23,6 +25,35 @@ export interface SiteInfo {
|
||||
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 BASE_URL = `${PROXY_HOST}/server`;
|
||||
|
||||
@@ -38,3 +69,18 @@ export const getSiteInfo = async (siteId: string) => {
|
||||
);
|
||||
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_USER: "/cms-service/api/v1/noti/user",
|
||||
BANNERS: "/cms-service/api/v1/bnrs/user",
|
||||
SITE_LIST: "/cms-service/api/v1/sites/users/me",
|
||||
},
|
||||
USER_SERVICE: {
|
||||
USERS: "/user-service/api/v1/users",
|
||||
|
||||
@@ -28,4 +28,24 @@
|
||||
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 { useEffect, useCallback, useState } from "react";
|
||||
import { useEffect, useCallback, useState, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { images } from "@common/assets";
|
||||
import { getConfig } from "@common/config";
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
getSiteInfo,
|
||||
getUserInfo,
|
||||
type Popup,
|
||||
getSiteList,
|
||||
type SiteListResponse,
|
||||
} from "../../api";
|
||||
import { Button } from "../button";
|
||||
import { Skeleton } from "../skeleton";
|
||||
@@ -27,6 +29,8 @@ const isLoggedIn = !!userId;
|
||||
|
||||
export function Header({ viewOnly }: { viewOnly?: boolean }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [logoWidth, setLogoWidth] = useState(0);
|
||||
const logoRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
const { data: userInfo } = useQuery({
|
||||
queryKey: [ENDPOINT.USER_SERVICE.USERS],
|
||||
@@ -54,6 +58,12 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) {
|
||||
enabled: isLoggedIn && !viewOnly,
|
||||
});
|
||||
|
||||
const { data: userSiteList } = useQuery<SiteListResponse>({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.SITE_LIST],
|
||||
queryFn: () => getSiteList(),
|
||||
enabled: isLoggedIn && !viewOnly,
|
||||
});
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/auth/logout", {
|
||||
@@ -118,20 +128,56 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) {
|
||||
}, []);
|
||||
|
||||
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">
|
||||
<SidebarTrigger className="size-12 !bg-transparent p-2 hover:!bg-accent [&>svg]:!size-full [&>svg]:text-foreground" />
|
||||
<h1 className="flex items-center gap-4">
|
||||
{mounted ? (
|
||||
<img src={getLogoImage()} className="h-[3rem]" alt="logo" />
|
||||
) : (
|
||||
<h1 className="flex flex-col items-center gap-2">
|
||||
{mounted ? (
|
||||
<img
|
||||
ref={logoRef}
|
||||
src={getLogoImage()}
|
||||
className="h-[3rem]"
|
||||
alt="logo"
|
||||
onLoad={() => {
|
||||
if (logoRef.current) {
|
||||
setLogoWidth(logoRef.current.offsetWidth);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="h-[3rem] w-[15rem]" />
|
||||
)}
|
||||
<span className="text-[1.8rem] font-semibold text-foreground">
|
||||
{siteInfo?.siteNm}
|
||||
</span>
|
||||
<span
|
||||
className="text-[1.2rem] font-semibold text-foreground"
|
||||
style={{
|
||||
letterSpacing: logoWidth > 0 ? `${(logoWidth - 120) / (siteInfo?.siteNm?.length || 1)}px` : '0.05em'
|
||||
}}
|
||||
>
|
||||
{siteInfo?.siteNm}
|
||||
</span>
|
||||
</h1>
|
||||
</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">
|
||||
<SearchForm />
|
||||
{mounted ? (
|
||||
|
||||
@@ -1,32 +1,63 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "../button";
|
||||
import { Input } from "../input";
|
||||
|
||||
export function SearchForm() {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
const keyword = formData.get("keyword") as string;
|
||||
|
||||
|
||||
if (keyword && keyword.trim().length > 0) {
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} className="relative flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
name="keyword"
|
||||
className="h-[3.6rem] w-96 rounded-[3.6rem] pl-4 pr-12"
|
||||
placeholder="검색어를 입력해주세요."
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className="absolute right-[0.4rem] top-[0.4rem] size-[2.8rem] rounded-full p-2"
|
||||
>
|
||||
<i className="diveicon di-search" />
|
||||
</Button>
|
||||
</form>
|
||||
<div className="w-96">
|
||||
<form onSubmit={handleSubmit} className="relative flex items-center justify-end">
|
||||
{isExpanded && (
|
||||
<Input
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onBlur={handleInputBlur}
|
||||
className="h-[3.6rem] w-96 rounded-[3.6rem] pl-4 pr-12 absolute left-0"
|
||||
placeholder="검색어를 입력해주세요."
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
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 { 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