241 lines
14 KiB
TypeScript
241 lines
14 KiB
TypeScript
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
|
|
|
import { getAudioContentCreateCharacterIdFromPath, getAudioContentDetailRouteFromPath, getAudioContentEditRouteFromPath, getAudioContentListCharacterIdFromPath, getCharacterEditIdFromPath, getCharacterIdFromPath, getCommunityPostCreateCharacterIdFromPath, getCommunityPostListCharacterIdFromPath, getFanTalkListCharacterIdFromPath, getSeriesCreateCharacterIdFromPath, getSeriesDetailRouteFromPath, getSeriesEditRouteFromPath, getSeriesListCharacterIdFromPath, getSeriesOrderCharacterIdFromPath, navigateTo, useBrowserLocation } from "@/app/browser-location";
|
|
import { routePaths } from "@/app/route-paths";
|
|
import { useAuthSession } from "@/features/auth/model/auth-session-context";
|
|
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
|
import type { ApiClient } from "@/shared/api/client";
|
|
import type { ApiMode } from "@/shared/config/env";
|
|
import { MockModeBanner } from "@/shared/ui/mock-mode-banner";
|
|
import { PageState } from "@/shared/ui/page-state";
|
|
|
|
const AudioContentDetailPage = lazy(() => import("@/features/audio-contents/pages/AudioContentDetailPage").then(({ AudioContentDetailPage }) => ({ default: AudioContentDetailPage })));
|
|
const AudioContentFormPage = lazy(() => import("@/features/audio-contents/pages/AudioContentFormPage").then(({ AudioContentFormPage }) => ({ default: AudioContentFormPage })));
|
|
const AudioContentListPage = lazy(() => import("@/features/audio-contents/pages/AudioContentListPage").then(({ AudioContentListPage }) => ({ default: AudioContentListPage })));
|
|
const CharacterCreatePage = lazy(() => import("@/features/characters/pages/CharacterCreatePage").then(({ CharacterCreatePage }) => ({ default: CharacterCreatePage })));
|
|
const CharacterDetailPage = lazy(() => import("@/features/characters/pages/CharacterDetailPage").then(({ CharacterDetailPage }) => ({ default: CharacterDetailPage })));
|
|
const CharacterEditPage = lazy(() => import("@/features/characters/pages/CharacterEditPage").then(({ CharacterEditPage }) => ({ default: CharacterEditPage })));
|
|
const CharacterListPage = lazy(() => import("@/features/characters/pages/CharacterListPage").then(({ CharacterListPage }) => ({ default: CharacterListPage })));
|
|
const CommunityPostFormPage = lazy(() => import("@/features/community-posts/pages/CommunityPostFormPage").then(({ CommunityPostFormPage }) => ({ default: CommunityPostFormPage })));
|
|
const CommunityPostListPage = lazy(() => import("@/features/community-posts/pages/CommunityPostListPage").then(({ CommunityPostListPage }) => ({ default: CommunityPostListPage })));
|
|
const FanTalkListPage = lazy(() => import("@/features/fan-talks/pages/FanTalkListPage").then(({ FanTalkListPage }) => ({ default: FanTalkListPage })));
|
|
const SeriesDetailPage = lazy(() => import("@/features/series/pages/SeriesDetailPage").then(({ SeriesDetailPage }) => ({ default: SeriesDetailPage })));
|
|
const SeriesFormPage = lazy(() => import("@/features/series/pages/SeriesFormPage").then(({ SeriesFormPage }) => ({ default: SeriesFormPage })));
|
|
const SeriesListPage = lazy(() => import("@/features/series/pages/SeriesListPage").then(({ SeriesListPage }) => ({ default: SeriesListPage })));
|
|
const SeriesOrderPage = lazy(() => import("@/features/series/pages/SeriesOrderPage").then(({ SeriesOrderPage }) => ({ default: SeriesOrderPage })));
|
|
|
|
const focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
|
|
const sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인하세요.";
|
|
|
|
function NavLink() {
|
|
return (
|
|
<a
|
|
className="rounded-md px-3 py-2 text-sm font-semibold text-accent-foreground hover:bg-accent"
|
|
href={routePaths.aiCharacters}
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
navigateTo(routePaths.aiCharacters);
|
|
}}
|
|
>
|
|
AI 캐릭터
|
|
</a>
|
|
);
|
|
}
|
|
|
|
export function ProtectedAdminShell({ apiClient, apiMode, routeError }: { readonly apiClient: ApiClient; readonly apiMode: ApiMode; readonly routeError: string | null }) {
|
|
const auth = useAuthSession();
|
|
const uploadAuth = {
|
|
clearSession: () => auth.clearSession(sessionExpiredNotice),
|
|
getToken: () => authSessionStorage.read()?.token ?? null,
|
|
onAuthExpired: () => navigateTo(routePaths.login),
|
|
};
|
|
const location = useBrowserLocation();
|
|
const audioContentDetailRoute = getAudioContentDetailRouteFromPath(location.path);
|
|
const audioContentEditRoute = getAudioContentEditRouteFromPath(location.path);
|
|
const audioContentCreateCharacterId = getAudioContentCreateCharacterIdFromPath(location.path);
|
|
const audioContentListCharacterId = getAudioContentListCharacterIdFromPath(location.path);
|
|
const seriesDetailRoute = getSeriesDetailRouteFromPath(location.path);
|
|
const seriesEditRoute = getSeriesEditRouteFromPath(location.path);
|
|
const seriesCreateCharacterId = getSeriesCreateCharacterIdFromPath(location.path);
|
|
const seriesOrderCharacterId = getSeriesOrderCharacterIdFromPath(location.path);
|
|
const seriesListCharacterId = getSeriesListCharacterIdFromPath(location.path);
|
|
const communityPostListCharacterId = getCommunityPostListCharacterIdFromPath(location.path);
|
|
const communityPostCreateCharacterId = getCommunityPostCreateCharacterIdFromPath(location.path);
|
|
const fanTalkListCharacterId = getFanTalkListCharacterIdFromPath(location.path);
|
|
const characterEditId = getCharacterEditIdFromPath(location.path);
|
|
const characterId = getCharacterIdFromPath(location.path);
|
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
|
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
|
const mobileMenuRef = useRef<HTMLElement>(null);
|
|
const mainRef = useRef<HTMLElement>(null);
|
|
const shouldRestoreMenuFocusRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (window.matchMedia === undefined) {
|
|
return undefined;
|
|
}
|
|
|
|
const desktopMediaQuery = window.matchMedia("(min-width: 1024px)");
|
|
|
|
function closeOnDesktopMatch() {
|
|
if (!desktopMediaQuery.matches) {
|
|
return;
|
|
}
|
|
|
|
shouldRestoreMenuFocusRef.current = false;
|
|
setIsMobileMenuOpen(false);
|
|
}
|
|
|
|
closeOnDesktopMatch();
|
|
desktopMediaQuery.addEventListener("change", closeOnDesktopMatch);
|
|
|
|
return () => desktopMediaQuery.removeEventListener("change", closeOnDesktopMatch);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isMobileMenuOpen || !shouldRestoreMenuFocusRef.current) {
|
|
return;
|
|
}
|
|
|
|
shouldRestoreMenuFocusRef.current = false;
|
|
menuButtonRef.current?.focus();
|
|
}, [isMobileMenuOpen]);
|
|
|
|
useEffect(() => {
|
|
if (!isMobileMenuOpen) {
|
|
return undefined;
|
|
}
|
|
|
|
closeButtonRef.current?.focus();
|
|
|
|
function closeOnEscape(event: KeyboardEvent) {
|
|
if (event.key === "Escape") {
|
|
shouldRestoreMenuFocusRef.current = true;
|
|
setIsMobileMenuOpen(false);
|
|
}
|
|
}
|
|
|
|
window.addEventListener("keydown", closeOnEscape);
|
|
|
|
return () => window.removeEventListener("keydown", closeOnEscape);
|
|
}, [isMobileMenuOpen]);
|
|
|
|
function keepFocusInMobileMenu(event: React.KeyboardEvent<HTMLElement>) {
|
|
if (event.key !== "Tab") {
|
|
return;
|
|
}
|
|
|
|
const focusableElements = Array.from(mobileMenuRef.current?.querySelectorAll<HTMLElement>(focusableSelector) ?? []);
|
|
const firstElement = focusableElements[0];
|
|
const lastElement = focusableElements.at(-1);
|
|
|
|
if (firstElement === undefined || lastElement === undefined) {
|
|
return;
|
|
}
|
|
|
|
if (event.shiftKey && document.activeElement === firstElement) {
|
|
event.preventDefault();
|
|
lastElement.focus();
|
|
return;
|
|
}
|
|
|
|
if (!event.shiftKey && document.activeElement === lastElement) {
|
|
event.preventDefault();
|
|
firstElement.focus();
|
|
}
|
|
}
|
|
|
|
function closeMobileMenu() {
|
|
shouldRestoreMenuFocusRef.current = true;
|
|
setIsMobileMenuOpen(false);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-[100dvh] bg-background text-foreground">
|
|
<div aria-hidden={isMobileMenuOpen} className="flex min-h-[100dvh] flex-col" inert={isMobileMenuOpen}>
|
|
<MockModeBanner apiMode={apiMode} />
|
|
<div className="flex min-h-0 flex-1">
|
|
<a className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-modal focus:rounded-md focus:bg-card focus:px-4 focus:py-2 focus:text-link" href="#app-main" onClick={(event) => {
|
|
event.preventDefault();
|
|
mainRef.current?.focus();
|
|
}}>
|
|
본문으로 건너뛰기
|
|
</a>
|
|
<aside className="hidden w-60 shrink-0 border-r border-border bg-card p-4 lg:block">
|
|
<p className="mb-4 text-xs font-semibold text-info">AI CHARACTER ADMIN</p>
|
|
<nav aria-label="데스크톱 주 메뉴" className="flex flex-col gap-2">
|
|
<NavLink />
|
|
</nav>
|
|
</aside>
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<header className="flex min-h-14 flex-wrap items-center justify-between gap-3 border-b border-border bg-card px-4 py-2" role="banner">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<button
|
|
aria-expanded={isMobileMenuOpen}
|
|
className="shrink-0 whitespace-nowrap rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold lg:hidden"
|
|
onClick={() => setIsMobileMenuOpen(true)}
|
|
ref={menuButtonRef}
|
|
type="button"
|
|
>
|
|
모바일 메뉴 열기
|
|
</button>
|
|
<nav aria-label="브레드크럼" className="hidden shrink-0 whitespace-nowrap text-sm text-muted-foreground sm:block">
|
|
<ol className="flex items-center gap-2">
|
|
<li>홈</li>
|
|
<li aria-hidden="true">/</li>
|
|
<li className="font-semibold text-foreground">AI 캐릭터</li>
|
|
</ol>
|
|
</nav>
|
|
</div>
|
|
<button className="shrink-0 whitespace-nowrap rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={() => void auth.logout()} type="button">
|
|
로그아웃
|
|
</button>
|
|
</header>
|
|
<main aria-label="AI 캐릭터 관리" className="min-h-0 flex-1 overflow-auto p-4" id="app-main" ref={mainRef} tabIndex={-1}>
|
|
{location.successNotification === null ? null : (
|
|
<p aria-label="작업 성공" className="mb-4 rounded-lg border border-border bg-success-surface p-3 text-sm font-semibold text-success" role="status">
|
|
{location.successNotification}
|
|
</p>
|
|
)}
|
|
<Suspense fallback={<PageState state="loading" title="화면을 불러오는 중" />} key={location.path}>
|
|
{location.path === routePaths.aiCharacterCreate ? <CharacterCreatePage apiClient={apiClient} /> : null}
|
|
{characterEditId !== null ? <CharacterEditPage apiClient={apiClient} characterId={characterEditId} /> : null}
|
|
{location.path !== routePaths.aiCharacterCreate && characterEditId === null && characterId === null && audioContentListCharacterId === null && audioContentCreateCharacterId === null && audioContentEditRoute === null && audioContentDetailRoute === null && communityPostCreateCharacterId === null && communityPostListCharacterId === null && fanTalkListCharacterId === null && seriesCreateCharacterId === null && seriesEditRoute === null && seriesListCharacterId === null && seriesOrderCharacterId === null && seriesDetailRoute === null ? <CharacterListPage apiClient={apiClient} routeError={routeError} /> : null}
|
|
{location.path !== routePaths.aiCharacterCreate && characterEditId === null && characterId !== null ? <CharacterDetailPage apiClient={apiClient} characterId={characterId} /> : null}
|
|
{audioContentListCharacterId !== null ? <AudioContentListPage apiClient={apiClient} characterId={audioContentListCharacterId} /> : null}
|
|
{audioContentCreateCharacterId !== null ? <AudioContentFormPage apiClient={apiClient} characterId={audioContentCreateCharacterId} uploadAuth={uploadAuth} /> : null}
|
|
{audioContentEditRoute !== null ? <AudioContentFormPage apiClient={apiClient} characterId={audioContentEditRoute.characterId} contentId={audioContentEditRoute.contentId} uploadAuth={uploadAuth} /> : null}
|
|
{audioContentDetailRoute !== null ? <AudioContentDetailPage apiClient={apiClient} characterId={audioContentDetailRoute.characterId} contentId={audioContentDetailRoute.contentId} /> : null}
|
|
{communityPostListCharacterId !== null ? <CommunityPostListPage apiClient={apiClient} characterId={communityPostListCharacterId} /> : null}
|
|
{communityPostCreateCharacterId !== null ? <CommunityPostFormPage apiClient={apiClient} characterId={communityPostCreateCharacterId} /> : null}
|
|
{fanTalkListCharacterId !== null ? <FanTalkListPage apiClient={apiClient} characterId={fanTalkListCharacterId} /> : null}
|
|
{seriesListCharacterId !== null ? <SeriesListPage apiClient={apiClient} characterId={seriesListCharacterId} /> : null}
|
|
{seriesCreateCharacterId !== null ? <SeriesFormPage apiClient={apiClient} characterId={seriesCreateCharacterId} /> : null}
|
|
{seriesEditRoute !== null ? <SeriesFormPage apiClient={apiClient} characterId={seriesEditRoute.characterId} seriesId={seriesEditRoute.seriesId} /> : null}
|
|
{seriesOrderCharacterId !== null ? <SeriesOrderPage apiClient={apiClient} characterId={seriesOrderCharacterId} /> : null}
|
|
{seriesDetailRoute !== null ? <SeriesDetailPage apiClient={apiClient} characterId={seriesDetailRoute.characterId} seriesId={seriesDetailRoute.seriesId} /> : null}
|
|
</Suspense>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{isMobileMenuOpen ? (
|
|
<div className="fixed inset-0 z-overlay bg-background/80 lg:hidden">
|
|
<nav
|
|
aria-label="모바일 주 메뉴"
|
|
className="flex min-h-[100dvh] w-[min(15rem,50vw)] flex-col gap-3 border-r border-border bg-card p-4"
|
|
onKeyDown={keepFocusInMobileMenu}
|
|
ref={mobileMenuRef}
|
|
>
|
|
<button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold" onClick={closeMobileMenu} ref={closeButtonRef} type="button">
|
|
모바일 메뉴 닫기
|
|
</button>
|
|
<NavLink />
|
|
</nav>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|