첫 커밋: 로컬 프로젝트 업로드

This commit is contained in:
2026-06-10 15:51:34 +09:00
commit 6a8dbeb2e9
1211 changed files with 312864 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
import { Menu, SquareMenu } from "lucide-react";
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;
collapsed?: boolean;
onToggleCollapsed?: () => void;
collapseLabel?: string;
expandLabel?: string;
};
export function AppSidebar({
brandLabel,
brandTitle,
brandIcon,
navContent,
footerContent,
collapsed = false,
onToggleCollapsed,
collapseLabel = "Collapse sidebar",
expandLabel = "Expand sidebar",
}: ShellSidebarProps) {
return (
<aside
className={
collapsed ? shellLayoutClasses.asideCollapsed : shellLayoutClasses.aside
}
>
<div>
<div
className={
collapsed
? shellLayoutClasses.brandSectionCollapsed
: shellLayoutClasses.brandSection
}
>
<div
className={
collapsed
? shellLayoutClasses.brandWrapCollapsed
: shellLayoutClasses.brandWrap
}
>
{onToggleCollapsed ? (
<button
type="button"
onClick={onToggleCollapsed}
className="grid h-11 w-11 place-items-center rounded-xl border border-border bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)] transition hover:bg-primary/20"
aria-label={collapsed ? expandLabel : collapseLabel}
title={collapsed ? expandLabel : collapseLabel}
>
<span className="sr-only">
{collapsed ? expandLabel : collapseLabel}
</span>
{collapsed ? <Menu size={20} /> : <SquareMenu size={20} />}
</button>
) : (
<div
className={
collapsed
? shellLayoutClasses.brandIconCollapsed
: shellLayoutClasses.brandIcon
}
>
{brandIcon}
</div>
)}
<div className={collapsed ? "hidden" : "block"}>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{brandLabel}
</p>
<h1 className="text-lg font-semibold">{brandTitle}</h1>
</div>
</div>
</div>
<nav
className={
collapsed
? shellLayoutClasses.navWrapCollapsed
: shellLayoutClasses.navWrap
}
>
{navContent}
</nav>
</div>
<div>{footerContent}</div>
</aside>
);
}

View File

@@ -0,0 +1,147 @@
import {
readSessionExpiryEnabled,
SESSION_EXPIRY_STORAGE_KEY,
writeSessionExpiryEnabled,
} from "../core/session";
export type ShellTheme = "light" | "dark";
export type ShellTranslator = (
key: string,
fallback: string,
vars?: Record<string, string | number>,
) => string;
type ShellSessionStatusParams = {
expiresAtSec?: number | null;
nowMs: number;
t: ShellTranslator;
};
type ShellProfileSummaryParams = {
profileName?: string | null;
profileEmail?: string | null;
fallbackName: string;
fallbackEmail: string;
};
export const SHELL_THEME_STORAGE_KEY = "admin_theme";
export const SHELL_SESSION_EXPIRY_STORAGE_KEY = SESSION_EXPIRY_STORAGE_KEY;
export const SHELL_SIDEBAR_COLLAPSED_STORAGE_KEY =
"baron_shell_sidebar_collapsed";
export type { ShellSidebarNavItem } from "./AppSidebar";
export { AppSidebar } from "./AppSidebar";
export { shellLayoutClasses } from "./layout";
export function readShellTheme(): ShellTheme {
return window.localStorage.getItem(SHELL_THEME_STORAGE_KEY) === "dark"
? "dark"
: "light";
}
export function applyShellTheme(theme: ShellTheme) {
const root = document.documentElement;
root.classList.remove("light", "dark");
root.classList.add(theme);
window.localStorage.setItem(SHELL_THEME_STORAGE_KEY, theme);
}
export function readShellSessionExpiryEnabled(defaultEnabled = true) {
return readSessionExpiryEnabled({ defaultEnabled });
}
export function writeShellSessionExpiryEnabled(isEnabled: boolean) {
writeSessionExpiryEnabled(isEnabled);
}
export function readShellSidebarCollapsed(defaultCollapsed = false) {
const stored = window.localStorage.getItem(
SHELL_SIDEBAR_COLLAPSED_STORAGE_KEY,
);
if (stored === null) {
return defaultCollapsed;
}
return stored === "true";
}
export function writeShellSidebarCollapsed(isCollapsed: boolean) {
window.localStorage.setItem(
SHELL_SIDEBAR_COLLAPSED_STORAGE_KEY,
String(isCollapsed),
);
}
export function buildShellProfileSummary({
profileName,
profileEmail,
fallbackName,
fallbackEmail,
}: ShellProfileSummaryParams) {
const resolvedName = profileName?.trim() || fallbackName;
const resolvedEmail = profileEmail?.trim() || fallbackEmail;
return {
name: resolvedName,
email: resolvedEmail,
initial: resolvedName.charAt(0).toUpperCase(),
};
}
export function buildShellSessionStatus({
expiresAtSec,
nowMs,
t,
}: ShellSessionStatusParams) {
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 toneClass =
"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
let text = t("ui.shell.session.active", "세션 활성");
if (remainingMs === null) {
toneClass = "border-border bg-card text-muted-foreground";
text = t("ui.shell.session.unknown", "알 수 없음");
} else if (remainingMs <= 0) {
toneClass =
"border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300";
text = t("ui.shell.session.expired", "세션 만료");
} else if (
remainingMinutes !== null &&
remainingSeconds !== null &&
remainingMinutes <= 5
) {
toneClass =
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
text = t(
"ui.shell.session.expiring",
"만료 임박: {{minutes}}분 {{seconds}}초 남음",
{
minutes: remainingMinutes,
seconds: remainingSeconds,
},
);
} else {
text = t(
"ui.shell.session.remaining",
"만료 예정: {{minutes}}분 {{seconds}}초 남음",
{
minutes: remainingMinutes ?? 0,
seconds: remainingSeconds ?? 0,
},
);
}
return {
toneClass,
text,
};
}

View File

@@ -0,0 +1,66 @@
export const shellLayoutClasses = {
root: "grid min-h-screen grid-cols-[240px,minmax(0,1fr)] bg-background text-foreground",
rootCollapsed:
"grid min-h-screen grid-cols-[80px,minmax(0,1fr)] bg-background text-foreground",
aside:
"sticky top-0 flex h-screen flex-col justify-between border-r border-border bg-card backdrop-blur",
asideCollapsed:
"sticky top-0 flex h-screen flex-col justify-between border-r border-border bg-card backdrop-blur",
asideStatic:
"sticky top-0 h-screen border-r border-border bg-card backdrop-blur",
brandSection:
"flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6",
brandSectionCollapsed:
"flex items-center justify-between px-3 py-4 md:block md:space-y-4 md:px-2 md:py-6",
brandWrap: "flex items-center gap-3 md:flex-col md:items-start",
brandWrapCollapsed: "flex items-center gap-3 md:flex-col md:items-center",
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)]",
brandIconCollapsed:
"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",
navWrapCollapsed: "px-2 pb-4 md:px-2 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",
navItemBaseCollapsed:
"flex items-center justify-center gap-0 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",
logoutButtonCollapsed:
"flex w-full items-center justify-center gap-0 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",
headerActionsCollapsed: "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",
sidebarToggleButton:
"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;