From 5153386fce54287765c34cbeb2c62bba0ed5b1a7 Mon Sep 17 00:00:00 2001 From: gbpark Date: Tue, 21 Apr 2026 22:59:51 +0900 Subject: [PATCH] =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(main)/main/page.tsx | 260 +++++-- frontend/app/(main)/page.tsx | 263 +++++-- frontend/app/globals.css | 5 +- .../components/dash/CreateDashboardModal.tsx | 55 +- frontend/components/dash/DashboardCanvas.tsx | 70 +- frontend/components/dash/DashboardLayout.tsx | 20 +- frontend/components/dash/TemplateRenderer.tsx | 462 +------------ frontend/components/layout/AppLayout.tsx | 247 +++++-- frontend/components/layout/SettingsModal.tsx | 186 +++-- frontend/components/layout/TopNavBar.tsx | 172 +++++ frontend/components/v5/BarCard.tsx | 51 ++ frontend/components/v5/Feed.tsx | 43 ++ frontend/components/v5/Kpi.tsx | 71 ++ frontend/components/v5/Modal.tsx | 63 ++ frontend/components/v5/PageHead.tsx | 47 ++ frontend/components/v5/Spark.tsx | 60 ++ frontend/components/v5/index.ts | 20 + frontend/lib/navOrientationTransition.ts | 85 +++ frontend/lib/utils/templateMigrate.ts | 38 +- frontend/styles/control-mode.css | 92 ++- frontend/styles/dashboard.css | 370 ++++++++-- frontend/styles/v5-atomics.css | 643 ++++++++++++++++++ frontend/styles/v5-layout.css | 320 ++++++--- .../gbpark/2026-04-21-db-sample/mask_dump.py | 236 +++++++ 24 files changed, 2992 insertions(+), 887 deletions(-) create mode 100644 frontend/components/layout/TopNavBar.tsx create mode 100644 frontend/components/v5/BarCard.tsx create mode 100644 frontend/components/v5/Feed.tsx create mode 100644 frontend/components/v5/Kpi.tsx create mode 100644 frontend/components/v5/Modal.tsx create mode 100644 frontend/components/v5/PageHead.tsx create mode 100644 frontend/components/v5/Spark.tsx create mode 100644 frontend/components/v5/index.ts create mode 100644 frontend/lib/navOrientationTransition.ts create mode 100644 frontend/styles/v5-atomics.css create mode 100644 notes/gbpark/2026-04-21-db-sample/mask_dump.py diff --git a/frontend/app/(main)/main/page.tsx b/frontend/app/(main)/main/page.tsx index 9f847608..722db02f 100644 --- a/frontend/app/(main)/main/page.tsx +++ b/frontend/app/(main)/main/page.tsx @@ -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 = { + 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 = { + 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 ( -
- {/* 헤더 영역 */} -
-

- {userName}님, 좋은 하루 되세요 -

-

{dateStr}

-
+
+ {/* 바로가기 */} -
-

바로가기

-
- {quickAccessItems.map((item) => { +
+
+ 바로가기 +
+
+ {QUICK_ITEMS.map((item) => { const Icon = item.icon; return ( ); })}
-
+ {/* 시스템 정보 */} -
-

시스템 정보

-
-
-
- -
-
-

플랫폼

-

Invyone ERP/PLM

-
-
-
-
- -
-
-

버전

-

v2.0.0

-
-
-
-
- -
-
-

오늘 날짜

-

- {today.toLocaleDateString("ko-KR", { year: "numeric", month: "long", day: "numeric" })} -

-
-
+
+
+
시스템 정보
+
+
+ + + +
+
+
+ ); +} + +function SystemCell({ + icon: Icon, + label, + value, +}: { + icon: typeof Server; + label: string; + value: string; +}) { + return ( +
+
+ +
+
+
+ {label} +
+
+ {value}
diff --git a/frontend/app/(main)/page.tsx b/frontend/app/(main)/page.tsx index a21a9d31..2abc30be 100644 --- a/frontend/app/(main)/page.tsx +++ b/frontend/app/(main)/page.tsx @@ -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 = { + 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 = { + 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 ( -
- {/* 헤더 영역 */} -
-

- {userName}님, 좋은 하루 되세요 -

-

{dateStr}

-
+
+ {/* 바로가기 */} -
-

바로가기

-
- {quickAccessItems.map((item) => { +
+
+ 바로가기 +
+
+ {QUICK_ITEMS.map((item) => { const Icon = item.icon; return ( ); })}
-
+ {/* 시스템 정보 */} -
-

시스템 정보

-
-
-
- -
-
-

플랫폼

-

Invyone ERP/PLM

-
-
-
-
- -
-
-

버전

-

v2.0.0

-
-
-
-
- -
-
-

오늘 날짜

-

- {today.toLocaleDateString("ko-KR", { year: "numeric", month: "long", day: "numeric" })} -

-
-
+
+
+
시스템 정보
+
+
+ + + +
+
+
+ ); +} + +function SystemCell({ + icon: Icon, + label, + value, +}: { + icon: typeof Server; + label: string; + value: string; +}) { + return ( +
+
+ +
+
+
+ {label} +
+
+ {value}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 76581837..a44c94d9 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -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"; diff --git a/frontend/components/dash/CreateDashboardModal.tsx b/frontend/components/dash/CreateDashboardModal.tsx index c255c353..04127361 100644 --- a/frontend/components/dash/CreateDashboardModal.tsx +++ b/frontend/components/dash/CreateDashboardModal.tsx @@ -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 (
{ if (e.target === e.currentTarget) onClose(); }} > -
+

새 대시보드 만들기

- ))} + {ICON_PRESETS.map((iconName) => { + const Ico = getIconComponent(iconName); + const selected = icon === iconName; + return ( + + ); + })}
@@ -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)]" />
회사 전체 공용
@@ -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)]" />
나만 보기 (개인 대시보드)
diff --git a/frontend/components/dash/DashboardCanvas.tsx b/frontend/components/dash/DashboardCanvas.tsx index 2d331162..72f4b74c 100644 --- a/frontend/components/dash/DashboardCanvas.tsx +++ b/frontend/components/dash/DashboardCanvas.tsx @@ -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
{children}
; +} + interface DashboardCanvasProps { dashboardName: string; onOpenLibrary: () => void; @@ -22,9 +49,11 @@ export const DashboardCanvas = forwardRef( }, 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(null); const canvasRef = (externalRef as React.RefObject) ?? internalRef; const dragRef = useRef<{ @@ -163,6 +192,11 @@ export const DashboardCanvas = forwardRef( } }, [activeDashboardId, removeCard]); + const handleRequestSave = useCallback(() => { + // 헤더/FAB 공용 저장 트리거 — DashboardLayout 가 수신해 실제 저장 실행 + window.dispatchEvent(new CustomEvent('dash:save')); + }, []); + return (
( ); }) )} + + {/* 편집 모드 FAB — 캔버스 우하단 */} + + + + 편집 모드 · 카드 {cards.length}개 + + + + + + + + {/* 제어 모드 FAB */} + + + + 제어 모드 · 실시간 데이터 + + + +
); }); diff --git a/frontend/components/dash/DashboardLayout.tsx b/frontend/components/dash/DashboardLayout.tsx index ee1685ee..42f8938b 100644 --- a/frontend/components/dash/DashboardLayout.tsx +++ b/frontend/components/dash/DashboardLayout.tsx @@ -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
{activeDashboardId ? ( <> - openLib()} - onSaveLayout={handleSaveLayout} - /> - {/* 제어 모드 툴바 + 오버레이 */} + {/* 편집/제어 툴바는 이제 헤더로 hoist. 캔버스 FAB 이 모드 내 액션 담당. */} {/* ★ flex container로 만들어야 안쪽 dash-canvas가 flex:1로 늘어남 */}
( - () => 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(); - 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,236 +136,20 @@ export function TemplateRenderer({ ); } - if (useLine) { - return ( - - ); - } - return ( -
- - - {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 ( -
- {mainRowBlocks.length > 0 && ( -
- {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 ( - - ); - })} -
- )} - {actionBlocks.length > 0 && ( -
- {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 ( - - ); - })} -
- )} - {band.overlays.map((ov) => ( - - ))} -
- ); - })} -
+ ); } // ───────────────────────────────────────────────────────────────────────────── -// 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 ( -
- -
- ); -} - -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 ( -
- -
- ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// 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; - for (let i = rowStart; i < rowEnd; i++) height += finalPx[i] ?? 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?: { diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 0533981f..56d600c0 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -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 {children}; +} + 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(".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(".v5-hdr-bc"); @@ -826,56 +909,102 @@ function AppLayoutInner({ children }: AppLayoutProps) {
Invy.one
-
- {isAdminMode ? "관리자" : "홈"} › {breadcrumbText} -
-
-
- 관리자 모드 -
+ {navOrientation === "vertical" && ( + <> +
+ {isAdminMode ? "관리자" : "홈"} › {breadcrumbText} +
+
+
+ 관리자 모드 +
+ + )} + {navOrientation === "horizontal" && ( + + )}
{/* mode transition 헤더 glow 라인 — 평소엔 opacity 0, mode change 시에만 flash */}
- {/* 대시보드 생성(전역) + 제어/편집(대시보드 페이지 전용) — 평소엔 페이지 내부 툴바 없음, 이 버튼 눌러야 툴바 등장 */} - - {pathname && !isAdminMode && /^\/\d+$/.test(pathname) && ( - <> + {/* 대시보드 페이지: [+ 대시보드] 를 편집/제어와 같은 그룹에 묶어 한 덩어리로. + 그 외 페이지: [+ 대시보드] 만 단독 노출. */} + {pathname && !isAdminMode && /^\/\d+$/.test(pathname) ? ( +
+ {/* 편집 ON 시 왼쪽으로 펼쳐지는 [템플릿][저장] pop-out */} + + + + + + + - - +
+ ) : ( + )} - {/* Theme pill */} -
- - -
+ {/* Theme toggle — single sun/moon icon (디자인시스템 준수, 2026-04-21) */} + {/* Mini tab icon (visible when tabs collapsed) */} {tabsCollapsed && ( @@ -890,17 +1019,30 @@ function AppLayoutInner({ children }: AppLayoutProps) { )} {/* Bell / Notifications */} - + + {/* Tweaks — 색상/테마 빠른 접근 (SettingsModal 오픈) */} + {/* Admin toggle (gear ↔ home) — 이벤트 전달해서 burst origin 으로 사용 */} {isAdmin && ( - )} @@ -961,8 +1103,9 @@ function AppLayoutInner({ children }: AppLayoutProps) { )} {/* ===== Body (sidebar + content) ===== */} -
- {/* Sidebar */} +
+ {/* Sidebar — horizontal 모드에서는 Mobile 용(햄버거) 로만 살리고 데스크톱 표시는 숨김 */} + {(navOrientation === "vertical" || isMobile) && ( + )} {/* Content area */} {/* ★ INVYONE 신규 페이지는 children 직접 렌더, 기존 VEX 페이지는 탭 시스템 */} @@ -1070,7 +1214,14 @@ function AppLayoutInner({ children }: AppLayoutProps) { - + {/* 전역 대시보드 생성 모달 — 헤더 "대시보드" 버튼에서 열림 */} 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,62 +59,124 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { }; return ( - - - - 설정 - 화면 테마와 색상을 변경합니다. - +
+
+ Tweaks + +
- {/* === 모드 (라이트/다크) === */} -
-
화면 모드
-
+ {/* === 테마 컬러 === */} +
+ +
+ {COLOR_THEMES.map((c) => { + const swatch = isDark ? c.dark : c.light; + const isActive = color === c.id; + return ( + + ); + })} +
+
+ + {/* === 모드 === */} +
+ +
+ + +
+
+ + {/* === 메뉴 방향 === */} + {onNavOrientationChange && ( +
+ +
+ )} - {/* === 색상 테마 === */} -
-
색상 테마
-
- {COLOR_THEMES.map((c) => { - const swatch = isDark ? c.dark : c.light; - const isActive = color === c.id; - return ( - - ); - })} + {/* === 사이드바 (세로일 때만 의미있음) === */} + {onSidebarCollapsedChange && navOrientation !== "horizontal" && ( +
+ +
+ +
- -
+ )} + +
+ 우측 상단 슬라이더 아이콘으로 다시 열기 +
+
); } diff --git a/frontend/components/layout/TopNavBar.tsx b/frontend/components/layout/TopNavBar.tsx new file mode 100644 index 00000000..1be9f3b9 --- /dev/null +++ b/frontend/components/layout/TopNavBar.tsx @@ -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(null); + const closeTimer = useRef | 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 ( + + ); +} + +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 ( +
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 && {item.icon}} + {item.name} + {typeof item.badge === "number" && item.badge > 0 && ( + {item.badge} + )} + {hasChildren && } + {hasChildren && subOpen && ( +
+ {item.children?.map((c, k) => ( +
{ + e.stopPropagation(); + onSelect(c); + }} + > + {c.icon && {c.icon}} + {c.name} + {typeof c.badge === "number" && c.badge > 0 && ( + {c.badge} + )} +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/components/v5/BarCard.tsx b/frontend/components/v5/BarCard.tsx new file mode 100644 index 00000000..faa1599b --- /dev/null +++ b/frontend/components/v5/BarCard.tsx @@ -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 ( +
+
+
+
{title}
+
+ {sum.toLocaleString("ko-KR")} + + {unit} + +
+
+ {action} +
+
+ {data.map((d, i) => ( +
+ ))} +
+
+ {data.map((d, i) => ( + {d.k} + ))} +
+
+ ); +} diff --git a/frontend/components/v5/Feed.tsx b/frontend/components/v5/Feed.tsx new file mode 100644 index 00000000..ea6797bf --- /dev/null +++ b/frontend/components/v5/Feed.tsx @@ -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 ... 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 ( +
+ {items.map((it) => { + const Icon = it.icon; + const dotCls = `v5-feed-dot ${it.tone && it.tone !== "primary" ? it.tone : ""}`.trim(); + return ( +
+
+ +
+
+ {it.text} + {it.time && {it.time}} +
+
+ ); + })} +
+ ); +} diff --git a/frontend/components/v5/Kpi.tsx b/frontend/components/v5/Kpi.tsx new file mode 100644 index 00000000..5868bba3 --- /dev/null +++ b/frontend/components/v5/Kpi.tsx @@ -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, 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 ( +
+
+
{title}
+
+
+ {value} + {showDelta && ( + + {isUp ? : } + {Math.abs(delta!)} + + )} +
+ {sub &&
{sub}
} + {spark && spark.length > 0 && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/components/v5/Modal.tsx b/frontend/components/v5/Modal.tsx new file mode 100644 index 00000000..c12dce87 --- /dev/null +++ b/frontend/components/v5/Modal.tsx @@ -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 ... 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 ( +
{ + if (!hardClose) onClose(); + }} + > +
e.stopPropagation()} + role="dialog" + aria-modal="true" + > +
+
{title}
+ +
+
{children}
+ {footer &&
{footer}
} +
+
+ ); +} diff --git a/frontend/components/v5/PageHead.tsx b/frontend/components/v5/PageHead.tsx new file mode 100644 index 00000000..1063822d --- /dev/null +++ b/frontend/components/v5/PageHead.tsx @@ -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