This commit is contained in:
@@ -2,90 +2,234 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { FileCheck, Menu, Users, Bell, FileText, Layout, Server, Shield, Calendar } from "lucide-react";
|
||||
import {
|
||||
FileCheck,
|
||||
Menu as MenuIcon,
|
||||
Users,
|
||||
Bell,
|
||||
FileText,
|
||||
Layout,
|
||||
Server,
|
||||
Shield,
|
||||
Calendar,
|
||||
ArrowUpRight,
|
||||
} from "lucide-react";
|
||||
import { PageHead } from "@/components/v5";
|
||||
|
||||
const quickAccessItems = [
|
||||
{ label: "결재함", icon: FileCheck, href: "/admin/approvalBox", color: "text-primary bg-primary/10" },
|
||||
{ label: "메뉴 관리", icon: Menu, href: "/admin/menu", color: "text-violet-600 bg-violet-50" },
|
||||
{ label: "사용자 관리", icon: Users, href: "/admin/userMng", color: "text-emerald-600 bg-emerald-50" },
|
||||
{ label: "공지사항", icon: Bell, href: "/admin/system-notices", color: "text-amber-600 bg-amber-50" },
|
||||
{ label: "감사 로그", icon: FileText, href: "/admin/audit-log", color: "text-rose-600 bg-rose-50" },
|
||||
{ label: "화면 관리", icon: Layout, href: "/admin/screenMng", color: "text-cyan-600 bg-cyan-50" },
|
||||
type QuickItem = {
|
||||
label: string;
|
||||
icon: typeof FileCheck;
|
||||
href: string;
|
||||
tone: "primary" | "cyan" | "green" | "amber" | "pink" | "red";
|
||||
badge?: number;
|
||||
};
|
||||
|
||||
const QUICK_ITEMS: QuickItem[] = [
|
||||
{ label: "결재함", icon: FileCheck, href: "/admin/approvalBox", tone: "primary", badge: 3 },
|
||||
{ label: "메뉴 관리", icon: MenuIcon, href: "/admin/menu", tone: "cyan" },
|
||||
{ label: "사용자 관리", icon: Users, href: "/admin/userMng", tone: "green" },
|
||||
{ label: "공지사항", icon: Bell, href: "/admin/system-notices", tone: "amber" },
|
||||
{ label: "감사 로그", icon: FileText, href: "/admin/audit-log", tone: "pink" },
|
||||
{ label: "화면 관리", icon: Layout, href: "/admin/screenMng", tone: "red" },
|
||||
];
|
||||
|
||||
const TONE_BG: Record<QuickItem["tone"], string> = {
|
||||
primary: "rgba(var(--v5-primary-rgb), .12)",
|
||||
cyan: "rgba(var(--v5-cyan-rgb), .12)",
|
||||
green: "rgba(var(--v5-green-rgb), .12)",
|
||||
amber: "rgba(var(--v5-amber-rgb), .18)",
|
||||
pink: "rgba(var(--v5-pink-rgb), .14)",
|
||||
red: "rgba(var(--v5-red-rgb), .12)",
|
||||
};
|
||||
const TONE_FG: Record<QuickItem["tone"], string> = {
|
||||
primary: "var(--v5-primary)",
|
||||
cyan: "rgb(var(--v5-cyan-rgb))",
|
||||
green: "rgb(var(--v5-green-rgb))",
|
||||
amber: "rgb(var(--v5-amber-rgb))",
|
||||
pink: "rgb(var(--v5-pink-rgb))",
|
||||
red: "rgb(var(--v5-red-rgb))",
|
||||
};
|
||||
|
||||
export default function MainPage() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
|
||||
const userName = user?.user_name || "사용자";
|
||||
const today = new Date();
|
||||
const dateStr = today.toLocaleDateString("ko-KR", { year: "numeric", month: "long", day: "numeric", weekday: "long" });
|
||||
const dateStr = today.toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
weekday: "long",
|
||||
});
|
||||
const dateShort = today.toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 sm:p-6 lg:p-8">
|
||||
{/* 헤더 영역 */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
{userName}님, 좋은 하루 되세요
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">{dateStr}</p>
|
||||
</div>
|
||||
<div style={{ padding: "var(--v5-sp-5)", display: "flex", flexDirection: "column", gap: "var(--v5-sp-5)" }}>
|
||||
<PageHead
|
||||
crumbs={[{ label: "홈" }]}
|
||||
title={`${userName}님, 좋은 하루 되세요`}
|
||||
sub={dateStr}
|
||||
/>
|
||||
|
||||
{/* 바로가기 */}
|
||||
<div>
|
||||
<h2 className="mb-3 text-base font-semibold text-foreground">바로가기</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{quickAccessItems.map((item) => {
|
||||
<section>
|
||||
<div className="v5-card-title" style={{ marginBottom: "var(--v5-sp-3)" }}>
|
||||
바로가기
|
||||
</div>
|
||||
<div
|
||||
className="v5-grid"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
|
||||
gap: "var(--v5-sp-3)",
|
||||
}}
|
||||
>
|
||||
{QUICK_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.href}
|
||||
onClick={() => router.push(item.href)}
|
||||
className="group flex flex-col items-center gap-2.5 rounded-lg border bg-card p-4 transition-all hover:shadow-md"
|
||||
className="v5-card"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: "var(--v5-sp-2)",
|
||||
padding: "var(--v5-sp-4)",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
position: "relative",
|
||||
background: "var(--v5-surface-solid)",
|
||||
border: "1px solid var(--v5-border)",
|
||||
font: "inherit",
|
||||
color: "inherit",
|
||||
transition:
|
||||
"border-color .2s var(--v5-ease-move), box-shadow .25s var(--v5-ease-move), transform .15s var(--v5-ease-move)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "rgba(var(--v5-primary-rgb), .35)";
|
||||
e.currentTarget.style.boxShadow = "var(--v5-glow-sm)";
|
||||
e.currentTarget.style.transform = "translateY(-1px)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--v5-border)";
|
||||
e.currentTarget.style.boxShadow = "";
|
||||
e.currentTarget.style.transform = "";
|
||||
}}
|
||||
>
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${item.color} transition-transform group-hover:scale-105`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "var(--v5-radius-md-2)",
|
||||
background: TONE_BG[item.tone],
|
||||
color: TONE_FG[item.tone],
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon size={18} strokeWidth={1.75} />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-foreground">{item.label}</span>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "var(--v5-fs-body)",
|
||||
fontWeight: "var(--v5-fw-semi)",
|
||||
color: "var(--v5-text)",
|
||||
letterSpacing: "var(--v5-ls-tight)",
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
{typeof item.badge === "number" && item.badge > 0 && (
|
||||
<span className="v5-bdg in" style={{ position: "absolute", top: 10, right: 10 }}>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
<ArrowUpRight
|
||||
size={13}
|
||||
strokeWidth={1.75}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 10,
|
||||
right: 10,
|
||||
color: "var(--v5-text-muted)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 시스템 정보 */}
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-5">
|
||||
<h2 className="mb-3 text-base font-semibold text-foreground">시스템 정보</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<section className="v5-card">
|
||||
<div className="v5-card-head">
|
||||
<div className="v5-card-title">시스템 정보</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">플랫폼</p>
|
||||
<p className="text-sm font-medium">Invyone ERP/PLM</p>
|
||||
<div className="v5-grid grid-3" style={{ gap: "var(--v5-sp-4)" }}>
|
||||
<SystemCell icon={Server} label="플랫폼" value="Invyone ERP/PLM" />
|
||||
<SystemCell icon={Shield} label="버전" value="v2.0.0" />
|
||||
<SystemCell icon={Calendar} label="오늘 날짜" value={dateShort} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">버전</p>
|
||||
<p className="text-sm font-medium">v2.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">오늘 날짜</p>
|
||||
<p className="text-sm font-medium">
|
||||
{today.toLocaleDateString("ko-KR", { year: "numeric", month: "long", day: "numeric" })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemCell({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: typeof Server;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--v5-sp-3)" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "var(--v5-radius-md-2)",
|
||||
background: "rgba(var(--v5-primary-rgb), .08)",
|
||||
color: "var(--v5-primary)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon size={16} strokeWidth={1.75} />
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "var(--v5-fs-caption)",
|
||||
fontWeight: "var(--v5-fw-bold)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "var(--v5-ls-wide)",
|
||||
color: "var(--v5-text-muted)",
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "var(--v5-fs-body)",
|
||||
fontWeight: "var(--v5-fw-semi)",
|
||||
color: "var(--v5-text)",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+200
-53
@@ -2,90 +2,237 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { FileCheck, Menu, Users, Bell, FileText, Layout, Server, Shield, Calendar, ArrowRight } from "lucide-react";
|
||||
import {
|
||||
FileCheck,
|
||||
Menu as MenuIcon,
|
||||
Users,
|
||||
Bell,
|
||||
FileText,
|
||||
Layout,
|
||||
Server,
|
||||
Shield,
|
||||
Calendar,
|
||||
ArrowUpRight,
|
||||
} from "lucide-react";
|
||||
import { PageHead } from "@/components/v5";
|
||||
|
||||
const quickAccessItems = [
|
||||
{ label: "결재함", icon: FileCheck, href: "/admin/approvalBox", color: "text-primary bg-primary/10" },
|
||||
{ label: "메뉴 관리", icon: Menu, href: "/admin/menu", color: "text-violet-600 bg-violet-50" },
|
||||
{ label: "사용자 관리", icon: Users, href: "/admin/userMng", color: "text-emerald-600 bg-emerald-50" },
|
||||
{ label: "공지사항", icon: Bell, href: "/admin/system-notices", color: "text-amber-600 bg-amber-50" },
|
||||
{ label: "감사 로그", icon: FileText, href: "/admin/audit-log", color: "text-rose-600 bg-rose-50" },
|
||||
{ label: "화면 관리", icon: Layout, href: "/admin/screenMng", color: "text-cyan-600 bg-cyan-50" },
|
||||
type QuickItem = {
|
||||
label: string;
|
||||
icon: typeof FileCheck;
|
||||
href: string;
|
||||
tone: "primary" | "cyan" | "green" | "amber" | "pink" | "red";
|
||||
badge?: number;
|
||||
};
|
||||
|
||||
const QUICK_ITEMS: QuickItem[] = [
|
||||
{ label: "결재함", icon: FileCheck, href: "/admin/approvalBox", tone: "primary", badge: 3 },
|
||||
{ label: "메뉴 관리", icon: MenuIcon, href: "/admin/menu", tone: "cyan" },
|
||||
{ label: "사용자 관리", icon: Users, href: "/admin/userMng", tone: "green" },
|
||||
{ label: "공지사항", icon: Bell, href: "/admin/system-notices", tone: "amber" },
|
||||
{ label: "감사 로그", icon: FileText, href: "/admin/audit-log", tone: "pink" },
|
||||
{ label: "화면 관리", icon: Layout, href: "/admin/screenMng", tone: "red" },
|
||||
];
|
||||
|
||||
const TONE_BG: Record<QuickItem["tone"], string> = {
|
||||
primary: "rgba(var(--v5-primary-rgb), .12)",
|
||||
cyan: "rgba(var(--v5-cyan-rgb), .12)",
|
||||
green: "rgba(var(--v5-green-rgb), .12)",
|
||||
amber: "rgba(var(--v5-amber-rgb), .18)",
|
||||
pink: "rgba(var(--v5-pink-rgb), .14)",
|
||||
red: "rgba(var(--v5-red-rgb), .12)",
|
||||
};
|
||||
const TONE_FG: Record<QuickItem["tone"], string> = {
|
||||
primary: "var(--v5-primary)",
|
||||
cyan: "rgb(var(--v5-cyan-rgb))",
|
||||
green: "rgb(var(--v5-green-rgb))",
|
||||
amber: "rgb(var(--v5-amber-rgb))",
|
||||
pink: "rgb(var(--v5-pink-rgb))",
|
||||
red: "rgb(var(--v5-red-rgb))",
|
||||
};
|
||||
|
||||
export default function MainHomePage() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
|
||||
const userName = user?.user_name || "사용자";
|
||||
const today = new Date();
|
||||
const dateStr = today.toLocaleDateString("ko-KR", { year: "numeric", month: "long", day: "numeric", weekday: "long" });
|
||||
const dateStr = today.toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
weekday: "long",
|
||||
});
|
||||
const dateShort = today.toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 sm:p-6 lg:p-8">
|
||||
{/* 헤더 영역 */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
{userName}님, 좋은 하루 되세요
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">{dateStr}</p>
|
||||
</div>
|
||||
<div style={{ padding: "var(--v5-sp-5)", display: "flex", flexDirection: "column", gap: "var(--v5-sp-5)" }}>
|
||||
<PageHead
|
||||
crumbs={[{ label: "홈" }]}
|
||||
title={`${userName}님, 좋은 하루 되세요`}
|
||||
sub={dateStr}
|
||||
/>
|
||||
|
||||
{/* 바로가기 */}
|
||||
<div>
|
||||
<h2 className="mb-3 text-base font-semibold text-foreground">바로가기</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{quickAccessItems.map((item) => {
|
||||
<section>
|
||||
<div className="v5-card-title" style={{ marginBottom: "var(--v5-sp-3)" }}>
|
||||
바로가기
|
||||
</div>
|
||||
<div
|
||||
className="v5-grid"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
|
||||
gap: "var(--v5-sp-3)",
|
||||
}}
|
||||
>
|
||||
{QUICK_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.href}
|
||||
onClick={() => router.push(item.href)}
|
||||
className="group flex flex-col items-center gap-2.5 rounded-lg border bg-card p-4 transition-all hover:shadow-md"
|
||||
className="v5-card"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: "var(--v5-sp-2)",
|
||||
padding: "var(--v5-sp-4)",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
position: "relative",
|
||||
background: "var(--v5-surface-solid)",
|
||||
border: "1px solid var(--v5-border)",
|
||||
font: "inherit",
|
||||
color: "inherit",
|
||||
transition:
|
||||
"border-color .2s var(--v5-ease-move), box-shadow .25s var(--v5-ease-move), transform .15s var(--v5-ease-move)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "rgba(var(--v5-primary-rgb), .35)";
|
||||
e.currentTarget.style.boxShadow = "var(--v5-glow-sm)";
|
||||
e.currentTarget.style.transform = "translateY(-1px)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--v5-border)";
|
||||
e.currentTarget.style.boxShadow = "";
|
||||
e.currentTarget.style.transform = "";
|
||||
}}
|
||||
>
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${item.color} transition-transform group-hover:scale-105`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "var(--v5-radius-md-2)",
|
||||
background: TONE_BG[item.tone],
|
||||
color: TONE_FG[item.tone],
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon size={18} strokeWidth={1.75} />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-foreground">{item.label}</span>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "var(--v5-fs-body)",
|
||||
fontWeight: "var(--v5-fw-semi)",
|
||||
color: "var(--v5-text)",
|
||||
letterSpacing: "var(--v5-ls-tight)",
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
{typeof item.badge === "number" && item.badge > 0 && (
|
||||
<span className="v5-bdg in" style={{ position: "absolute", top: 10, right: 10 }}>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
<ArrowUpRight
|
||||
size={13}
|
||||
strokeWidth={1.75}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 10,
|
||||
right: 10,
|
||||
color: "var(--v5-text-muted)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 시스템 정보 */}
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-5">
|
||||
<h2 className="mb-3 text-base font-semibold text-foreground">시스템 정보</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<section className="v5-card">
|
||||
<div className="v5-card-head">
|
||||
<div className="v5-card-title">시스템 정보</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">플랫폼</p>
|
||||
<p className="text-sm font-medium">Invyone ERP/PLM</p>
|
||||
<div
|
||||
className="v5-grid grid-3"
|
||||
style={{ gap: "var(--v5-sp-4)" }}
|
||||
>
|
||||
<SystemCell icon={Server} label="플랫폼" value="Invyone ERP/PLM" />
|
||||
<SystemCell icon={Shield} label="버전" value="v2.0.0" />
|
||||
<SystemCell icon={Calendar} label="오늘 날짜" value={dateShort} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">버전</p>
|
||||
<p className="text-sm font-medium">v2.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">오늘 날짜</p>
|
||||
<p className="text-sm font-medium">
|
||||
{today.toLocaleDateString("ko-KR", { year: "numeric", month: "long", day: "numeric" })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemCell({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: typeof Server;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--v5-sp-3)" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "var(--v5-radius-md-2)",
|
||||
background: "rgba(var(--v5-primary-rgb), .08)",
|
||||
color: "var(--v5-primary)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon size={16} strokeWidth={1.75} />
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "var(--v5-fs-caption)",
|
||||
fontWeight: "var(--v5-fw-bold)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "var(--v5-ls-wide)",
|
||||
color: "var(--v5-text-muted)",
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "var(--v5-fs-body)",
|
||||
fontWeight: "var(--v5-fw-semi)",
|
||||
color: "var(--v5-text)",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,9 +5,12 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
/* ===== V5 Cosmic Layout System ===== */
|
||||
/* ===== V5 Solid + Glow Layout System ===== */
|
||||
@import "../styles/v5-layout.css";
|
||||
|
||||
/* ===== V5 Atomic Component Library (btn / bdg / card / tbl / kpi / page-head / etc.) ===== */
|
||||
@import "../styles/v5-atomics.css";
|
||||
|
||||
/* ===== Builder IDE Theme (ScreenDesigner 스코프 오버라이드) ===== */
|
||||
@import "../styles/builder-ide.css";
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { getIconComponent } from '@/components/admin/MenuIconPicker';
|
||||
|
||||
interface CreateDashboardModalProps {
|
||||
open: boolean;
|
||||
@@ -12,14 +13,19 @@ interface CreateDashboardModalProps {
|
||||
submitting?: boolean;
|
||||
}
|
||||
|
||||
const ICON_PRESETS = ['📋', '📊', '📈', '📉', '📦', '🚚', '🏭', '🧭', '🗺️', '🔧', '⚙️', '📁'];
|
||||
const ICON_PRESETS = [
|
||||
'ClipboardList', 'BarChart3', 'TrendingUp', 'TrendingDown',
|
||||
'Package', 'Truck', 'Factory', 'Compass',
|
||||
'Map', 'Wrench', 'Settings', 'Folder',
|
||||
'Boxes', 'Users', 'Calendar', 'LayoutDashboard',
|
||||
];
|
||||
|
||||
export function CreateDashboardModal({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
defaultName = '',
|
||||
defaultIcon = '📋',
|
||||
defaultIcon = 'ClipboardList',
|
||||
submitting = false,
|
||||
}: CreateDashboardModalProps) {
|
||||
const [name, setName] = useState(defaultName);
|
||||
@@ -47,13 +53,10 @@ export function CreateDashboardModal({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40"
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
className="w-[420px] max-w-[92vw] rounded-xl border border-[var(--v5-glass-border)] bg-[var(--v5-glass)] p-5 shadow-[var(--v5-glow-md)]"
|
||||
style={{ backdropFilter: 'blur(20px) saturate(1.4)' }}
|
||||
>
|
||||
<div className="w-[420px] max-w-[92vw] rounded-xl border border-border bg-[var(--v5-surface-solid)] p-5 shadow-[var(--v5-glow-md)]">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-[0.95rem] font-bold text-foreground">새 대시보드 만들기</h3>
|
||||
<button
|
||||
@@ -86,21 +89,25 @@ export function CreateDashboardModal({
|
||||
아이콘
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ICON_PRESETS.map((i) => (
|
||||
{ICON_PRESETS.map((iconName) => {
|
||||
const Ico = getIconComponent(iconName);
|
||||
const selected = icon === iconName;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
key={iconName}
|
||||
type="button"
|
||||
onClick={() => setIcon(i)}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-md border text-base transition-colors ${
|
||||
icon === i
|
||||
? 'border-[var(--v5-primary)] bg-[var(--v5-primary)]/10'
|
||||
: 'border-border hover:bg-accent'
|
||||
onClick={() => setIcon(iconName)}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-md border transition-colors ${
|
||||
selected
|
||||
? 'border-[var(--v5-primary)] bg-[var(--v5-primary)]/10 text-[var(--v5-primary)]'
|
||||
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
}`}
|
||||
aria-label={`아이콘 ${i}`}
|
||||
aria-label={`아이콘 ${iconName}`}
|
||||
>
|
||||
{i}
|
||||
{Ico ? <Ico className="h-4 w-4" /> : null}
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -115,7 +122,7 @@ export function CreateDashboardModal({
|
||||
name="scope"
|
||||
checked={!isPersonal}
|
||||
onChange={() => setIsPersonal(false)}
|
||||
className="mt-0.5"
|
||||
className="mt-0.5 accent-[var(--v5-primary)]"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-foreground">회사 전체 공용</div>
|
||||
@@ -128,7 +135,7 @@ export function CreateDashboardModal({
|
||||
name="scope"
|
||||
checked={isPersonal}
|
||||
onChange={() => setIsPersonal(true)}
|
||||
className="mt-0.5"
|
||||
className="mt-0.5 accent-[var(--v5-primary)]"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-foreground">나만 보기 (개인 대시보드)</div>
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useCallback, useEffect, forwardRef } from 'react';
|
||||
import { useRef, useCallback, useEffect, useState, forwardRef, type ReactNode } from 'react';
|
||||
import { Plus, Save, X } from 'lucide-react';
|
||||
import { useDashboardStore } from '@/stores/dashboardStore';
|
||||
import { useControlMode } from '@/components/control/hooks/useControlMode';
|
||||
import { deleteDashboardCard } from '@/lib/api/dashMenu';
|
||||
import { toast } from 'sonner';
|
||||
import { DashboardCard } from './DashboardCard';
|
||||
import { DashboardEmpty } from './DashboardEmpty';
|
||||
|
||||
/**
|
||||
* AnimatedFab — enter/exit 애니메이션을 가진 플로팅 액션 바.
|
||||
* show=false 전환 시 closing 클래스를 주고 320ms 후 unmount.
|
||||
* Ref: INVYONE Design System / ui_kits/app/dashboard-user.jsx (AnimatedFab).
|
||||
*/
|
||||
function AnimatedFab({ show, children }: { show: boolean; children: ReactNode }) {
|
||||
const [rendered, setRendered] = useState(show);
|
||||
const [closing, setClosing] = useState(false);
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
setRendered(true);
|
||||
setClosing(false);
|
||||
} else if (rendered) {
|
||||
setClosing(true);
|
||||
const t = window.setTimeout(() => {
|
||||
setRendered(false);
|
||||
setClosing(false);
|
||||
}, 320);
|
||||
return () => window.clearTimeout(t);
|
||||
}
|
||||
}, [show, rendered]);
|
||||
if (!rendered) return null;
|
||||
return <div className={`ud-fab${closing ? ' closing' : ''}`}>{children}</div>;
|
||||
}
|
||||
|
||||
interface DashboardCanvasProps {
|
||||
dashboardName: string;
|
||||
onOpenLibrary: () => void;
|
||||
@@ -22,9 +49,11 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||
}, externalRef) {
|
||||
const cards = useDashboardStore((s) => s.cards);
|
||||
const editMode = useDashboardStore((s) => s.editMode);
|
||||
const setEditMode = useDashboardStore((s) => s.setEditMode);
|
||||
const updateCard = useDashboardStore((s) => s.updateCard);
|
||||
const removeCard = useDashboardStore((s) => s.removeCard);
|
||||
const activeDashboardId = useDashboardStore((s) => s.activeDashboardId);
|
||||
const toggleControlMode = useControlMode((s) => s.toggleControlMode);
|
||||
const internalRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = (externalRef as React.RefObject<HTMLDivElement | null>) ?? internalRef;
|
||||
const dragRef = useRef<{
|
||||
@@ -163,6 +192,11 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||
}
|
||||
}, [activeDashboardId, removeCard]);
|
||||
|
||||
const handleRequestSave = useCallback(() => {
|
||||
// 헤더/FAB 공용 저장 트리거 — DashboardLayout 가 수신해 실제 저장 실행
|
||||
window.dispatchEvent(new CustomEvent('dash:save'));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={canvasRef}
|
||||
@@ -203,6 +237,40 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* 편집 모드 FAB — 캔버스 우하단 */}
|
||||
<AnimatedFab show={editMode && !controlActive}>
|
||||
<span className="ud-fab-badge">
|
||||
<span className="dot" />
|
||||
편집 모드 · 카드 {cards.length}개
|
||||
</span>
|
||||
<span className="ud-fab-sep" />
|
||||
<button className="ud-fab-btn" onClick={onOpenLibrary} title="템플릿 추가">
|
||||
<Plus size={12} />
|
||||
<span>템플릿</span>
|
||||
</button>
|
||||
<button className="ud-fab-btn primary" onClick={handleRequestSave} title="레이아웃 저장">
|
||||
<Save size={12} />
|
||||
<span>저장</span>
|
||||
</button>
|
||||
<button className="ud-fab-btn ghost" onClick={() => setEditMode(false)} title="편집 종료">
|
||||
<X size={12} />
|
||||
<span>종료</span>
|
||||
</button>
|
||||
</AnimatedFab>
|
||||
|
||||
{/* 제어 모드 FAB */}
|
||||
<AnimatedFab show={!!controlActive}>
|
||||
<span className="ud-fab-badge ctrl">
|
||||
<span className="dot" />
|
||||
제어 모드 · 실시간 데이터
|
||||
</span>
|
||||
<span className="ud-fab-sep" />
|
||||
<button className="ud-fab-btn ghost" onClick={() => toggleControlMode()} title="제어 종료">
|
||||
<X size={12} />
|
||||
<span>종료</span>
|
||||
</button>
|
||||
</AnimatedFab>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
updateCardPositionsBatch,
|
||||
} from '@/lib/api/dashMenu';
|
||||
import { DashboardSidebar } from './DashboardSidebar';
|
||||
import { DashboardToolbar } from './DashboardToolbar';
|
||||
import { DashboardCanvas } from './DashboardCanvas';
|
||||
import { TemplateLibraryModal } from './TemplateLibraryModal';
|
||||
import { CardSettingsPanel } from './CardSettingsPanel';
|
||||
@@ -191,7 +190,7 @@ export function DashboardLayout({ dashboardId: singleDashboardId }: DashboardLay
|
||||
};
|
||||
|
||||
// 레이아웃 저장
|
||||
const handleSaveLayout = async () => {
|
||||
const handleSaveLayout = useCallback(async () => {
|
||||
if (!activeDashboardId) return;
|
||||
try {
|
||||
const cardPositions = cards.map((c) => ({
|
||||
@@ -207,7 +206,14 @@ export function DashboardLayout({ dashboardId: singleDashboardId }: DashboardLay
|
||||
} catch (err) {
|
||||
toast.error('저장 실패');
|
||||
}
|
||||
};
|
||||
}, [activeDashboardId, cards]);
|
||||
|
||||
// 헤더/FAB 가 dispatchEvent('dash:save') 로 저장 요청 → 여기서 수신해 실행
|
||||
useEffect(() => {
|
||||
const onSaveReq = () => { handleSaveLayout(); };
|
||||
window.addEventListener('dash:save', onSaveReq);
|
||||
return () => window.removeEventListener('dash:save', onSaveReq);
|
||||
}, [handleSaveLayout]);
|
||||
|
||||
// 설정 카드 정보
|
||||
const settingsCard = settingsCardId
|
||||
@@ -242,13 +248,7 @@ export function DashboardLayout({ dashboardId: singleDashboardId }: DashboardLay
|
||||
<div className="dash-content">
|
||||
{activeDashboardId ? (
|
||||
<>
|
||||
<DashboardToolbar
|
||||
dashboardName={dashName}
|
||||
cardCount={cards.length}
|
||||
onOpenLibrary={() => openLib()}
|
||||
onSaveLayout={handleSaveLayout}
|
||||
/>
|
||||
{/* 제어 모드 툴바 + 오버레이 */}
|
||||
{/* 편집/제어 툴바는 이제 헤더로 hoist. 캔버스 FAB 이 모드 내 액션 담당. */}
|
||||
{/* ★ flex container로 만들어야 안쪽 dash-canvas가 flex:1로 늘어남 */}
|
||||
<div style={{ position: 'relative', flex: '1 1 auto', display: 'flex', flexDirection: 'column', minHeight: 0, minWidth: 0 }}>
|
||||
<DashboardCanvas
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
BlockRole,
|
||||
BlockV2,
|
||||
CanvasV2,
|
||||
FieldConfig,
|
||||
ResponsivePolicy,
|
||||
Template,
|
||||
ViewV2,
|
||||
} from '@/types/invyone-component';
|
||||
@@ -35,31 +26,15 @@ import '@/lib/registry/components';
|
||||
* 입력: Template. views 는 정규 모델(% 좌표 + role + responsivePolicy + bandId).
|
||||
* 레거시 absolute 포맷은 ensureV2Views 에서 1회 normalize 후 같은 파이프라인.
|
||||
*
|
||||
* 렌더 모델 — 추측 없음:
|
||||
* 블록의 role 값을 직접 보고 분기한다. centerY/overlap/top-tolerance/clamp
|
||||
* 같은 좌표 기반 추측은 사용하지 않는다.
|
||||
* 렌더 모델:
|
||||
* 자유배치 좌표(xPct/yPct/wPct/hPct)에서 세로/가로 line 을 추출하고,
|
||||
* variable grid track 으로 재구성해 CSS Grid 로 렌더한다.
|
||||
*
|
||||
* role=main → band 의 flex row 에 가로 배치
|
||||
* role=companion → 같은 bandId band 의 main 옆에 같은 flex row 합류
|
||||
* role=action → 같은 bandId band 바로 아래 별도 action line
|
||||
* role=overlay → 같은 bandId band 위에 absolute 얹힘
|
||||
*
|
||||
* bandId fallback (단순 2 규칙):
|
||||
* - bandId 없는 main → yPct 순으로 자동 band 생성
|
||||
* - bandId 없는 companion/action/overlay → 직전 main band 에 합류
|
||||
*
|
||||
* 레이아웃 원칙:
|
||||
* - wrapper / band / action-line / block 모두 content size (height 고정 없음)
|
||||
* → 카드 내부 세로 스크롤 발생하지 않음. 카드가 band 총 높이만큼 자란다.
|
||||
* - main + companion 은 flex-row nowrap. wPct 합이 넘치면 shrink 로 흡수.
|
||||
* - action line 은 flex-row, 기본 우측 정렬.
|
||||
* - overlay 는 overlay role 이 명시된 블록만. 남발 금지.
|
||||
*
|
||||
* responsivePolicy (블록 wrapper 에 적용):
|
||||
* - fixed : 원본 wPct 폭 유지, 줄지 않음 (flex 0 0 auto)
|
||||
* - scroll : 남은 공간 grow + 내부 overflow:auto (테이블/컨테이너)
|
||||
* - reflow : 남은 공간 grow + 내부 자체 auto-fit (stats)
|
||||
* - wrap : 원본 wPct 폭 + 부모 flex-wrap 허용
|
||||
* 원칙:
|
||||
* - 좌표가 진실이고, role 은 semantic marker 로만 사용한다.
|
||||
* - 일반 블록은 grid span 으로 렌더한다.
|
||||
* - role='overlay' 만 absolute overlay 예외 경로로 빠진다.
|
||||
* - 버튼류는 실제 runtime cell 크기 안으로만 clamp 하여 clipping 을 막는다.
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
@@ -91,167 +66,6 @@ interface TemplateRendererProps {
|
||||
view?: ViewKey;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Feature flag — line 엔진 활성화 (Step 2, 2026-04-20)
|
||||
// 우선순위:
|
||||
// 1. URL query ?layout=line | ?layout=band
|
||||
// 2. localStorage.INVYONE_LAYOUT === 'line' | 'band'
|
||||
// 3. process.env.NEXT_PUBLIC_LAYOUT_ENGINE === 'line'
|
||||
// 기본값은 'band' (env 가 'line' 이 아니면 false). SSR 초기 렌더는 env 기반
|
||||
// 으로 결정되며 클라이언트 초기 렌더도 동일 → useEffect 에서 URL/LS 반영해
|
||||
// 재렌더한다 (hydration mismatch 방지). 기존 band 경로는 손대지 않는다.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function readEngineFromRuntime(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return process.env.NEXT_PUBLIC_LAYOUT_ENGINE === 'line';
|
||||
}
|
||||
try {
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
const q = qs.get('layout');
|
||||
if (q === 'line') return true;
|
||||
if (q === 'band') return false;
|
||||
const ls = window.localStorage?.getItem('INVYONE_LAYOUT');
|
||||
if (ls === 'line') return true;
|
||||
if (ls === 'band') return false;
|
||||
} catch {
|
||||
// 접근 제한 환경(iframe 등) 에서는 env 기본값으로 폴백
|
||||
}
|
||||
return process.env.NEXT_PUBLIC_LAYOUT_ENGINE === 'line';
|
||||
}
|
||||
|
||||
function useLineEngineFlag(): boolean {
|
||||
const [enabled, setEnabled] = useState<boolean>(
|
||||
() => process.env.NEXT_PUBLIC_LAYOUT_ENGINE === 'line',
|
||||
);
|
||||
useEffect(() => {
|
||||
setEnabled(readEngineFromRuntime());
|
||||
}, []);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Band 구조 — role 기반 분류. 추측 없음, bandId 그대로 or 단순 fallback.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Band {
|
||||
id: string;
|
||||
mains: BlockV2[];
|
||||
companions: BlockV2[];
|
||||
actions: BlockV2[];
|
||||
overlays: BlockV2[];
|
||||
/** band 내 main+companion 의 최대 yPct+hPct — 행간 gap 계산용 */
|
||||
topPct: number;
|
||||
bottomPct: number;
|
||||
}
|
||||
|
||||
function buildBands(blocks: BlockV2[]): Band[] {
|
||||
// yPct 오름차순 → 위쪽 band 가 먼저 나오도록 순서 보존.
|
||||
const sorted = [...blocks].sort(
|
||||
(a, b) => a.yPct - b.yPct || a.xPct - b.xPct,
|
||||
);
|
||||
|
||||
const bandMap = new Map<string, Band>();
|
||||
const bandOrder: string[] = [];
|
||||
let autoIdx = 0;
|
||||
let lastMainBandId: string | null = null;
|
||||
|
||||
const ensureBand = (id: string): Band => {
|
||||
let band = bandMap.get(id);
|
||||
if (!band) {
|
||||
band = {
|
||||
id,
|
||||
mains: [],
|
||||
companions: [],
|
||||
actions: [],
|
||||
overlays: [],
|
||||
topPct: 1,
|
||||
bottomPct: 0,
|
||||
};
|
||||
bandMap.set(id, band);
|
||||
bandOrder.push(id);
|
||||
}
|
||||
return band;
|
||||
};
|
||||
|
||||
for (const b of sorted) {
|
||||
let bandId: string;
|
||||
if (b.bandId) {
|
||||
bandId = b.bandId;
|
||||
} else if (b.role === 'main') {
|
||||
bandId = `auto-${autoIdx++}`;
|
||||
} else {
|
||||
// companion/action/overlay 는 직전 main band 에 합류
|
||||
bandId = lastMainBandId ?? `orphan-${autoIdx++}`;
|
||||
}
|
||||
|
||||
const band = ensureBand(bandId);
|
||||
switch (b.role) {
|
||||
case 'main':
|
||||
band.mains.push(b);
|
||||
lastMainBandId = bandId;
|
||||
break;
|
||||
case 'companion':
|
||||
band.companions.push(b);
|
||||
break;
|
||||
case 'action':
|
||||
band.actions.push(b);
|
||||
break;
|
||||
case 'overlay':
|
||||
band.overlays.push(b);
|
||||
break;
|
||||
}
|
||||
|
||||
// band y 범위 갱신 (main/companion 기준 — action/overlay 는 band 크기에
|
||||
// 영향을 주지 않는다)
|
||||
if (b.role === 'main' || b.role === 'companion') {
|
||||
if (b.yPct < band.topPct) band.topPct = b.yPct;
|
||||
const bottom = b.yPct + b.hPct;
|
||||
if (bottom > band.bottomPct) band.bottomPct = bottom;
|
||||
}
|
||||
}
|
||||
|
||||
return bandOrder.map((id) => bandMap.get(id)!);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// responsivePolicy → flex child 스타일
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function slotStyle(
|
||||
policy: ResponsivePolicy,
|
||||
role: BlockRole,
|
||||
wPct: number,
|
||||
): CSSProperties {
|
||||
const widthPct = `${Math.min(100, Math.max(0, wPct * 100))}%`;
|
||||
// action 은 역할상 content size — 버튼 bar 등이 디자이너 박스 크기(wPct)에
|
||||
// 끌려가 커지는 문제 방지. action row 자체가 우측 정렬이라 자연스레 오른쪽
|
||||
// 에 모임.
|
||||
if (role === 'action') {
|
||||
return { flex: '0 0 auto' };
|
||||
}
|
||||
switch (policy) {
|
||||
case 'fixed':
|
||||
// main/companion 의 fixed: 원본 wPct 엄격 유지. grow/shrink 없음.
|
||||
return { flex: `0 0 ${widthPct}`, minWidth: 0 };
|
||||
case 'scroll':
|
||||
// 테이블/컨테이너: 가로는 wPct 기반 basis + grow/shrink, 세로는 부모
|
||||
// band 의 남은 공간을 alignSelf:stretch + height 100% 로 채움.
|
||||
return {
|
||||
flex: `1 1 ${widthPct}`,
|
||||
minWidth: 0,
|
||||
alignSelf: 'stretch',
|
||||
height: '100%',
|
||||
};
|
||||
case 'reflow':
|
||||
// stats: 원본 wPct 엄격 유지 (디자이너 카드 크기 재현). narrow 모드
|
||||
// (@container) 에서만 1열로 스택.
|
||||
return { flex: `0 0 ${widthPct}`, minWidth: 0 };
|
||||
case 'wrap':
|
||||
return { flex: `0 0 auto`, width: widthPct };
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// TemplateRenderer 본체
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -274,9 +88,6 @@ export function TemplateRenderer({
|
||||
: v2Views.edit;
|
||||
|
||||
const blocks = currentView?.blocks ?? [];
|
||||
const bands = useMemo(() => buildBands(blocks), [blocks]);
|
||||
|
||||
const useLine = useLineEngineFlag();
|
||||
const canvas: CanvasV2 = v2Views?.canvas ?? {
|
||||
baseWidth: 1920,
|
||||
baseHeight: 1080,
|
||||
@@ -294,7 +105,7 @@ export function TemplateRenderer({
|
||||
console.groupCollapsed(
|
||||
'%c[TemplateRenderer]',
|
||||
'color:#6c5ce7;font-weight:bold',
|
||||
`engine=${useLine ? 'line' : 'band'} view=${view} blocks=${blocks.length} bands=${bands.length}`,
|
||||
`engine=line view=${view} blocks=${blocks.length}`,
|
||||
);
|
||||
console.table(
|
||||
blocks.map((b) => ({
|
||||
@@ -309,18 +120,6 @@ export function TemplateRenderer({
|
||||
h: b.hPct.toFixed(3),
|
||||
})),
|
||||
);
|
||||
console.log(
|
||||
'bands:',
|
||||
bands.map((b) => ({
|
||||
id: b.id,
|
||||
mains: b.mains.length,
|
||||
companions: b.companions.length,
|
||||
actions: b.actions.length,
|
||||
overlays: b.overlays.length,
|
||||
topPct: b.topPct.toFixed(3),
|
||||
bottomPct: b.bottomPct.toFixed(3),
|
||||
})),
|
||||
);
|
||||
console.groupEnd();
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
@@ -337,7 +136,6 @@ export function TemplateRenderer({
|
||||
);
|
||||
}
|
||||
|
||||
if (useLine) {
|
||||
return (
|
||||
<LineGridView
|
||||
blocks={blocks}
|
||||
@@ -348,225 +146,10 @@ export function TemplateRenderer({
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="itpl-wrapper">
|
||||
<style>{`
|
||||
/* wrapper: position:relative + 자식 band 들이 absolute 로 yPct/hPct
|
||||
기반 위치/높이 차지. 디자이너 % 좌표를 그대로 보존한다. wrapper
|
||||
padding/gap 은 0 — 디자이너 좌표가 wrapper 와 1:1 매핑되어야 하기
|
||||
때문. 카드 폭/높이가 변하면 % 가 비례 축소되어 자연스레 반응형. */
|
||||
.itpl-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
container-type: inline-size;
|
||||
container-name: tpl;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* band: top/height 는 inline style 로 yPct 기반 주입. 내부는 main row
|
||||
가 항상 100% 차지하고, action line 이 있으면 그 아래 content size. */
|
||||
.itpl-band {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* main row: main + companion 가로 flex. nowrap — 디자이너가 한 라인에
|
||||
그렸으면 한 라인 그대로. 폭 부족 시 컴포넌트별 policy 가 처리. */
|
||||
.itpl-band-main {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
gap: 0;
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
/* action line: main 아래 별도 줄. content size, 우측 정렬. */
|
||||
.itpl-band-action {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
gap: 0;
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.itpl-slot {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
.itpl-overlay {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
pointer-events: auto;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{bands.map((band) => {
|
||||
const mainRowBlocks = [...band.mains, ...band.companions].sort(
|
||||
(a, b) => a.xPct - b.xPct,
|
||||
);
|
||||
const actionBlocks = [...band.actions].sort(
|
||||
(a, b) => a.xPct - b.xPct,
|
||||
);
|
||||
|
||||
// band 의 yPct 범위 → wrapper 안에서 absolute 위치/높이.
|
||||
// 디자이너 % 갭이 그대로 band 사이 빈 공간으로 나타난다.
|
||||
const topPct = Math.max(0, Math.min(1, band.topPct));
|
||||
const heightPct = Math.max(
|
||||
0,
|
||||
Math.min(1 - topPct, band.bottomPct - band.topPct),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={band.id}
|
||||
className="itpl-band"
|
||||
data-band={band.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: `${topPct * 100}%`,
|
||||
height: `${heightPct * 100}%`,
|
||||
}}
|
||||
>
|
||||
{mainRowBlocks.length > 0 && (
|
||||
<div className="itpl-band-main">
|
||||
{mainRowBlocks.map((b, i) => {
|
||||
// 디자이너 가로 갭 보존: 직전 블록의 우측 끝(prevRight) 과
|
||||
// 현재 블록의 xPct 차이를 marginLeft 로 복원. 첫 블록은
|
||||
// wrapper 좌측부터의 빈 공간을 그대로 marginLeft 로 부여.
|
||||
const prev = i === 0 ? null : mainRowBlocks[i - 1];
|
||||
const prevRight = prev ? prev.xPct + prev.wPct : 0;
|
||||
const leftGapPct = Math.max(0, b.xPct - prevRight);
|
||||
return (
|
||||
<BlockSlot
|
||||
key={b.id}
|
||||
block={b}
|
||||
context={context}
|
||||
view={view}
|
||||
leftGapPct={leftGapPct}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{actionBlocks.length > 0 && (
|
||||
<div className="itpl-band-action">
|
||||
{actionBlocks.map((b, i) => {
|
||||
// action line 도 디자이너 xPct 순으로 갭 보존. 단 우측
|
||||
// 정렬이라 첫 블록의 leftGap 은 무의미(우측에서부터 채움).
|
||||
const prev = i === 0 ? null : actionBlocks[i - 1];
|
||||
const leftGapPct = prev
|
||||
? Math.max(0, b.xPct - (prev.xPct + prev.wPct))
|
||||
: 0;
|
||||
return (
|
||||
<BlockSlot
|
||||
key={b.id}
|
||||
block={b}
|
||||
context={context}
|
||||
view={view}
|
||||
leftGapPct={leftGapPct}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{band.overlays.map((ov) => (
|
||||
<OverlaySlot
|
||||
key={ov.id}
|
||||
block={ov}
|
||||
context={context}
|
||||
view={view}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Slot — 개별 블록 렌더
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function BlockSlot({
|
||||
block,
|
||||
context,
|
||||
view,
|
||||
leftGapPct = 0,
|
||||
}: {
|
||||
block: BlockV2;
|
||||
context: TemplateRenderContext;
|
||||
view: ViewKey;
|
||||
leftGapPct?: number;
|
||||
}) {
|
||||
const baseStyle = slotStyle(
|
||||
block.responsivePolicy,
|
||||
block.role,
|
||||
block.wPct,
|
||||
);
|
||||
// marginLeft: percentage 는 부모(.itpl-band-main / .itpl-band-action) 의
|
||||
// width 기준 — band 가 wrapper 100% 폭이라 디자이너 카드 폭 % 와 일치.
|
||||
const style: CSSProperties =
|
||||
leftGapPct > 0
|
||||
? { ...baseStyle, marginLeft: `${leftGapPct * 100}%` }
|
||||
: baseStyle;
|
||||
return (
|
||||
<div
|
||||
className={`itpl-slot role-${block.role} policy-${block.responsivePolicy}`}
|
||||
data-comp={block.componentId}
|
||||
style={style}
|
||||
>
|
||||
<BlockRenderer block={block} context={context} view={view} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverlaySlot({
|
||||
block,
|
||||
context,
|
||||
view,
|
||||
}: {
|
||||
block: BlockV2;
|
||||
context: TemplateRenderContext;
|
||||
view: ViewKey;
|
||||
}) {
|
||||
// overlay 는 band 기준 좌표 — 필요한 경우만 사용된다(남발 금지).
|
||||
// x/y/w/h 를 band wrapper 기준 % 로 그대로 매핑.
|
||||
const style: CSSProperties = {
|
||||
left: `${block.xPct * 100}%`,
|
||||
top: `${block.yPct * 100}%`,
|
||||
width: `${block.wPct * 100}%`,
|
||||
height: `${block.hPct * 100}%`,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={`itpl-overlay role-overlay policy-${block.responsivePolicy}`}
|
||||
data-comp={block.componentId}
|
||||
style={style}
|
||||
>
|
||||
<BlockRenderer block={block} context={context} view={view} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LineGridView — feature flag 'line' 활성 시 사용되는 렌더러 (Step 2)
|
||||
// LineGridView — 기본 템플릿 렌더러
|
||||
// 좌표에서 variable line grid 를 추출해 CSS Grid 로 렌더. role 은 semantic
|
||||
// marker 로만 남고 레이아웃 계산에 개입하지 않는다. overlay 는 예외 경로만.
|
||||
// 기존 band 경로(TemplateRenderer 본체 return) 와 공존하며, feature flag
|
||||
// OFF 일 때 전혀 호출되지 않는다.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function LineGridView({
|
||||
@@ -663,6 +246,7 @@ function LineGridView({
|
||||
|
||||
for (const bl of layout.blocks) {
|
||||
if (bl.mode !== 'grid') continue;
|
||||
const block = byId.get(bl.blockId);
|
||||
const colStart = Math.max(1, bl.colStart ?? 1) - 1;
|
||||
const colEnd = Math.max(colStart, (bl.colEnd ?? 1) - 1);
|
||||
const rowStart = Math.max(1, bl.rowStart ?? 1) - 1;
|
||||
@@ -672,12 +256,16 @@ function LineGridView({
|
||||
for (let i = colStart; i < colEnd; i++) width += colWidthsPx[i] ?? 0;
|
||||
|
||||
let height = 0;
|
||||
if (aspectPolicy === 'free') {
|
||||
height = block ? block.hPct * canvas.baseHeight : 0;
|
||||
} else {
|
||||
for (let i = rowStart; i < rowEnd; i++) height += finalPx[i] ?? 0;
|
||||
}
|
||||
|
||||
m.set(bl.blockId, { width, height });
|
||||
}
|
||||
return m;
|
||||
}, [layout, containerSize, finalPx]);
|
||||
}, [layout, containerSize, finalPx, byId, aspectPolicy, canvas.baseHeight]);
|
||||
|
||||
// 각 블록 cell 의 final 높이가 preferred 대비 많이 줄어든 경우 compact 클래스.
|
||||
// 기준: finalH < preferredH * 0.9 (10% 이상 축소)
|
||||
@@ -1001,7 +589,7 @@ function BlockRenderer({
|
||||
view: ViewKey;
|
||||
/**
|
||||
* 선택: 제공되면 block 의 %좌표를 px 로 환산해 컴포넌트에 전달한다.
|
||||
* line 경로에서만 넘어오고, band 경로는 기존 동작(size 0) 유지 — 회귀 방지.
|
||||
* line grid 의 실제 runtime cell 크기를 component.size 로 전달한다.
|
||||
*/
|
||||
canvas?: CanvasV2;
|
||||
runtimeSize?: {
|
||||
|
||||
@@ -23,6 +23,11 @@ import {
|
||||
Plus,
|
||||
Edit3,
|
||||
Zap,
|
||||
Save,
|
||||
SlidersHorizontal,
|
||||
Sun,
|
||||
Moon,
|
||||
Bell,
|
||||
} from "lucide-react";
|
||||
import { useDashboardStore } from "@/stores/dashboardStore";
|
||||
import { useControlMode } from "@/components/control/hooks/useControlMode";
|
||||
@@ -43,6 +48,8 @@ import { TabBar } from "./TabBar";
|
||||
import { TabContent } from "./TabContent";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { TopNavBar } from "./TopNavBar";
|
||||
import { animatedNavOrientationChange } from "@/lib/navOrientationTransition";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -238,6 +245,27 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 헤더 pop-out 툴 그룹 — show=false 전환 시 closing 클래스 후 320ms 뒤 unmount.
|
||||
* Ref: INVYONE Design System / ui_kits/app/dashboard-user.jsx (AnimatedHtoolGroup).
|
||||
*/
|
||||
function AnimatedHtoolGroup({ show, children }: { show: boolean; children: React.ReactNode }) {
|
||||
const [rendered, setRendered] = useState(show);
|
||||
const [closing, setClosing] = useState(false);
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
setRendered(true);
|
||||
setClosing(false);
|
||||
} else if (rendered) {
|
||||
setClosing(true);
|
||||
const t = window.setTimeout(() => { setRendered(false); setClosing(false); }, 320);
|
||||
return () => window.clearTimeout(t);
|
||||
}
|
||||
}, [show, rendered]);
|
||||
if (!rendered) return null;
|
||||
return <span className={`ud-htool-group${closing ? " closing" : ""}`}>{children}</span>;
|
||||
}
|
||||
|
||||
function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -256,6 +284,25 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const { theme, setTheme: rawSetTheme } = useTheme();
|
||||
|
||||
// 메뉴 방향 (vertical: 사이드바 / horizontal: 헤더 내 TopNav). localStorage 유지.
|
||||
const [navOrientation, setNavOrientationRaw] = useState<"vertical" | "horizontal">("vertical");
|
||||
const navOrientationRef = useRef<"vertical" | "horizontal">("vertical");
|
||||
navOrientationRef.current = navOrientation;
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem("invyone-nav-orientation");
|
||||
if (saved === "horizontal" || saved === "vertical") setNavOrientationRaw(saved);
|
||||
} catch {}
|
||||
}, []);
|
||||
const setNavOrientation = useCallback((next: "vertical" | "horizontal") => {
|
||||
if (navOrientationRef.current === next) return;
|
||||
// Ghost-slide transition: 나가는 nav 를 축 방향으로 밀어내며 페이드, 새 nav 는 자체 enter 애니 실행
|
||||
animatedNavOrientationChange(next, () => {
|
||||
setNavOrientationRaw(next);
|
||||
try { localStorage.setItem("invyone-nav-orientation", next); } catch {}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 대시보드 생성 (전역) + 제어/편집 (대시보드 페이지에서만 조건부 노출)
|
||||
const dashCreateOpen = useDashboardStore((s) => s.createOpen);
|
||||
const openDashCreate = useDashboardStore((s) => s.openCreate);
|
||||
@@ -263,6 +310,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
const dashEditMode = useDashboardStore((s) => s.editMode);
|
||||
const toggleDashEditMode = useDashboardStore((s) => s.toggleEditMode);
|
||||
const setDashEditMode = useDashboardStore((s) => s.setEditMode);
|
||||
const openDashLib = useDashboardStore((s) => s.openLib);
|
||||
const dashControlActive = useControlMode((s) => s.active);
|
||||
const toggleDashControlMode = useControlMode((s) => s.toggleControlMode);
|
||||
const [dashCreateSubmitting, setDashCreateSubmitting] = useState(false);
|
||||
@@ -518,7 +566,42 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
hdrGlow.classList.add("mode-flash");
|
||||
}
|
||||
|
||||
// (d) 토글 버튼 burst 효과는 제거됨 — 가운데 밝아지는 게 거슬려서 통째로 뺌
|
||||
// (d) 토글 버튼 burst — 디자인시스템 mode-burst 포팅
|
||||
// 클릭 좌표에 ring 1개 + radial particle 10개. admin 진입은 cyan, 사용자 복귀는 primary.
|
||||
const targetEl = e?.currentTarget as HTMLElement | undefined;
|
||||
const rect = targetEl?.getBoundingClientRect();
|
||||
const bx = rect ? rect.left + rect.width / 2 : (e?.clientX ?? window.innerWidth - 80);
|
||||
const by = rect ? rect.top + rect.height / 2 : (e?.clientY ?? 25);
|
||||
const burst = document.createElement("div");
|
||||
burst.className = `v5-mode-burst${goingToAdmin ? " admin" : ""}`;
|
||||
burst.style.left = `${bx}px`;
|
||||
burst.style.top = `${by}px`;
|
||||
const ring = document.createElement("span");
|
||||
ring.className = "burst-ring";
|
||||
burst.appendChild(ring);
|
||||
const N = 10;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const p = document.createElement("span");
|
||||
p.className = "burst-particle";
|
||||
const angle = (i / N) * Math.PI * 2;
|
||||
const dist = 36 + Math.random() * 22;
|
||||
p.style.setProperty("--tx", `${Math.cos(angle) * dist}px`);
|
||||
p.style.setProperty("--ty", `${Math.sin(angle) * dist}px`);
|
||||
p.style.animationDelay = `${i * 8}ms`;
|
||||
burst.appendChild(p);
|
||||
}
|
||||
document.body.appendChild(burst);
|
||||
setTimeout(() => burst.remove(), 1100);
|
||||
|
||||
// (d2) 헤더 하단 좌→우 sweep (admin 진입은 cyan→primary→pink, 사용자 복귀는 primary)
|
||||
const hdrEl = document.querySelector<HTMLElement>(".v5-hdr");
|
||||
if (hdrEl) {
|
||||
const sweep = document.createElement("div");
|
||||
sweep.className = "v5-mode-sweep";
|
||||
sweep.setAttribute("data-mode", goingToAdmin ? "admin" : "user");
|
||||
hdrEl.appendChild(sweep);
|
||||
setTimeout(() => sweep.remove(), 900);
|
||||
}
|
||||
|
||||
// (e) breadcrumb swap-out
|
||||
const bc = document.querySelector<HTMLElement>(".v5-hdr-bc");
|
||||
@@ -826,6 +909,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
<Menu size={16} />
|
||||
</button>
|
||||
<div className="v5-hdr-logo">Invy.one</div>
|
||||
{navOrientation === "vertical" && (
|
||||
<>
|
||||
<div className="v5-hdr-bc">
|
||||
{isAdminMode ? "관리자" : "홈"} › <b>{breadcrumbText}</b>
|
||||
</div>
|
||||
@@ -833,11 +918,74 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
<div className="badge-dot" />
|
||||
관리자 모드
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{navOrientation === "horizontal" && (
|
||||
<TopNavBar
|
||||
menus={uiMenus}
|
||||
isMenuActive={isMenuActive}
|
||||
onSelect={handleMenuClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* mode transition 헤더 glow 라인 — 평소엔 opacity 0, mode change 시에만 flash */}
|
||||
<div className="v5-hdr-glow" />
|
||||
<div className="v5-hdr-r">
|
||||
{/* 대시보드 생성(전역) + 제어/편집(대시보드 페이지 전용) — 평소엔 페이지 내부 툴바 없음, 이 버튼 눌러야 툴바 등장 */}
|
||||
{/* 대시보드 페이지: [+ 대시보드] 를 편집/제어와 같은 그룹에 묶어 한 덩어리로.
|
||||
그 외 페이지: [+ 대시보드] 만 단독 노출. */}
|
||||
{pathname && !isAdminMode && /^\/\d+$/.test(pathname) ? (
|
||||
<div className="ud-htools">
|
||||
<button
|
||||
className="ud-htool"
|
||||
onClick={openDashCreate}
|
||||
title="새 대시보드 만들기"
|
||||
>
|
||||
<Plus size={11} />
|
||||
<span>대시보드</span>
|
||||
</button>
|
||||
{/* 편집 ON 시 왼쪽으로 펼쳐지는 [템플릿][저장] pop-out */}
|
||||
<AnimatedHtoolGroup show={dashEditMode && !dashControlActive}>
|
||||
<button
|
||||
className="ud-htool"
|
||||
onClick={() => openDashLib()}
|
||||
title="템플릿 추가"
|
||||
>
|
||||
<Plus size={11} />
|
||||
<span>템플릿</span>
|
||||
</button>
|
||||
<button
|
||||
className="ud-htool"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent("dash:save"))}
|
||||
title="레이아웃 저장"
|
||||
>
|
||||
<Save size={11} />
|
||||
<span>저장</span>
|
||||
</button>
|
||||
<span className="ud-hsep" />
|
||||
</AnimatedHtoolGroup>
|
||||
<button
|
||||
className={`ud-htool${dashEditMode ? " on" : ""}`}
|
||||
onClick={toggleDashEditMode}
|
||||
disabled={dashControlActive}
|
||||
title={dashControlActive ? "제어 모드 중에는 편집 불가" : (dashEditMode ? "편집 종료" : "편집 모드")}
|
||||
>
|
||||
<Edit3 size={11} />
|
||||
<span>{dashEditMode ? "편집중" : "편집"}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`ud-htool${dashControlActive ? " on" : ""}`}
|
||||
data-mode="ctrl"
|
||||
onClick={() => {
|
||||
if (!dashControlActive) setDashEditMode(false);
|
||||
toggleDashControlMode();
|
||||
}}
|
||||
title={dashControlActive ? "제어 종료" : "제어 모드 — 데이터 흐름 시각화"}
|
||||
>
|
||||
<Zap size={11} />
|
||||
<span>{dashControlActive ? "제어중" : "제어"}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="v5-dash-btn"
|
||||
onClick={openDashCreate}
|
||||
@@ -846,36 +994,17 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
<Plus size={13} />
|
||||
<span>대시보드</span>
|
||||
</button>
|
||||
{pathname && !isAdminMode && /^\/\d+$/.test(pathname) && (
|
||||
<>
|
||||
<button
|
||||
className={`v5-dash-btn${dashControlActive ? " on" : ""}`}
|
||||
onClick={() => {
|
||||
if (!dashControlActive) setDashEditMode(false);
|
||||
toggleDashControlMode();
|
||||
}}
|
||||
title={dashControlActive ? "제어 모드 끄기" : "제어 모드 — 데이터 흐름 시각화"}
|
||||
>
|
||||
<Zap size={13} />
|
||||
<span>{dashControlActive ? "제어 ✓" : "제어"}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`v5-dash-btn${dashEditMode ? " on" : ""}`}
|
||||
onClick={toggleDashEditMode}
|
||||
disabled={dashControlActive}
|
||||
title={dashControlActive ? "제어 모드 중에는 편집 불가" : (dashEditMode ? "편집 모드 끄기" : "편집 모드 켜기")}
|
||||
>
|
||||
<Edit3 size={13} />
|
||||
<span>{dashEditMode ? "편집 중" : "편집"}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Theme pill */}
|
||||
<div className="v5-pill">
|
||||
<button className={theme !== "dark" ? "on" : ""} onClick={(e) => setNextTheme("light", e)}>Light</button>
|
||||
<button className={theme === "dark" ? "on" : ""} onClick={(e) => setNextTheme("dark", e)}>Dark</button>
|
||||
</div>
|
||||
{/* Theme toggle — single sun/moon icon (디자인시스템 준수, 2026-04-21) */}
|
||||
<button
|
||||
className="v5-hdr-icon"
|
||||
onClick={(e) => setNextTheme(theme === "dark" ? "light" : "dark", e)}
|
||||
title={theme === "dark" ? "라이트 모드로" : "다크 모드로"}
|
||||
aria-label="테마 전환"
|
||||
>
|
||||
{theme === "dark" ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</button>
|
||||
|
||||
{/* Mini tab icon (visible when tabs collapsed) */}
|
||||
{tabsCollapsed && (
|
||||
@@ -890,17 +1019,30 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
)}
|
||||
|
||||
{/* Bell / Notifications */}
|
||||
<button className="v5-bell">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<div className="v5-bell-dot" />
|
||||
<button className="v5-hdr-icon" title="알림" aria-label="알림">
|
||||
<Bell size={16} />
|
||||
<span className="v5-hdr-icon-dot" />
|
||||
</button>
|
||||
|
||||
{/* Tweaks — 색상/테마 빠른 접근 (SettingsModal 오픈) */}
|
||||
<button
|
||||
className={`v5-hdr-icon${settingsOpen ? " on" : ""}`}
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
title="Tweaks — 테마 / 색상"
|
||||
aria-label="설정"
|
||||
>
|
||||
<SlidersHorizontal size={16} />
|
||||
</button>
|
||||
|
||||
{/* Admin toggle (gear ↔ home) — 이벤트 전달해서 burst origin 으로 사용 */}
|
||||
{isAdmin && (
|
||||
<button className="v5-admin-btn" onClick={(e) => handleModeSwitch(e)} title={isAdminMode ? "홈으로" : "관리자"}>
|
||||
<Settings size={14} className="ic-gear" />
|
||||
<Home size={14} className="ic-home" />
|
||||
<span className="v5-admin-label">{isAdminMode ? "홈으로" : "관리자"}</span>
|
||||
<button
|
||||
className="v5-hdr-icon v5-mode-toggle"
|
||||
onClick={(e) => handleModeSwitch(e)}
|
||||
title={isAdminMode ? "홈으로" : "관리자 모드"}
|
||||
aria-label={isAdminMode ? "홈으로" : "관리자 모드"}
|
||||
>
|
||||
{isAdminMode ? <Home size={16} /> : <Shield size={16} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -961,8 +1103,9 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
)}
|
||||
|
||||
{/* ===== Body (sidebar + content) ===== */}
|
||||
<div className="v5-body">
|
||||
{/* Sidebar */}
|
||||
<div className={`v5-body ${navOrientation === "horizontal" ? "v5-body-horizontal" : ""}`}>
|
||||
{/* Sidebar — horizontal 모드에서는 Mobile 용(햄버거) 로만 살리고 데스크톱 표시는 숨김 */}
|
||||
{(navOrientation === "vertical" || isMobile) && (
|
||||
<aside className={`v5-side v5-side-anim ${isAdminMode ? "v5-admin-side" : ""} ${sidebarCollapsed ? "collapsed" : ""} ${isMobile ? (sidebarOpen ? "mobile-open" : "") : ""} ${modeTransition === "out" ? "slide-out" : modeTransition === "in" ? "slide-in" : ""}`}
|
||||
style={isMobile ? { position: "fixed", left: 0, top: 0, bottom: 0, zIndex: 30, paddingTop: "60px", width: "260px", transform: sidebarOpen ? "none" : "translateX(-100%)" } : undefined}
|
||||
>
|
||||
@@ -1004,6 +1147,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
</button>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Content area */}
|
||||
{/* ★ INVYONE 신규 페이지는 children 직접 렌더, 기존 VEX 페이지는 탭 시스템 */}
|
||||
@@ -1070,7 +1214,14 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<SettingsModal open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
<SettingsModal
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onSidebarCollapsedChange={setSidebarCollapsed}
|
||||
navOrientation={navOrientation}
|
||||
onNavOrientationChange={setNavOrientation}
|
||||
/>
|
||||
|
||||
{/* 전역 대시보드 생성 모달 — 헤더 "대시보드" 버튼에서 열림 */}
|
||||
<CreateDashboardModal
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Check, Moon, Sun } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Check, Moon, Sun, X, PanelLeftClose, PanelLeft, AlignJustify, AlignHorizontalJustifyCenter } from "lucide-react";
|
||||
import { useColorTheme, COLOR_THEMES, type ColorTheme } from "@/hooks/useColorTheme";
|
||||
import { animatedThemeChange } from "@/lib/themeTransition";
|
||||
import { animatedColorChange } from "@/lib/colorTransition";
|
||||
@@ -16,13 +10,39 @@ import { animatedColorChange } from "@/lib/colorTransition";
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sidebarCollapsed?: boolean;
|
||||
onSidebarCollapsedChange?: (collapsed: boolean) => void;
|
||||
navOrientation?: "vertical" | "horizontal";
|
||||
onNavOrientationChange?: (next: "vertical" | "horizontal") => void;
|
||||
}
|
||||
|
||||
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
/**
|
||||
* Tweaks panel — 디자인시스템 `Tweaks` (app.jsx) 포팅.
|
||||
* 중앙 모달 → 우하단 플로팅 240px 패널로 변경 (2026-04-21 재단).
|
||||
* 키워드 "설정" 유지 (파일명/컴포넌트명). 외부 API 는 props.open/onOpenChange 기존과 동일.
|
||||
*/
|
||||
export function SettingsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
sidebarCollapsed,
|
||||
onSidebarCollapsedChange,
|
||||
navOrientation,
|
||||
onNavOrientationChange,
|
||||
}: SettingsModalProps) {
|
||||
const { color, setColor } = useColorTheme();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
// Escape 로 닫기
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onOpenChange(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
const handleModeClick = (next: "light" | "dark", e: React.MouseEvent) => {
|
||||
if (next === theme) return;
|
||||
animatedThemeChange(next, setTheme, { x: e.clientX, y: e.clientY });
|
||||
@@ -39,40 +59,28 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>설정</DialogTitle>
|
||||
<DialogDescription>화면 테마와 색상을 변경합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* === 모드 (라이트/다크) === */}
|
||||
<div className="settings-section">
|
||||
<div className="settings-label">화면 모드</div>
|
||||
<div className="settings-mode-row">
|
||||
<button
|
||||
type="button"
|
||||
className={`settings-mode-btn ${!isDark ? "on" : ""}`}
|
||||
onClick={(e) => handleModeClick("light", e)}
|
||||
<div
|
||||
className={`v5-tweaks-panel${open ? " on" : ""}`}
|
||||
role="dialog"
|
||||
aria-label="Tweaks"
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<Sun size={14} />
|
||||
<span>라이트</span>
|
||||
</button>
|
||||
<div className="v5-tweaks-head">
|
||||
<span>Tweaks</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`settings-mode-btn ${isDark ? "on" : ""}`}
|
||||
onClick={(e) => handleModeClick("dark", e)}
|
||||
className="v5-hdr-icon"
|
||||
onClick={() => onOpenChange(false)}
|
||||
aria-label="Tweaks 닫기"
|
||||
style={{ width: 24, height: 24, borderRadius: 8 }}
|
||||
>
|
||||
<Moon size={14} />
|
||||
<span>다크</span>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* === 색상 테마 === */}
|
||||
<div className="settings-section">
|
||||
<div className="settings-label">색상 테마</div>
|
||||
<div className="settings-color-grid">
|
||||
{/* === 테마 컬러 === */}
|
||||
<div className="v5-tweaks-row">
|
||||
<label>테마 컬러</label>
|
||||
<div className="v5-tweaks-swatches">
|
||||
{COLOR_THEMES.map((c) => {
|
||||
const swatch = isDark ? c.dark : c.light;
|
||||
const isActive = color === c.id;
|
||||
@@ -80,21 +88,95 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className={`settings-color-swatch ${isActive ? "on" : ""}`}
|
||||
className={`v5-tweaks-swatch${isActive ? " on" : ""}`}
|
||||
onClick={(e) => handleColorClick(c.id as ColorTheme, e)}
|
||||
title={c.label}
|
||||
aria-label={c.label}
|
||||
style={{ background: swatch }}
|
||||
>
|
||||
<span className="swatch-circle" style={{ background: swatch }}>
|
||||
{isActive && <Check size={12} strokeWidth={3} />}
|
||||
</span>
|
||||
<span className="swatch-label">{c.label}</span>
|
||||
{isActive && <Check size={11} strokeWidth={3.5} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* === 모드 === */}
|
||||
<div className="v5-tweaks-row">
|
||||
<label>모드</label>
|
||||
<div className="v5-tweaks-seg">
|
||||
<button
|
||||
type="button"
|
||||
className={`v5-btn ${!isDark ? "primary" : "secondary"} sm`}
|
||||
onClick={(e) => handleModeClick("light", e)}
|
||||
>
|
||||
<Sun size={12} />
|
||||
라이트
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`v5-btn ${isDark ? "primary" : "secondary"} sm`}
|
||||
onClick={(e) => handleModeClick("dark", e)}
|
||||
>
|
||||
<Moon size={12} />
|
||||
다크
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* === 메뉴 방향 === */}
|
||||
{onNavOrientationChange && (
|
||||
<div className="v5-tweaks-row">
|
||||
<label>메뉴 방향</label>
|
||||
<div className="v5-tweaks-seg">
|
||||
<button
|
||||
type="button"
|
||||
className={`v5-btn ${navOrientation === "vertical" ? "primary" : "secondary"} sm`}
|
||||
onClick={() => onNavOrientationChange("vertical")}
|
||||
>
|
||||
<AlignJustify size={12} />
|
||||
세로
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`v5-btn ${navOrientation === "horizontal" ? "primary" : "secondary"} sm`}
|
||||
onClick={() => onNavOrientationChange("horizontal")}
|
||||
>
|
||||
<AlignHorizontalJustifyCenter size={12} />
|
||||
가로
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* === 사이드바 (세로일 때만 의미있음) === */}
|
||||
{onSidebarCollapsedChange && navOrientation !== "horizontal" && (
|
||||
<div className="v5-tweaks-row">
|
||||
<label>사이드바</label>
|
||||
<div className="v5-tweaks-seg">
|
||||
<button
|
||||
type="button"
|
||||
className={`v5-btn ${!sidebarCollapsed ? "primary" : "secondary"} sm`}
|
||||
onClick={() => onSidebarCollapsedChange(false)}
|
||||
>
|
||||
<PanelLeft size={12} />
|
||||
펼침
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`v5-btn ${sidebarCollapsed ? "primary" : "secondary"} sm`}
|
||||
onClick={() => onSidebarCollapsedChange(true)}
|
||||
>
|
||||
<PanelLeftClose size={12} />
|
||||
접힘
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="v5-tweaks-foot">
|
||||
우측 상단 슬라이더 아이콘으로 다시 열기
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
type UIMenu = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: React.ReactNode;
|
||||
hasChildren?: boolean;
|
||||
children?: UIMenu[];
|
||||
url?: string;
|
||||
badge?: number;
|
||||
};
|
||||
|
||||
type TopNavBarProps = {
|
||||
menus: UIMenu[];
|
||||
isMenuActive: (m: UIMenu) => boolean;
|
||||
onSelect: (m: UIMenu) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* TopNav — 디자인시스템 `TopNav` 포팅 (shell-components.jsx).
|
||||
* invyone 메뉴 트리(최상위 = 섹션)에 맞게 단순화.
|
||||
* 섹션 hover → flyout (첫 번째 레벨), flyout 아이템에 자식이 있으면 hover 로 2단계 sub-flyout.
|
||||
*/
|
||||
export function TopNavBar({ menus, isMenuActive, onSelect }: TopNavBarProps) {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelClose = useCallback(() => {
|
||||
if (closeTimer.current) {
|
||||
clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
const scheduleClose = useCallback(() => {
|
||||
cancelClose();
|
||||
// 섹션 헤더 → flyout 이동 중 마우스가 경계 근처에서 순간적으로 빠져도 안 닫히도록 여유있게.
|
||||
closeTimer.current = setTimeout(() => setOpenId(null), 260);
|
||||
}, [cancelClose]);
|
||||
const openNow = useCallback(
|
||||
(id: string) => {
|
||||
cancelClose();
|
||||
setOpenId(id);
|
||||
},
|
||||
[cancelClose],
|
||||
);
|
||||
|
||||
const handleSectionClick = (m: UIMenu) => {
|
||||
// leaf 섹션은 바로 선택. 자식이 있는 섹션은 첫 leaf 로 점프.
|
||||
if (!m.hasChildren) {
|
||||
onSelect(m);
|
||||
return;
|
||||
}
|
||||
const first = m.children?.[0];
|
||||
if (!first) return;
|
||||
if (first.hasChildren && first.children?.[0]) onSelect(first.children[0]);
|
||||
else onSelect(first);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="v5-topnav" aria-label="주 내비게이션">
|
||||
{menus.map((sec, i) => {
|
||||
const isOpen = openId === sec.id;
|
||||
const isActive = isMenuActive(sec);
|
||||
return (
|
||||
<div
|
||||
key={sec.id}
|
||||
className={`v5-tn-section ${isActive ? "on" : ""} ${isOpen ? "open" : ""}`}
|
||||
onMouseEnter={() => openNow(sec.id)}
|
||||
onMouseLeave={scheduleClose}
|
||||
style={{ animationDelay: `${i * 40}ms` }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="v5-tn-item"
|
||||
onClick={() => handleSectionClick(sec)}
|
||||
>
|
||||
{sec.icon && <span className="v5-tn-ic">{sec.icon}</span>}
|
||||
<span>{sec.name}</span>
|
||||
{sec.hasChildren && <ChevronDown size={12} />}
|
||||
</button>
|
||||
|
||||
{isOpen && sec.hasChildren && (
|
||||
/*
|
||||
flyout 의 onMouseEnter/Leave 는 의도적으로 달지 않음.
|
||||
- flyout 은 section 의 DOM 자식이므로, section 의 mouseleave 는
|
||||
"마우스가 flyout 과 section 모두 벗어났을 때" 에만 발화함.
|
||||
- 따라서 섹션 내부 ↔ flyout 이동은 아무 이벤트도 발사되지 않고 유지됨.
|
||||
- 이전엔 flyout mouseleave → scheduleClose 가 타서 섹션 쪽으로
|
||||
위로 이동 시 플라이아웃이 사라지던 버그가 있었음.
|
||||
*/
|
||||
<div className="v5-tn-flyout">
|
||||
{sec.children?.map((it, j) => (
|
||||
<TnRow
|
||||
key={it.id}
|
||||
item={it}
|
||||
isMenuActive={isMenuActive}
|
||||
onSelect={(x) => {
|
||||
onSelect(x);
|
||||
setOpenId(null);
|
||||
}}
|
||||
delay={j * 28}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function TnRow({
|
||||
item,
|
||||
isMenuActive,
|
||||
onSelect,
|
||||
delay,
|
||||
}: {
|
||||
item: UIMenu;
|
||||
isMenuActive: (m: UIMenu) => boolean;
|
||||
onSelect: (m: UIMenu) => void;
|
||||
delay: number;
|
||||
}) {
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const hasChildren = !!item.hasChildren && !!item.children?.length;
|
||||
const isOn = isMenuActive(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`v5-tn-row ${isOn ? "on" : ""} ${hasChildren ? "has-sub" : ""}`}
|
||||
style={{ animationDelay: `${delay}ms` }}
|
||||
onMouseEnter={() => hasChildren && setSubOpen(true)}
|
||||
onMouseLeave={() => setSubOpen(false)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest(".v5-tn-sub")) return;
|
||||
if (hasChildren && item.children?.[0]) onSelect(item.children[0]);
|
||||
else onSelect(item);
|
||||
}}
|
||||
>
|
||||
{item.icon && <span className="v5-tn-ic">{item.icon}</span>}
|
||||
<span className="v5-tn-row-label">{item.name}</span>
|
||||
{typeof item.badge === "number" && item.badge > 0 && (
|
||||
<span className="v5-tn-badge">{item.badge}</span>
|
||||
)}
|
||||
{hasChildren && <ChevronRight size={12} />}
|
||||
{hasChildren && subOpen && (
|
||||
<div className="v5-tn-sub">
|
||||
{item.children?.map((c, k) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`v5-tn-row v5-tn-sub-row ${isMenuActive(c) ? "on" : ""}`}
|
||||
style={{ animationDelay: `${k * 22}ms` }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(c);
|
||||
}}
|
||||
>
|
||||
{c.icon && <span className="v5-tn-ic">{c.icon}</span>}
|
||||
<span className="v5-tn-row-label">{c.name}</span>
|
||||
{typeof c.badge === "number" && c.badge > 0 && (
|
||||
<span className="v5-tn-badge">{c.badge}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type BarPoint = { k: string; v: number };
|
||||
|
||||
type BarCardProps = {
|
||||
title: ReactNode;
|
||||
data: BarPoint[];
|
||||
unit?: string;
|
||||
/** Right-aligned action node in the card head (e.g., a small ghost button). */
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function BarCard({ title, data, unit = "건", action, className = "" }: BarCardProps) {
|
||||
const sum = data.reduce((acc, d) => acc + d.v, 0);
|
||||
const max = Math.max(...data.map((d) => d.v), 1);
|
||||
|
||||
return (
|
||||
<div className={`v5-card ${className}`.trim()}>
|
||||
<div className="v5-card-head">
|
||||
<div>
|
||||
<div className="v5-card-title">{title}</div>
|
||||
<div style={{ fontSize: "1.1rem", fontWeight: 800, letterSpacing: "-.02em", marginTop: ".15rem" }}>
|
||||
{sum.toLocaleString("ko-KR")}
|
||||
<span style={{ fontSize: ".62rem", fontWeight: 600, color: "var(--v5-text-muted)", marginLeft: ".25rem" }}>
|
||||
{unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
<div className="v5-bars">
|
||||
{data.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="v5-bar"
|
||||
style={{ height: `${Math.max(2, (d.v / max) * 100)}%` }}
|
||||
title={`${d.k}: ${d.v.toLocaleString("ko-KR")}${unit}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="v5-bars-ax">
|
||||
{data.map((d, i) => (
|
||||
<span key={i}>{d.k}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
type FeedDotTone = "primary" | "g" | "a" | "c" | "r";
|
||||
|
||||
export type FeedItem = {
|
||||
id: string | number;
|
||||
icon: LucideIcon;
|
||||
tone?: FeedDotTone;
|
||||
/** Body text (can include <b>...</b> via React node). */
|
||||
text: ReactNode;
|
||||
/** Time string (e.g., "3분 전", "오늘 14:20"). Mono font, muted color. */
|
||||
time?: ReactNode;
|
||||
};
|
||||
|
||||
type FeedProps = {
|
||||
items: FeedItem[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Feed({ items, className = "" }: FeedProps) {
|
||||
return (
|
||||
<div className={`v5-feed ${className}`.trim()}>
|
||||
{items.map((it) => {
|
||||
const Icon = it.icon;
|
||||
const dotCls = `v5-feed-dot ${it.tone && it.tone !== "primary" ? it.tone : ""}`.trim();
|
||||
return (
|
||||
<div key={it.id} className="v5-feed-row">
|
||||
<div className={dotCls}>
|
||||
<Icon />
|
||||
</div>
|
||||
<div className="v5-feed-txt">
|
||||
{it.text}
|
||||
{it.time && <span className="tm">{it.time}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { TrendingUp, TrendingDown } from "lucide-react";
|
||||
import { Spark } from "./Spark";
|
||||
|
||||
type SparkColor = "primary" | "cyan" | "green" | "pink" | "amber" | "red";
|
||||
|
||||
type KpiProps = {
|
||||
title: ReactNode;
|
||||
value: ReactNode;
|
||||
/** Optional subtitle line under the number (e.g., "이번 달") */
|
||||
sub?: ReactNode;
|
||||
/** +12 / -3 etc. — green up-pill or red down-pill rendered automatically. */
|
||||
delta?: number;
|
||||
/** Color of the BIG number. Default = text color. */
|
||||
color?: "default" | "cyan" | "green" | "pink" | "amber";
|
||||
/** Sparkline data to render under the number (small inline trend). */
|
||||
spark?: number[];
|
||||
sparkColor?: SparkColor;
|
||||
/** Add primary glow shadow to the card. */
|
||||
glow?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const NUM_COLOR_CLASS: Record<NonNullable<KpiProps["color"]>, string> = {
|
||||
default: "",
|
||||
cyan: "kpi-cyan",
|
||||
green: "kpi-green",
|
||||
pink: "kpi-pink",
|
||||
amber: "kpi-amber",
|
||||
};
|
||||
|
||||
export function Kpi({
|
||||
title,
|
||||
value,
|
||||
sub,
|
||||
delta,
|
||||
color = "default",
|
||||
spark,
|
||||
sparkColor,
|
||||
glow = false,
|
||||
className = "",
|
||||
}: KpiProps) {
|
||||
const showDelta = typeof delta === "number" && delta !== 0;
|
||||
const isUp = (delta ?? 0) > 0;
|
||||
const cardCls = `v5-card ${glow ? "glow" : ""} ${className}`.trim();
|
||||
|
||||
return (
|
||||
<div className={cardCls}>
|
||||
<div className="v5-card-head">
|
||||
<div className="v5-card-title">{title}</div>
|
||||
</div>
|
||||
<div className={`v5-kpi-num ${NUM_COLOR_CLASS[color]}`.trim()}>
|
||||
<span>{value}</span>
|
||||
{showDelta && (
|
||||
<span className={`v5-kpi-delta ${isUp ? "up" : "down"}`}>
|
||||
{isUp ? <TrendingUp /> : <TrendingDown />}
|
||||
{Math.abs(delta!)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{sub && <div className="v5-kpi-sub">{sub}</div>}
|
||||
{spark && spark.length > 0 && (
|
||||
<div style={{ marginTop: ".4rem" }}>
|
||||
<Spark values={spark} color={sparkColor ?? (color === "default" ? "primary" : (color as SparkColor))} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
type ModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: ReactNode;
|
||||
/** Body content. Can include <b>...</b> for emphasis. */
|
||||
children: ReactNode;
|
||||
/** Footer slot. Pass v5-btn nodes. */
|
||||
footer?: ReactNode;
|
||||
/** Visual width (default 420px). */
|
||||
maxWidth?: number;
|
||||
/** Disable backdrop click to close. */
|
||||
hardClose?: boolean;
|
||||
};
|
||||
|
||||
export function Modal({ open, onClose, title, children, footer, maxWidth = 420, hardClose = false }: ModalProps) {
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !hardClose) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose, hardClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="v5-overlay"
|
||||
onClick={() => {
|
||||
if (!hardClose) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="v5-modal"
|
||||
style={{ maxWidth }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="v5-modal-head">
|
||||
<div className="v5-modal-title">{title}</div>
|
||||
<button
|
||||
className="v5-btn ghost sm"
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
style={{ width: 28, height: 28, padding: 0 }}
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="v5-modal-body">{children}</div>
|
||||
{footer && <div className="v5-modal-foot">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
export type Crumb = { label: string; href?: string };
|
||||
|
||||
type PageHeadProps = {
|
||||
crumbs?: Crumb[];
|
||||
title: ReactNode;
|
||||
sub?: ReactNode;
|
||||
/**
|
||||
* Right-aligned action area. Design-system rule:
|
||||
* priority `secondary` → `secondary` → `primary`, max 3 buttons.
|
||||
* Pass <button class="v5-btn …"> nodes (or shadcn Buttons that visually match).
|
||||
*/
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PageHead({ crumbs, title, sub, actions, className = "" }: PageHeadProps) {
|
||||
return (
|
||||
<div className={`v5-page-head ${className}`}>
|
||||
<div className="v5-page-head-l">
|
||||
{crumbs && crumbs.length > 0 && (
|
||||
<div className="v5-crumbs">
|
||||
{crumbs.map((c, i) => (
|
||||
<span key={i} style={{ display: "inline-flex", alignItems: "center", gap: ".35rem" }}>
|
||||
{i > 0 && <ChevronRight size={10} className="sep" />}
|
||||
{c.href ? (
|
||||
<a href={c.href} style={{ color: "inherit", textDecoration: "none" }}>
|
||||
{c.label}
|
||||
</a>
|
||||
) : (
|
||||
<span>{c.label}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="v5-page-title">{title}</div>
|
||||
{sub && <div className="v5-page-sub">{sub}</div>}
|
||||
</div>
|
||||
{actions && <div className="v5-page-head-r">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
|
||||
export type SparkColor = "primary" | "cyan" | "green" | "pink" | "amber" | "red";
|
||||
|
||||
type SparkProps = {
|
||||
values: number[];
|
||||
color?: SparkColor;
|
||||
height?: number;
|
||||
fill?: boolean;
|
||||
};
|
||||
|
||||
const COLOR_VAR: Record<SparkColor, string> = {
|
||||
primary: "var(--v5-primary)",
|
||||
cyan: "rgb(var(--v5-cyan-rgb))",
|
||||
green: "rgb(var(--v5-green-rgb))",
|
||||
pink: "rgb(var(--v5-pink-rgb))",
|
||||
amber: "rgb(var(--v5-amber-rgb))",
|
||||
red: "rgb(var(--v5-red-rgb))",
|
||||
};
|
||||
|
||||
export function Spark({ values, color = "primary", height = 36, fill = true }: SparkProps) {
|
||||
const id = useId().replace(/:/g, "");
|
||||
if (!values || values.length === 0) return null;
|
||||
|
||||
const max = Math.max(...values);
|
||||
const min = Math.min(...values);
|
||||
const range = max - min || 1;
|
||||
const stepX = 100 / Math.max(1, values.length - 1);
|
||||
|
||||
const points = values.map((v, i) => {
|
||||
const x = i * stepX;
|
||||
const y = 100 - ((v - min) / range) * 100;
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
});
|
||||
|
||||
const linePath = `M ${points.join(" L ")}`;
|
||||
const areaPath = `${linePath} L 100,100 L 0,100 Z`;
|
||||
const stroke = COLOR_VAR[color];
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="v5-spark"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
style={{ height }}
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={`sg-${id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" style={{ stopColor: stroke, stopOpacity: 0.35 }} />
|
||||
<stop offset="100%" style={{ stopColor: stroke, stopOpacity: 0 }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{fill && <path d={areaPath} fill={`url(#sg-${id})`} />}
|
||||
<path d={linePath} fill="none" stroke={stroke} strokeWidth="1.6" vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* INVYONE v5 — Atomic component barrel
|
||||
*
|
||||
* Components ported from the INVYONE Design System
|
||||
* (`ui_kits/app/dashboard-components.jsx`, `shell-components.jsx`).
|
||||
*
|
||||
* CSS lives in `frontend/styles/v5-atomics.css` (imported via globals.css).
|
||||
* Tokens live in `frontend/styles/v5-layout.css`.
|
||||
*
|
||||
* Use these to compose new screens. They are intentionally small —
|
||||
* if you need something more, prefer extending these over inline styles.
|
||||
*/
|
||||
export { PageHead } from "./PageHead";
|
||||
export type { Crumb } from "./PageHead";
|
||||
export { Kpi } from "./Kpi";
|
||||
export { BarCard } from "./BarCard";
|
||||
export { Spark } from "./Spark";
|
||||
export { Feed } from "./Feed";
|
||||
export type { FeedItem } from "./Feed";
|
||||
export { Modal } from "./Modal";
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Nav orientation transition — ported from INVYONE Design System
|
||||
* (`ui_kits/app/app.jsx` → animatedNavOrientationChange).
|
||||
*
|
||||
* Clones the outgoing nav (sidebar OR topnav) as a ghost fixed over its
|
||||
* original rect, slides it along its own axis + fades, then applies the
|
||||
* state change. The new nav fades in via its own CSS enter animation.
|
||||
*
|
||||
* No 3D, no fold — just clean motion matching the axis change.
|
||||
*/
|
||||
export type NavOrientation = "vertical" | "horizontal";
|
||||
|
||||
export function animatedNavOrientationChange(
|
||||
next: NavOrientation,
|
||||
apply: (next: NavOrientation) => void,
|
||||
): void {
|
||||
const root = document.documentElement;
|
||||
root.setAttribute("data-nav-anim", next);
|
||||
|
||||
// Grab the current outgoing nav. invyone uses .v5-side / .v5-topnav.
|
||||
const oldAside = document.querySelector<HTMLElement>("aside.v5-side");
|
||||
const oldTopnav = document.querySelector<HTMLElement>(".v5-topnav");
|
||||
const oldNav = oldAside || oldTopnav;
|
||||
|
||||
if (oldNav) {
|
||||
const rect = oldNav.getBoundingClientRect();
|
||||
const ghost = oldNav.cloneNode(true) as HTMLElement;
|
||||
ghost.classList.add("v5-nav-exit-ghost");
|
||||
const fromOrient: NavOrientation = next === "horizontal" ? "vertical" : "horizontal";
|
||||
Object.assign(ghost.style, {
|
||||
position: "fixed",
|
||||
left: `${rect.left}px`,
|
||||
top: `${rect.top}px`,
|
||||
width: `${rect.width}px`,
|
||||
height: `${rect.height}px`,
|
||||
margin: "0",
|
||||
zIndex: "60",
|
||||
pointerEvents: "none",
|
||||
willChange: "transform, opacity",
|
||||
});
|
||||
// Kill child animations on the ghost so it stays visually static while sliding.
|
||||
ghost.querySelectorAll<HTMLElement>("*").forEach((el) => {
|
||||
el.style.animation = "none";
|
||||
el.style.transition = "none";
|
||||
});
|
||||
document.body.appendChild(ghost);
|
||||
|
||||
// Drive via setInterval because React.flushSync in the same tick can swallow rAF.
|
||||
const DURATION = 320;
|
||||
const startTs = performance.now();
|
||||
// sidebar (vertical) slides left; topnav (horizontal) slides up.
|
||||
const deltaAxis: "X" | "Y" = fromOrient === "vertical" ? "X" : "Y";
|
||||
const deltaMax = fromOrient === "vertical" ? -28 : -14;
|
||||
const ease = (t: number) => (t >= 1 ? 1 : 1 - Math.pow(1 - t, 2.2));
|
||||
const iv = setInterval(() => {
|
||||
if (!ghost.isConnected) {
|
||||
clearInterval(iv);
|
||||
return;
|
||||
}
|
||||
const t = Math.min(1, (performance.now() - startTs) / DURATION);
|
||||
const e = ease(t);
|
||||
ghost.style.transform = `translate${deltaAxis}(${deltaMax * e}px)`;
|
||||
ghost.style.opacity = String(1 - e);
|
||||
if (t >= 1) {
|
||||
clearInterval(iv);
|
||||
ghost.remove();
|
||||
}
|
||||
}, 16);
|
||||
// Safety net — if the ghost hangs around, nuke it.
|
||||
setTimeout(() => {
|
||||
clearInterval(iv);
|
||||
if (ghost.isConnected) ghost.remove();
|
||||
}, 900);
|
||||
}
|
||||
|
||||
// Apply the state change. New nav mounts and plays its own enter animation.
|
||||
apply(next);
|
||||
|
||||
// Clean up the root attribute after the "enter" window.
|
||||
setTimeout(() => {
|
||||
if (root.getAttribute("data-nav-anim") === next) {
|
||||
root.removeAttribute("data-nav-anim");
|
||||
}
|
||||
}, 520);
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import type {
|
||||
ResponsivePolicy,
|
||||
TemplateViewsV2,
|
||||
ViewV2,
|
||||
BlockRole,
|
||||
} from '@/types/invyone-component';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -342,27 +341,18 @@ export function isV2Views(views: unknown): views is TemplateViewsV2 {
|
||||
/**
|
||||
* 런타임 정규화.
|
||||
* 이미 v2 포맷으로 저장된 stale 데이터가 최신 규칙을 반영하지 못해 화면이
|
||||
* 틀어지는 문제 방지. 두 단계로 동작:
|
||||
* 틀어지는 문제 방지. 현재는 한 단계로 동작:
|
||||
*
|
||||
* [Stage 1] componentId / responsivePolicy 보정
|
||||
* - LEGACY_TO_UNIFIED 로 componentId 를 통합 ID 로 교체
|
||||
* - unified ID 기준으로 inferPolicy 재적용
|
||||
*
|
||||
* [Stage 2] button/input role 좌표 기반 재분류 (view 단위)
|
||||
* - main 블록의 yPct 범위를 모은 뒤
|
||||
* - button/input 의 centerY 가 어떤 main 범위 안이면 companion
|
||||
* (= 같은 가로 라인에 배치), 밖이면 action (= 별도 액션 라인)
|
||||
* - 디자이너가 명시 main/overlay 로 둔 경우는 그대로 보존
|
||||
*
|
||||
* 분류가 바뀌면 bandId 는 리셋 → 런타임 buildBands fallback 에 위임.
|
||||
* - role / bandId 는 사용자가 저장한 값을 그대로 보존
|
||||
*/
|
||||
const COORD_DEPENDENT_ROLES = new Set(['button', 'input']);
|
||||
|
||||
function normalizeRoles(v: TemplateViewsV2): TemplateViewsV2 {
|
||||
const patchView = (view: ViewV2 | undefined): ViewV2 | undefined => {
|
||||
if (!view) return view;
|
||||
|
||||
// Stage 1: componentId / policy 보정. role 은 일단 보존.
|
||||
// Stage 1: componentId / policy 보정. role / bandId 는 그대로 보존.
|
||||
const stage1: BlockV2[] = view.blocks.map((b) => {
|
||||
const unified = LEGACY_TO_UNIFIED[b.componentId] ?? b.componentId;
|
||||
const idChanged = unified !== b.componentId;
|
||||
@@ -378,27 +368,7 @@ function normalizeRoles(v: TemplateViewsV2): TemplateViewsV2 {
|
||||
responsivePolicy: nextPolicy,
|
||||
};
|
||||
});
|
||||
|
||||
// Stage 2: 좌표 기반 role 재분류 (button/input 만).
|
||||
// main 블록 y 범위 수집 — 디자이너가 명시한 role='main' 을 신뢰.
|
||||
const mainRanges = stage1
|
||||
.filter((b) => b.role === 'main')
|
||||
.map((b) => ({ top: b.yPct, bottom: b.yPct + b.hPct }));
|
||||
|
||||
const stage2: BlockV2[] = stage1.map((b) => {
|
||||
if (!COORD_DEPENDENT_ROLES.has(b.componentId)) return b;
|
||||
if (b.role === 'main' || b.role === 'overlay') return b;
|
||||
const centerY = b.yPct + b.hPct / 2;
|
||||
const inMain = mainRanges.some(
|
||||
(r) => centerY >= r.top && centerY <= r.bottom,
|
||||
);
|
||||
const nextRole: BlockRole = inMain ? 'companion' : 'action';
|
||||
if (b.role === nextRole) return b;
|
||||
const { bandId: _drop, ...rest } = b;
|
||||
return { ...rest, role: nextRole };
|
||||
});
|
||||
|
||||
return { ...view, blocks: stage2 };
|
||||
return { ...view, blocks: stage1 };
|
||||
};
|
||||
return {
|
||||
...v,
|
||||
|
||||
@@ -22,14 +22,61 @@
|
||||
--ctrl-glass-strong: rgba(255, 255, 255, .06);
|
||||
}
|
||||
|
||||
/* ═══ 제어 모드 캔버스 배경 ═══ */
|
||||
/* ═══ 편집/제어 모드 캔버스 — INVYONE Design System (ui_kits/app) 포팅 ═══
|
||||
primary/cyan tint + 24px line grid + inset 2px border.
|
||||
기본 dash-canvas 의 dot grid 를 override. */
|
||||
.dash-canvas.edit-mode {
|
||||
background-color: var(--v5-bg);
|
||||
background-image:
|
||||
linear-gradient(0deg, rgba(var(--v5-primary-rgb), .035), rgba(var(--v5-primary-rgb), .035));
|
||||
box-shadow: inset 0 0 0 2px rgba(var(--v5-primary-rgb), .22);
|
||||
}
|
||||
.dash-canvas.edit-mode::before {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(var(--v5-primary-rgb), .08) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(var(--v5-primary-rgb), .08) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
animation: ud-grid-fade .4s var(--v5-ease-move) both;
|
||||
}
|
||||
|
||||
.dash-canvas.control-mode {
|
||||
background-image: radial-gradient(circle at 0.5px 0.5px, rgba(var(--v5-cyan-rgb),.22) 0.5px, transparent 0);
|
||||
background-size: 24px 24px;
|
||||
background-color: var(--v5-bg);
|
||||
background-image:
|
||||
linear-gradient(0deg, rgba(var(--v5-cyan-rgb), .035), rgba(var(--v5-cyan-rgb), .035));
|
||||
box-shadow: inset 0 0 0 2px rgba(var(--v5-cyan-rgb), .22);
|
||||
overflow: auto;
|
||||
}
|
||||
.dash-canvas.control-mode::before {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(var(--v5-cyan-rgb), .08) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(var(--v5-cyan-rgb), .08) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
animation: ud-grid-fade .4s var(--v5-ease-move) both;
|
||||
}
|
||||
|
||||
/* 다크모드에서는 tint를 조금 강하게 */
|
||||
.dark .dash-canvas.edit-mode {
|
||||
background-image:
|
||||
linear-gradient(0deg, rgba(var(--v5-primary-rgb), .05), rgba(var(--v5-primary-rgb), .05));
|
||||
box-shadow: inset 0 0 0 2px rgba(var(--v5-primary-rgb), .28);
|
||||
}
|
||||
.dark .dash-canvas.control-mode {
|
||||
background-image: radial-gradient(circle at 0.5px 0.5px, rgba(var(--v5-cyan-rgb),.18) 0.5px, transparent 0);
|
||||
background-image:
|
||||
linear-gradient(0deg, rgba(var(--v5-cyan-rgb), .05), rgba(var(--v5-cyan-rgb), .05));
|
||||
box-shadow: inset 0 0 0 2px rgba(var(--v5-cyan-rgb), .28);
|
||||
}
|
||||
|
||||
@keyframes ud-grid-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* 카드 축소 + 클릭 가능 */
|
||||
@@ -77,13 +124,12 @@ html:not(.dark) .ctrl-line-tpl { stroke: #e0559e; stroke-width: 3; opacity: .6;
|
||||
/* ═══ 연결선 위 뱃지 ═══ */
|
||||
.ctrl-badge {
|
||||
position: absolute; padding: .2rem .6rem; border-radius: 9px;
|
||||
background: var(--ctrl-glass-strong);
|
||||
backdrop-filter: blur(16px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.4);
|
||||
background: var(--v5-surface-solid);
|
||||
border: 1px solid rgba(var(--v5-cyan-rgb),.3);
|
||||
font-size: .55rem; font-weight: 700; color: var(--ctrl-cyan);
|
||||
white-space: nowrap; z-index: 15; cursor: pointer;
|
||||
transition: all .25s; box-shadow: 0 4px 16px rgba(var(--v5-cyan-rgb),.12);
|
||||
transition: all .25s var(--v5-ease-move);
|
||||
box-shadow: 0 4px 16px rgba(var(--v5-cyan-rgb),.12), 0 0 12px rgba(var(--v5-cyan-rgb),.1);
|
||||
pointer-events: auto; transform: translate(-50%, -50%);
|
||||
}
|
||||
.ctrl-badge:hover {
|
||||
@@ -128,12 +174,10 @@ html:not(.dark) .ctrl-badge { background: rgba(255,255,255,.92);
|
||||
/* ═══ 테이블 노드 ═══ */
|
||||
.tbl-node {
|
||||
position: absolute; width: 200px;
|
||||
background: var(--ctrl-glass-strong);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
background: var(--v5-surface-solid);
|
||||
border: 1px solid var(--ctrl-glass-border); border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.08), 0 0 20px rgba(var(--v5-cyan-rgb),.08);
|
||||
z-index: 20; overflow: hidden; transition: border-color .2s, box-shadow .2s;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.08), 0 0 20px rgba(var(--v5-cyan-rgb),.12);
|
||||
z-index: 20; overflow: hidden; transition: border-color .2s var(--v5-ease-move), box-shadow .2s var(--v5-ease-move);
|
||||
}
|
||||
.dark .tbl-node { box-shadow: 0 8px 24px rgba(0,0,0,.5), 0 0 20px rgba(var(--v5-cyan-rgb),.06); }
|
||||
.tbl-node:hover {
|
||||
@@ -185,12 +229,10 @@ html:not(.dark) .ctrl-badge { background: rgba(255,255,255,.92);
|
||||
/* ═══ 제어 노드 (액션/조건/타이머) ═══ */
|
||||
.ctrl-action-node {
|
||||
position: absolute; width: 160px;
|
||||
background: var(--ctrl-glass-strong);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
background: var(--v5-surface-solid);
|
||||
border: 1px solid rgba(var(--na-rgb, 0,206,201), .25); border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.08), 0 0 16px rgba(var(--na-rgb, 0,206,201), .08);
|
||||
z-index: 20; overflow: visible; transition: border-color .2s, box-shadow .2s;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.08), 0 0 16px rgba(var(--na-rgb, 0,206,201), .12);
|
||||
z-index: 20; overflow: visible; transition: border-color .2s var(--v5-ease-move), box-shadow .2s var(--v5-ease-move);
|
||||
}
|
||||
.ctrl-action-node:hover {
|
||||
border-color: rgba(var(--na-rgb, 0,206,201), .5);
|
||||
@@ -288,11 +330,10 @@ html:not(.dark) .ctrl-badge { background: rgba(255,255,255,.92);
|
||||
.conn-x {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 20px; height: 20px; border-radius: 50%;
|
||||
background: var(--ctrl-glass-strong);
|
||||
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
||||
background: var(--v5-surface-solid);
|
||||
border: 1.5px solid rgba(var(--v5-red-rgb),.15);
|
||||
color: var(--v5-text-muted, #888); font-size: .55rem; font-weight: 700; cursor: pointer;
|
||||
opacity: 0; transition: all .2s; transform: scale(.6);
|
||||
opacity: 0; transition: all .2s var(--v5-ease-move); transform: scale(.6);
|
||||
}
|
||||
.rule-conn-badge:hover .conn-x {
|
||||
opacity: 1; transform: scale(1);
|
||||
@@ -303,13 +344,12 @@ html:not(.dark) .ctrl-badge { background: rgba(255,255,255,.92);
|
||||
/* ═══ 설정 팝오버 ═══ */
|
||||
.ctrl-cfg-pop {
|
||||
position: absolute; width: 220px;
|
||||
background: var(--ctrl-glass-strong);
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||
background: var(--v5-surface-solid);
|
||||
border: 1px solid rgba(var(--v5-primary-rgb),.3); border-radius: 12px;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,.15), 0 0 24px rgba(var(--v5-primary-rgb),.1);
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,.15), 0 0 24px rgba(var(--v5-primary-rgb),.18);
|
||||
z-index: 50; padding: .7rem;
|
||||
opacity: 0; transform: translateX(-8px); transition: opacity .2s, transform .2s;
|
||||
opacity: 0; transform: translateX(-8px);
|
||||
transition: opacity .2s var(--v5-ease-move), transform .2s var(--v5-ease-move);
|
||||
}
|
||||
.ctrl-cfg-pop.open { opacity: 1; transform: translateX(0); }
|
||||
|
||||
|
||||
+315
-55
@@ -16,10 +16,8 @@
|
||||
/* ── 사이드바 ── */
|
||||
.dash-side {
|
||||
width: 220px;
|
||||
background: var(--v5-glass);
|
||||
backdrop-filter: blur(20px) saturate(1.3);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.3);
|
||||
border-right: 1px solid var(--v5-glass-border);
|
||||
background: var(--v5-surface-solid);
|
||||
border-right: 1px solid var(--v5-border);
|
||||
padding: .85rem .6rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
@@ -93,9 +91,8 @@
|
||||
/* ── 캔버스 툴바 ── */
|
||||
.dash-toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: .75rem 1.25rem; background: var(--v5-glass);
|
||||
backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--v5-glass-border); flex-shrink: 0;
|
||||
padding: .75rem 1.25rem; background: var(--v5-surface-solid);
|
||||
border-bottom: 1px solid var(--v5-border); flex-shrink: 0;
|
||||
}
|
||||
.dash-toolbar-l { display: flex; align-items: center; gap: .75rem; }
|
||||
.dash-cv-title { font-size: .95rem; font-weight: 700; color: var(--v5-text);
|
||||
@@ -108,8 +105,8 @@
|
||||
.dash-toolbar-r { display: flex; align-items: center; gap: .5rem; }
|
||||
.dash-btn {
|
||||
display: flex; align-items: center; gap: .4rem; padding: .42rem .8rem;
|
||||
border-radius: 10px; border: 1px solid var(--v5-glass-border);
|
||||
background: var(--v5-surface); color: var(--v5-text-sec);
|
||||
border-radius: 10px; border: 1px solid var(--v5-border);
|
||||
background: var(--v5-surface-solid); color: var(--v5-text-sec);
|
||||
font-size: .68rem; font-weight: 600; cursor: pointer; font-family: inherit;
|
||||
transition: all .2s;
|
||||
}
|
||||
@@ -132,16 +129,12 @@
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background-image: radial-gradient(circle at 0.5px 0.5px, var(--v5-glass-border) 0.5px, transparent 0);
|
||||
background-color: var(--v5-bg);
|
||||
background-image: radial-gradient(circle at 0.5px 0.5px, var(--v5-border) 0.5px, transparent 0);
|
||||
background-size: 20px 20px;
|
||||
transition: background-color .3s var(--v5-ease-move), box-shadow .3s var(--v5-ease-move);
|
||||
}
|
||||
.dash-canvas.edit-mode {
|
||||
background-image: radial-gradient(circle at 0.5px 0.5px, rgba(var(--v5-primary-rgb),.25) 0.5px, transparent 0);
|
||||
outline: 1px dashed rgba(var(--v5-primary-rgb),.18); outline-offset: -8px;
|
||||
}
|
||||
.dark .dash-canvas.edit-mode {
|
||||
background-image: radial-gradient(circle at 0.5px 0.5px, rgba(var(--v5-primary-rgb),.3) 0.5px, transparent 0);
|
||||
}
|
||||
/* edit / control 모드 시각 효과는 control-mode.css 에서 관리 */
|
||||
|
||||
/* ── 카드 ── */
|
||||
/* ★ wrapper(position:absolute)가 위치 잡고, .dash-card는 wrapper 채움 */
|
||||
@@ -149,10 +142,10 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255,255,255,0.92);
|
||||
border: 1px solid var(--v5-glass-border, rgba(var(--v5-primary-rgb),.2));
|
||||
background: var(--v5-surface-solid);
|
||||
border: 1px solid var(--v5-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.12), 0 0 20px rgba(var(--v5-primary-rgb),.15);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.04), 0 0 20px rgba(var(--v5-primary-rgb),.10);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -160,9 +153,9 @@
|
||||
z-index: 10;
|
||||
}
|
||||
.dark .dash-card {
|
||||
background: rgba(28, 26, 56, 0.92);
|
||||
border-color: rgba(var(--v5-primary-rgb),.2);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 0 20px rgba(var(--v5-primary-rgb),.15);
|
||||
background: var(--v5-surface-solid);
|
||||
border-color: var(--v5-border);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.4), 0 0 20px rgba(var(--v5-primary-rgb),.15);
|
||||
}
|
||||
.dark .dash-card { box-shadow: 0 8px 32px rgba(0,0,0,0.4), var(--v5-glow-sm); }
|
||||
.dash-card:hover { border-color: rgba(var(--v5-primary-rgb),.25);
|
||||
@@ -183,7 +176,7 @@
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: .65rem .9rem;
|
||||
border-bottom: 1px solid var(--v5-border-subtle); flex-shrink: 0;
|
||||
background: var(--v5-glass);
|
||||
background: transparent;
|
||||
}
|
||||
.dash-card-head-l { display: flex; align-items: center; gap: .55rem; }
|
||||
.dash-card-icon {
|
||||
@@ -326,8 +319,8 @@
|
||||
gap: .45rem; flex: 1; align-content: start;
|
||||
}
|
||||
.dash-mini-stat {
|
||||
padding: .55rem .65rem; border-radius: 9px; background: var(--v5-glass);
|
||||
border: 1px solid var(--v5-glass-border); display: flex; flex-direction: column;
|
||||
padding: .55rem .65rem; border-radius: 9px; background: var(--v5-bg-subtle);
|
||||
border: 1px solid var(--v5-border); display: flex; flex-direction: column;
|
||||
justify-content: center; min-height: 54px;
|
||||
}
|
||||
.dash-mini-stat .ms-label { font-size: .5rem; font-weight: 600;
|
||||
@@ -355,23 +348,20 @@
|
||||
|
||||
/* ── 라이브러리 모달 ── */
|
||||
.dash-lib-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(6,5,14,0.5);
|
||||
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
||||
position: fixed; inset: 0; background: rgba(6,5,14,0.55);
|
||||
z-index: 200; opacity: 0; pointer-events: none;
|
||||
transition: opacity .3s cubic-bezier(.4,0,.2,1);
|
||||
transition: opacity .3s var(--v5-ease-move);
|
||||
}
|
||||
.dash-lib-backdrop.open { opacity: 1; pointer-events: auto; }
|
||||
|
||||
.dash-lib-modal {
|
||||
position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%) scale(.96);
|
||||
width: min(920px, 90vw); height: min(620px, 85vh);
|
||||
background: var(--v5-glass-strong, rgba(255,255,255,.65));
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||
border: 1px solid var(--v5-glass-border); border-radius: 20px;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.2), var(--v5-glow-lg, var(--v5-glow-md));
|
||||
background: var(--v5-surface-solid);
|
||||
border: 1px solid var(--v5-border); border-radius: 20px;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.2), var(--v5-glow-lg);
|
||||
z-index: 201; display: flex; flex-direction: column; overflow: hidden;
|
||||
opacity: 0; pointer-events: none; transition: all .3s cubic-bezier(.16,1,.3,1);
|
||||
opacity: 0; pointer-events: none; transition: all .3s var(--v5-ease-move);
|
||||
}
|
||||
.dash-lib-modal.open { opacity: 1; transform: translate(-50%,-50%) scale(1);
|
||||
pointer-events: auto; }
|
||||
@@ -393,8 +383,8 @@
|
||||
.dash-lib-body { display: flex; flex: 1; overflow: hidden; }
|
||||
|
||||
.dash-lib-cats {
|
||||
width: 160px; flex-shrink: 0; background: var(--v5-glass);
|
||||
border-right: 1px solid var(--v5-glass-border);
|
||||
width: 160px; flex-shrink: 0; background: var(--v5-bg-subtle);
|
||||
border-right: 1px solid var(--v5-border);
|
||||
padding: .7rem .5rem; overflow-y: auto; display: flex; flex-direction: column; gap: 1px;
|
||||
}
|
||||
.dash-lib-cat {
|
||||
@@ -413,9 +403,9 @@
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: .7rem;
|
||||
}
|
||||
.dash-lib-card {
|
||||
padding: .85rem; border-radius: 13px; background: var(--v5-glass);
|
||||
border: 1px solid var(--v5-glass-border); cursor: pointer;
|
||||
transition: all .25s; display: flex; flex-direction: column; gap: .4rem;
|
||||
padding: .85rem; border-radius: 13px; background: var(--v5-surface-solid);
|
||||
border: 1px solid var(--v5-border); cursor: pointer;
|
||||
transition: all .25s var(--v5-ease-move); display: flex; flex-direction: column; gap: .4rem;
|
||||
}
|
||||
.dash-lib-card:hover { border-color: var(--v5-primary);
|
||||
transform: translateY(-2px); box-shadow: var(--v5-glow-md); }
|
||||
@@ -437,10 +427,8 @@
|
||||
.dash-settings {
|
||||
position: absolute; top: 46px; right: 10px; width: 280px;
|
||||
max-height: calc(100% - 60px);
|
||||
background: var(--v5-glass-strong, rgba(255,255,255,.65));
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||
border: 1px solid var(--v5-glass-border); border-radius: 14px;
|
||||
background: var(--v5-surface-solid);
|
||||
border: 1px solid var(--v5-border); border-radius: 14px;
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,.15), var(--v5-glow-md);
|
||||
z-index: 60; display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
@@ -477,13 +465,12 @@
|
||||
/* ── Toast ── */
|
||||
.dash-toast {
|
||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px);
|
||||
background: var(--v5-glass-strong, rgba(255,255,255,.65));
|
||||
backdrop-filter: blur(20px) saturate(1.4); border: 1px solid var(--v5-glass-border);
|
||||
background: var(--v5-surface-solid); border: 1px solid var(--v5-border);
|
||||
border-radius: 12px; padding: .65rem 1.1rem;
|
||||
font-size: .7rem; font-weight: 600; color: var(--v5-text);
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,.15), var(--v5-glow-md);
|
||||
z-index: 300; opacity: 0; pointer-events: none;
|
||||
transition: all .3s cubic-bezier(.16,1,.3,1);
|
||||
transition: all .3s var(--v5-ease-move);
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
}
|
||||
.dash-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
@@ -505,13 +492,13 @@
|
||||
gap: .25rem;
|
||||
padding: .3rem .7rem;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--v5-glass-border);
|
||||
background: var(--v5-glass);
|
||||
border: 1px solid var(--v5-border);
|
||||
background: var(--v5-surface-solid);
|
||||
color: var(--v5-text-sec);
|
||||
font-size: .65rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all .15s;
|
||||
transition: all .15s var(--v5-ease-move);
|
||||
}
|
||||
.dash-crud-btn:hover:not(:disabled) {
|
||||
border-color: var(--v5-primary);
|
||||
@@ -568,8 +555,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0,0,0,.35);
|
||||
backdrop-filter: blur(6px) saturate(1.2);
|
||||
background: rgba(0,0,0,.5);
|
||||
animation: dashFormFade .2s ease-out;
|
||||
}
|
||||
.dash-form-modal {
|
||||
@@ -578,8 +564,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--v5-glass-border);
|
||||
background: var(--v5-surface);
|
||||
border: 1px solid var(--v5-border);
|
||||
background: var(--v5-surface-solid);
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,.35), var(--v5-glow-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -589,7 +575,7 @@
|
||||
justify-content: space-between;
|
||||
padding: .7rem .95rem;
|
||||
border-bottom: 1px solid var(--v5-border-subtle);
|
||||
background: var(--v5-glass);
|
||||
background: transparent;
|
||||
}
|
||||
.dash-form-title {
|
||||
font-size: .78rem;
|
||||
@@ -606,3 +592,277 @@
|
||||
from { opacity: 0; transform: scale(.98); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
ud-* — INVYONE Design System (ui_kits/app) 포팅
|
||||
Floating Action Bar (FAB) + Header Tools (pop-out segment) + Card stagger.
|
||||
solid + glow 스타일 · 반투명/blur 미사용 (2026-04-21 정책).
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── FAB: 캔버스 우하단 플로팅 액션 바 ── */
|
||||
.ud-fab {
|
||||
position: absolute;
|
||||
bottom: 24px; right: 24px; z-index: 50;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 8px 10px 8px 14px;
|
||||
background: var(--v5-surface-solid);
|
||||
border: 1px solid var(--v5-border);
|
||||
border-radius: 14px;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0,0,0,.06),
|
||||
0 8px 24px rgba(var(--v5-primary-rgb), .14),
|
||||
var(--v5-glow-md);
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
transform-origin: bottom right;
|
||||
animation: ud-fab-in .42s cubic-bezier(.22,1.4,.36,1) both;
|
||||
}
|
||||
.dark .ud-fab {
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0,0,0,.4),
|
||||
0 8px 24px rgba(var(--v5-primary-rgb), .25),
|
||||
var(--v5-glow-lg);
|
||||
}
|
||||
.ud-fab.closing {
|
||||
animation: ud-fab-out .3s var(--v5-ease-exit) both;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes ud-fab-in {
|
||||
from { opacity: 0; transform: translateY(16px) scale(.6); }
|
||||
60% { transform: translateY(-2px) scale(1.08); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes ud-fab-out {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||
to { opacity: 0; transform: translateY(12px) scale(.95); }
|
||||
}
|
||||
.ud-fab > * { animation: ud-fab-item-in .45s var(--v5-ease-move) both; }
|
||||
.ud-fab > *:nth-child(1) { animation-delay: .06s; }
|
||||
.ud-fab > *:nth-child(2) { animation-delay: .10s; }
|
||||
.ud-fab > *:nth-child(3) { animation-delay: .14s; }
|
||||
.ud-fab > *:nth-child(4) { animation-delay: .18s; }
|
||||
.ud-fab > *:nth-child(5) { animation-delay: .22s; }
|
||||
.ud-fab > *:nth-child(6) { animation-delay: .26s; }
|
||||
@keyframes ud-fab-item-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.ud-fab-badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: .68rem; font-weight: 700;
|
||||
color: rgb(var(--v5-primary-rgb));
|
||||
font-family: var(--v5-font-mono);
|
||||
letter-spacing: .02em;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.ud-fab-badge .dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: rgb(var(--v5-primary-rgb));
|
||||
box-shadow: 0 0 8px rgba(var(--v5-primary-rgb), .6);
|
||||
animation: ud-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.ud-fab-badge.ctrl { color: rgb(var(--v5-cyan-rgb)); }
|
||||
.ud-fab-badge.ctrl .dot {
|
||||
background: rgb(var(--v5-cyan-rgb));
|
||||
box-shadow: 0 0 8px rgba(var(--v5-cyan-rgb), .6);
|
||||
}
|
||||
@keyframes ud-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
|
||||
|
||||
.ud-fab-sep {
|
||||
width: 1px; height: 18px;
|
||||
background: var(--v5-border);
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.ud-fab-btn {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
height: 30px; padding: 0 12px;
|
||||
background: transparent;
|
||||
color: var(--v5-text-sec);
|
||||
font-size: .72rem; font-weight: 600;
|
||||
border: 1px solid transparent; border-radius: 8px;
|
||||
cursor: pointer; font-family: inherit; white-space: nowrap;
|
||||
transition: all .18s var(--v5-ease-move);
|
||||
}
|
||||
.ud-fab-btn:hover {
|
||||
background: rgba(var(--v5-primary-rgb), .06);
|
||||
color: var(--v5-text);
|
||||
}
|
||||
.ud-fab-btn:active { transform: translateY(.5px); }
|
||||
.ud-fab-btn.primary {
|
||||
background: rgb(var(--v5-primary-rgb));
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgba(var(--v5-primary-rgb), .3);
|
||||
}
|
||||
.ud-fab-btn.primary:hover {
|
||||
filter: brightness(1.08);
|
||||
background: rgb(var(--v5-primary-rgb));
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 10px rgba(var(--v5-primary-rgb), .4);
|
||||
}
|
||||
.ud-fab-btn.ghost { color: var(--v5-text-muted); }
|
||||
.ud-fab-btn.ghost:hover {
|
||||
background: rgba(0,0,0,.04);
|
||||
color: var(--v5-text);
|
||||
}
|
||||
.dark .ud-fab-btn.ghost:hover { background: rgba(255,255,255,.05); }
|
||||
|
||||
/* ── Header Tools — segmented, underline active + color glow ── */
|
||||
.ud-htools {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 0; isolation: isolate;
|
||||
}
|
||||
|
||||
.ud-htool {
|
||||
position: relative;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
height: 30px; padding: 0 14px;
|
||||
background: transparent;
|
||||
color: var(--v5-text-sec);
|
||||
font-size: .7rem; font-weight: 600; letter-spacing: -.005em;
|
||||
border: 0; border-radius: 0;
|
||||
cursor: pointer; font-family: inherit; white-space: nowrap;
|
||||
transition: color .18s var(--v5-ease-move), text-shadow .22s var(--v5-ease-move);
|
||||
animation: ud-htool-in .45s var(--v5-ease-move) both;
|
||||
}
|
||||
.ud-htool + .ud-htool,
|
||||
.ud-hsep + .ud-htool {
|
||||
box-shadow: -1px 0 0 0 rgba(var(--v5-primary-rgb), .12);
|
||||
}
|
||||
.ud-htool:hover { color: var(--v5-text); }
|
||||
.ud-htool:active { transform: translateY(.5px); }
|
||||
.ud-htool:focus { outline: none; }
|
||||
.ud-htool:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--v5-primary-rgb), .45);
|
||||
}
|
||||
.ud-htool:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
.ud-htool.on {
|
||||
color: rgb(var(--v5-primary-rgb));
|
||||
background: transparent;
|
||||
}
|
||||
.ud-htool.on::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 14px; right: 14px; bottom: 4px;
|
||||
height: 2px;
|
||||
background: rgb(var(--v5-primary-rgb));
|
||||
border-radius: 2px;
|
||||
transform-origin: left center;
|
||||
animation: ud-htool-underline .35s var(--v5-ease-move) both;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 0 8px rgba(var(--v5-primary-rgb), .45);
|
||||
}
|
||||
@keyframes ud-htool-underline {
|
||||
from { transform: scaleX(0); opacity: 0; }
|
||||
to { transform: scaleX(1); opacity: 1; }
|
||||
}
|
||||
.ud-htool.on:hover { filter: brightness(1.1); }
|
||||
|
||||
/* 제어 모드 = cyan */
|
||||
.ud-htool.on[data-mode="ctrl"] { color: rgb(var(--v5-cyan-rgb)); }
|
||||
.ud-htool.on[data-mode="ctrl"]::before {
|
||||
background: rgb(var(--v5-cyan-rgb));
|
||||
box-shadow: 0 0 8px rgba(var(--v5-cyan-rgb), .45);
|
||||
}
|
||||
|
||||
.ud-htool span { font-size: inherit; position: relative; z-index: 1; }
|
||||
.ud-htool svg { position: relative; z-index: 1; }
|
||||
|
||||
.ud-hsep {
|
||||
width: 1px; height: 14px;
|
||||
background: rgba(var(--v5-primary-rgb), .14);
|
||||
margin: 0 2px;
|
||||
animation: ud-sep-in .45s var(--v5-ease-move) both;
|
||||
}
|
||||
|
||||
/* 편집 모드 추가 버튼 그룹 — 편집 버튼 기준으로 왼쪽으로 펼쳐짐 (pop-out) */
|
||||
.ud-htool-group {
|
||||
display: inline-flex; align-items: center;
|
||||
overflow: hidden;
|
||||
transform-origin: right center;
|
||||
animation: ud-htool-group-in .42s var(--v5-ease-move) both;
|
||||
}
|
||||
.ud-htool-group.closing {
|
||||
animation: ud-htool-group-out .32s var(--v5-ease-exit) both;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ud-htool-group > * { animation: ud-htool-item-in .4s var(--v5-ease-move) both; }
|
||||
/* 편집 버튼에서 먼 것부터 나타남 (오른쪽→왼쪽 순차 등장) */
|
||||
.ud-htool-group > *:nth-child(3) { animation-delay: .04s; }
|
||||
.ud-htool-group > *:nth-child(2) { animation-delay: .10s; }
|
||||
.ud-htool-group > *:nth-child(1) { animation-delay: .16s; }
|
||||
.ud-htool-group.closing > * { animation: ud-htool-item-out .26s var(--v5-ease-exit) both; }
|
||||
|
||||
@keyframes ud-htool-group-in {
|
||||
from { opacity: 0; max-width: 0; transform: translateX(12px); }
|
||||
to { opacity: 1; max-width: 400px; transform: translateX(0); }
|
||||
}
|
||||
@keyframes ud-htool-group-out {
|
||||
from { opacity: 1; max-width: 400px; transform: translateX(0); }
|
||||
to { opacity: 0; max-width: 0; transform: translateX(12px); }
|
||||
}
|
||||
@keyframes ud-htool-item-in {
|
||||
from { opacity: 0; transform: translateX(8px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes ud-htool-item-out {
|
||||
from { opacity: 1; transform: translateX(0); }
|
||||
to { opacity: 0; transform: translateX(8px); }
|
||||
}
|
||||
@keyframes ud-htool-in {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes ud-sep-in {
|
||||
from { opacity: 0; transform: scaleY(.3); }
|
||||
to { opacity: 1; transform: scaleY(1); }
|
||||
}
|
||||
|
||||
/* ── 카드 stagger: dash-canvas 내부 카드가 index × 35ms delay로 등장 ── */
|
||||
.dash-canvas > [data-card-id] {
|
||||
animation: ud-card-in .55s var(--v5-ease-move) both;
|
||||
}
|
||||
.dash-canvas > [data-card-id]:nth-child(1) { animation-delay: 35ms; }
|
||||
.dash-canvas > [data-card-id]:nth-child(2) { animation-delay: 70ms; }
|
||||
.dash-canvas > [data-card-id]:nth-child(3) { animation-delay: 105ms; }
|
||||
.dash-canvas > [data-card-id]:nth-child(4) { animation-delay: 140ms; }
|
||||
.dash-canvas > [data-card-id]:nth-child(5) { animation-delay: 175ms; }
|
||||
.dash-canvas > [data-card-id]:nth-child(6) { animation-delay: 210ms; }
|
||||
.dash-canvas > [data-card-id]:nth-child(7) { animation-delay: 245ms; }
|
||||
.dash-canvas > [data-card-id]:nth-child(8) { animation-delay: 280ms; }
|
||||
.dash-canvas > [data-card-id]:nth-child(9) { animation-delay: 315ms; }
|
||||
.dash-canvas > [data-card-id]:nth-child(10) { animation-delay: 350ms; }
|
||||
.dash-canvas > [data-card-id]:nth-child(n+11) { animation-delay: 385ms; }
|
||||
@keyframes ud-card-in {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── 모드 배지: 캔버스 좌상단 혹은 FAB 왼쪽에서 재활용 ── */
|
||||
.ud-badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: .58rem; font-weight: 700;
|
||||
padding: .22rem .6rem;
|
||||
border-radius: 999px;
|
||||
font-family: var(--v5-font-mono);
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.ud-badge.edit {
|
||||
background: rgba(var(--v5-primary-rgb), .12);
|
||||
color: rgb(var(--v5-primary-rgb));
|
||||
border: 1px solid rgba(var(--v5-primary-rgb), .25);
|
||||
}
|
||||
.ud-badge.edit .dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: rgb(var(--v5-primary-rgb));
|
||||
box-shadow: 0 0 8px rgba(var(--v5-primary-rgb), .6);
|
||||
animation: ud-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.ud-badge.ctrl {
|
||||
background: rgba(var(--v5-cyan-rgb), .12);
|
||||
color: rgb(var(--v5-cyan-rgb));
|
||||
border: 1px solid rgba(var(--v5-cyan-rgb), .25);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
/* ===================================================================
|
||||
INVYONE v5 — Atomic Component Library
|
||||
Ported from INVYONE Design System (ui_kits/app + preview/*).
|
||||
All classes use .v5- prefix to avoid shadcn/Tailwind collision.
|
||||
Tokens defined in v5-layout.css :root / .dark.
|
||||
Concept: "Solid + Glow" (no blur on main app).
|
||||
=================================================================== */
|
||||
|
||||
/* =================================================================
|
||||
Buttons (.v5-btn + variants)
|
||||
Defaults: 30px height, 0.7rem text, primary fills, glow on hover.
|
||||
================================================================= */
|
||||
.v5-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: .35rem;
|
||||
height: 30px; padding: 0 var(--v5-sp-3);
|
||||
font-family: var(--v5-font-sans);
|
||||
font-size: .7rem; font-weight: var(--v5-fw-semi);
|
||||
border: 1px solid transparent; border-radius: var(--v5-radius-md);
|
||||
background: transparent; color: var(--v5-text);
|
||||
cursor: pointer; user-select: none; white-space: nowrap;
|
||||
transition: background .2s var(--v5-ease-move),
|
||||
border-color .2s var(--v5-ease-move),
|
||||
color .2s var(--v5-ease-move),
|
||||
box-shadow .2s var(--v5-ease-move),
|
||||
transform .12s var(--v5-ease-move),
|
||||
opacity .2s var(--v5-ease-move);
|
||||
}
|
||||
.v5-btn svg { width: 13px; height: 13px; stroke-width: 1.75; flex-shrink: 0; }
|
||||
.v5-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.v5-btn:active:not(:disabled) { transform: scale(.98); }
|
||||
|
||||
/* primary — filled accent */
|
||||
.v5-btn.primary {
|
||||
background: var(--v5-primary); color: #fff;
|
||||
border-color: var(--v5-primary);
|
||||
}
|
||||
.v5-btn.primary:hover:not(:disabled) {
|
||||
opacity: .92; box-shadow: var(--v5-glow-sm); transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* secondary — solid surface, neutral */
|
||||
.v5-btn.secondary {
|
||||
background: var(--v5-surface-solid); color: var(--v5-text);
|
||||
border-color: var(--v5-border);
|
||||
}
|
||||
.v5-btn.secondary:hover:not(:disabled) {
|
||||
background: var(--v5-surface-hover); border-color: rgba(var(--v5-primary-rgb), .25);
|
||||
}
|
||||
|
||||
/* ghost — text-only, no fill */
|
||||
.v5-btn.ghost {
|
||||
background: transparent; color: var(--v5-text-sec); border-color: transparent;
|
||||
}
|
||||
.v5-btn.ghost:hover:not(:disabled) {
|
||||
background: var(--v5-surface-hover); color: var(--v5-text);
|
||||
}
|
||||
|
||||
/* danger — destructive action */
|
||||
.v5-btn.danger {
|
||||
background: var(--v5-red); color: #fff; border-color: var(--v5-red);
|
||||
}
|
||||
.v5-btn.danger:hover:not(:disabled) {
|
||||
opacity: .92; box-shadow: var(--v5-glow-danger); transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* size: sm — compact 26px */
|
||||
.v5-btn.sm {
|
||||
height: 26px; padding: 0 var(--v5-sp-2);
|
||||
font-size: .64rem; border-radius: var(--v5-radius-sm);
|
||||
}
|
||||
.v5-btn.sm svg { width: 12px; height: 12px; }
|
||||
|
||||
/* focus ring (keyboard) */
|
||||
.v5-btn:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--v5-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--v5-primary-rgb), .15), var(--v5-glow-sm);
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Badges (.v5-bdg + status variants)
|
||||
Pill, tiny caps, status-tinted.
|
||||
================================================================= */
|
||||
.v5-bdg {
|
||||
display: inline-flex; align-items: center; gap: .3rem;
|
||||
padding: .18rem .5rem;
|
||||
font-size: .58rem; font-weight: var(--v5-fw-bold);
|
||||
letter-spacing: var(--v5-ls-wide); text-transform: uppercase;
|
||||
border-radius: var(--v5-radius-pill);
|
||||
background: var(--v5-bg-subtle); color: var(--v5-text-sec);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.v5-bdg .v5-bdg-dot {
|
||||
width: 5px; height: 5px; border-radius: 50%;
|
||||
background: currentColor; flex-shrink: 0;
|
||||
}
|
||||
.v5-bdg.in { background: rgba(var(--v5-primary-rgb), .10); color: var(--v5-primary); }
|
||||
.v5-bdg.ok { background: rgba(var(--v5-green-rgb), .12); color: rgb(var(--v5-green-rgb)); }
|
||||
.v5-bdg.warn { background: rgba(var(--v5-amber-rgb), .22); color: #8a5a00; }
|
||||
.v5-bdg.err { background: rgba(var(--v5-red-rgb), .10); color: rgb(var(--v5-red-rgb)); }
|
||||
.v5-bdg.off { background: var(--v5-bg-subtle); color: var(--v5-text-sec); }
|
||||
.v5-bdg.cy { background: rgba(var(--v5-cyan-rgb), .12); color: rgb(var(--v5-cyan-rgb)); }
|
||||
.dark .v5-bdg.warn { color: rgb(var(--v5-amber-rgb)); }
|
||||
|
||||
/* =================================================================
|
||||
Cards (.v5-card + .glow / .elev)
|
||||
Solid surface, primary border, optional glow.
|
||||
================================================================= */
|
||||
.v5-card {
|
||||
background: var(--v5-surface-solid);
|
||||
border: 1px solid var(--v5-border);
|
||||
border-radius: var(--v5-radius-md-2);
|
||||
padding: var(--v5-sp-4);
|
||||
transition: border-color .2s var(--v5-ease-move), box-shadow .3s var(--v5-ease-move);
|
||||
}
|
||||
.v5-card.glow { box-shadow: var(--v5-glow-sm); }
|
||||
.v5-card.elev { box-shadow: var(--v5-glow-lg); }
|
||||
.v5-card:hover { border-color: rgba(var(--v5-primary-rgb), .22); }
|
||||
|
||||
.v5-card-head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: .6rem; margin-bottom: .4rem;
|
||||
}
|
||||
.v5-card-title {
|
||||
font-size: .66rem; font-weight: var(--v5-fw-bold);
|
||||
text-transform: uppercase; letter-spacing: var(--v5-ls-wide);
|
||||
color: var(--v5-text-muted);
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Page Head (.v5-page-head)
|
||||
crumbs / title / sub / actions(우정렬, max 3)
|
||||
================================================================= */
|
||||
.v5-page-head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: 1rem; margin-bottom: var(--v5-sp-5);
|
||||
}
|
||||
.v5-page-head-l { min-width: 0; flex: 1; }
|
||||
.v5-page-head-r { display: flex; align-items: center; gap: .4rem; flex-shrink: 0; }
|
||||
.v5-crumbs {
|
||||
font-size: .6rem; color: var(--v5-text-muted);
|
||||
display: flex; align-items: center; gap: .35rem;
|
||||
font-family: var(--v5-font-mono);
|
||||
letter-spacing: var(--v5-ls-wide);
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
.v5-crumbs .sep { opacity: .5; }
|
||||
.v5-page-title {
|
||||
font-size: var(--v5-fs-h1);
|
||||
font-weight: 800; letter-spacing: var(--v5-ls-tight);
|
||||
line-height: var(--v5-lh-tight);
|
||||
color: var(--v5-text);
|
||||
}
|
||||
.v5-page-sub {
|
||||
font-size: .68rem; color: var(--v5-text-muted);
|
||||
margin-top: .15rem;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Tables (.v5-tbl wrapped in .v5-table-wrap)
|
||||
================================================================= */
|
||||
.v5-table-wrap {
|
||||
border: 1px solid var(--v5-border);
|
||||
border-radius: var(--v5-radius-md-2);
|
||||
overflow: hidden;
|
||||
background: var(--v5-surface-solid);
|
||||
}
|
||||
.v5-tbl {
|
||||
width: 100%; border-collapse: collapse;
|
||||
font-size: var(--v5-fs-body-sm);
|
||||
color: var(--v5-text);
|
||||
}
|
||||
.v5-tbl thead th {
|
||||
text-align: left;
|
||||
padding: var(--v5-sp-3) var(--v5-sp-4);
|
||||
font-size: var(--v5-fs-caption);
|
||||
font-weight: var(--v5-fw-bold);
|
||||
letter-spacing: var(--v5-ls-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--v5-text-muted);
|
||||
background: var(--v5-bg-subtle);
|
||||
border-bottom: 1px solid var(--v5-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.v5-tbl tbody td {
|
||||
padding: .55rem var(--v5-sp-4);
|
||||
border-bottom: 1px solid var(--v5-border-subtle);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.v5-tbl tbody tr:last-child td { border-bottom: none; }
|
||||
.v5-tbl tbody tr:hover td { background: rgba(var(--v5-primary-rgb), .04); }
|
||||
.v5-tbl .mono {
|
||||
font-family: var(--v5-font-mono);
|
||||
font-size: .68rem;
|
||||
color: var(--v5-text-sec);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.v5-tbl .num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
KPI (.v5-kpi-num + .v5-kpi-delta + .v5-kpi-sub)
|
||||
Large number with trend pill underneath.
|
||||
================================================================= */
|
||||
.v5-kpi-num {
|
||||
font-size: 1.85rem; font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.05;
|
||||
color: var(--v5-text);
|
||||
display: flex; align-items: baseline; gap: .55rem;
|
||||
}
|
||||
.v5-kpi-num.kpi-cyan { color: rgb(var(--v5-cyan-rgb)); }
|
||||
.v5-kpi-num.kpi-green { color: rgb(var(--v5-green-rgb)); }
|
||||
.v5-kpi-num.kpi-pink { color: rgb(var(--v5-pink-rgb)); }
|
||||
.v5-kpi-num.kpi-amber { color: rgb(var(--v5-amber-rgb)); }
|
||||
|
||||
.v5-kpi-delta {
|
||||
display: inline-flex; align-items: center; gap: .15rem;
|
||||
font-size: .62rem; font-weight: var(--v5-fw-bold);
|
||||
padding: .12rem .4rem; border-radius: var(--v5-radius-sm);
|
||||
}
|
||||
.v5-kpi-delta.up { background: rgba(var(--v5-green-rgb), .12); color: rgb(var(--v5-green-rgb)); }
|
||||
.v5-kpi-delta.down { background: rgba(var(--v5-red-rgb), .12); color: rgb(var(--v5-red-rgb)); }
|
||||
.v5-kpi-delta svg { width: 11px; height: 11px; stroke-width: 2; }
|
||||
|
||||
.v5-kpi-sub {
|
||||
font-size: .62rem;
|
||||
color: var(--v5-text-muted);
|
||||
margin-top: .25rem;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Bar chart (.v5-bars / .v5-bar / .v5-bars-ax)
|
||||
================================================================= */
|
||||
.v5-bars {
|
||||
display: flex; gap: 4px; height: 80px; align-items: flex-end;
|
||||
padding: .3rem 0;
|
||||
}
|
||||
.v5-bar {
|
||||
flex: 1; border-radius: 3px 3px 0 0;
|
||||
background: linear-gradient(180deg, rgba(var(--v5-primary-rgb), .55), rgba(var(--v5-primary-rgb), .18));
|
||||
transition: background .25s var(--v5-ease-move);
|
||||
min-height: 2px;
|
||||
}
|
||||
.v5-bar:hover {
|
||||
background: linear-gradient(180deg, var(--v5-primary), rgba(var(--v5-primary-rgb), .4));
|
||||
}
|
||||
.v5-bars-ax {
|
||||
display: flex; gap: 4px; margin-top: .35rem;
|
||||
font-size: .52rem; color: var(--v5-text-muted);
|
||||
font-family: var(--v5-font-mono);
|
||||
}
|
||||
.v5-bars-ax > * { flex: 1; text-align: center; }
|
||||
|
||||
/* =================================================================
|
||||
Activity Feed (.v5-feed / .v5-feed-row / .v5-feed-dot / .v5-feed-txt)
|
||||
================================================================= */
|
||||
.v5-feed {
|
||||
display: flex; flex-direction: column; gap: .55rem;
|
||||
}
|
||||
.v5-feed-row {
|
||||
display: flex; gap: .6rem; align-items: flex-start;
|
||||
padding: .35rem .15rem; border-radius: var(--v5-radius-sm);
|
||||
transition: background .2s var(--v5-ease-move);
|
||||
}
|
||||
.v5-feed-row:hover { background: var(--v5-surface-hover); }
|
||||
.v5-feed-dot {
|
||||
width: 28px; height: 28px; border-radius: var(--v5-radius-sm);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(var(--v5-primary-rgb), .12);
|
||||
color: var(--v5-primary); flex-shrink: 0;
|
||||
}
|
||||
.v5-feed-dot.g { background: rgba(var(--v5-green-rgb), .12); color: rgb(var(--v5-green-rgb)); }
|
||||
.v5-feed-dot.a { background: rgba(var(--v5-amber-rgb), .18); color: rgb(var(--v5-amber-rgb)); }
|
||||
.v5-feed-dot.c { background: rgba(var(--v5-cyan-rgb), .12); color: rgb(var(--v5-cyan-rgb)); }
|
||||
.v5-feed-dot.r { background: rgba(var(--v5-red-rgb), .12); color: rgb(var(--v5-red-rgb)); }
|
||||
.v5-feed-dot svg { width: 14px; height: 14px; stroke-width: 1.75; }
|
||||
.v5-feed-txt {
|
||||
flex: 1; min-width: 0;
|
||||
font-size: var(--v5-fs-body-sm);
|
||||
color: var(--v5-text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.v5-feed-txt b { color: var(--v5-text); font-weight: var(--v5-fw-semi); }
|
||||
.v5-feed-txt .tm {
|
||||
display: block;
|
||||
font-size: .6rem; color: var(--v5-text-muted);
|
||||
font-family: var(--v5-font-mono);
|
||||
margin-top: .15rem;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Sparkline wrapper (.v5-spark)
|
||||
================================================================= */
|
||||
.v5-spark { display: block; width: 100%; height: 36px; }
|
||||
|
||||
/* =================================================================
|
||||
Grid layouts (.v5-grid + .grid-2 / 3 / 4)
|
||||
================================================================= */
|
||||
.v5-grid { display: grid; gap: var(--v5-sp-4); }
|
||||
.v5-grid.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.v5-grid.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.v5-grid.grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
@media (max-width: 1024px) {
|
||||
.v5-grid.grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.v5-grid.grid-3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.v5-grid.grid-2,
|
||||
.v5-grid.grid-3,
|
||||
.v5-grid.grid-4 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Form input wrapper (.v5-fi) + bare input (.v5-input)
|
||||
================================================================= */
|
||||
.v5-fi {
|
||||
position: relative; display: inline-flex; align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
.v5-fi > svg, .v5-fi > .v5-fi-icon {
|
||||
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
|
||||
width: 14px; height: 14px; stroke-width: 1.75;
|
||||
color: var(--v5-text-muted); pointer-events: none;
|
||||
transition: color .2s var(--v5-ease-move);
|
||||
}
|
||||
.v5-fi:focus-within > svg, .v5-fi:focus-within > .v5-fi-icon { color: var(--v5-primary); }
|
||||
.v5-input,
|
||||
.v5-fi > input,
|
||||
.v5-fi > select {
|
||||
height: 32px; width: 100%;
|
||||
padding: 0 var(--v5-sp-3) 0 2rem;
|
||||
font-family: var(--v5-font-sans);
|
||||
font-size: var(--v5-fs-body-sm);
|
||||
color: var(--v5-text);
|
||||
background: var(--v5-surface-solid);
|
||||
border: 1px solid var(--v5-border);
|
||||
border-radius: var(--v5-radius-md);
|
||||
outline: none;
|
||||
transition: border-color .2s var(--v5-ease-move), box-shadow .2s var(--v5-ease-move);
|
||||
}
|
||||
.v5-input:focus,
|
||||
.v5-fi > input:focus,
|
||||
.v5-fi > select:focus {
|
||||
border-color: var(--v5-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--v5-primary-rgb), .15), var(--v5-glow-sm);
|
||||
}
|
||||
.v5-input::placeholder,
|
||||
.v5-fi > input::placeholder { color: var(--v5-text-muted); }
|
||||
|
||||
/* When wrapper has no leading icon, remove the left padding. */
|
||||
.v5-fi.no-icon > input,
|
||||
.v5-fi.no-icon > select { padding-left: var(--v5-sp-3); }
|
||||
|
||||
/* Checkbox / radio */
|
||||
.v5-check, .v5-radio {
|
||||
width: 14px; height: 14px;
|
||||
accent-color: var(--v5-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Toggle switch (.v5-toggle) */
|
||||
.v5-toggle {
|
||||
display: inline-block; position: relative;
|
||||
width: 30px; height: 16px;
|
||||
background: var(--v5-border); border-radius: var(--v5-radius-pill);
|
||||
cursor: pointer; transition: background .25s var(--v5-ease-move);
|
||||
}
|
||||
.v5-toggle::after {
|
||||
content: ''; position: absolute; left: 2px; top: 2px;
|
||||
width: 12px; height: 12px; background: #fff; border-radius: 50%;
|
||||
transition: transform .25s var(--v5-ease-move);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .15);
|
||||
}
|
||||
.v5-toggle.on { background: var(--v5-primary); }
|
||||
.v5-toggle.on::after { transform: translateX(14px); }
|
||||
|
||||
/* =================================================================
|
||||
Modal (.v5-overlay + .v5-modal)
|
||||
Solid surface, glow shadow, scale-in animation.
|
||||
================================================================= */
|
||||
.v5-overlay {
|
||||
position: fixed; inset: 0; z-index: 100;
|
||||
background: rgba(6, 5, 14, .45);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 1rem;
|
||||
animation: v5-overlay-in .22s var(--v5-ease-enter);
|
||||
}
|
||||
.v5-modal {
|
||||
width: 100%; max-width: 420px;
|
||||
background: var(--v5-surface-solid);
|
||||
border: 1px solid var(--v5-border);
|
||||
border-radius: var(--v5-radius-lg-2);
|
||||
box-shadow: var(--v5-glow-lg);
|
||||
padding: var(--v5-sp-5);
|
||||
animation: v5-modal-in .3s var(--v5-ease-enter);
|
||||
}
|
||||
.v5-modal-head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: .6rem; margin-bottom: .7rem;
|
||||
}
|
||||
.v5-modal-title {
|
||||
font-size: var(--v5-fs-h2);
|
||||
font-weight: var(--v5-fw-bold);
|
||||
letter-spacing: var(--v5-ls-tight);
|
||||
color: var(--v5-text);
|
||||
}
|
||||
.v5-modal-body {
|
||||
font-size: var(--v5-fs-body-sm);
|
||||
color: var(--v5-text-sec);
|
||||
line-height: 1.55;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.v5-modal-body b { color: var(--v5-text); font-weight: var(--v5-fw-bold); }
|
||||
.v5-modal-foot {
|
||||
display: flex; justify-content: flex-end; gap: .4rem;
|
||||
}
|
||||
|
||||
@keyframes v5-overlay-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes v5-modal-in {
|
||||
from { opacity: 0; transform: scale(.97) translateY(8px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Generic icon helper (.v5-ic) — keeps lucide aspect-locked.
|
||||
================================================================= */
|
||||
.v5-ic { display: inline-flex; flex-shrink: 0; }
|
||||
.v5-ic svg { width: 100%; height: 100%; display: block; stroke-width: 1.75; }
|
||||
|
||||
/* =================================================================
|
||||
TopNav (horizontal 메뉴바) — 디자인시스템 shell-components.jsx TopNav 포팅.
|
||||
헤더 로고 오른쪽에 위치. 섹션 hover → flyout → sub-flyout.
|
||||
================================================================= */
|
||||
.v5-topnav{
|
||||
display:flex;align-items:center;height:100%;
|
||||
gap:.1rem;
|
||||
font-family:var(--v5-font-sans);
|
||||
}
|
||||
.v5-tn-section{
|
||||
position:relative;height:100%;display:flex;align-items:center;
|
||||
animation:v5-tn-in .4s var(--v5-ease-enter) backwards;
|
||||
}
|
||||
@keyframes v5-tn-in{
|
||||
from{opacity:0;transform:translateY(-4px);}
|
||||
to {opacity:1;transform:translateY(0);}
|
||||
}
|
||||
.v5-tn-item{
|
||||
display:inline-flex;align-items:center;gap:.3rem;
|
||||
height:30px;padding:0 var(--v5-sp-4);
|
||||
border:none;background:transparent;color:var(--v5-text-sec);
|
||||
font-family:inherit;font-size:.78rem;font-weight:var(--v5-fw-semi);
|
||||
letter-spacing:var(--v5-ls-tight);cursor:pointer;
|
||||
border-radius:var(--v5-radius-md);
|
||||
transition:background .2s var(--v5-ease-move),color .2s var(--v5-ease-move);
|
||||
}
|
||||
.v5-tn-item svg{opacity:.55;transition:opacity .2s var(--v5-ease-move),transform .2s var(--v5-ease-move);}
|
||||
.v5-tn-section:hover .v5-tn-item,
|
||||
.v5-tn-section.open .v5-tn-item{color:var(--v5-text);background:var(--v5-surface-hover);}
|
||||
.v5-tn-section:hover .v5-tn-item svg,
|
||||
.v5-tn-section.open .v5-tn-item svg{opacity:1;}
|
||||
/* open 상태에선 chevron 을 살짝 내려서(2px) "펼쳤다" 감만 주기 — 180° 회전은 flyout 방향과 역방향이라 혼동 유발 */
|
||||
.v5-tn-section.open .v5-tn-item svg{transform:translateY(1px);}
|
||||
.v5-tn-section.on .v5-tn-item{color:var(--v5-primary);}
|
||||
.v5-admin-mode .v5-tn-section.on .v5-tn-item{color:var(--v5-cyan);}
|
||||
|
||||
/* Flyout (1단) — 섹션과 border 가 맞닿게(top:100%) + 내부 top 패딩으로 시각 여백만 유지.
|
||||
→ 2px 공백 구간에서 mouseleave 가 타서 flyout 이 순간 사라지던 문제 제거. */
|
||||
.v5-tn-flyout{
|
||||
position:absolute;top:100%;left:0;min-width:200px;
|
||||
background:var(--v5-surface-solid);
|
||||
border:1px solid var(--v5-border);
|
||||
border-radius:var(--v5-radius-md);
|
||||
padding:.6rem .3rem .3rem .3rem;z-index:40;
|
||||
box-shadow:var(--v5-glow-md), 0 12px 32px rgba(0,0,0,.12);
|
||||
animation:v5-tn-flyout-in .2s var(--v5-ease-enter) backwards;
|
||||
}
|
||||
/* 섹션의 마지막 1px 과 flyout 의 첫 1px 를 겹쳐 "끊김" 제로. 시각적으론 boundary 가 안 보임(밝은 border 2중). */
|
||||
.v5-tn-flyout::before{
|
||||
content:'';position:absolute;left:0;right:0;top:-6px;height:6px;
|
||||
background:transparent;pointer-events:auto;
|
||||
}
|
||||
.dark .v5-tn-flyout{box-shadow:var(--v5-glow-md), 0 12px 32px rgba(0,0,0,.5);}
|
||||
@keyframes v5-tn-flyout-in{
|
||||
from{opacity:0;transform:translateY(-6px);}
|
||||
to {opacity:1;transform:translateY(0);}
|
||||
}
|
||||
|
||||
/* Row (flyout 아이템) */
|
||||
.v5-tn-row{
|
||||
display:flex;align-items:center;gap:.45rem;
|
||||
padding:.5rem .55rem;border-radius:var(--v5-radius-sm);
|
||||
font-size:.74rem;font-weight:500;
|
||||
color:var(--v5-text-sec);cursor:pointer;position:relative;
|
||||
animation:v5-tn-row-in .28s var(--v5-ease-enter) backwards;
|
||||
transition:
|
||||
background .18s var(--v5-ease-move),
|
||||
color .18s var(--v5-ease-move),
|
||||
transform .18s var(--v5-ease-move);
|
||||
white-space:nowrap;
|
||||
}
|
||||
@keyframes v5-tn-row-in{
|
||||
from{opacity:0;transform:translateX(-6px);}
|
||||
to {opacity:1;transform:translateX(0);}
|
||||
}
|
||||
.v5-tn-row:hover{background:var(--v5-surface-hover);color:var(--v5-text);transform:translateX(2px);}
|
||||
.v5-tn-row.on{background:rgba(var(--v5-primary-rgb),.1);color:var(--v5-primary);font-weight:600;}
|
||||
.v5-admin-mode .v5-tn-row.on{background:rgba(var(--v5-cyan-rgb),.1);color:var(--v5-cyan);}
|
||||
.v5-tn-row .v5-tn-row-label{flex:1;min-width:0;}
|
||||
.v5-tn-row .v5-tn-ic{display:inline-flex;width:14px;height:14px;color:currentColor;opacity:.7;}
|
||||
.v5-tn-row svg:last-of-type{opacity:.5;flex-shrink:0;}
|
||||
|
||||
/* Badge (e.g., pending count) */
|
||||
.v5-tn-badge{
|
||||
display:inline-flex;align-items:center;justify-content:center;
|
||||
min-width:16px;height:14px;padding:0 .3rem;
|
||||
background:rgba(var(--v5-primary-rgb),.14);
|
||||
color:var(--v5-primary);
|
||||
font-size:.56rem;font-weight:var(--v5-fw-bold);
|
||||
border-radius:var(--v5-radius-pill);
|
||||
line-height:1;
|
||||
}
|
||||
|
||||
/* Sub-flyout (2단) */
|
||||
.v5-tn-sub{
|
||||
position:absolute;left:calc(100% + 6px);top:0;min-width:200px;
|
||||
background:var(--v5-surface-solid);
|
||||
border:1px solid var(--v5-border);
|
||||
border-radius:var(--v5-radius-md-2);
|
||||
padding:.3rem;z-index:45;
|
||||
box-shadow:var(--v5-glow-md), 0 12px 32px rgba(0,0,0,.12);
|
||||
animation:v5-tn-flyout-in .2s var(--v5-ease-enter) backwards;
|
||||
}
|
||||
.dark .v5-tn-sub{box-shadow:var(--v5-glow-md), 0 12px 32px rgba(0,0,0,.5);}
|
||||
|
||||
/* =================================================================
|
||||
Tweaks floating panel (디자인시스템 Tweaks UX).
|
||||
우하단 240px 고정, 슬라이드 인/아웃. SettingsModal 이 사용.
|
||||
================================================================= */
|
||||
.v5-tweaks-panel{
|
||||
position:fixed;right:14px;bottom:14px;
|
||||
width:240px;z-index:200;
|
||||
background:var(--v5-surface-solid);
|
||||
border:1px solid var(--v5-border);
|
||||
border-radius:var(--v5-radius-lg-2);
|
||||
box-shadow:var(--v5-glow-md), 0 8px 24px rgba(0,0,0,.08);
|
||||
padding:var(--v5-sp-4);
|
||||
font-family:var(--v5-font-sans);
|
||||
opacity:0;transform:translateY(10px) scale(.97);pointer-events:none;
|
||||
transition:
|
||||
opacity .25s var(--v5-ease-enter),
|
||||
transform .3s var(--v5-ease-enter),
|
||||
box-shadow .3s var(--v5-ease-move);
|
||||
}
|
||||
.v5-tweaks-panel.on{
|
||||
opacity:1;transform:translateY(0) scale(1);pointer-events:auto;
|
||||
}
|
||||
.dark .v5-tweaks-panel{
|
||||
box-shadow:var(--v5-glow-md), 0 12px 32px rgba(0,0,0,.5);
|
||||
}
|
||||
|
||||
.v5-tweaks-head{
|
||||
display:flex;align-items:center;justify-content:space-between;
|
||||
font-size:var(--v5-fs-body);
|
||||
font-weight:var(--v5-fw-bold);
|
||||
letter-spacing:var(--v5-ls-tight);
|
||||
color:var(--v5-text);
|
||||
margin-bottom:var(--v5-sp-3);
|
||||
}
|
||||
|
||||
.v5-tweaks-row{
|
||||
display:flex;flex-direction:column;gap:.35rem;
|
||||
padding:.4rem 0;
|
||||
border-top:1px solid var(--v5-border-subtle);
|
||||
}
|
||||
.v5-tweaks-row:first-of-type{border-top:none;padding-top:0;}
|
||||
.v5-tweaks-row label{
|
||||
font-size:var(--v5-fs-caption);
|
||||
font-weight:var(--v5-fw-bold);
|
||||
text-transform:uppercase;
|
||||
letter-spacing:var(--v5-ls-wide);
|
||||
color:var(--v5-text-muted);
|
||||
}
|
||||
|
||||
.v5-tweaks-swatches{
|
||||
display:grid;grid-template-columns:repeat(6,1fr);gap:5px;
|
||||
}
|
||||
.v5-tweaks-swatch{
|
||||
position:relative;width:100%;aspect-ratio:1/1;
|
||||
border-radius:var(--v5-radius-sm);
|
||||
border:2px solid transparent;
|
||||
cursor:pointer;
|
||||
display:inline-flex;align-items:center;justify-content:center;
|
||||
color:#fff;
|
||||
transition:
|
||||
transform .15s var(--v5-ease-move),
|
||||
border-color .2s var(--v5-ease-move),
|
||||
box-shadow .2s var(--v5-ease-move);
|
||||
}
|
||||
.v5-tweaks-swatch:hover{transform:scale(1.08);}
|
||||
.v5-tweaks-swatch.on{
|
||||
border-color:var(--v5-text);
|
||||
box-shadow:0 0 0 2px var(--v5-surface-solid), 0 0 0 3px currentColor, var(--v5-glow-sm);
|
||||
}
|
||||
|
||||
.v5-tweaks-seg{
|
||||
display:flex;gap:4px;
|
||||
}
|
||||
.v5-tweaks-seg .v5-btn{flex:1;justify-content:center;}
|
||||
|
||||
.v5-tweaks-foot{
|
||||
font-size:.52rem;
|
||||
color:var(--v5-text-muted);
|
||||
margin-top:.6rem;
|
||||
padding-top:.5rem;
|
||||
border-top:1px solid var(--v5-border-subtle);
|
||||
font-family:var(--v5-font-mono);
|
||||
letter-spacing:.03em;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Mode line (cyan/primary gradient under the header).
|
||||
Already styled in v5-layout.css for shell — this is the standalone
|
||||
helper for any sub-shell that wants the same accent.
|
||||
================================================================= */
|
||||
.v5-mode-line {
|
||||
position: absolute; left: 0; right: 0; bottom: -1px; height: 1px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent,
|
||||
var(--v5-primary),
|
||||
var(--v5-cyan),
|
||||
var(--v5-pink),
|
||||
transparent);
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity .35s var(--v5-ease-move);
|
||||
}
|
||||
.v5-mode-line.on { opacity: 1; }
|
||||
+237
-83
@@ -1,5 +1,7 @@
|
||||
/* ===================================================================
|
||||
INVION v5 — Cosmic Glassmorphism Layout System
|
||||
INVION v5 — Solid + Glow Layout System (revised 2026-04-21)
|
||||
Main-app surfaces are opaque. Shadows are primary-color glows, not black.
|
||||
Glass/blur is legal ONLY on the login page and builder-ide.
|
||||
All variables use --v5- prefix to avoid shadcn/Tailwind collision.
|
||||
=================================================================== */
|
||||
|
||||
@@ -15,7 +17,7 @@
|
||||
|
||||
--v5-bg:#fafaff; --v5-bg-subtle:#f3f2fa;
|
||||
--v5-surface:rgba(255,255,255,0.55); --v5-surface-solid:#ffffff;
|
||||
--v5-surface-hover:rgba(255,255,255,0.7);
|
||||
--v5-surface-hover:#f6f5fb;
|
||||
--v5-text:#0f0e1a; --v5-text-sec:#6b6a80; --v5-text-muted:#9998ad;
|
||||
--v5-primary:rgb(var(--v5-primary-rgb)); --v5-primary-light:#a29bfe; --v5-primary-glow:rgba(var(--v5-primary-rgb),0.25);
|
||||
--v5-cyan:rgb(var(--v5-cyan-rgb)); --v5-cyan-glow:rgba(var(--v5-cyan-rgb),0.2);
|
||||
@@ -27,8 +29,63 @@
|
||||
--v5-glow-sm:0 0 20px rgba(var(--v5-primary-rgb),0.12);
|
||||
--v5-glow-md:0 0 40px rgba(var(--v5-primary-rgb),0.2);
|
||||
--v5-glow-lg:0 0 80px rgba(var(--v5-primary-rgb),0.25);
|
||||
--v5-glow-cyan-sm:0 0 16px rgba(var(--v5-cyan-rgb),0.20);
|
||||
--v5-glow-danger:0 0 16px rgba(var(--v5-red-rgb),0.25);
|
||||
--v5-sidebar-w:220px;
|
||||
|
||||
/* ===== Motion / Easing (design-system tokens) =====
|
||||
enter = overshoot-to-settle, move = linear feel, exit = quick pull-away, bounce = playful (badges only). */
|
||||
--v5-ease-enter: cubic-bezier(.16,1,.3,1);
|
||||
--v5-ease-move: cubic-bezier(.4,0,.2,1);
|
||||
--v5-ease-exit: cubic-bezier(.5,0,.75,0);
|
||||
--v5-ease-bounce: cubic-bezier(.34,1.56,.64,1);
|
||||
|
||||
/* ===== Radii ===== */
|
||||
--v5-radius-sm:8px;
|
||||
--v5-radius-md:10px;
|
||||
--v5-radius-md-2:12px;
|
||||
--v5-radius-lg:14px;
|
||||
--v5-radius-lg-2:16px;
|
||||
--v5-radius-modal:16px;
|
||||
--v5-radius-pill:999px;
|
||||
|
||||
/* ===== Spacing (tight, ERP-dense) ===== */
|
||||
--v5-sp-1:0.3rem;
|
||||
--v5-sp-2:0.45rem;
|
||||
--v5-sp-3:0.6rem;
|
||||
--v5-sp-4:0.85rem;
|
||||
--v5-sp-5:1.1rem;
|
||||
--v5-gap-row:0.35rem;
|
||||
--v5-card-pad:0.625rem;
|
||||
|
||||
/* ===== Fonts ===== */
|
||||
--v5-font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Pretendard",
|
||||
"Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans KR", system-ui, sans-serif;
|
||||
--v5-font-mono: 'JetBrains Mono', 'D2Coding', ui-monospace, SFMono-Regular, Monaco, Consolas, monospace;
|
||||
|
||||
/* ===== Type scale (deliberately dense, ERP). Do not go above these. ===== */
|
||||
--v5-fs-caption:0.60rem; /* labels, table headers, chips */
|
||||
--v5-fs-caption-lg:0.68rem;
|
||||
--v5-fs-body-sm:0.72rem; /* table rows */
|
||||
--v5-fs-body:0.78rem; /* default body */
|
||||
--v5-fs-body-lg:0.85rem; /* max body */
|
||||
--v5-fs-h3:0.92rem;
|
||||
--v5-fs-h2:1.00rem;
|
||||
--v5-fs-h1:1.12rem; /* page/section title */
|
||||
--v5-fs-display:1.60rem; /* KPI numbers only */
|
||||
|
||||
--v5-fw-regular:400;
|
||||
--v5-fw-semi:600;
|
||||
--v5-fw-bold:700;
|
||||
|
||||
--v5-lh-tight:1.25;
|
||||
--v5-lh-normal:1.45;
|
||||
--v5-lh-loose:1.6;
|
||||
|
||||
--v5-ls-tight:-0.02em;
|
||||
--v5-ls-wide:0.08em;
|
||||
--v5-ls-wider:0.12em;
|
||||
|
||||
/* ===== Template Grid System (2026-04-10) =====
|
||||
카드 내부 Template 컴포넌트 배치용 12-col grid 토큰.
|
||||
@container 쿼리 브레이크포인트는 카드 너비 기준. */
|
||||
@@ -55,7 +112,7 @@
|
||||
|
||||
--v5-bg:#06050e; --v5-bg-subtle:#0c0b18;
|
||||
--v5-surface:rgba(17,16,42,0.5); --v5-surface-solid:#11102a;
|
||||
--v5-surface-hover:rgba(25,24,64,0.6);
|
||||
--v5-surface-hover:#191840;
|
||||
--v5-text:#eae8f4; --v5-text-sec:#8d8ba8; --v5-text-muted:#5a587a;
|
||||
--v5-primary:rgb(var(--v5-primary-rgb)); --v5-primary-light:#c8c4ff; --v5-primary-glow:rgba(var(--v5-primary-rgb),0.25);
|
||||
--v5-cyan:rgb(var(--v5-cyan-rgb)); --v5-cyan-glow:rgba(var(--v5-cyan-rgb),0.15);
|
||||
@@ -66,6 +123,8 @@
|
||||
--v5-glow-sm:0 0 20px rgba(var(--v5-primary-rgb),0.1);
|
||||
--v5-glow-md:0 0 40px rgba(var(--v5-primary-rgb),0.18);
|
||||
--v5-glow-lg:0 0 80px rgba(var(--v5-primary-rgb),0.22);
|
||||
--v5-glow-cyan-sm:0 0 16px rgba(var(--v5-cyan-rgb),0.18);
|
||||
--v5-glow-danger:0 0 16px rgba(var(--v5-red-rgb),0.22);
|
||||
--grid-line:rgba(var(--v5-primary-rgb),.1);
|
||||
--grid-line-hover:rgba(var(--v5-primary-rgb),.25);
|
||||
--grid-drop-preview:rgba(var(--v5-primary-rgb),.15);
|
||||
@@ -148,15 +207,15 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
/* ===== LAYOUT SHELL ===== */
|
||||
.v5-shell{display:flex;flex-direction:column;height:100vh;position:relative;z-index:1;}
|
||||
|
||||
/* ===== GLASS HEADER ===== */
|
||||
/* ===== SOLID HEADER (2026-04-21 no-glass 정책) ===== */
|
||||
.v5-hdr{height:50px;display:flex;align-items:center;justify-content:space-between;padding:0 1.25rem;
|
||||
background:var(--v5-glass);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border-bottom:1px solid var(--v5-glass-border);position:relative;z-index:20;flex-shrink:0;}
|
||||
/* Light mode: 헤더가 본문 위에 떠있는 입체감 + 더 명확한 흰 톤 */
|
||||
background:var(--v5-surface-solid);
|
||||
border-bottom:1px solid var(--v5-border);position:relative;z-index:20;flex-shrink:0;}
|
||||
/* Light mode: solid white 위에 primary glow 살짝 */
|
||||
html:not(.dark) .v5-hdr{
|
||||
background:linear-gradient(180deg,rgba(255,255,255,0.9),rgba(252,250,255,0.78));
|
||||
background:var(--v5-surface-solid);
|
||||
border-bottom-color:rgba(var(--v5-primary-rgb),0.18);
|
||||
box-shadow:0 1px 0 rgba(255,255,255,0.6) inset, 0 4px 20px rgba(var(--v5-primary-rgb),0.06);}
|
||||
box-shadow:0 4px 20px rgba(var(--v5-primary-rgb),0.06);}
|
||||
.v5-hdr-l{display:flex;align-items:center;gap:1rem;}
|
||||
.v5-hdr-logo{font-size:1.05rem;font-weight:900;letter-spacing:-.03em;
|
||||
background:linear-gradient(135deg,var(--v5-primary),var(--v5-cyan));-webkit-background-clip:text;
|
||||
@@ -165,37 +224,76 @@ html:not(.dark) .v5-hdr{
|
||||
.v5-hdr-bc b{color:var(--v5-text);font-weight:600;}
|
||||
.v5-hdr-r{display:flex;align-items:center;gap:.65rem;}
|
||||
|
||||
/* Theme pill */
|
||||
.v5-pill{display:flex;background:var(--v5-surface);backdrop-filter:blur(8px);border:1px solid var(--v5-glass-border);
|
||||
/* Theme pill (legacy — design-system prefers single .v5-hdr-icon sun/moon toggle) */
|
||||
.v5-pill{display:flex;background:var(--v5-bg-subtle);border:1px solid var(--v5-border);
|
||||
border-radius:999px;padding:2px;}
|
||||
.v5-pill button{padding:.22rem .65rem;border-radius:999px;border:none;background:transparent;
|
||||
color:var(--v5-text-muted);cursor:pointer;font-size:.6rem;font-weight:600;font-family:inherit;
|
||||
transition:all .3s cubic-bezier(.4,0,.2,1);}
|
||||
.v5-pill button.on{background:var(--v5-primary);color:white;box-shadow:var(--v5-glow-sm);}
|
||||
|
||||
/* 대시보드 생성 + 편집 모드 (헤더, Light/Dark 토글 왼쪽) */
|
||||
/* ===== Design-system header icon button (Solid + Glow, borderless base) =====
|
||||
32×32, transparent by default, shows surface-hover bg + text on hover.
|
||||
Use for: theme, bell, tweaks, admin, tab-mini — anything next to .v5-avatar. */
|
||||
.v5-hdr-icon{
|
||||
position:relative;width:32px;height:32px;border-radius:var(--v5-radius-md);
|
||||
border:1px solid transparent;background:transparent;color:var(--v5-text-sec);
|
||||
cursor:pointer;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;
|
||||
transition:background .2s var(--v5-ease-move),color .2s var(--v5-ease-move),
|
||||
border-color .2s var(--v5-ease-move),box-shadow .2s var(--v5-ease-move),
|
||||
transform .15s var(--v5-ease-move);
|
||||
}
|
||||
.v5-hdr-icon svg{width:16px;height:16px;stroke-width:1.75;}
|
||||
.v5-hdr-icon:hover{background:var(--v5-surface-hover);color:var(--v5-text);}
|
||||
.v5-hdr-icon:active{transform:scale(.95);}
|
||||
.v5-hdr-icon.on{
|
||||
background:rgba(var(--v5-primary-rgb),.10);
|
||||
color:var(--v5-primary);
|
||||
}
|
||||
.v5-hdr-icon .v5-hdr-icon-dot{
|
||||
position:absolute;top:6px;right:6px;width:6px;height:6px;border-radius:50%;
|
||||
background:var(--v5-pink);
|
||||
box-shadow:0 0 6px rgba(var(--v5-pink-rgb),.8);
|
||||
animation:v5-pdot 2s infinite;
|
||||
}
|
||||
/* Admin mode tint: when .v5-admin-mode, the mode-toggle glows cyan. */
|
||||
.v5-admin-mode .v5-hdr-icon.v5-mode-toggle{
|
||||
color:var(--v5-cyan);
|
||||
background:rgba(var(--v5-cyan-rgb),.10);
|
||||
}
|
||||
|
||||
/* 대시보드 생성 버튼 (헤더, Light/Dark 토글 왼쪽) */
|
||||
.v5-dash-btn{display:inline-flex;align-items:center;gap:.3rem;padding:.32rem .7rem;
|
||||
border:1px solid var(--v5-glass-border);background:var(--v5-surface);backdrop-filter:blur(8px);
|
||||
border:1px solid var(--v5-border);background:var(--v5-surface-solid);
|
||||
border-radius:10px;color:var(--v5-text-muted);cursor:pointer;font-size:.62rem;font-weight:600;
|
||||
font-family:inherit;transition:all .2s cubic-bezier(.4,0,.2,1);}
|
||||
font-family:inherit;transition:all .2s var(--v5-ease-move);}
|
||||
.v5-dash-btn:hover:not(:disabled){border-color:var(--v5-primary);color:var(--v5-primary);box-shadow:var(--v5-glow-sm);}
|
||||
.v5-dash-btn:disabled{opacity:.4;cursor:not-allowed;}
|
||||
.v5-dash-btn.on{background:var(--v5-primary);border-color:var(--v5-primary);color:white;box-shadow:var(--v5-glow-sm);}
|
||||
.v5-dash-btn.on:hover{color:white;}
|
||||
|
||||
/* Bell */
|
||||
.v5-bell{position:relative;width:32px;height:32px;border-radius:10px;border:1px solid var(--v5-glass-border);
|
||||
background:var(--v5-surface);backdrop-filter:blur(8px);color:var(--v5-text-muted);cursor:pointer;
|
||||
.v5-bell{position:relative;width:32px;height:32px;border-radius:10px;border:1px solid var(--v5-border);
|
||||
background:var(--v5-surface-solid);color:var(--v5-text-muted);cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;transition:all .2s;}
|
||||
.v5-bell:hover{border-color:var(--v5-primary);color:var(--v5-primary);box-shadow:var(--v5-glow-sm);}
|
||||
/* Borderless base (Solid + Glow 2026-04-21): transparent until hover → surface-hover bg. */
|
||||
.v5-bell{border:1px solid transparent !important;background:transparent !important;color:var(--v5-text-sec);}
|
||||
.v5-bell:hover{border-color:transparent !important;background:var(--v5-surface-hover) !important;color:var(--v5-text);box-shadow:none;}
|
||||
.v5-bell-dot{position:absolute;top:5px;right:5px;width:7px;height:7px;background:var(--v5-red);border-radius:50%;animation:v5-pdot 2s infinite;}
|
||||
@keyframes v5-pdot{0%,100%{box-shadow:0 0 0 0 rgba(var(--v5-red-rgb),.4)}50%{box-shadow:0 0 0 5px rgba(var(--v5-red-rgb),0)}}
|
||||
|
||||
/* Tweaks button (sliders-horizontal) — 프로토타입 Tweaks UX. 클릭 시 SettingsModal 열림 */
|
||||
.v5-tweaks-btn{position:relative;width:32px;height:32px;border-radius:10px;border:1px solid transparent;
|
||||
background:transparent;color:var(--v5-text-sec);cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;transition:all .2s var(--v5-ease-move);}
|
||||
.v5-tweaks-btn:hover{background:var(--v5-surface-hover);color:var(--v5-text);}
|
||||
.v5-tweaks-btn.on{background:rgba(var(--v5-primary-rgb),.10);color:var(--v5-primary);}
|
||||
|
||||
/* Admin button */
|
||||
.v5-admin-btn{position:relative;width:32px;height:32px;border-radius:10px;border:1px solid var(--v5-glass-border);
|
||||
background:var(--v5-surface);backdrop-filter:blur(8px);color:var(--v5-text-muted);cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;transition:all .25s;}
|
||||
.v5-admin-btn:hover{border-color:var(--v5-primary);color:var(--v5-primary);box-shadow:var(--v5-glow-sm);transform:scale(1.1);}
|
||||
.v5-admin-btn{position:relative;width:32px;height:32px;border-radius:10px;border:1px solid transparent;
|
||||
background:transparent;color:var(--v5-text-sec);cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;transition:all .25s var(--v5-ease-move);}
|
||||
.v5-admin-btn:hover{background:var(--v5-surface-hover);color:var(--v5-text);transform:scale(1.05);}
|
||||
.v5-admin-btn .v5-admin-label{position:absolute;top:110%;left:50%;transform:translateX(-50%);
|
||||
font-size:.52rem;font-weight:600;color:var(--v5-primary);white-space:nowrap;
|
||||
opacity:0;transition:opacity .2s,color .2s;pointer-events:none;}
|
||||
@@ -204,8 +302,8 @@ html:not(.dark) .v5-hdr{
|
||||
.v5-admin-btn .ic-home{display:none;}
|
||||
.v5-admin-mode .v5-admin-btn .ic-gear{display:none;}
|
||||
.v5-admin-mode .v5-admin-btn .ic-home{display:block;}
|
||||
.v5-admin-mode .v5-admin-btn{border-color:var(--v5-cyan);color:var(--v5-cyan);background:rgba(var(--v5-cyan-rgb),.08);box-shadow:0 0 15px var(--v5-cyan-glow);}
|
||||
.v5-admin-mode .v5-admin-btn:hover{color:var(--v5-cyan);border-color:var(--v5-cyan);}
|
||||
.v5-admin-mode .v5-admin-btn{color:var(--v5-cyan);background:rgba(var(--v5-cyan-rgb),.10);}
|
||||
.v5-admin-mode .v5-admin-btn:hover{color:var(--v5-cyan);background:rgba(var(--v5-cyan-rgb),.16);}
|
||||
.v5-admin-mode .v5-admin-btn .v5-admin-label{color:var(--v5-cyan);}
|
||||
|
||||
/* Avatar */
|
||||
@@ -217,11 +315,11 @@ html:not(.dark) .v5-hdr{
|
||||
|
||||
/* Avatar dropdown */
|
||||
.v5-avatar-dd{position:absolute;top:calc(100% + 10px);right:0;width:220px;
|
||||
background:var(--v5-glass-strong);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border:1px solid var(--v5-glass-border);border-radius:16px;padding:.5rem;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,0.12),var(--v5-glow-sm);
|
||||
background:var(--v5-surface-solid);
|
||||
border:1px solid var(--v5-border);border-radius:16px;padding:.5rem;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,0.12),var(--v5-glow-md);
|
||||
opacity:0;transform:translateY(-8px) scale(.96);pointer-events:none;
|
||||
transition:all .25s cubic-bezier(.16,1,.3,1);z-index:100;}
|
||||
transition:all .25s var(--v5-ease-move);z-index:100;}
|
||||
.dark .v5-avatar-dd{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--v5-glow-md);}
|
||||
.v5-avatar-dd.open{opacity:1;transform:none;pointer-events:auto;}
|
||||
.v5-avatar-dd .av-profile{display:flex;align-items:center;gap:.6rem;padding:.55rem .6rem;
|
||||
@@ -242,11 +340,11 @@ html:not(.dark) .v5-hdr{
|
||||
|
||||
/* Notification panel */
|
||||
.v5-noti-panel{position:absolute;top:calc(100% + 10px);right:0;width:300px;max-height:400px;
|
||||
background:var(--v5-glass-strong);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border:1px solid var(--v5-glass-border);border-radius:16px;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,0.12),var(--v5-glow-sm);
|
||||
background:var(--v5-surface-solid);
|
||||
border:1px solid var(--v5-border);border-radius:16px;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,0.12),var(--v5-glow-md);
|
||||
opacity:0;transform:translateY(-8px) scale(.96);pointer-events:none;
|
||||
transition:all .25s cubic-bezier(.16,1,.3,1);z-index:100;overflow:hidden;}
|
||||
transition:all .25s var(--v5-ease-move);z-index:100;overflow:hidden;}
|
||||
.dark .v5-noti-panel{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--v5-glow-md);}
|
||||
.v5-noti-panel.open{opacity:1;transform:none;pointer-events:auto;}
|
||||
.v5-noti-head{display:flex;align-items:center;justify-content:space-between;padding:.7rem .85rem;
|
||||
@@ -298,10 +396,10 @@ html:not(.dark) .v5-hdr{
|
||||
box-shadow:0 0 8px var(--v5-cyan-glow);animation:v5-bdPulse 2s infinite;}
|
||||
@keyframes v5-bdPulse{0%,100%{box-shadow:0 0 4px var(--v5-cyan-glow)}50%{box-shadow:0 0 12px var(--v5-cyan-glow)}}
|
||||
|
||||
/* ===== GLASS TABS ===== */
|
||||
/* ===== SOLID TABS ===== */
|
||||
.v5-tabs{height:36px;display:flex;align-items:stretch;padding:0 .5rem;gap:1px;overflow-x:auto;
|
||||
background:var(--v5-glass);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
|
||||
border-bottom:1px solid var(--v5-glass-border);position:relative;z-index:15;flex-shrink:0;}
|
||||
background:var(--v5-surface-solid);
|
||||
border-bottom:1px solid var(--v5-border);position:relative;z-index:15;flex-shrink:0;}
|
||||
.v5-tab{display:flex;align-items:center;gap:.4rem;padding:0 .85rem;font-size:.7rem;font-weight:500;
|
||||
color:var(--v5-text-muted);cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap;transition:all .25s;}
|
||||
.v5-tab:hover{color:var(--v5-text-sec);background:var(--v5-surface-hover);}
|
||||
@@ -326,8 +424,8 @@ html:not(.dark) .v5-hdr{
|
||||
.v5-tabs:not(.collapsed){transition:height .3s cubic-bezier(.16,1,.3,1),padding .3s;}
|
||||
|
||||
/* Tab mini icon */
|
||||
.v5-tab-mini{position:relative;display:none;width:32px;height:32px;border-radius:10px;border:1px solid var(--v5-glass-border);
|
||||
background:var(--v5-surface);backdrop-filter:blur(8px);color:var(--v5-text-muted);cursor:pointer;
|
||||
.v5-tab-mini{position:relative;display:none;width:32px;height:32px;border-radius:10px;border:1px solid var(--v5-border);
|
||||
background:var(--v5-surface-solid);color:var(--v5-text-muted);cursor:pointer;
|
||||
align-items:center;justify-content:center;transition:all .25s;}
|
||||
.v5-tab-mini:hover{border-color:var(--v5-primary);color:var(--v5-primary);box-shadow:var(--v5-glow-sm);}
|
||||
.v5-tab-mini.visible{display:flex;animation:v5-miniIn .3s cubic-bezier(.16,1,.3,1) both;}
|
||||
@@ -338,11 +436,11 @@ html:not(.dark) .v5-hdr{
|
||||
|
||||
/* Tab dropdown */
|
||||
.v5-tab-dropdown{position:absolute;top:calc(100% + 8px);right:0;width:220px;
|
||||
background:var(--v5-glass-strong);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border:1px solid var(--v5-glass-border);border-radius:14px;padding:.4rem;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,0.12),var(--v5-glow-sm);
|
||||
background:var(--v5-surface-solid);
|
||||
border:1px solid var(--v5-border);border-radius:14px;padding:.4rem;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,0.12),var(--v5-glow-md);
|
||||
opacity:0;transform:translateY(-8px) scale(.96);pointer-events:none;
|
||||
transition:all .25s cubic-bezier(.16,1,.3,1);z-index:100;}
|
||||
transition:all .25s var(--v5-ease-move);z-index:100;}
|
||||
.dark .v5-tab-dropdown{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--v5-glow-md);}
|
||||
.v5-tab-dropdown.open{opacity:1;transform:none;pointer-events:auto;}
|
||||
.v5-tab-dropdown .td-item{display:flex;align-items:center;justify-content:space-between;
|
||||
@@ -368,14 +466,14 @@ html:not(.dark) .v5-hdr{
|
||||
/* Light mode: 본문 영역에 옅은 라벤더 톤 깔아 헤더/사이드바 흰색과 분리 */
|
||||
html:not(.dark) .v5-body{
|
||||
background:linear-gradient(135deg,#e8e1f5 0%,#ede6f7 35%,#e6dff3 70%,#e0d8ee 100%);}
|
||||
.v5-side{width:var(--v5-sidebar-w);background:var(--v5-glass);backdrop-filter:blur(20px) saturate(1.3);
|
||||
-webkit-backdrop-filter:blur(20px) saturate(1.3);border-right:1px solid var(--v5-glass-border);
|
||||
.v5-side{width:var(--v5-sidebar-w);background:var(--v5-surface-solid);
|
||||
border-right:1px solid var(--v5-border);
|
||||
padding:.85rem .6rem;overflow-y:auto;display:flex;flex-direction:column;gap:1px;flex-shrink:0;}
|
||||
/* Light mode: 사이드바가 본문에서 분리되어 떠있는 입체감 */
|
||||
/* Light mode: solid 배경 + primary glow */
|
||||
html:not(.dark) .v5-side{
|
||||
background:linear-gradient(180deg,rgba(254,253,255,0.85),rgba(250,248,254,0.7));
|
||||
background:var(--v5-surface-solid);
|
||||
border-right-color:rgba(var(--v5-primary-rgb),0.16);
|
||||
box-shadow:1px 0 0 rgba(255,255,255,0.5) inset, 4px 0 20px rgba(var(--v5-primary-rgb),0.05);}
|
||||
box-shadow:4px 0 20px rgba(var(--v5-primary-rgb),0.05);}
|
||||
|
||||
.v5-side-sec{font-size:.55rem;font-weight:700;text-transform:uppercase;letter-spacing:.12em;
|
||||
color:var(--v5-text-muted);padding:1rem .65rem .35rem;
|
||||
@@ -402,7 +500,7 @@ html:not(.dark) .v5-side{
|
||||
|
||||
/* Sidebar toggle */
|
||||
.v5-side-toggle{margin-top:auto;padding:.5rem .7rem;border-radius:10px;border:none;
|
||||
background:var(--v5-surface);backdrop-filter:blur(8px);color:var(--v5-text-muted);cursor:pointer;
|
||||
background:var(--v5-bg-subtle);color:var(--v5-text-muted);cursor:pointer;
|
||||
display:flex;align-items:center;gap:.6rem;font-size:.7rem;font-weight:500;font-family:inherit;
|
||||
transition:all .25s;flex-shrink:0;}
|
||||
.v5-side-toggle:hover{background:var(--v5-surface-hover);color:var(--v5-primary);}
|
||||
@@ -440,10 +538,10 @@ html:not(.dark) .v5-side{
|
||||
|
||||
/* Tooltip on hover */
|
||||
.v5-side.collapsed .v5-si:hover::after{content:attr(title);position:absolute;left:calc(100% + 10px);top:50%;transform:translateY(-50%);
|
||||
background:var(--v5-surface-solid);backdrop-filter:blur(12px);border:1px solid var(--v5-glass-border);
|
||||
background:var(--v5-surface-solid);border:1px solid var(--v5-border);
|
||||
padding:.3rem .6rem;border-radius:8px;font-size:.68rem;font-weight:500;color:var(--v5-text);
|
||||
white-space:nowrap;z-index:100;box-shadow:0 4px 15px rgba(0,0,0,.1);pointer-events:none;
|
||||
animation:v5-tipIn .2s cubic-bezier(.16,1,.3,1) both;}
|
||||
white-space:nowrap;z-index:100;box-shadow:0 4px 15px rgba(0,0,0,.1),var(--v5-glow-sm);pointer-events:none;
|
||||
animation:v5-tipIn .2s var(--v5-ease-move) both;}
|
||||
@keyframes v5-tipIn{from{opacity:0;transform:translateX(-4px) translateY(-50%)}to{opacity:1;transform:translateX(0) translateY(-50%)}}
|
||||
|
||||
.v5-side.collapsed .v5-si .ic{opacity:.7;margin:0;transition:opacity .25s,transform .25s;}
|
||||
@@ -539,11 +637,11 @@ html:not(.dark) .v5-side{
|
||||
|
||||
/* Flyout panel */
|
||||
.v5-side-flyout{position:absolute;left:calc(100% + 8px);top:0;width:180px;
|
||||
background:var(--v5-surface-solid);backdrop-filter:blur(24px) saturate(1.5);-webkit-backdrop-filter:blur(24px) saturate(1.5);
|
||||
border:1px solid var(--v5-glass-border);border-radius:14px;padding:.4rem;
|
||||
box-shadow:0 8px 32px rgba(0,0,0,0.12),var(--v5-glow-sm);
|
||||
background:var(--v5-surface-solid);
|
||||
border:1px solid var(--v5-border);border-radius:14px;padding:.4rem;
|
||||
box-shadow:0 8px 32px rgba(0,0,0,0.12),var(--v5-glow-md);
|
||||
opacity:0;transform:translateX(-12px) scale(.92);pointer-events:none;
|
||||
transition:opacity .2s cubic-bezier(.16,1,.3,1),transform .3s cubic-bezier(.16,1,.3,1);z-index:100;}
|
||||
transition:opacity .2s var(--v5-ease-move),transform .3s var(--v5-ease-move);z-index:100;}
|
||||
.dark .v5-side-flyout{box-shadow:0 8px 32px rgba(0,0,0,0.5),var(--v5-glow-md);}
|
||||
.v5-side-flyout.open{opacity:1;transform:none;pointer-events:auto;}
|
||||
.v5-side-flyout .fly-item{animation:v5-flyItemIn .25s cubic-bezier(.16,1,.3,1) both;}
|
||||
@@ -620,23 +718,80 @@ html:not(.dark) .v5-side{
|
||||
from{opacity:0;transform:translateY(8px) scale(.9);filter:blur(4px)}
|
||||
to{opacity:1;transform:translateY(0) scale(1);filter:blur(0)}}
|
||||
|
||||
/* ===== MODE TRANSITION — toggle button burst (option d, 절제된 버전) ===== */
|
||||
/* JS 가 .v5-mode-burst 컨테이너를 body 에 append. ring 1개 + particle 6개 정도로 미니멀. */
|
||||
.v5-mode-burst{position:fixed;pointer-events:none;z-index:1000;}
|
||||
.v5-mode-burst .burst-ring{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
|
||||
width:0;height:0;border-radius:50%;border:1.5px solid var(--v5-primary);opacity:.6;
|
||||
animation:v5-mode-burst-ring .65s cubic-bezier(.16,1,.3,1) forwards;}
|
||||
.v5-mode-burst.admin .burst-ring{border-color:var(--v5-cyan);}
|
||||
/* ===== MODE TRANSITION — toggle button burst (디자인시스템 mode-burst 포팅) =====
|
||||
JS 가 .v5-mode-burst 컨테이너를 fixed 위치(클릭점)에 append.
|
||||
기본 = primary(보라, → 사용자 모드), .admin = cyan(시안, → 관리자 모드). */
|
||||
.v5-mode-burst{
|
||||
position:fixed;width:0;height:0;
|
||||
pointer-events:none;z-index:9998;
|
||||
--burst-rgb:var(--v5-primary-rgb);
|
||||
}
|
||||
.v5-mode-burst.admin{--burst-rgb:var(--v5-cyan-rgb);}
|
||||
|
||||
/* Center expanding ring */
|
||||
.v5-mode-burst .burst-ring{
|
||||
position:absolute;left:0;top:0;
|
||||
width:28px;height:28px;margin-left:-14px;margin-top:-14px;
|
||||
border-radius:50%;
|
||||
border:2px solid rgba(var(--burst-rgb),.85);
|
||||
box-shadow:0 0 18px rgba(var(--burst-rgb),.55);
|
||||
animation:v5-mode-burst-ring .7s cubic-bezier(.22,1,.36,1) forwards;
|
||||
}
|
||||
@keyframes v5-mode-burst-ring{
|
||||
0%{width:0;height:0;opacity:.8;border-width:1.5px}
|
||||
100%{width:140px;height:140px;opacity:0;border-width:.5px}}
|
||||
.v5-mode-burst .burst-particle{position:absolute;left:50%;top:50%;width:3px;height:3px;
|
||||
border-radius:50%;background:var(--v5-primary);box-shadow:0 0 4px var(--v5-primary);opacity:.7;
|
||||
animation:v5-mode-burst-particle .55s cubic-bezier(.16,1,.3,1) forwards;}
|
||||
.v5-mode-burst.admin .burst-particle{background:var(--v5-cyan);box-shadow:0 0 4px var(--v5-cyan);}
|
||||
0% {opacity:0;transform:scale(.2);}
|
||||
18% {opacity:1;border-width:2px;}
|
||||
100%{opacity:0;transform:scale(5.5);border-width:0;}
|
||||
}
|
||||
|
||||
/* 10 radial particles. JS sets --tx / --ty per particle. */
|
||||
.v5-mode-burst .burst-particle{
|
||||
position:absolute;left:0;top:0;
|
||||
width:5px;height:5px;margin-left:-2.5px;margin-top:-2.5px;
|
||||
border-radius:50%;
|
||||
background:rgb(var(--burst-rgb));
|
||||
box-shadow:0 0 6px rgba(var(--burst-rgb),.9);
|
||||
opacity:0;
|
||||
animation:v5-mode-burst-particle .85s cubic-bezier(.22,1,.36,1) forwards;
|
||||
}
|
||||
@keyframes v5-mode-burst-particle{
|
||||
0%{transform:translate(-50%,-50%) scale(1);opacity:.8}
|
||||
100%{transform:translate(calc(-50% + var(--bx)),calc(-50% + var(--by))) scale(0);opacity:0}}
|
||||
0% {opacity:0;transform:translate(0,0) scale(.4);}
|
||||
15% {opacity:1;}
|
||||
100%{opacity:0;transform:translate(var(--tx),var(--ty)) scale(.2);}
|
||||
}
|
||||
|
||||
/* ===== MODE TRANSITION — header sweep (좌→우 그라데이션 빛띠) =====
|
||||
JS 가 .v5-hdr 안에 .v5-mode-sweep 을 append, 0.85s 후 자동 제거. */
|
||||
.v5-mode-sweep{
|
||||
position:absolute;left:0;right:0;bottom:-1px;height:2px;
|
||||
pointer-events:none;overflow:hidden;
|
||||
}
|
||||
.v5-mode-sweep::before{
|
||||
content:'';position:absolute;inset:0;
|
||||
background:linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
rgba(var(--v5-cyan-rgb),0) 15%,
|
||||
rgba(var(--v5-cyan-rgb),.9) 40%,
|
||||
rgba(var(--v5-primary-rgb),1) 50%,
|
||||
rgba(var(--v5-pink-rgb),.9) 60%,
|
||||
rgba(var(--v5-cyan-rgb),0) 85%,
|
||||
transparent 100%);
|
||||
filter:blur(.5px);
|
||||
transform:translateX(-100%);
|
||||
animation:v5-mode-sweep .85s cubic-bezier(.65,0,.35,1) forwards;
|
||||
}
|
||||
.v5-mode-sweep[data-mode="user"]::before{
|
||||
background:linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
rgba(var(--v5-primary-rgb),0) 15%,
|
||||
rgba(var(--v5-primary-rgb),.95) 50%,
|
||||
rgba(var(--v5-primary-rgb),0) 85%,
|
||||
transparent 100%);
|
||||
animation-direction:reverse;
|
||||
}
|
||||
@keyframes v5-mode-sweep{
|
||||
from{transform:translateX(-100%);}
|
||||
to {transform:translateX(100%);}
|
||||
}
|
||||
|
||||
/* ===== THEME TRANSITION ===== */
|
||||
.v5-theme-fade{position:fixed;inset:0;z-index:9999;pointer-events:none;opacity:0;
|
||||
@@ -648,21 +803,20 @@ html:not(.dark) .v5-side{
|
||||
.v5-placeholder{
|
||||
flex:1;display:flex;align-items:center;justify-content:center;
|
||||
border:2px dashed var(--v5-border);border-radius:16px;
|
||||
background:var(--v5-glass);backdrop-filter:blur(12px);
|
||||
background:var(--v5-bg-subtle);
|
||||
color:var(--v5-text-muted);font-size:.85rem;font-weight:500;min-height:300px;}
|
||||
|
||||
/* ===== V5 DROPDOWN GLASS OVERRIDES ===== */
|
||||
/* ===== V5 DROPDOWN SOLID OVERRIDES (2026-04-21 no-glass) ===== */
|
||||
.v5-hdr-r [data-radix-popper-content-wrapper]{z-index:100 !important;}
|
||||
.v5-hdr-r [role="menu"],
|
||||
.v5-avatar-dd-content{
|
||||
background:var(--v5-glass-strong) !important;backdrop-filter:blur(20px) saturate(1.4) !important;
|
||||
-webkit-backdrop-filter:blur(20px) saturate(1.4) !important;
|
||||
border:1px solid var(--v5-glass-border) !important;border-radius:14px !important;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,0.12),var(--v5-glow-sm) !important;
|
||||
animation:v5-ddIn .25s cubic-bezier(.16,1,.3,1) both !important;
|
||||
background:var(--v5-surface-solid) !important;
|
||||
border:1px solid var(--v5-border) !important;border-radius:14px !important;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,0.12),var(--v5-glow-md) !important;
|
||||
animation:v5-ddIn .25s var(--v5-ease-move) both !important;
|
||||
padding:.4rem !important;}
|
||||
.dark .v5-hdr-r [role="menu"],
|
||||
.dark .v5-avatar-dd-content{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--v5-glow-md) !important;}
|
||||
.dark .v5-avatar-dd-content{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--v5-glow-lg) !important;}
|
||||
@keyframes v5-ddIn{from{opacity:0;transform:translateY(-8px) scale(.95)}to{opacity:1;transform:none}}
|
||||
|
||||
/* Avatar dropdown items glass style */
|
||||
@@ -670,8 +824,8 @@ html:not(.dark) .v5-side{
|
||||
.v5-avatar-dd-content [role="menuitem"]:hover{background:var(--v5-surface-hover) !important;transform:translateX(2px);}
|
||||
|
||||
/* ===== MOBILE RESPONSIVE ===== */
|
||||
.v5-mobile-toggle{display:none;width:36px;height:36px;border-radius:10px;border:1px solid var(--v5-glass-border);
|
||||
background:var(--v5-surface);backdrop-filter:blur(8px);color:var(--v5-text-muted);cursor:pointer;
|
||||
.v5-mobile-toggle{display:none;width:36px;height:36px;border-radius:10px;border:1px solid var(--v5-border);
|
||||
background:var(--v5-surface-solid);color:var(--v5-text-muted);cursor:pointer;
|
||||
align-items:center;justify-content:center;transition:all .2s;flex-shrink:0;}
|
||||
.v5-mobile-toggle:hover{border-color:var(--v5-primary);color:var(--v5-primary);}
|
||||
|
||||
@@ -1012,8 +1166,8 @@ html:not(.dark) .v5-side{
|
||||
animation:v5-mm-overlayIn .3s ease-out !important;
|
||||
}
|
||||
@keyframes v5-mm-overlayIn{
|
||||
from{opacity:0;backdrop-filter:blur(0);}
|
||||
to{opacity:1;backdrop-filter:blur(6px);}
|
||||
from{opacity:0;}
|
||||
to{opacity:1;}
|
||||
}
|
||||
.v5-mm-pane-wrap{padding:clamp(.9rem,1.6vw,1.4rem) clamp(1rem,2vw,1.75rem) 2rem;
|
||||
overflow-y:auto;height:100%;width:100%;}
|
||||
@@ -1181,10 +1335,10 @@ select.v5-mm-inp{padding-right:2rem;appearance:none;-webkit-appearance:none;
|
||||
/* 모드 토글 (라이트/다크) */
|
||||
.settings-mode-row{display:flex;gap:.45rem;}
|
||||
.settings-mode-btn{flex:1;display:flex;align-items:center;justify-content:center;gap:.4rem;
|
||||
padding:.55rem .8rem;border-radius:10px;border:1px solid var(--v5-glass-border);
|
||||
background:var(--v5-surface);backdrop-filter:blur(8px);color:var(--v5-text-sec);cursor:pointer;
|
||||
padding:.55rem .8rem;border-radius:10px;border:1px solid var(--v5-border);
|
||||
background:var(--v5-surface-solid);color:var(--v5-text-sec);cursor:pointer;
|
||||
font-size:.72rem;font-weight:500;font-family:inherit;
|
||||
transition:all .2s cubic-bezier(.4,0,.2,1);}
|
||||
transition:all .2s var(--v5-ease-move);}
|
||||
.settings-mode-btn:hover{color:var(--v5-text);border-color:var(--v5-primary);}
|
||||
.settings-mode-btn.on{background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.12),rgba(var(--v5-primary-rgb),.05));
|
||||
color:var(--v5-primary);border-color:rgba(var(--v5-primary-rgb),.35);
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
인비온 DB 덤프 민감정보 마스킹 스크립트 (v2).
|
||||
|
||||
단계:
|
||||
1) 원본 company_mng / user_info 에서 매핑 수집
|
||||
- 실제 회사명 -> '회사_{company_code}'
|
||||
- 실제 실명 -> '사용자_{sabun}'
|
||||
2) INSERT 문의 대상 컬럼은 구조적 마스킹 (user_password, 연락처, 사업자번호 등)
|
||||
3) 파일 전체에서 실제 회사명/실명 문자열을 전역 치환
|
||||
(메뉴 이름, 부서 company_name, 다국어 텍스트 등 어디 박혀있든)
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
INPUT = Path("/Users/gbpark/invyone/notes/gbpark/2026-04-21-db-sample/invion-masters.sql")
|
||||
OUTPUT = Path("/Users/gbpark/invyone/notes/gbpark/2026-04-21-db-sample/invion-masters.masked.sql")
|
||||
|
||||
INSERT_RE = re.compile(
|
||||
r"^INSERT INTO public\.(\w+) \(([^)]+)\) VALUES \((.+)\);\s*$",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
# 일반명사/짧은 약자는 전역 치환하면 다른 무해한 단어를 오염시킬 수 있어 스킵
|
||||
SKIP_COMPANY_NAMES = {"공통", "테스트회사", "시연용 회사"}
|
||||
# 일반 역할명/호칭은 실명이 아니라 메뉴/UI 에 흔히 등장하므로 스킵
|
||||
SKIP_USER_NAMES = {"관리자", "운영자", "담당자", "사용자", "개발자", "테스트"}
|
||||
|
||||
|
||||
def parse_sql_values(s: str):
|
||||
values = []
|
||||
i, n = 0, len(s)
|
||||
while i < n:
|
||||
while i < n and s[i] in " \t\n":
|
||||
i += 1
|
||||
if i >= n:
|
||||
break
|
||||
if s[i] == ",":
|
||||
i += 1
|
||||
continue
|
||||
if s[i] == "'":
|
||||
j = i + 1
|
||||
while j < n:
|
||||
if s[j] == "'":
|
||||
if j + 1 < n and s[j + 1] == "'":
|
||||
j += 2
|
||||
else:
|
||||
break
|
||||
else:
|
||||
j += 1
|
||||
values.append(s[i:j + 1])
|
||||
i = j + 1
|
||||
elif s[i:i + 4].upper() == "NULL" and (i + 4 == n or not s[i + 4].isalnum()):
|
||||
values.append("NULL")
|
||||
i += 4
|
||||
elif s[i:i + 4].upper() == "TRUE" and (i + 4 == n or not s[i + 4].isalnum()):
|
||||
values.append("TRUE")
|
||||
i += 4
|
||||
elif s[i:i + 5].upper() == "FALSE" and (i + 5 == n or not s[i + 5].isalnum()):
|
||||
values.append("FALSE")
|
||||
i += 5
|
||||
elif s[i] == "-" or s[i].isdigit():
|
||||
j = i + 1
|
||||
while j < n and (s[j].isdigit() or s[j] in ".-eE+"):
|
||||
j += 1
|
||||
values.append(s[i:j])
|
||||
i = j
|
||||
else:
|
||||
j = i
|
||||
depth = 0
|
||||
while j < n and (s[j] != "," or depth > 0):
|
||||
if s[j] == "(":
|
||||
depth += 1
|
||||
elif s[j] == ")":
|
||||
depth -= 1
|
||||
j += 1
|
||||
values.append(s[i:j].strip())
|
||||
i = j
|
||||
return values
|
||||
|
||||
|
||||
def strip_q(v: str) -> str:
|
||||
if v.startswith("'") and v.endswith("'"):
|
||||
return v[1:-1].replace("''", "'")
|
||||
return v
|
||||
|
||||
|
||||
def collect_mappings(src: str):
|
||||
company_map: dict[str, str] = {}
|
||||
user_map: dict[str, str] = {}
|
||||
for line in src.splitlines():
|
||||
m = INSERT_RE.match(line + "\n")
|
||||
if not m:
|
||||
continue
|
||||
tbl, cols_s, vals_s = m.groups()
|
||||
if tbl not in ("company_mng", "user_info"):
|
||||
continue
|
||||
cols = [c.strip() for c in cols_s.split(",")]
|
||||
vals = parse_sql_values(vals_s)
|
||||
if len(cols) != len(vals):
|
||||
continue
|
||||
row = dict(zip(cols, vals))
|
||||
if tbl == "company_mng":
|
||||
code = strip_q(row.get("company_code", ""))
|
||||
name = strip_q(row.get("company_name", ""))
|
||||
if name and name not in SKIP_COMPANY_NAMES and len(name) >= 2:
|
||||
masked = f"회사_{code}"
|
||||
# 등록명 그대로
|
||||
company_map[name] = masked
|
||||
# 법인 접두/접미 제거 alias (짧은 이름으로만 쓰이는 경우 대응)
|
||||
for stripped in (
|
||||
name.removeprefix("(주)").strip(),
|
||||
name.removeprefix("주식회사 ").strip(),
|
||||
name.removesuffix(" 주식회사").strip(),
|
||||
name.removesuffix("주식회사").strip(),
|
||||
):
|
||||
if stripped and stripped != name and stripped not in SKIP_COMPANY_NAMES and len(stripped) >= 2:
|
||||
company_map[stripped] = masked
|
||||
elif tbl == "user_info":
|
||||
sabun = strip_q(row.get("sabun", ""))
|
||||
name = strip_q(row.get("user_name", ""))
|
||||
if name and name not in SKIP_USER_NAMES and len(name) >= 2:
|
||||
user_map[name] = f"사용자_{sabun}"
|
||||
return company_map, user_map
|
||||
|
||||
|
||||
def mask_line_columns(line: str) -> str:
|
||||
m = INSERT_RE.match(line)
|
||||
if not m:
|
||||
return line
|
||||
tbl, cols_s, vals_s = m.groups()
|
||||
cols = [c.strip() for c in cols_s.split(",")]
|
||||
vals = parse_sql_values(vals_s)
|
||||
if len(cols) != len(vals):
|
||||
return line
|
||||
row = dict(zip(cols, vals))
|
||||
|
||||
if tbl == "user_info":
|
||||
if "user_password" in row:
|
||||
row["user_password"] = "'REDACTED'"
|
||||
if "sabun" in row and "user_name" in row:
|
||||
sabun = strip_q(row["sabun"]) or "UNKNOWN"
|
||||
row["user_name"] = f"'사용자_{sabun}'"
|
||||
if "user_name_eng" in row:
|
||||
row["user_name_eng"] = "''"
|
||||
if "user_name_cn" in row:
|
||||
row["user_name_cn"] = "''"
|
||||
for f in ("email", "tel", "cell_phone", "fax_no"):
|
||||
if f in row:
|
||||
row[f] = "NULL"
|
||||
|
||||
elif tbl == "company_mng":
|
||||
if "company_code" in row and "company_name" in row:
|
||||
code = strip_q(row["company_code"]) or "UNKNOWN"
|
||||
row["company_name"] = f"'회사_{code}'"
|
||||
for f in (
|
||||
"business_registration_number",
|
||||
"representative_name",
|
||||
"representative_phone",
|
||||
"email",
|
||||
"website",
|
||||
"address",
|
||||
"company_image",
|
||||
"company_logo",
|
||||
"company_seal",
|
||||
):
|
||||
if f in row:
|
||||
row[f] = "NULL"
|
||||
|
||||
elif tbl == "dept_info":
|
||||
# 부서 자체 이름은 유지 (일반 부서명), 회사명 컬럼은 전역 치환이 처리
|
||||
pass
|
||||
|
||||
new_vals = [row[c] for c in cols]
|
||||
return f"INSERT INTO public.{tbl} ({', '.join(cols)}) VALUES ({', '.join(new_vals)});\n"
|
||||
|
||||
|
||||
def apply_global_replacements(text: str, company_map: dict, user_map: dict):
|
||||
# 긴 문자열부터 치환 (부분 중복 방지: "(주)제일그라스" 먼저, "제일그라스" 나중)
|
||||
items = sorted(
|
||||
list(company_map.items()) + list(user_map.items()),
|
||||
key=lambda kv: -len(kv[0]),
|
||||
)
|
||||
replacements = 0
|
||||
for original, masked in items:
|
||||
before = text.count(original)
|
||||
if before:
|
||||
text = text.replace(original, masked)
|
||||
replacements += before
|
||||
return text, replacements
|
||||
|
||||
|
||||
def main():
|
||||
src = INPUT.read_text(encoding="utf-8")
|
||||
company_map, user_map = collect_mappings(src)
|
||||
|
||||
print(f"수집된 회사 매핑 {len(company_map)}건:")
|
||||
for k, v in company_map.items():
|
||||
print(f" '{k}' -> '{v}'")
|
||||
print(f"수집된 실명 매핑 {len(user_map)}건:")
|
||||
for k, v in user_map.items():
|
||||
print(f" '{k}' -> '{v}'")
|
||||
|
||||
# 1) 컬럼 단위 마스킹
|
||||
col_masked_lines = []
|
||||
col_counts = {"user_info": 0, "company_mng": 0}
|
||||
for line in src.splitlines(keepends=True):
|
||||
if line.startswith("INSERT INTO public."):
|
||||
m = INSERT_RE.match(line)
|
||||
if m:
|
||||
tbl = m.group(1)
|
||||
new_line = mask_line_columns(line)
|
||||
if tbl in col_counts and new_line != line:
|
||||
col_counts[tbl] += 1
|
||||
col_masked_lines.append(new_line)
|
||||
continue
|
||||
col_masked_lines.append(line)
|
||||
|
||||
stage1_text = "".join(col_masked_lines)
|
||||
|
||||
# 2) 전역 문자열 치환 (나머지 위치의 회사명/실명)
|
||||
stage2_text, global_replacements = apply_global_replacements(
|
||||
stage1_text, company_map, user_map
|
||||
)
|
||||
|
||||
OUTPUT.write_text(stage2_text, encoding="utf-8")
|
||||
|
||||
print(f"\n완료: {OUTPUT}")
|
||||
print(f" 컬럼 단위 마스킹 - user_info: {col_counts['user_info']}건")
|
||||
print(f" 컬럼 단위 마스킹 - company_mng: {col_counts['company_mng']}건")
|
||||
print(f" 전역 문자열 치환: {global_replacements}건 (회사명/실명)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user