feat(ai-character): Mock Preview 모드 구현

This commit is contained in:
Yu Sung
2026-07-27 23:23:36 +09:00
parent b4841ff579
commit 2a5efb0f3e
38 changed files with 4763 additions and 289 deletions

View File

@@ -0,0 +1,176 @@
import { useEffect, useRef, useState } from "react";
import { navigateTo } from "@/app/browser-location";
import { AiCharactersPage } from "@/app/admin-pages";
import { routePaths } from "@/app/route-paths";
import { useAuthSession } from "@/features/auth/model/auth-session-context";
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'])";
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({ apiMode, routeError }: { readonly apiMode: ApiMode; readonly routeError: string | null }) {
const auth = useAuthSession();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const menuButtonRef = useRef<HTMLButtonElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const mobileMenuRef = 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">
</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 items-center justify-between gap-3 border-b border-border bg-card px-4" role="banner">
<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"
onClick={() => setIsMobileMenuOpen(true)}
ref={menuButtonRef}
type="button"
>
</button>
<nav aria-label="브레드크럼" className="text-sm text-muted-foreground">
<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="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>
</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>
);
}