Files
voiceon-character-admin/src/app/protected-admin-shell.tsx

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>
);
}