324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
import "./header.css";
|
|
|
|
import { useEffect, useCallback, useState, useRef } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { images } from "@common/assets";
|
|
import { getConfig } from "@common/config";
|
|
import { ENDPOINT } from "../../config";
|
|
import { setCookie, getCookie, removeCookie } from "../../lib";
|
|
import {
|
|
getPopupInfo,
|
|
getUserPopupList,
|
|
getSiteInfo,
|
|
getUserInfo,
|
|
type Popup,
|
|
getSiteList,
|
|
type SiteListResponse,
|
|
} from "../../api";
|
|
import { Button } from "../button";
|
|
import { Skeleton } from "../skeleton";
|
|
import { SidebarTrigger } from "../sidebar";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
|
|
import { SearchForm } from "./search-form";
|
|
import { NoticePopover } from "./notice-popover";
|
|
|
|
const appConfig = getConfig();
|
|
const userId = getCookie(appConfig.AUTH_USER_ID ?? "");
|
|
const siteId = getCookie("site-id-key") ?? "";
|
|
const isLoggedIn = !!userId;
|
|
|
|
export function Header({ viewOnly }: { viewOnly?: boolean }) {
|
|
const [mounted, setMounted] = useState(false);
|
|
const [logoWidth, setLogoWidth] = useState(0);
|
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
const logoRef = useRef<HTMLImageElement>(null);
|
|
|
|
const { data: userInfo } = useQuery({
|
|
queryKey: [ENDPOINT.USER_SERVICE.USERS],
|
|
queryFn: () => getUserInfo(),
|
|
enabled: isLoggedIn && !viewOnly,
|
|
});
|
|
|
|
const { data: siteInfo } = useQuery({
|
|
queryKey: [ENDPOINT.CMS_SERVICE.SITES, siteId],
|
|
queryFn: () => getSiteInfo(siteId),
|
|
select: (data) => {
|
|
if (data.siteSkn) {
|
|
const siteSkin = data.siteSkn.toLowerCase();
|
|
setCookie("site-theme-key", siteSkin);
|
|
document.documentElement.setAttribute("data-theme", siteSkin);
|
|
}
|
|
return data;
|
|
},
|
|
enabled: !viewOnly,
|
|
});
|
|
|
|
const { data: userPopupList } = useQuery({
|
|
queryKey: [ENDPOINT.CMS_SERVICE.POPUPS],
|
|
queryFn: () => getUserPopupList(),
|
|
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", {
|
|
method: "POST",
|
|
});
|
|
if (!response.ok) throw new Error("Failed to logout");
|
|
|
|
const actualHost = window.location.hostname;
|
|
|
|
// 서브도메인 쿠키 삭제를 위한 루트 도메인 추출
|
|
const getRootDomain = (hostname: string): string => {
|
|
// localhost나 IP 주소인 경우
|
|
if (hostname === "localhost" || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
|
|
return hostname;
|
|
}
|
|
|
|
// hasuon.or.kr 도메인인 경우
|
|
if (hostname.includes("hasuon.or.kr")) {
|
|
return ".hasuon.or.kr";
|
|
}
|
|
|
|
// 일반적인 도메인의 경우 루트 도메인 추출 (예: sub.example.com → .example.com)
|
|
const parts = hostname.split(".");
|
|
if (parts.length >= 2) {
|
|
// 마지막 두 부분을 사용 (예: example.com)
|
|
return `.${parts.slice(-2).join(".")}`;
|
|
}
|
|
|
|
return hostname;
|
|
};
|
|
|
|
const domain = getRootDomain(actualHost);
|
|
const cookieOptions = domain.startsWith(".") || domain === "localhost"
|
|
? { domain }
|
|
: {};
|
|
|
|
// 쿠키 삭제 (서브도메인 포함)
|
|
if (appConfig.AUTH_USER_ID) {
|
|
removeCookie(appConfig.AUTH_USER_ID, cookieOptions);
|
|
}
|
|
if (appConfig.REFRESH_TOKEN) {
|
|
removeCookie(appConfig.REFRESH_TOKEN, cookieOptions);
|
|
}
|
|
if (appConfig.ACCESS_TOKEN) {
|
|
removeCookie(appConfig.ACCESS_TOKEN, cookieOptions);
|
|
}
|
|
if (appConfig.CLAIM_NAME) {
|
|
removeCookie(appConfig.CLAIM_NAME, cookieOptions);
|
|
}
|
|
|
|
removeCookie("site-id-key", cookieOptions);
|
|
removeCookie("site-theme-key", cookieOptions);
|
|
|
|
window.location.href = "/auth/login";
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
};
|
|
|
|
const openPopup = useCallback(async (popup: Popup) => {
|
|
try {
|
|
const cookieName = `cs-popup-hide-for-day-${popup.popupSn}`;
|
|
const hasCookie = getCookie(cookieName);
|
|
if (hasCookie) return;
|
|
|
|
const response = await getPopupInfo(popup.popupSn);
|
|
if (!response) return;
|
|
|
|
const width = response.popupWdth || 400;
|
|
const height = response.popupHgt || 300;
|
|
const left = (response.popupHrzntltyPstn || 0) + window.screenX;
|
|
const top = (response.popupVtclPstn || 0) + window.screenY;
|
|
|
|
const popupWindow = window.open(
|
|
`/view/popup/${popup.popupSn}`,
|
|
"",
|
|
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes,status=no,location=no,menubar=no`,
|
|
);
|
|
|
|
if (popupWindow) {
|
|
popupWindow.focus();
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to open popup ${popup.popupSn}:`, error);
|
|
}
|
|
}, []);
|
|
|
|
const getLogoImage = () => {
|
|
const siteThemeKey = getCookie("site-theme-key");
|
|
if (siteThemeKey === "blue" || siteThemeKey === "green")
|
|
return images.logoWhite;
|
|
return images.logo;
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!userPopupList?.length) return;
|
|
|
|
const timer = setTimeout(() => {
|
|
userPopupList.forEach((popup) => {
|
|
openPopup(popup);
|
|
});
|
|
}, 500);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [userPopupList, openPopup]);
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
return (
|
|
<header className="sticky top-0 z-30 box-border flex flex-col sm:flex-row h-[6rem] w-full items-center justify-between border-b border-solid border-border bg-background">
|
|
<div className="flex items-center px-2 sm:px-4 md:px-6 w-full sm:w-auto justify-between sm:justify-start">
|
|
<div className="flex items-center gap-2 sm:gap-4">
|
|
<SidebarTrigger className="size-12 !bg-transparent p-2 hover:!bg-accent [&>svg]:!size-full [&>svg]:text-foreground" />
|
|
<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.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 gap-1 sm:hidden">
|
|
{/* 모바일 메뉴 토글 버튼 */}
|
|
{mounted && (
|
|
<Button
|
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
|
className="m-0 flex h-[3rem] items-center !bg-background px-3 py-0 text-label"
|
|
>
|
|
<i className={`diveicon ${isMobileMenuOpen ? 'di-menu-close-fill' : 'di-menu-open-fill'} static text-4xl`} />
|
|
</Button>
|
|
)}
|
|
{mounted && isLoggedIn && (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button className="group m-0 flex h-[3rem] items-center !bg-background px-2 py-0 text-label">
|
|
<i className="diveicon di-account-circle static translate-y-0.5 pr-2 text-3xl" />
|
|
<span className="text-xs hidden xs:inline">{userInfo?.userName}</span>
|
|
<i className="diveicon di-chevron-down static p-0 transition-transform group-data-[state=open]:rotate-180" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="w-48 border-border p-1">
|
|
<ul>
|
|
<li className="cursor-pointer rounded-sm p-3 text-xl hover:bg-accent">
|
|
<NoticePopover />
|
|
</li>
|
|
<li
|
|
onClick={logout}
|
|
className="cursor-pointer rounded-sm p-3 text-xl hover:bg-accent"
|
|
>
|
|
로그아웃
|
|
</li>
|
|
</ul>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* 모바일에서 네비게이션 (토글 시 표시) */}
|
|
{isMobileMenuOpen && (
|
|
<div className="w-full sm:hidden border-t border-border bg-background py-2">
|
|
{mounted && userSiteList?.content && userSiteList.content.length > 0 && (
|
|
<nav className="flex flex-col items-center gap-2 px-4">
|
|
{userSiteList.content.map((site) => (
|
|
<a
|
|
key={site.siteId}
|
|
href={site.dmnAddr}
|
|
className="text-xl font-medium text-foreground hover:text-primary transition-colors w-full text-center py-2"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={() => setIsMobileMenuOpen(false)}
|
|
>
|
|
{site.siteNm}
|
|
</a>
|
|
))}
|
|
</nav>
|
|
)}
|
|
</div>
|
|
)}
|
|
{/* 데스크톱에서 중간 네비게이션 */}
|
|
<div className="hidden sm:flex items-center flex-1 justify-center px-2">
|
|
{mounted && userSiteList?.content && userSiteList.content.length > 0 && (
|
|
<nav className="flex items-center gap-3 md:gap-6 flex-wrap justify-center">
|
|
{userSiteList.content.map((site, index) => (
|
|
<div key={site.siteId} className="flex items-center">
|
|
<a
|
|
href={site.dmnAddr}
|
|
className="text-sm md:text-lg lg: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-4 md:h-6 bg-border ml-3 md:ml-6"></div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</nav>
|
|
)}
|
|
</div>
|
|
{/* 데스크톱에서 오른쪽 요소들 */}
|
|
<div className="hidden sm:flex items-center gap-2 px-2 md:px-4">
|
|
<SearchForm />
|
|
{mounted ? (
|
|
isLoggedIn && (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button className="group m-0 flex h-[3rem] items-center !bg-background px-2 md:px-4 py-0 text-label">
|
|
<i className="diveicon di-account-circle static translate-y-0.5 pr-1 md:pr-2 text-2xl md:text-3xl" />
|
|
<span className="text-sm md:text-base hidden md:inline">{userInfo?.userName}</span>
|
|
<i className="diveicon di-chevron-down static p-0 transition-transform group-data-[state=open]:rotate-180" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="w-48 border-border p-1">
|
|
<ul>
|
|
<li className="cursor-pointer rounded-sm p-3 text-xl hover:bg-accent">
|
|
<NoticePopover />
|
|
</li>
|
|
<li
|
|
onClick={logout}
|
|
className="cursor-pointer rounded-sm p-3 text-xl hover:bg-accent"
|
|
>
|
|
로그아웃
|
|
</li>
|
|
</ul>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
) : (
|
|
<Skeleton className="mx-4 h-[3rem] w-[15rem]" />
|
|
)}
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|