디자인 수정
Build & Deploy to K8s / build-and-deploy (push) Successful in 3m59s

This commit is contained in:
2026-04-21 22:59:51 +09:00
parent bc66d8c549
commit 5153386fce
24 changed files with 2992 additions and 887 deletions
+197 -53
View File
@@ -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
View File
@@ -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>
+4 -1
View File
@@ -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>
+69 -1
View File
@@ -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>
);
});
+10 -10
View File
@@ -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
+18 -430
View File
@@ -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}
@@ -346,227 +144,12 @@ export function TemplateRenderer({
canvas={canvas}
/>
);
}
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?: {
+191 -40
View File
@@ -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 ? "관리자" : "홈"} &rsaquo; <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
+126 -44
View File
@@ -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>
);
}
+172
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}
+71
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+47
View File
@@ -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>
);
}
+60
View File
@@ -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>
);
}
+20
View File
@@ -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";
+85
View File
@@ -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);
}
+4 -34
View File
@@ -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,
+66 -26
View File
@@ -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
View File
@@ -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);
}
+643
View File
@@ -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
View File
@@ -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()