feat(ai-character): 리소스 관리 기반 정비

This commit is contained in:
Yu Sung
2026-08-01 01:30:24 +09:00
parent 55ba0df77a
commit a1cae336d9
73 changed files with 5162 additions and 816 deletions

View File

@@ -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>