1
0
forked from baron/baron-sso

common shell frame/state helper 공용화

This commit is contained in:
2026-05-11 17:12:46 +09:00
parent 7d7f17ab69
commit 85e1a172dd
5 changed files with 362 additions and 323 deletions

View File

@@ -20,6 +20,15 @@ import {
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
} from "../../lib/sessionSliding";
import {
applyShellTheme,
buildShellProfileSummary,
buildShellSessionStatus,
readShellSessionExpiryEnabled,
readShellTheme,
shellLayoutClasses,
writeShellSessionExpiryEnabled,
} from "../../../../common/shell";
import LanguageSelector from "../common/LanguageSelector";
import { Toaster } from "../ui/toaster";
@@ -52,15 +61,11 @@ function AppLayout() {
const isRenewInFlightRef = useRef(false);
const lastRenewAttemptAtRef = useRef(0);
const lastVisitedRouteRef = useRef<string | null>(null);
const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light";
});
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => {
const stored = window.localStorage.getItem("baron_session_expiry_enabled");
return stored !== "false";
});
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
readShellSessionExpiryEnabled,
);
const [nowMs, setNowMs] = useState(() => Date.now());
const hasAccessToken = Boolean(auth.user?.access_token);
@@ -82,14 +87,7 @@ function AppLayout() {
};
useEffect(() => {
const root = document.documentElement;
root.classList.remove("light", "dark");
if (theme === "light") {
root.classList.add("light");
} else {
root.classList.add("dark");
}
window.localStorage.setItem("admin_theme", theme);
applyShellTheme(theme);
}, [theme]);
useEffect(() => {
@@ -265,84 +263,41 @@ function AppLayout() {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
const profileName =
profile?.name?.trim() ||
auth.user?.profile?.name?.toString().trim() ||
auth.user?.profile?.preferred_username?.toString().trim() ||
auth.user?.profile?.nickname?.toString().trim() ||
t("ui.dev.profile.unknown_name", "Unknown User");
const profileEmail =
profile?.email?.trim() ||
auth.user?.profile?.email?.toString().trim() ||
t("ui.dev.profile.unknown_email", "unknown@example.com");
const profileInitial = profileName.charAt(0).toUpperCase();
const profileSummary = buildShellProfileSummary({
profileName:
profile?.name ||
auth.user?.profile?.name?.toString() ||
auth.user?.profile?.preferred_username?.toString() ||
auth.user?.profile?.nickname?.toString(),
profileEmail: profile?.email || auth.user?.profile?.email?.toString(),
fallbackName: t("ui.dev.profile.unknown_name", "Unknown User"),
fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"),
});
const currentRole = resolveProfileRole(
auth.user?.profile as Record<string, unknown> | undefined,
);
const displayRoleKey = profile?.role || currentRole;
const expiresAtSec = auth.user?.expires_at;
const remainingMs =
typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null;
const remainingTotalSec =
remainingMs !== null ? Math.max(0, Math.floor(remainingMs / 1000)) : null;
const remainingMinutes =
remainingTotalSec !== null ? Math.floor(remainingTotalSec / 60) : null;
const remainingSeconds =
remainingTotalSec !== null ? remainingTotalSec % 60 : null;
let sessionToneClass =
"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
let sessionText = t("ui.dev.session.active", "Session active");
if (remainingMs === null) {
sessionToneClass = "border-border bg-card text-muted-foreground";
sessionText = t("ui.dev.session.unknown", "Unknown");
} else if (remainingMs <= 0) {
sessionToneClass =
"border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300";
sessionText = t("ui.dev.session.expired", "Session expired");
} else if (
remainingMinutes !== null &&
remainingSeconds !== null &&
remainingMinutes <= 5
) {
sessionToneClass =
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
sessionText = t(
"ui.dev.session.expiring",
"Expiring soon: {{minutes}}m {{seconds}}s left",
{
minutes: remainingMinutes,
seconds: remainingSeconds,
},
);
} else {
sessionText = t(
"ui.dev.session.remaining",
"Expires in {{minutes}}m {{seconds}}s",
{
minutes: remainingMinutes ?? 0,
seconds: remainingSeconds ?? 0,
},
);
}
const sessionStatus = buildShellSessionStatus({
expiresAtSec: auth.user?.expires_at,
nowMs,
t,
});
const handleSessionExpiryToggle = () => {
setIsSessionExpiryEnabled((prev) => {
const next = !prev;
window.localStorage.setItem("baron_session_expiry_enabled", String(next));
writeShellSessionExpiryEnabled(next);
return next;
});
};
return (
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
<aside className="flex flex-col justify-between border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
<div className={shellLayoutClasses.root}>
<aside className={shellLayoutClasses.aside}>
<div>
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
<div className="flex items-center gap-3 md:flex-col md:items-start">
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
<div className={shellLayoutClasses.brandSection}>
<div className={shellLayoutClasses.brandWrap}>
<div className={shellLayoutClasses.brandIcon}>
<ShieldHalf size={20} />
</div>
<div>
@@ -354,28 +309,28 @@ function AppLayout() {
</h1>
</div>
</div>
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
<div className={shellLayoutClasses.scopeBadge}>
<BadgeCheck size={14} />
{t("ui.dev.scope_badge", "Scoped to /dev")}
</div>
</div>
<nav className="px-2 pb-4 md:px-3 md:pb-8">
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
<nav className={shellLayoutClasses.navWrap}>
<div className={shellLayoutClasses.navMeta}>
<span className="rounded-full border border-border px-3 py-1">
{t("ui.dev.env_badge", "Env: dev")}
</span>
</div>
<div className="flex flex-col gap-1">
<div className={shellLayoutClasses.navList}>
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
shellLayoutClasses.navItemBase,
isActive
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
? shellLayoutClasses.navItemActive
: shellLayoutClasses.navItemIdle,
].join(" ")
}
>
@@ -392,13 +347,13 @@ function AppLayout() {
<button
type="button"
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
className={shellLayoutClasses.logoutButton}
>
<LogOut size={18} />
<span>{t("ui.dev.nav.logout", "Logout")}</span>
</button>
</div>
<div className="hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block">
<div className={shellLayoutClasses.sidebarFooterNotice}>
<p>{t("msg.dev.sidebar.notice", "Developer Console")}</p>
<p>
{t(
@@ -410,10 +365,10 @@ function AppLayout() {
</div>
</aside>
<div className="relative">
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:px-8">
<div className="flex flex-col gap-1">
<div className={shellLayoutClasses.content}>
<header className={shellLayoutClasses.header}>
<div className={shellLayoutClasses.headerInner}>
<div className={shellLayoutClasses.headerTitleWrap}>
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
{t("ui.dev.header.plane", "Dev Plane")}
</p>
@@ -421,12 +376,12 @@ function AppLayout() {
{t("ui.dev.header.subtitle", "Manage your applications")}
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<div className={shellLayoutClasses.headerActions}>
<LanguageSelector />
<button
type="button"
onClick={toggleTheme}
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
className={shellLayoutClasses.actionButton}
aria-label={t("ui.common.theme_toggle", "Toggle theme")}
>
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
@@ -437,11 +392,11 @@ function AppLayout() {
{isSessionExpiryEnabled ? (
<span
className={[
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
sessionToneClass,
shellLayoutClasses.sessionBadge,
sessionStatus.toneClass,
].join(" ")}
>
{sessionText}
{sessionStatus.text}
</span>
) : null}
<div className="relative" ref={profileMenuRef}>
@@ -456,15 +411,15 @@ function AppLayout() {
"Open account menu",
)}
>
<div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
{profileInitial}
<div className={shellLayoutClasses.profileInitial}>
{profileSummary.initial}
</div>
<div className="hidden min-w-0 text-left md:block">
<p className="truncate text-xs font-medium text-foreground">
{profileName}
{profileSummary.name}
</p>
<p className="truncate text-[11px] text-muted-foreground">
{profileEmail}
{profileSummary.email}
</p>
</div>
<ChevronDown
@@ -475,18 +430,18 @@ function AppLayout() {
{isProfileMenuOpen ? (
<div
role="menu"
className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
className={shellLayoutClasses.profileMenu}
>
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{t("ui.dev.profile.menu_title", "Account")}
</p>
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
<div className={shellLayoutClasses.profileCard}>
<div>
<p className="truncate text-sm font-semibold text-foreground">
{profileName}
{profileSummary.name}
</p>
<p className="truncate text-xs text-muted-foreground">
{profileEmail}
{profileSummary.email}
</p>
</div>
<div className="flex items-center pt-1">
@@ -499,7 +454,7 @@ function AppLayout() {
</div>
</div>
<div className="mt-2 rounded-lg border border-border px-3 py-3">
<div className={shellLayoutClasses.settingsCard}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">
@@ -507,7 +462,7 @@ function AppLayout() {
</p>
<p className="text-xs text-muted-foreground">
{isSessionExpiryEnabled
? sessionText
? sessionStatus.text
: t(
"ui.dev.session.disabled",
"Session expiry disabled",
@@ -563,7 +518,7 @@ function AppLayout() {
</div>
</div>
</header>
<main className="px-5 py-6 md:px-10 md:py-10">
<main className={shellLayoutClasses.main}>
<Outlet />
</main>
</div>