feat(ai-character): Mock Preview 모드 구현
This commit is contained in:
176
src/app/protected-admin-shell.tsx
Normal file
176
src/app/protected-admin-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user