diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 5950ddf4..e16ffefb 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -21,12 +21,14 @@ import { useEffect, useRef, useState } from "react"; import { useAuth } from "react-oidc-context"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { + AppSidebar, type ShellTranslator, applyShellTheme, buildShellProfileSummary, buildShellSessionStatus, readShellSessionExpiryEnabled, readShellTheme, + type ShellSidebarNavItem, shellLayoutClasses, writeShellSessionExpiryEnabled, } from "../../../../common/shell"; @@ -41,19 +43,38 @@ import { import LanguageSelector from "../common/LanguageSelector"; import RoleSwitcher from "./RoleSwitcher"; -interface NavItem { - label: string; - to: string; - icon: React.ComponentType<{ size?: number | string }>; - isExternal?: boolean; -} - -const staticNavItems: NavItem[] = [ - { label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard }, - { label: "ui.admin.nav.users", to: "/users", icon: Users }, - { label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key }, - { label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs }, - { label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound }, +const staticNavItems: ShellSidebarNavItem[] = [ + { + labelKey: "ui.admin.nav.overview", + labelFallback: "Overview", + to: "/", + icon: LayoutDashboard, + end: true, + }, + { + labelKey: "ui.admin.nav.users", + labelFallback: "Users", + to: "/users", + icon: Users, + }, + { + labelKey: "ui.admin.nav.api_keys", + labelFallback: "API Keys", + to: "/api-keys", + icon: Key, + }, + { + labelKey: "ui.admin.nav.audit_logs", + labelFallback: "Audit Logs", + to: "/audit-logs", + icon: NotebookTabs, + }, + { + labelKey: "ui.admin.nav.auth_guard", + labelFallback: "Auth Guard", + to: "/auth", + icon: KeyRound, + }, ]; type SessionStatusProps = { @@ -145,7 +166,7 @@ function AppLayout() { ._IS_TEST_MODE === true, }); - const navItems = React.useMemo(() => { + const navItems = React.useMemo(() => { const items = [...staticNavItems]; const isTest = (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }) @@ -167,36 +188,42 @@ function AppLayout() { if (isSuperAdmin) { filteredItems.splice(1, 0, { - label: "ui.admin.nav.tenants", + labelKey: "ui.admin.nav.tenants", + labelFallback: "Tenants", to: "/tenants", icon: Building2, }); filteredItems.splice(2, 0, { - label: "ui.admin.nav.org_chart", + labelKey: "ui.admin.nav.org_chart", + labelFallback: "Org Chart", to: orgfrontUrl, icon: Network, isExternal: true, }); filteredItems.splice(4, 0, { - label: "ui.admin.nav.user_projection", + labelKey: "ui.admin.nav.user_projection", + labelFallback: "User Projection", to: "/system/projections/users", icon: Database, }); filteredItems.splice(5, 0, { - label: "ui.admin.nav.data_integrity", + labelKey: "ui.admin.nav.data_integrity", + labelFallback: "Data Integrity", to: "/system/data-integrity", icon: ShieldCheck, }); } else if (isTenantAdmin || manageableCount > 0) { if (manageableCount <= 1 && profile?.tenantId) { filteredItems.splice(1, 0, { - label: "ui.admin.nav.my_tenant", + labelKey: "ui.admin.nav.my_tenant", + labelFallback: "My Tenant", to: `/tenants/${profile.tenantId}`, icon: Building2, }); } else if (manageableCount > 1) { filteredItems.splice(1, 0, { - label: "ui.admin.nav.tenants", + labelKey: "ui.admin.nav.tenants", + labelFallback: "Tenants", to: "/tenants", icon: Building2, }); @@ -205,7 +232,8 @@ function AppLayout() { manageableCount <= 1 && profile?.tenantId ? 2 : 2, 0, { - label: "ui.admin.nav.org_chart", + labelKey: "ui.admin.nav.org_chart", + labelFallback: "Org Chart", to: orgfrontUrl, icon: Network, isExternal: true, @@ -214,7 +242,8 @@ function AppLayout() { } else { // 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다. filteredItems.splice(1, 0, { - label: "ui.admin.nav.org_chart", + labelKey: "ui.admin.nav.org_chart", + labelFallback: "Org Chart", to: orgfrontUrl, icon: Network, isExternal: true, @@ -442,6 +471,66 @@ function AppLayout() { return next; }); }; + const sidebarNavContent = ( +
+ {navItems.map((item) => { + const { labelKey, labelFallback, to, icon: Icon, isExternal } = item; + + if (isExternal) { + return ( + + + {t(labelKey, labelFallback)} + + ); + } + + return ( + + [ + shellLayoutClasses.navItemBase, + item.isActive !== undefined + ? item.isActive + ? shellLayoutClasses.navItemActive + : shellLayoutClasses.navItemIdle + : isActive + ? shellLayoutClasses.navItemActive + : shellLayoutClasses.navItemIdle, + ].join(" ") + } + > + + {t(labelKey, labelFallback)} + + ); + })} +
+ ); + const sidebarFooterContent = ( +
+ +
+ ); if (auth.isLoading) { return ( @@ -453,84 +542,13 @@ function AppLayout() { return (
- + } + navContent={sidebarNavContent} + footerContent={sidebarFooterContent} + />
diff --git a/common/shell/AppSidebar.tsx b/common/shell/AppSidebar.tsx new file mode 100644 index 00000000..d49f56cc --- /dev/null +++ b/common/shell/AppSidebar.tsx @@ -0,0 +1,49 @@ +import type { ComponentType, ReactNode } from "react"; +import { shellLayoutClasses } from "./layout"; + +export type ShellSidebarNavItem = { + labelKey: string; + labelFallback: string; + to: string; + icon: ComponentType<{ size?: number | string }>; + isExternal?: boolean; + end?: boolean; + isActive?: boolean; +}; + +type ShellSidebarProps = { + brandLabel: string; + brandTitle: string; + brandIcon: ReactNode; + navContent: ReactNode; + footerContent: ReactNode; +}; + +export function AppSidebar({ + brandLabel, + brandTitle, + brandIcon, + navContent, + footerContent, +}: ShellSidebarProps) { + return ( + + ); +} diff --git a/common/shell/index.ts b/common/shell/index.ts index 9f36799c..2c794547 100644 --- a/common/shell/index.ts +++ b/common/shell/index.ts @@ -22,55 +22,9 @@ type ShellProfileSummaryParams = { export const SHELL_THEME_STORAGE_KEY = "admin_theme"; export const SHELL_SESSION_EXPIRY_STORAGE_KEY = "baron_session_expiry_enabled"; - -export const shellLayoutClasses = { - root: "grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]", - aside: - "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", - asideStatic: - "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", - brandSection: - "flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6", - brandWrap: "flex items-center gap-3 md:flex-col md:items-start", - brandIcon: - "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)]", - scopeBadge: - "hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2", - navWrap: "px-2 pb-4 md:px-3 md:pb-8", - navMeta: - "flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start", - navList: "flex flex-col gap-1", - navItemBase: - "flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition", - navItemActive: - "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]", - navItemIdle: "text-muted-foreground hover:bg-muted/10 hover:text-foreground", - sidebarFooterNotice: - "hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block", - logoutButton: - "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", - header: "sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur", - headerElevated: - "sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur", - headerInner: "flex items-center justify-between px-5 py-4 md:px-8", - headerTitleWrap: "flex flex-col gap-1", - headerActions: "flex items-center gap-2 text-sm", - actionButton: - "inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20", - sessionBadge: - "hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex", - profileInitial: - "grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary", - profileMenu: - "absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl", - profileCard: - "mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3", - settingsCard: "mt-2 rounded-lg border border-border px-3 py-3", - content: "relative", - contentWide: "relative min-w-0", - main: "px-5 py-6 md:px-10 md:py-10", - mainMinWidth: "min-w-0 px-5 py-6 md:px-10 md:py-10", -} as const; +export { AppSidebar } from "./AppSidebar"; +export type { ShellSidebarNavItem } from "./AppSidebar"; +export { shellLayoutClasses } from "./layout"; export function readShellTheme(): ShellTheme { return window.localStorage.getItem(SHELL_THEME_STORAGE_KEY) === "dark" diff --git a/common/shell/layout.ts b/common/shell/layout.ts new file mode 100644 index 00000000..52328239 --- /dev/null +++ b/common/shell/layout.ts @@ -0,0 +1,48 @@ +export const shellLayoutClasses = { + root: "grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]", + aside: + "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", + asideStatic: + "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", + brandSection: + "flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6", + brandWrap: "flex items-center gap-3 md:flex-col md:items-start", + brandIcon: + "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)]", + scopeBadge: + "hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2", + navWrap: "px-2 pb-4 md:px-3 md:pb-8", + navMeta: + "flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start", + navList: "flex flex-col gap-1", + navItemBase: + "flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition", + navItemActive: + "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]", + navItemIdle: "text-muted-foreground hover:bg-muted/10 hover:text-foreground", + sidebarFooterNotice: + "hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block", + logoutButton: + "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", + header: "sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur", + headerElevated: + "sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur", + headerInner: "flex items-center justify-between px-5 py-4 md:px-8", + headerTitleWrap: "flex flex-col gap-1", + headerActions: "flex items-center gap-2 text-sm", + actionButton: + "inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20", + sessionBadge: + "hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex", + profileInitial: + "grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary", + profileMenu: + "absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl", + profileCard: + "mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3", + settingsCard: "mt-2 rounded-lg border border-border px-3 py-3", + content: "relative", + contentWide: "relative min-w-0", + main: "px-5 py-6 md:px-10 md:py-10", + mainMinWidth: "min-w-0 px-5 py-6 md:px-10 md:py-10", +} as const; diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index d125c8bf..fab75d84 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -1,6 +1,5 @@ import { useQuery } from "@tanstack/react-query"; import { - BadgeCheck, ChevronDown, ClipboardCheck, LayoutDashboard, @@ -15,12 +14,14 @@ import { useEffect, useRef, useState } from "react"; import { useAuth } from "react-oidc-context"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { + AppSidebar, type ShellTranslator, applyShellTheme, buildShellProfileSummary, buildShellSessionStatus, readShellSessionExpiryEnabled, readShellTheme, + type ShellSidebarNavItem, shellLayoutClasses, writeShellSessionExpiryEnabled, } from "../../../../common/shell"; @@ -34,12 +35,13 @@ import { import LanguageSelector from "../common/LanguageSelector"; import { Toaster } from "../ui/toaster"; -const navItems = [ +const navItems: ShellSidebarNavItem[] = [ { labelKey: "ui.dev.nav.overview", labelFallback: "Overview", to: "/", icon: LayoutDashboard, + end: true, }, { labelKey: "ui.dev.nav.developer_request", @@ -323,81 +325,50 @@ function AppLayout() { return next; }); }; + const sidebarNavContent = ( +
+ {navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => ( + + [ + shellLayoutClasses.navItemBase, + isActive + ? shellLayoutClasses.navItemActive + : shellLayoutClasses.navItemIdle, + ].join(" ") + } + > + + {t(labelKey, labelFallback)} + + ))} +
+ ); + const sidebarFooterContent = ( +
+ +
+ ); return (
- + } + navContent={sidebarNavContent} + footerContent={sidebarFooterContent} + />