161 lines
5.2 KiB
React
161 lines
5.2 KiB
React
import { useEffect, useState } from "react";
|
|
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
|
|
|
import { NAVIGATION_ROUTES, routePath } from "../../contracts/routes.js";
|
|
import { useSession } from "../providers/session-provider.jsx";
|
|
import { useTheme } from "../providers/theme-provider.jsx";
|
|
|
|
const SESSION_LABELS = Object.freeze({
|
|
authenticated: "인증됨",
|
|
unauthenticated: "로그인 전",
|
|
"recovery-pending": "복구 대기",
|
|
"integration-failed": "연동 필요",
|
|
});
|
|
|
|
export function AppShell() {
|
|
const location = useLocation();
|
|
const { sessionState, beginSignIn, signOut, recover } = useSession();
|
|
const { preference, setPreference } = useTheme();
|
|
const [navigationOpen, setNavigationOpen] = useState(false);
|
|
const [sessionActionPending, setSessionActionPending] = useState(false);
|
|
const [sessionActionFailed, setSessionActionFailed] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setNavigationOpen(false);
|
|
}, [location.pathname]);
|
|
|
|
useEffect(() => {
|
|
if (!navigationOpen) return undefined;
|
|
/** @param {KeyboardEvent} event */
|
|
const closeOnEscape = (event) => {
|
|
if (event.key === "Escape") setNavigationOpen(false);
|
|
};
|
|
window.addEventListener("keydown", closeOnEscape);
|
|
return () => window.removeEventListener("keydown", closeOnEscape);
|
|
}, [navigationOpen]);
|
|
|
|
async function runSessionAction() {
|
|
setSessionActionPending(true);
|
|
setSessionActionFailed(false);
|
|
try {
|
|
if (sessionState === "authenticated") {
|
|
await signOut();
|
|
} else if (sessionState === "recovery-pending") {
|
|
await recover();
|
|
} else {
|
|
await beginSignIn(
|
|
`${location.pathname}${location.search}${location.hash}`,
|
|
);
|
|
}
|
|
} catch {
|
|
setSessionActionFailed(true);
|
|
} finally {
|
|
setSessionActionPending(false);
|
|
}
|
|
}
|
|
|
|
const sessionActionLabel =
|
|
sessionState === "authenticated"
|
|
? "로그아웃"
|
|
: sessionState === "recovery-pending"
|
|
? "세션 복구"
|
|
: "로그인";
|
|
const integrationAvailable = sessionState !== "integration-failed";
|
|
|
|
return (
|
|
<div className="app-shell">
|
|
<a className="skip-link" href="#main-content">
|
|
본문으로 건너뛰기
|
|
</a>
|
|
<header className="app-shell__header">
|
|
<button
|
|
className="app-shell__menu-button"
|
|
type="button"
|
|
aria-controls="primary-navigation"
|
|
aria-expanded={navigationOpen}
|
|
onClick={() => setNavigationOpen((open) => !open)}
|
|
>
|
|
<span aria-hidden="true">☰</span>
|
|
<span>메뉴</span>
|
|
</button>
|
|
<NavLink className="app-shell__brand" to={routePath("APP_HOME")}>
|
|
Frontend Skeleton
|
|
</NavLink>
|
|
<div className="app-shell__session">
|
|
<label className="visually-hidden" htmlFor="theme-preference">
|
|
색상 테마
|
|
</label>
|
|
<select
|
|
className="theme-selector"
|
|
id="theme-preference"
|
|
value={preference}
|
|
onChange={(event) =>
|
|
setPreference(
|
|
/** @type {"system" | "light" | "dark"} */ (
|
|
event.currentTarget.value
|
|
),
|
|
)
|
|
}
|
|
>
|
|
<option value="system">시스템 테마</option>
|
|
<option value="light">라이트 테마</option>
|
|
<option value="dark">다크 테마</option>
|
|
</select>
|
|
<span className="session-status" data-state={sessionState}>
|
|
{SESSION_LABELS[sessionState]}
|
|
</span>
|
|
{integrationAvailable ? (
|
|
<button
|
|
className="ui-button ui-button--compact"
|
|
type="button"
|
|
disabled={sessionActionPending}
|
|
onClick={() => void runSessionAction()}
|
|
>
|
|
{sessionActionPending ? "처리 중…" : sessionActionLabel}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
{sessionActionFailed ? (
|
|
<p className="app-shell__session-error" role="alert">
|
|
세션 작업을 완료하지 못했습니다.
|
|
</p>
|
|
) : null}
|
|
</header>
|
|
<aside
|
|
className="app-shell__sidebar"
|
|
data-open={navigationOpen}
|
|
aria-label="사이드바"
|
|
>
|
|
<nav id="primary-navigation" aria-label="주요 탐색">
|
|
<ul className="app-navigation">
|
|
{NAVIGATION_ROUTES.map((definition) => (
|
|
<li key={definition.routeId}>
|
|
<NavLink
|
|
className={({ isActive }) =>
|
|
`app-navigation__link${isActive ? " is-active" : ""}`
|
|
}
|
|
end={definition.path === "/"}
|
|
to={definition.path}
|
|
>
|
|
{definition.navigationLabel}
|
|
</NavLink>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</nav>
|
|
</aside>
|
|
{navigationOpen ? (
|
|
<button
|
|
className="app-shell__scrim"
|
|
type="button"
|
|
aria-label="메뉴 닫기"
|
|
onClick={() => setNavigationOpen(false)}
|
|
/>
|
|
) : null}
|
|
<main className="app-shell__content" id="main-content" tabIndex={-1}>
|
|
<Outlet />
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|