feat(ai-character): 리소스 관리 기반 정비
This commit is contained in:
@@ -1,13 +1,29 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { navigateTo } from "@/app/browser-location";
|
||||
import { AiCharactersPage } from "@/app/admin-pages";
|
||||
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 { AudioContentDetailPage } from "@/features/audio-contents/pages/AudioContentDetailPage";
|
||||
import { AudioContentFormPage } from "@/features/audio-contents/pages/AudioContentFormPage";
|
||||
import { AudioContentListPage } from "@/features/audio-contents/pages/AudioContentListPage";
|
||||
import { useAuthSession } from "@/features/auth/model/auth-session-context";
|
||||
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
||||
import { CharacterCreatePage } from "@/features/characters/pages/CharacterCreatePage";
|
||||
import { CharacterDetailPage } from "@/features/characters/pages/CharacterDetailPage";
|
||||
import { CharacterEditPage } from "@/features/characters/pages/CharacterEditPage";
|
||||
import { CharacterListPage } from "@/features/characters/pages/CharacterListPage";
|
||||
import { CommunityPostListPage } from "@/features/community-posts/pages/CommunityPostListPage";
|
||||
import { CommunityPostFormPage } from "@/features/community-posts/pages/CommunityPostFormPage";
|
||||
import { FanTalkListPage } from "@/features/fan-talks/pages/FanTalkListPage";
|
||||
import { SeriesDetailPage } from "@/features/series/pages/SeriesDetailPage";
|
||||
import { SeriesFormPage } from "@/features/series/pages/SeriesFormPage";
|
||||
import { SeriesListPage } from "@/features/series/pages/SeriesListPage";
|
||||
import { SeriesOrderPage } from "@/features/series/pages/SeriesOrderPage";
|
||||
import type { ApiClient } from "@/shared/api/client";
|
||||
import type { ApiMode } from "@/shared/config/env";
|
||||
import { MockModeBanner } from "@/shared/ui/mock-mode-banner";
|
||||
|
||||
const focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
|
||||
const sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인하세요.";
|
||||
|
||||
function NavLink() {
|
||||
return (
|
||||
@@ -24,12 +40,33 @@ function NavLink() {
|
||||
);
|
||||
}
|
||||
|
||||
export function ProtectedAdminShell({ apiMode, routeError }: { readonly apiMode: ApiMode; readonly routeError: string | null }) {
|
||||
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(() => {
|
||||
@@ -117,7 +154,10 @@ export function ProtectedAdminShell({ apiMode, routeError }: { readonly apiMode:
|
||||
<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">
|
||||
<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">
|
||||
@@ -131,14 +171,14 @@ export function ProtectedAdminShell({ apiMode, routeError }: { readonly apiMode:
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
aria-expanded={isMobileMenuOpen}
|
||||
className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold lg:hidden"
|
||||
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="text-sm text-muted-foreground">
|
||||
<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>
|
||||
@@ -146,12 +186,32 @@ export function ProtectedAdminShell({ apiMode, routeError }: { readonly apiMode:
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
<button className="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 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">
|
||||
<AiCharactersPage routeError={routeError} />
|
||||
<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>
|
||||
)}
|
||||
{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}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user