INVYONE Studio Config Panel — CP 프리미티브 신설 + V2FieldConfigPanel HTML V4 스펙 풀 매칭
- _shared/cp 프리미티브 14종 (CPSection/CPRow/CPSelect 커스텀 popover/CPSegment 평면/CPCrumb 통합 팝오버 등) - V2FieldConfigPanel: 4 kinds × 10 types × format별 옵션 패널로 재작성 (panel-input-new.jsx 매칭) - resolveTriple/applyTriple 양방향 매핑 — 기존 fieldType/source/autoGeneration 호환 - 미구현(formula/audit/금액 외화/주민·주소·카드/다중 entity) 영역은 Hint 로 명시 - V2PropertiesPanel cp 톤 + 중복 Separator 정리, ScreenDesigner 우측 패널 너비 드래그 - 다크 luminance 분리 / 섹션 헤더 gradient underline / brumb 좌·우 transparent 영역 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -133,6 +133,7 @@ export function BlockRenderer({
|
||||
value={resolvedValue}
|
||||
isDesignMode={false}
|
||||
isPreview={true}
|
||||
isInteractive={true}
|
||||
formData={context.formRow}
|
||||
form_data={context.formRow}
|
||||
onFormDataChange={(fieldName: string, value: any) =>
|
||||
|
||||
@@ -146,8 +146,6 @@ import { type ViewType } from "./ViewTabBar";
|
||||
|
||||
// 컴포넌트 초기화 (새 시스템)
|
||||
import "@/lib/registry/components";
|
||||
// 성능 최적화 도구 초기화 (필요시 사용)
|
||||
import "@/lib/registry/utils/performanceOptimizer";
|
||||
|
||||
interface ScreenDesignerProps {
|
||||
selectedScreen: ScreenDefinition | null;
|
||||
@@ -464,6 +462,40 @@ export default function ScreenDesigner({
|
||||
}: ScreenDesignerProps) {
|
||||
// POP 모드 여부에 따른 API 분기
|
||||
const USE_POP_API = isPop;
|
||||
|
||||
// 우측 속성 패널 너비 (드래그로 조절, localStorage 보관) — 240~600px
|
||||
const [rightPanelWidth, setRightPanelWidth] = useState<number>(() => {
|
||||
if (typeof window === "undefined") return 320;
|
||||
const saved = window.localStorage.getItem("inv-right-panel-width");
|
||||
const n = saved ? Number(saved) : NaN;
|
||||
return Number.isFinite(n) && n >= 240 && n <= 600 ? n : 320;
|
||||
});
|
||||
useEffect(() => {
|
||||
try { window.localStorage.setItem("inv-right-panel-width", String(rightPanelWidth)); } catch {}
|
||||
}, [rightPanelWidth]);
|
||||
const startRightPanelDrag = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
setRightPanelWidth((curr) => {
|
||||
const startWidth = curr;
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const next = Math.min(600, Math.max(240, startWidth + (startX - ev.clientX)));
|
||||
setRightPanelWidth(next);
|
||||
};
|
||||
const onUp = () => {
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
document.body.style.cursor = "ew-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
return curr;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [layout, setLayout] = useState<LayoutData>({
|
||||
screenId: 0,
|
||||
components: [],
|
||||
@@ -8735,18 +8767,26 @@ export default function ScreenDesigner({
|
||||
* - 좌측 "편집" 섹션은 다음 Edit 에서 제거 (500줄 블록이라 분리).
|
||||
*/}
|
||||
{isPropertiesPanelOpen && (
|
||||
<aside className="inv-right-panel border-border bg-card flex h-full w-[280px] flex-col overflow-hidden border-l shadow-sm">
|
||||
<div className="border-border flex shrink-0 items-center justify-between border-b px-3 py-2">
|
||||
<h3 className="text-foreground text-xs font-bold tracking-wider uppercase">
|
||||
속성
|
||||
</h3>
|
||||
<span className="text-muted-foreground text-[0.55rem]">
|
||||
{groupState.selectedComponents.length > 0
|
||||
? `${groupState.selectedComponents.length} selected`
|
||||
: "Inspector"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
style={{ width: rightPanelWidth }}
|
||||
className="inv-right-panel border-border bg-card relative flex h-full flex-col overflow-hidden border-l shadow-sm"
|
||||
>
|
||||
{/* 좌측 리사이즈 핸들 (드래그해서 패널 너비 조절) */}
|
||||
<div
|
||||
onMouseDown={startRightPanelDrag}
|
||||
onDoubleClick={() => setRightPanelWidth(320)}
|
||||
title="드래그해서 너비 조절 · 더블클릭으로 기본값"
|
||||
className="hover:bg-primary/40 active:bg-primary/60 transition-colors"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 4,
|
||||
cursor: "ew-resize",
|
||||
zIndex: 20,
|
||||
}}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
{selectedComponent ? (
|
||||
<V2PropertiesPanel
|
||||
|
||||
@@ -53,9 +53,12 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{/* 테두리 섹션 */}
|
||||
<Collapsible open={openSections.border} onOpenChange={() => toggleSection("border")}>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-md bg-muted px-2 py-1.5 hover:bg-muted">
|
||||
<CollapsibleTrigger
|
||||
className="flex w-full items-center justify-between rounded-md px-2 py-1.5"
|
||||
style={{ background: "var(--cp-bg-subtle)", border: "1px solid var(--cp-border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Square className="text-primary h-3 w-3" />
|
||||
<Square className="text-muted-foreground h-3 w-3" />
|
||||
<span className="text-xs font-medium">테두리</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
@@ -142,9 +145,12 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
||||
|
||||
{/* 배경 섹션 */}
|
||||
<Collapsible open={openSections.background} onOpenChange={() => toggleSection("background")}>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-md bg-muted px-2 py-1.5 hover:bg-muted">
|
||||
<CollapsibleTrigger
|
||||
className="flex w-full items-center justify-between rounded-md px-2 py-1.5"
|
||||
style={{ background: "var(--cp-bg-subtle)", border: "1px solid var(--cp-border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Palette className="text-primary h-3 w-3" />
|
||||
<Palette className="text-muted-foreground h-3 w-3" />
|
||||
<span className="text-xs font-medium">배경</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
@@ -186,9 +192,12 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
||||
|
||||
{/* 텍스트 섹션 */}
|
||||
<Collapsible open={openSections.text} onOpenChange={() => toggleSection("text")}>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-md bg-muted px-2 py-1.5 hover:bg-muted">
|
||||
<CollapsibleTrigger
|
||||
className="flex w-full items-center justify-between rounded-md px-2 py-1.5"
|
||||
style={{ background: "var(--cp-bg-subtle)", border: "1px solid var(--cp-border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Type className="text-primary h-3 w-3" />
|
||||
<Type className="text-muted-foreground h-3 w-3" />
|
||||
<span className="text-xs font-medium">텍스트</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
|
||||
@@ -50,6 +50,15 @@ import { columnMetaCache, loadColumnMeta } from "@/lib/registry/DynamicComponent
|
||||
import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
||||
import StyleEditor from "../StyleEditor";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import "@/components/v2/config-panels/_shared/cp/cp.css";
|
||||
import {
|
||||
CPSection,
|
||||
CPRow,
|
||||
CPText,
|
||||
CPNumber,
|
||||
CPSwitch,
|
||||
CPGroup,
|
||||
} from "@/components/v2/config-panels/_shared/cp";
|
||||
import { Zap } from "lucide-react";
|
||||
import { ConditionalConfigPanel } from "@/components/v2/ConditionalConfigPanel";
|
||||
import { ConditionalConfig } from "@/types/v2-components";
|
||||
@@ -158,7 +167,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||
// 컴포넌트가 선택되지 않았을 때는 안내 메시지만 표시
|
||||
if (!selectedComponent) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-x-auto bg-white">
|
||||
<div className="flex h-full flex-col overflow-x-auto">
|
||||
<div className="flex-1 overflow-x-auto overflow-y-auto p-2">
|
||||
<div className="space-y-4 text-xs">
|
||||
{/* 안내 메시지 */}
|
||||
@@ -426,23 +435,17 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{/* DIMENSIONS 섹션 */}
|
||||
<div className="border-border/50 mb-3 border-b pb-3">
|
||||
<h4 className="text-muted-foreground py-2 text-[10px] font-semibold tracking-wider uppercase">DIMENSIONS</h4>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-muted-foreground text-[10px]">너비</Label>
|
||||
<Input
|
||||
{/* DIMENSIONS → 크기 (CPSection + CPRow) */}
|
||||
<div style={{ padding: "0 12px" }}>
|
||||
<CPSection title="크기">
|
||||
<CPRow label="너비">
|
||||
<CPText
|
||||
type="number"
|
||||
min={10}
|
||||
max={3840}
|
||||
step="1"
|
||||
mono
|
||||
value={localWidth}
|
||||
onChange={(e) => {
|
||||
setLocalWidth(e.target.value);
|
||||
}}
|
||||
onChange={(v) => setLocalWidth(v)}
|
||||
onBlur={(e) => {
|
||||
const value = parseInt(e.target.value) || 0;
|
||||
const value = parseInt((e.target as HTMLInputElement).value) || 0;
|
||||
if (value >= 10) {
|
||||
const snappedValue = Math.round(value / 10) * 10;
|
||||
handleUpdate("size.width", snappedValue);
|
||||
@@ -451,29 +454,27 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const value = parseInt(e.currentTarget.value) || 0;
|
||||
const value = parseInt((e.currentTarget as HTMLInputElement).value) || 0;
|
||||
if (value >= 10) {
|
||||
const snappedValue = Math.round(value / 10) * 10;
|
||||
handleUpdate("size.width", snappedValue);
|
||||
setLocalWidth(String(snappedValue));
|
||||
}
|
||||
e.currentTarget.blur();
|
||||
(e.currentTarget as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
placeholder="100"
|
||||
className="h-7 w-full text-xs"
|
||||
suffix="px"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-muted-foreground text-[10px]">높이</Label>
|
||||
<Input
|
||||
</CPRow>
|
||||
<CPRow label="높이">
|
||||
<CPText
|
||||
type="number"
|
||||
mono
|
||||
value={localHeight}
|
||||
onChange={(e) => {
|
||||
setLocalHeight(e.target.value);
|
||||
}}
|
||||
onChange={(v) => setLocalHeight(v)}
|
||||
onBlur={(e) => {
|
||||
const value = parseInt(e.target.value) || 0;
|
||||
const value = parseInt((e.target as HTMLInputElement).value) || 0;
|
||||
if (value >= 10) {
|
||||
const snappedValue = Math.round(value / 10) * 10;
|
||||
handleUpdate("size.height", snappedValue);
|
||||
@@ -482,256 +483,219 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const value = parseInt(e.currentTarget.value) || 0;
|
||||
const value = parseInt((e.currentTarget as HTMLInputElement).value) || 0;
|
||||
if (value >= 10) {
|
||||
const snappedValue = Math.round(value / 10) * 10;
|
||||
handleUpdate("size.height", snappedValue);
|
||||
setLocalHeight(String(snappedValue));
|
||||
}
|
||||
e.currentTarget.blur();
|
||||
(e.currentTarget as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
step={1}
|
||||
placeholder="10"
|
||||
className="h-7 w-full text-xs"
|
||||
suffix="px"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-muted-foreground text-[10px]">Z-Index</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
</CPRow>
|
||||
<CPRow label="Z-Index">
|
||||
<CPNumber
|
||||
value={currentPosition.z || 1}
|
||||
onChange={(e) => handleUpdate("position.z", parseInt(e.target.value) || 1)}
|
||||
className="h-7 w-full text-xs"
|
||||
onChange={(v) => handleUpdate("position.z", v ?? 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CPRow>
|
||||
</CPSection>
|
||||
</div>
|
||||
|
||||
{/* Title (group/area) */}
|
||||
{/* CONTENT → 내용 (group/area) */}
|
||||
{(selectedComponent.type === "group" || selectedComponent.type === "area") && (
|
||||
<div className="border-border/50 mb-3 border-b pb-3">
|
||||
<h4 className="text-muted-foreground py-2 text-[10px] font-semibold tracking-wider uppercase">CONTENT</h4>
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-muted-foreground text-xs">제목</span>
|
||||
<div className="w-[160px]">
|
||||
<Input
|
||||
<div style={{ padding: "0 12px" }}>
|
||||
<CPSection title="내용">
|
||||
<CPRow label="제목">
|
||||
<CPText
|
||||
value={(group as any).title || (area as any).title || ""}
|
||||
onChange={(e) => handleUpdate("title", e.target.value)}
|
||||
onChange={(v) => handleUpdate("title", v)}
|
||||
placeholder="제목"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{selectedComponent.type === "area" && (
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-muted-foreground text-xs">설명</span>
|
||||
<div className="w-[160px]">
|
||||
<Input
|
||||
</CPRow>
|
||||
{selectedComponent.type === "area" && (
|
||||
<CPRow label="설명">
|
||||
<CPText
|
||||
value={(area as any).description || ""}
|
||||
onChange={(e) => handleUpdate("description", e.target.value)}
|
||||
onChange={(v) => handleUpdate("description", v)}
|
||||
placeholder="설명"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CPRow>
|
||||
)}
|
||||
</CPSection>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OPTIONS 섹션 */}
|
||||
<div className="border-border/50 mb-3 border-b pb-3">
|
||||
<h4 className="text-muted-foreground py-2 text-[10px] font-semibold tracking-wider uppercase">OPTIONS</h4>
|
||||
{(isInputField || widget.required !== undefined) &&
|
||||
(() => {
|
||||
const colName = widget.columnName || (selectedComponent as any)?.columnName;
|
||||
const colMeta = colName
|
||||
? (currentTable as any)?.columns?.find(
|
||||
(c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase(),
|
||||
)
|
||||
: null;
|
||||
const isNotNull =
|
||||
colMeta &&
|
||||
((colMeta as any).isNullable === "NO" ||
|
||||
(colMeta as any).isNullable === "N" ||
|
||||
(colMeta as any).is_nullable === "NO" ||
|
||||
(colMeta as any).is_nullable === "N");
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
필수
|
||||
{isNotNull && <span className="text-muted-foreground/60 ml-1">(NOT NULL)</span>}
|
||||
</span>
|
||||
<Checkbox
|
||||
checked={
|
||||
isNotNull || widget.required === true || selectedComponent.componentConfig?.required === true
|
||||
{/* OPTIONS → 옵션 (CPRow + CPSwitch) */}
|
||||
<div style={{ padding: "0 12px" }}>
|
||||
<CPSection title="옵션">
|
||||
{(isInputField || widget.required !== undefined) &&
|
||||
(() => {
|
||||
const colName = widget.columnName || (selectedComponent as any)?.columnName;
|
||||
const colMeta = colName
|
||||
? (currentTable as any)?.columns?.find(
|
||||
(c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase(),
|
||||
)
|
||||
: null;
|
||||
const isNotNull =
|
||||
colMeta &&
|
||||
((colMeta as any).isNullable === "NO" ||
|
||||
(colMeta as any).isNullable === "N" ||
|
||||
(colMeta as any).is_nullable === "NO" ||
|
||||
(colMeta as any).is_nullable === "N");
|
||||
return (
|
||||
<CPRow
|
||||
label={
|
||||
<>
|
||||
필수
|
||||
{isNotNull && (
|
||||
<span style={{ marginLeft: 4, fontSize: 9, color: "var(--v5-text-muted)" }}>
|
||||
(NOT NULL)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
if (isNotNull) return;
|
||||
handleUpdate("required", checked);
|
||||
handleUpdate("componentConfig.required", checked);
|
||||
}}
|
||||
disabled={!!isNotNull}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{(isInputField || widget.readonly !== undefined) && (
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-muted-foreground text-xs">읽기전용</span>
|
||||
<Checkbox
|
||||
checked={widget.readonly === true || selectedComponent.componentConfig?.readonly === true}
|
||||
onCheckedChange={(checked) => {
|
||||
handleUpdate("readonly", checked);
|
||||
handleUpdate("componentConfig.readonly", checked);
|
||||
>
|
||||
<CPSwitch
|
||||
value={
|
||||
isNotNull || widget.required === true || selectedComponent.componentConfig?.required === true
|
||||
}
|
||||
onChange={(checked) => {
|
||||
if (isNotNull) return;
|
||||
handleUpdate("required", checked);
|
||||
handleUpdate("componentConfig.required", checked);
|
||||
}}
|
||||
/>
|
||||
</CPRow>
|
||||
);
|
||||
})()}
|
||||
{(isInputField || widget.readonly !== undefined) && (
|
||||
<CPRow label="읽기전용">
|
||||
<CPSwitch
|
||||
value={widget.readonly === true || selectedComponent.componentConfig?.readonly === true}
|
||||
onChange={(checked) => {
|
||||
handleUpdate("readonly", checked);
|
||||
handleUpdate("componentConfig.readonly", checked);
|
||||
}}
|
||||
/>
|
||||
</CPRow>
|
||||
)}
|
||||
<CPRow label="숨김">
|
||||
<CPSwitch
|
||||
value={(selectedComponent as any).hidden === true || selectedComponent.componentConfig?.hidden === true}
|
||||
onChange={(checked) => {
|
||||
handleUpdate("hidden", checked);
|
||||
handleUpdate("componentConfig.hidden", checked);
|
||||
}}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-muted-foreground text-xs">숨김</span>
|
||||
<Checkbox
|
||||
checked={(selectedComponent as any).hidden === true || selectedComponent.componentConfig?.hidden === true}
|
||||
onCheckedChange={(checked) => {
|
||||
handleUpdate("hidden", checked);
|
||||
handleUpdate("componentConfig.hidden", checked);
|
||||
}}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</CPRow>
|
||||
</CPSection>
|
||||
</div>
|
||||
|
||||
{/* LABEL 섹션 - 입력 필드에서만 표시 */}
|
||||
{/* LABEL → 라벨 (CPGroup) */}
|
||||
{isInputField && (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 text-left">
|
||||
<span className="text-muted-foreground text-[10px] font-semibold tracking-wider uppercase">LABEL</span>
|
||||
<ChevronDown className="text-muted-foreground/50 h-3 w-3 shrink-0" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1.5 space-y-1">
|
||||
{/* 라벨 텍스트 */}
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-muted-foreground text-xs">텍스트</span>
|
||||
<div className="w-[160px]">
|
||||
<Input
|
||||
value={
|
||||
selectedComponent.style?.labelText !== undefined
|
||||
? selectedComponent.style.labelText
|
||||
: selectedComponent.label || selectedComponent.componentConfig?.label || ""
|
||||
<div style={{ padding: "0 12px" }}>
|
||||
<CPGroup title="라벨" defaultOpen={false}>
|
||||
<CPRow label="텍스트">
|
||||
<CPText
|
||||
value={
|
||||
selectedComponent.style?.labelText !== undefined
|
||||
? selectedComponent.style.labelText
|
||||
: selectedComponent.label || selectedComponent.componentConfig?.label || ""
|
||||
}
|
||||
onChange={(v) => {
|
||||
handleUpdate("style.labelText", v);
|
||||
handleUpdate("label", v);
|
||||
}}
|
||||
placeholder="라벨"
|
||||
/>
|
||||
</CPRow>
|
||||
<CPRow label="위치">
|
||||
<Select
|
||||
value={selectedComponent.style?.labelPosition || "top"}
|
||||
onValueChange={(value) => handleUpdate("style.labelPosition", value)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="top">위</SelectItem>
|
||||
<SelectItem value="bottom">아래</SelectItem>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CPRow>
|
||||
<CPRow label="간격">
|
||||
<CPText
|
||||
mono
|
||||
value={
|
||||
selectedComponent.style?.labelPosition === "left" ||
|
||||
selectedComponent.style?.labelPosition === "right"
|
||||
? selectedComponent.style?.labelGap || "8px"
|
||||
: selectedComponent.style?.labelMarginBottom || "4px"
|
||||
}
|
||||
onChange={(v) => {
|
||||
const pos = selectedComponent.style?.labelPosition;
|
||||
if (pos === "left" || pos === "right") {
|
||||
handleUpdate("style.labelGap", v);
|
||||
} else {
|
||||
handleUpdate("style.labelMarginBottom", v);
|
||||
}
|
||||
onChange={(e) => {
|
||||
handleUpdate("style.labelText", e.target.value);
|
||||
handleUpdate("label", e.target.value);
|
||||
}}
|
||||
placeholder="라벨"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 위치 + 간격 */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-muted-foreground text-[10px]">위치</Label>
|
||||
<Select
|
||||
value={selectedComponent.style?.labelPosition || "top"}
|
||||
onValueChange={(value) => handleUpdate("style.labelPosition", value)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="top">위</SelectItem>
|
||||
<SelectItem value="bottom">아래</SelectItem>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-muted-foreground text-[10px]">간격</Label>
|
||||
<Input
|
||||
value={
|
||||
selectedComponent.style?.labelPosition === "left" ||
|
||||
selectedComponent.style?.labelPosition === "right"
|
||||
? selectedComponent.style?.labelGap || "8px"
|
||||
: selectedComponent.style?.labelMarginBottom || "4px"
|
||||
}
|
||||
onChange={(e) => {
|
||||
const pos = selectedComponent.style?.labelPosition;
|
||||
if (pos === "left" || pos === "right") {
|
||||
handleUpdate("style.labelGap", e.target.value);
|
||||
} else {
|
||||
handleUpdate("style.labelMarginBottom", e.target.value);
|
||||
}
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 크기 + 색상 */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-muted-foreground text-[10px]">크기</Label>
|
||||
<Input
|
||||
value={selectedComponent.style?.labelFontSize || "12px"}
|
||||
onChange={(e) => handleUpdate("style.labelFontSize", e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-muted-foreground text-[10px]">색상</Label>
|
||||
<ColorPickerWithTransparent
|
||||
value={selectedComponent.style?.labelColor}
|
||||
onChange={(value) => handleUpdate("style.labelColor", value)}
|
||||
defaultColor="#212121"
|
||||
placeholder="#212121"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 굵기 */}
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-muted-foreground text-xs">굵기</span>
|
||||
<div className="w-[160px]">
|
||||
<Select
|
||||
value={selectedComponent.style?.labelFontWeight || "500"}
|
||||
onValueChange={(value) => handleUpdate("style.labelFontWeight", value)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="400">보통</SelectItem>
|
||||
<SelectItem value="500">중간</SelectItem>
|
||||
<SelectItem value="600">굵게</SelectItem>
|
||||
<SelectItem value="700">매우 굵게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{/* 표시 */}
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-muted-foreground text-xs">표시</span>
|
||||
<Checkbox
|
||||
checked={selectedComponent.style?.labelDisplay === true || (selectedComponent as any).labelDisplay === true}
|
||||
onCheckedChange={(checked) => {
|
||||
const boolValue = checked === true;
|
||||
handleUpdate("style.labelDisplay", boolValue);
|
||||
handleUpdate("labelDisplay", boolValue);
|
||||
if (boolValue && !selectedComponent.style?.labelText) {
|
||||
}}
|
||||
/>
|
||||
</CPRow>
|
||||
<CPRow label="크기">
|
||||
<CPText
|
||||
mono
|
||||
value={selectedComponent.style?.labelFontSize || "12px"}
|
||||
onChange={(v) => handleUpdate("style.labelFontSize", v)}
|
||||
/>
|
||||
</CPRow>
|
||||
<CPRow label="색상">
|
||||
<ColorPickerWithTransparent
|
||||
value={selectedComponent.style?.labelColor}
|
||||
onChange={(value) => handleUpdate("style.labelColor", value)}
|
||||
defaultColor="#212121"
|
||||
placeholder="#212121"
|
||||
/>
|
||||
</CPRow>
|
||||
<CPRow label="굵기">
|
||||
<Select
|
||||
value={selectedComponent.style?.labelFontWeight || "500"}
|
||||
onValueChange={(value) => handleUpdate("style.labelFontWeight", value)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="400">보통</SelectItem>
|
||||
<SelectItem value="500">중간</SelectItem>
|
||||
<SelectItem value="600">굵게</SelectItem>
|
||||
<SelectItem value="700">매우 굵게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CPRow>
|
||||
<CPRow label="표시">
|
||||
<CPSwitch
|
||||
value={selectedComponent.style?.labelDisplay === true || (selectedComponent as any).labelDisplay === true}
|
||||
onChange={(checked) => {
|
||||
handleUpdate("style.labelDisplay", checked);
|
||||
handleUpdate("labelDisplay", checked);
|
||||
if (checked && !selectedComponent.style?.labelText) {
|
||||
const labelValue = selectedComponent.label || selectedComponent.componentConfig?.label || "";
|
||||
if (labelValue) {
|
||||
handleUpdate("style.labelText", labelValue);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CPRow>
|
||||
</CPGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -1213,7 +1177,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 - 간소화 */}
|
||||
<div className="border-border border-b px-3 py-2">
|
||||
{selectedComponent.type === "widget" && (
|
||||
@@ -1230,19 +1194,13 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||
{renderBasicTab()}
|
||||
|
||||
{/* 상세 설정 */}
|
||||
<Separator className="my-2" />
|
||||
{renderDetailTab()}
|
||||
|
||||
{/* 조건부 표시 설정 */}
|
||||
{selectedComponent && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Zap className="text-primary h-3 w-3" />
|
||||
<h4 className="text-xs font-semibold">조건부 표시</h4>
|
||||
</div>
|
||||
<div className="border-border rounded-md border p-2">
|
||||
<div style={{ padding: "0 12px" }}>
|
||||
<CPGroup title="조건부 표시" defaultOpen={false}>
|
||||
<ConditionalConfigPanel
|
||||
config={
|
||||
(selectedComponent as any).conditional || {
|
||||
@@ -1315,7 +1273,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||
}
|
||||
currentComponentId={selectedComponent.id}
|
||||
/>
|
||||
</div>
|
||||
</CPGroup>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1323,22 +1281,19 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||
{/* 스타일 설정 */}
|
||||
{selectedComponent && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Palette className="text-primary h-3 w-3" />
|
||||
<h4 className="text-xs font-semibold">컴포넌트 스타일</h4>
|
||||
</div>
|
||||
<StyleEditor
|
||||
style={selectedComponent.style || {}}
|
||||
onStyleChange={(style) => {
|
||||
if (onStyleChange) {
|
||||
onStyleChange(style);
|
||||
} else {
|
||||
handleUpdate("style", style);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div style={{ padding: "0 12px" }}>
|
||||
<CPGroup title="컴포넌트 스타일" defaultOpen={false}>
|
||||
<StyleEditor
|
||||
style={selectedComponent.style || {}}
|
||||
onStyleChange={(style) => {
|
||||
if (onStyleChange) {
|
||||
onStyleChange(style);
|
||||
} else {
|
||||
handleUpdate("style", style);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</CPGroup>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,533 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ConfigPanel 헤더/탭/그룹/바인드칩/브레드크럼
|
||||
* - 시안 panel-input-new 의 CPHeader, CPTabs, CPGroup, CPBindChip + V4 헤더 brumb 포팅
|
||||
*/
|
||||
import React from "react";
|
||||
import { CPIconBtn } from "./CPPrimitives";
|
||||
import { CP_ICONS } from "./icons";
|
||||
|
||||
const CP_FONT = "var(--v5-font-sans)";
|
||||
const CP_MONO = "var(--v5-font-mono)";
|
||||
|
||||
// ── 헤더 ───────────────────────────────────────────────
|
||||
export function CPHeader({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
badge,
|
||||
onReset,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
badge?: React.ReactNode;
|
||||
onReset?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
borderBottom: "1px solid var(--cp-border-subtle)",
|
||||
background: "var(--cp-surface)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{icon && (
|
||||
<div
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 7,
|
||||
background: "linear-gradient(135deg, rgba(var(--v5-primary-rgb),0.15), rgba(var(--v5-cyan-rgb),0.15))",
|
||||
border: "1px solid var(--cp-border)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--v5-primary)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, lineHeight: 1.1 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "var(--cp-text)",
|
||||
letterSpacing: "-0.01em",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{badge && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--v5-primary)",
|
||||
background: "rgba(var(--v5-primary-rgb),0.08)",
|
||||
border: "1px solid rgba(var(--v5-primary-rgb),0.18)",
|
||||
padding: "1px 5px",
|
||||
borderRadius: 3,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
color: "var(--cp-text-muted)",
|
||||
marginTop: 2,
|
||||
fontFamily: CP_MONO,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{onReset && (
|
||||
<CPIconBtn title="기본값으로 초기화" onClick={onReset}>
|
||||
{CP_ICONS.reset}
|
||||
</CPIconBtn>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 탭 ─────────────────────────────────────────────────
|
||||
export type CPTabItem = { id: string; label: React.ReactNode; count?: number; dot?: boolean };
|
||||
|
||||
export function CPTabs({
|
||||
tabs,
|
||||
value,
|
||||
onChange,
|
||||
variant = "underline",
|
||||
}: {
|
||||
tabs: CPTabItem[];
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
variant?: "underline" | "pill";
|
||||
}) {
|
||||
if (variant === "pill") {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
borderBottom: "1px solid var(--cp-border-subtle)",
|
||||
background: "var(--cp-bg-subtle)",
|
||||
}}
|
||||
>
|
||||
{tabs.map((t) => {
|
||||
const active = t.id === value;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => onChange(t.id)}
|
||||
style={{
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: "5px 11px",
|
||||
borderRadius: 6,
|
||||
fontSize: 11.5,
|
||||
fontWeight: active ? 700 : 500,
|
||||
color: active ? "#fff" : "var(--cp-text-sec)",
|
||||
background: active ? "var(--v5-primary)" : "transparent",
|
||||
boxShadow: active ? "0 0 14px rgba(var(--v5-primary-rgb),0.35)" : "none",
|
||||
fontFamily: CP_FONT,
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
{t.count != null && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 5,
|
||||
fontSize: 10,
|
||||
color: active ? "rgba(255,255,255,.7)" : "var(--cp-text-muted)",
|
||||
}}
|
||||
>
|
||||
{t.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 0,
|
||||
padding: "0 12px",
|
||||
borderBottom: "1px solid var(--cp-border-subtle)",
|
||||
background: "var(--cp-surface)",
|
||||
}}
|
||||
>
|
||||
{tabs.map((t) => {
|
||||
const active = t.id === value;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => onChange(t.id)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
padding: "9px 10px 8px",
|
||||
fontSize: 11.5,
|
||||
fontWeight: active ? 700 : 500,
|
||||
color: active ? "var(--cp-text)" : "var(--cp-text-sec)",
|
||||
borderBottom: active ? "2px solid var(--v5-primary)" : "2px solid transparent",
|
||||
marginBottom: -1,
|
||||
fontFamily: CP_FONT,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
{t.dot && (
|
||||
<span
|
||||
style={{
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: 999,
|
||||
background: "var(--v5-primary)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 접이식 그룹 ────────────────────────────────────────
|
||||
export function CPGroup({
|
||||
title,
|
||||
desc,
|
||||
defaultOpen = true,
|
||||
badge,
|
||||
children,
|
||||
}: {
|
||||
title: React.ReactNode;
|
||||
desc?: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
badge?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(defaultOpen);
|
||||
return (
|
||||
<div style={{ borderBottom: "1px solid var(--cp-border-subtle)" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
padding: "10px 0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontFamily: CP_FONT,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
transform: open ? "rotate(0deg)" : "rotate(-90deg)",
|
||||
transition: "transform .15s var(--v5-ease-move)",
|
||||
color: "var(--cp-text-sec)",
|
||||
display: "inline-flex",
|
||||
}}
|
||||
>
|
||||
{CP_ICONS.chevron}
|
||||
</span>
|
||||
<span style={{ fontSize: 11.5, fontWeight: 700, color: "var(--cp-text)", letterSpacing: "-0.01em" }}>
|
||||
{title}
|
||||
</span>
|
||||
{badge && <span style={{ fontSize: 10, color: "var(--cp-text-muted)", fontFamily: CP_MONO }}>{badge}</span>}
|
||||
{desc && <span style={{ fontSize: 10.5, color: "var(--cp-text-muted)", marginLeft: "auto" }}>{desc}</span>}
|
||||
</button>
|
||||
{open && <div style={{ padding: "0 0 12px" }}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 바인드 칩 (data-binding 시각화) ────────────────────
|
||||
export function CPBindChip({
|
||||
table,
|
||||
column,
|
||||
onClear,
|
||||
}: {
|
||||
table: React.ReactNode;
|
||||
column: React.ReactNode;
|
||||
onClear?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
padding: "2px 4px 2px 7px",
|
||||
background: "rgba(var(--v5-cyan-rgb),0.08)",
|
||||
border: "1px solid rgba(var(--v5-cyan-rgb),0.22)",
|
||||
borderRadius: 5,
|
||||
fontSize: 11,
|
||||
fontFamily: CP_MONO,
|
||||
color: "var(--cp-text)",
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "inline-flex", color: "rgb(var(--v5-cyan-rgb))" }}>{CP_ICONS.link}</span>
|
||||
<span style={{ fontWeight: 600 }}>{table}</span>
|
||||
<span style={{ opacity: 0.4 }}>.</span>
|
||||
<span style={{ color: "var(--cp-text-sec)" }}>{column}</span>
|
||||
{onClear && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
style={{
|
||||
marginLeft: 2,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: "var(--cp-text-muted)",
|
||||
cursor: "pointer",
|
||||
padding: 2,
|
||||
borderRadius: 3,
|
||||
display: "inline-flex",
|
||||
}}
|
||||
>
|
||||
{CP_ICONS.close}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── kind ▸ type 헤더 brumb (좌/우 분할 · 양쪽 클릭) ──────
|
||||
export type CPCrumbType = { id: string; name: string; desc?: string; icon?: React.ReactNode; col?: string };
|
||||
export type CPCrumbKind = { id: string; name: string; icon?: React.ReactNode };
|
||||
|
||||
export function CPCrumb({
|
||||
kinds,
|
||||
currentKind,
|
||||
onChangeKind,
|
||||
types,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
kinds: CPCrumbKind[];
|
||||
currentKind: string;
|
||||
onChangeKind: (next: string) => void;
|
||||
types: CPCrumbType[];
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const kind = kinds.find((k) => k.id === currentKind) || kinds[0];
|
||||
const current = types.find((t) => t.id === value) || types[0];
|
||||
const hasMultipleKinds = kinds.length > 1;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
document.addEventListener("keydown", onEsc);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDoc);
|
||||
document.removeEventListener("keydown", onEsc);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const toggle = () => setOpen((v) => !v);
|
||||
|
||||
return (
|
||||
<div className="cp-bar" ref={ref}>
|
||||
<div className="cp-crumb-split">
|
||||
{/* 좌측 : kind — 클릭 시 통합 팝오버 토글 */}
|
||||
<div className="cp-crumb-side cp-crumb-kind-slot">
|
||||
<button
|
||||
type="button"
|
||||
className="cp-crumb-side-btn cp-crumb-kind-btn"
|
||||
data-open={open}
|
||||
data-clickable={hasMultipleKinds}
|
||||
onClick={() => hasMultipleKinds && toggle()}
|
||||
>
|
||||
{kind?.icon && <span className="cp-crumb-kind-icon">{kind.icon}</span>}
|
||||
<span className="cp-crumb-kind-name">{kind?.name}</span>
|
||||
{hasMultipleKinds && <span className="cp-crumb-kind-chev">{CP_ICONS.chevron}</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="cp-crumb-divider" aria-hidden="true" />
|
||||
|
||||
{/* 우측 : type — 클릭 시 통합 팝오버 토글 */}
|
||||
<div className="cp-crumb-side cp-crumb-type-slot">
|
||||
<button
|
||||
type="button"
|
||||
className="cp-crumb-side-btn cp-crumb-type-btn"
|
||||
data-open={open}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span className="cp-crumb-type-icon">{current?.icon}</span>
|
||||
<div className="cp-crumb-type-text">
|
||||
<div className="cp-crumb-type-name">{current?.name}</div>
|
||||
{current?.desc && <div className="cp-crumb-type-desc">{current.desc}</div>}
|
||||
</div>
|
||||
<span className="cp-crumb-type-chev">{CP_ICONS.chevron}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통합 팝오버 — kinds(좌) + types(우). kind 선택은 popover 안 닫고 type 갱신만 */}
|
||||
{open && (
|
||||
<div className="cp-pop cp-pop-combined">
|
||||
{hasMultipleKinds && (
|
||||
<div className="cp-pop-col cp-pop-kinds-col">
|
||||
{kinds.map((k, i) => (
|
||||
<button
|
||||
key={k.id}
|
||||
type="button"
|
||||
className="cp-pop-kind-item"
|
||||
data-active={currentKind === k.id}
|
||||
onClick={() => onChangeKind(k.id)}
|
||||
style={{ animationDelay: `${i * 18}ms` }}
|
||||
>
|
||||
{k.icon && <span className="cp-pop-kind-item-icon">{k.icon}</span>}
|
||||
<span className="cp-pop-kind-item-name">{k.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="cp-pop-col cp-pop-types-col">
|
||||
{types.map((t, i) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className="cp-pop-type"
|
||||
data-active={value === t.id}
|
||||
onClick={() => {
|
||||
onChange(t.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
style={{ animationDelay: `${i * 18}ms` }}
|
||||
>
|
||||
<span className="cp-pop-type-icon">{t.icon}</span>
|
||||
<div className="cp-pop-type-text">
|
||||
<div className="cp-pop-type-name">{t.name}</div>
|
||||
{t.desc && <div className="cp-pop-type-desc">{t.desc}</div>}
|
||||
</div>
|
||||
{t.col && <span className="cp-pop-type-col">{t.col}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 형식 트리거 (본문 안에서 [형식 · ▾]) ───────────────
|
||||
export type CPFormatItem = { id: string; name: string; desc?: string; icon?: React.ReactNode };
|
||||
|
||||
export function CPFormatTrigger({
|
||||
formats,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
formats: CPFormatItem[];
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const current = formats.find((f) => f.id === value) || formats[0];
|
||||
const hasMultiple = formats.length > 1;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="cp-fmt-head">
|
||||
<span className="cp-fmt-head-cap">형식</span>
|
||||
<span className="cp-fmt-head-dot">·</span>
|
||||
<div style={{ position: "relative" }} ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
className="cp-fmt-trigger"
|
||||
data-open={open}
|
||||
onClick={() => hasMultiple && setOpen(!open)}
|
||||
style={{ cursor: hasMultiple ? "pointer" : "default" }}
|
||||
>
|
||||
<span className="cp-fmt-trigger-icon">{current?.icon}</span>
|
||||
<span className="cp-fmt-trigger-name">{current?.name}</span>
|
||||
{hasMultiple && <span className="cp-fmt-trigger-chev">▾</span>}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="cp-fmt-pop">
|
||||
{formats.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
className="cp-fmt-pop-item"
|
||||
data-active={f.id === value}
|
||||
onClick={() => {
|
||||
onChange(f.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="cp-fmt-pop-item-icon">{f.icon}</span>
|
||||
<div className="cp-fmt-pop-item-text">
|
||||
<div className="cp-fmt-pop-item-name">{f.name}</div>
|
||||
{f.desc && <div className="cp-fmt-pop-item-desc">{f.desc}</div>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{current?.desc && <span className="cp-fmt-head-desc">{current.desc}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,851 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ConfigPanel 프리미티브 (시안 panel-input-new 의 CP* 포팅)
|
||||
* - inline style 위주 (시안의 토큰 활용 패턴 보존)
|
||||
* - v5 토큰만 사용. 라이트/다크 모두 자동 대응
|
||||
*/
|
||||
import React from "react";
|
||||
import { CP_ICONS } from "./icons";
|
||||
|
||||
const CP_FONT = "var(--v5-font-sans)";
|
||||
const CP_MONO = "var(--v5-font-mono)";
|
||||
|
||||
// ── Section ──────────────────────────────────────────────
|
||||
export function CPSection({
|
||||
title,
|
||||
desc,
|
||||
tone = "default",
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
title: React.ReactNode;
|
||||
desc?: React.ReactNode;
|
||||
tone?: "default" | "solid";
|
||||
children?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
marginTop: 2,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{tone === "solid" ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
flexWrap: "wrap",
|
||||
gap: "4px 8px",
|
||||
padding: "6px 10px",
|
||||
background: "rgba(var(--v5-primary-rgb), 0.05)",
|
||||
border: "1px solid var(--cp-border)",
|
||||
borderRadius: 8,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.04em", color: "var(--cp-text)", whiteSpace: "nowrap" }}>
|
||||
{title}
|
||||
</span>
|
||||
{desc && <span style={{ fontSize: 11, color: "var(--cp-text-muted)", minWidth: 0 }}>{desc}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
flexWrap: "wrap",
|
||||
gap: "2px 8px",
|
||||
padding: "0 0 4px",
|
||||
marginBottom: 6,
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, rgba(var(--v5-primary-rgb), 0.55), transparent 75%)",
|
||||
backgroundSize: "100% 1px",
|
||||
backgroundPosition: "bottom left",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.01em",
|
||||
color: "var(--cp-text)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{desc && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
color: "var(--cp-text-muted)",
|
||||
letterSpacing: 0,
|
||||
minWidth: 0,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
· {desc}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Row (label 좌, control 우) ───────────────────────────
|
||||
export function CPRow({
|
||||
label,
|
||||
help,
|
||||
required,
|
||||
advanced,
|
||||
children,
|
||||
align = "center",
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
help?: React.ReactNode;
|
||||
required?: boolean;
|
||||
advanced?: boolean;
|
||||
children?: React.ReactNode;
|
||||
align?: "center" | "top";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(60px, 80px) minmax(0, 1fr)",
|
||||
gap: 8,
|
||||
alignItems: align === "top" ? "flex-start" : "center",
|
||||
padding: "5px 0",
|
||||
minHeight: 28,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11.5,
|
||||
fontWeight: 600,
|
||||
color: "var(--cp-text-sec)",
|
||||
lineHeight: 1.3,
|
||||
paddingTop: align === "top" ? 6 : 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
flexWrap: "wrap",
|
||||
wordBreak: "keep-all",
|
||||
}}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{required && <span style={{ color: "var(--v5-red)", fontWeight: 700 }}>*</span>}
|
||||
{advanced && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
color: "var(--cp-text-muted)",
|
||||
textTransform: "uppercase",
|
||||
padding: "1px 4px",
|
||||
border: "1px solid var(--cp-border)",
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
PRO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
{children}
|
||||
{help && (
|
||||
<div style={{ fontSize: 10.5, color: "var(--cp-text-muted)", marginTop: 4, lineHeight: 1.4 }}>{help}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// label 위 / control 아래
|
||||
export function CPStacked({
|
||||
label,
|
||||
help,
|
||||
required,
|
||||
advanced,
|
||||
children,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
help?: React.ReactNode;
|
||||
required?: boolean;
|
||||
advanced?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ padding: "4px 0", marginBottom: 6 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "var(--cp-text-sec)",
|
||||
marginBottom: 5,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{required && <span style={{ color: "var(--v5-red)" }}>*</span>}
|
||||
{advanced && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
color: "var(--cp-text-muted)",
|
||||
textTransform: "uppercase",
|
||||
padding: "1px 4px",
|
||||
border: "1px solid var(--cp-border)",
|
||||
borderRadius: 3,
|
||||
marginLeft: "auto",
|
||||
}}
|
||||
>
|
||||
PRO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
{help && <div style={{ fontSize: 10.5, color: "var(--cp-text-muted)", marginTop: 4, lineHeight: 1.4 }}>{help}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Inputs ──────────────────────────────────────────────
|
||||
const inputBaseStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
height: 28,
|
||||
padding: "0 8px",
|
||||
fontSize: 12,
|
||||
fontFamily: CP_FONT,
|
||||
background: "var(--cp-surface)",
|
||||
border: "1px solid var(--cp-border)",
|
||||
borderRadius: 6,
|
||||
color: "var(--cp-text)",
|
||||
outline: "none",
|
||||
transition: "border-color .15s, box-shadow .15s",
|
||||
};
|
||||
|
||||
type CPTextProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange" | "prefix"> & {
|
||||
value?: string | number;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
mono?: boolean;
|
||||
suffix?: React.ReactNode;
|
||||
prefix?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function CPText({ value, onChange, placeholder, mono, suffix, prefix, ...rest }: CPTextProps) {
|
||||
const [focus, setFocus] = React.useState(false);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
border: focus ? "1px solid var(--v5-primary)" : "1px solid var(--cp-border)",
|
||||
borderRadius: 6,
|
||||
boxShadow: focus ? "0 0 0 3px rgba(var(--v5-primary-rgb),0.12)" : "none",
|
||||
background: "var(--cp-surface)",
|
||||
height: 28,
|
||||
transition: "border-color .15s, box-shadow .15s",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{prefix && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0 8px",
|
||||
fontSize: 11,
|
||||
color: "var(--cp-text-muted)",
|
||||
borderRight: "1px solid var(--cp-border-subtle)",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{prefix}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
{...rest}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange && onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
onFocus={(e) => {
|
||||
setFocus(true);
|
||||
rest.onFocus?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setFocus(false);
|
||||
rest.onBlur?.(e);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
border: "none",
|
||||
outline: "none",
|
||||
background: "transparent",
|
||||
height: "100%",
|
||||
padding: "0 8px",
|
||||
fontSize: 12,
|
||||
fontFamily: mono ? CP_MONO : CP_FONT,
|
||||
color: "var(--cp-text)",
|
||||
}}
|
||||
/>
|
||||
{suffix && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0 8px",
|
||||
fontSize: 11,
|
||||
color: "var(--cp-text-muted)",
|
||||
borderLeft: "1px solid var(--cp-border-subtle)",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{suffix}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CPSelectOption = { value: string; label: React.ReactNode; disabled?: boolean };
|
||||
|
||||
type CPSelectProps = Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "value" | "onChange"> & {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
children?: React.ReactNode;
|
||||
options?: CPSelectOption[];
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
// children 으로 들어온 <option> 엘리먼트들을 옵션 배열로 평탄화
|
||||
function parseOptionChildren(children: React.ReactNode): CPSelectOption[] {
|
||||
const out: CPSelectOption[] = [];
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (!React.isValidElement(child)) return;
|
||||
const el = child as React.ReactElement<{
|
||||
value?: string | number;
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
if (el.type === "option") {
|
||||
out.push({
|
||||
value: String(el.props.value ?? ""),
|
||||
label: el.props.children ?? String(el.props.value ?? ""),
|
||||
disabled: el.props.disabled,
|
||||
});
|
||||
} else if (el.type === "optgroup") {
|
||||
// optgroup 안의 option 들을 재귀로 평탄화 (label 은 무시)
|
||||
out.push(...parseOptionChildren(el.props.children));
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function CPSelect({
|
||||
value,
|
||||
onChange,
|
||||
children,
|
||||
options,
|
||||
placeholder,
|
||||
disabled,
|
||||
...rest
|
||||
}: CPSelectProps) {
|
||||
const opts: CPSelectOption[] = options ?? parseOptionChildren(children);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const wrapRef = React.useRef<HTMLDivElement>(null);
|
||||
const popRef = React.useRef<HTMLDivElement>(null);
|
||||
const listRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// 첫 옵션이 빈값이면 placeholder 후보로 사용 (호환: <option value="">선택...</option>)
|
||||
const firstEmpty = opts.find((o) => o.value === "");
|
||||
const placeholderText =
|
||||
placeholder ??
|
||||
(typeof firstEmpty?.label === "string" ? firstEmpty.label : "선택");
|
||||
const realOpts = opts.filter((o) => o.value !== "");
|
||||
|
||||
const current = realOpts.find((o) => o.value === value);
|
||||
const showPlaceholder = !current;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
const t = e.target as Node;
|
||||
if (
|
||||
wrapRef.current && !wrapRef.current.contains(t) &&
|
||||
popRef.current && !popRef.current.contains(t)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
document.addEventListener("keydown", onEsc);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDoc);
|
||||
document.removeEventListener("keydown", onEsc);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// 열릴 때 active 항목으로 스크롤
|
||||
React.useEffect(() => {
|
||||
if (!open || !listRef.current) return;
|
||||
const active = listRef.current.querySelector('[data-active="true"]') as HTMLElement | null;
|
||||
if (active) active.scrollIntoView({ block: "nearest" });
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} style={{ position: "relative", width: "100%" }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && setOpen((v) => !v)}
|
||||
data-state={open ? "open" : "closed"}
|
||||
{...(rest as any)}
|
||||
style={{
|
||||
...inputBaseStyle,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
textAlign: "left",
|
||||
paddingRight: 24,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
color: showPlaceholder ? "var(--cp-text-muted)" : "var(--cp-text)",
|
||||
backgroundImage:
|
||||
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10' fill='none' stroke='%239998ad' stroke-width='1.6' stroke-linecap='round'><path d='M2 3.5l3 3 3-3'/></svg>\")",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "right 8px center",
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
fontFamily: CP_FONT,
|
||||
...(open
|
||||
? { borderColor: "var(--v5-primary)", boxShadow: "0 0 0 3px rgba(var(--v5-primary-rgb),0.12)" }
|
||||
: null),
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{showPlaceholder ? placeholderText : current?.label}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={popRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "calc(100% + 2px)",
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 60,
|
||||
background: "var(--cp-surface)",
|
||||
border: "1px solid var(--cp-border)",
|
||||
borderRadius: 4,
|
||||
boxShadow: "0 8px 22px rgba(0,0,0,0.18)",
|
||||
padding: 4,
|
||||
animation: "cp-pop 0.14s var(--v5-ease-enter)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={listRef}
|
||||
style={{ maxHeight: 280, overflowY: "auto", display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{/* placeholder (값 = '') 가 children 으로 들어왔으면 첫 항목으로 노출, 클릭 시 onChange("") */}
|
||||
{firstEmpty && (
|
||||
<CPSelectItem
|
||||
option={firstEmpty}
|
||||
active={!current}
|
||||
onPick={(v) => {
|
||||
onChange?.(v);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{realOpts.map((o) => (
|
||||
<CPSelectItem
|
||||
key={o.value}
|
||||
option={o}
|
||||
active={o.value === value}
|
||||
onPick={(v) => {
|
||||
onChange?.(v);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{realOpts.length === 0 && !firstEmpty && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px",
|
||||
fontSize: 11,
|
||||
color: "var(--cp-text-muted)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
옵션이 없습니다
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CPSelectItem({
|
||||
option,
|
||||
active,
|
||||
onPick,
|
||||
}: {
|
||||
option: CPSelectOption;
|
||||
active: boolean;
|
||||
onPick: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
data-active={active}
|
||||
onClick={() => !option.disabled && onPick(option.value)}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
padding: "5px 8px 5px 6px",
|
||||
fontSize: 11.5,
|
||||
fontFamily: CP_FONT,
|
||||
color: active ? "var(--cp-text)" : "var(--cp-text-sec)",
|
||||
fontWeight: active ? 700 : 500,
|
||||
background: active ? "rgba(var(--v5-primary-rgb), 0.10)" : "transparent",
|
||||
border: "none",
|
||||
borderLeft: active ? "2px solid rgb(var(--v5-primary-rgb))" : "2px solid transparent",
|
||||
borderRadius: 0,
|
||||
cursor: option.disabled ? "not-allowed" : "pointer",
|
||||
opacity: option.disabled ? 0.5 : 1,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
transition: "background .1s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active && !option.disabled)
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--cp-surface-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!active) (e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type CPTextareaProps = Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "onChange"> & {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
rows?: number;
|
||||
mono?: boolean;
|
||||
};
|
||||
|
||||
export function CPTextarea({ value, onChange, placeholder, rows = 3, mono, ...rest }: CPTextareaProps) {
|
||||
const [focus, setFocus] = React.useState(false);
|
||||
return (
|
||||
<textarea
|
||||
{...rest}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange && onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
onFocus={(e) => {
|
||||
setFocus(true);
|
||||
rest.onFocus?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setFocus(false);
|
||||
rest.onBlur?.(e);
|
||||
}}
|
||||
style={{
|
||||
...inputBaseStyle,
|
||||
height: "auto",
|
||||
minHeight: 28 * rows,
|
||||
padding: 8,
|
||||
resize: "vertical",
|
||||
lineHeight: 1.45,
|
||||
fontFamily: mono ? CP_MONO : CP_FONT,
|
||||
borderColor: focus ? "var(--v5-primary)" : "var(--cp-border)",
|
||||
boxShadow: focus ? "0 0 0 3px rgba(var(--v5-primary-rgb),0.12)" : "none",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CPSwitch({
|
||||
value,
|
||||
onChange,
|
||||
labels,
|
||||
}: {
|
||||
value?: boolean;
|
||||
onChange?: (next: boolean) => void;
|
||||
labels?: [string, string];
|
||||
}) {
|
||||
const v = !!value;
|
||||
return (
|
||||
<label style={{ display: "inline-flex", alignItems: "center", gap: 8, cursor: "pointer", userSelect: "none" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 16,
|
||||
borderRadius: 999,
|
||||
background: v ? "var(--v5-primary)" : "rgba(var(--v5-primary-rgb),0.15)",
|
||||
position: "relative",
|
||||
transition: "background .15s",
|
||||
boxShadow: v ? "0 0 8px rgba(var(--v5-primary-rgb),0.35)" : "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 999,
|
||||
background: "#fff",
|
||||
position: "absolute",
|
||||
top: 2,
|
||||
left: v ? 14 : 2,
|
||||
transition: "left .18s var(--v5-ease-enter)",
|
||||
boxShadow: "0 1px 2px rgba(0,0,0,0.15)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{labels && <span style={{ fontSize: 11, color: "var(--cp-text-sec)" }}>{v ? labels[0] : labels[1]}</span>}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={v}
|
||||
onChange={(e) => onChange && onChange(e.target.checked)}
|
||||
style={{ position: "absolute", opacity: 0, pointerEvents: "none" }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function CPNumber({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
suffix,
|
||||
}: {
|
||||
value?: number | "";
|
||||
onChange?: (next: number | undefined) => void;
|
||||
placeholder?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
suffix?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<CPText
|
||||
type="number"
|
||||
mono
|
||||
value={value as any}
|
||||
onChange={(v) => onChange && onChange(v === "" ? undefined : Number(v))}
|
||||
placeholder={placeholder}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
suffix={suffix}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CPColor({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
border: "1px solid var(--cp-border)",
|
||||
borderRadius: 6,
|
||||
background: "var(--cp-surface)",
|
||||
height: 28,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 26,
|
||||
height: "100%",
|
||||
background: value || "#ffffff",
|
||||
borderRight: "1px solid var(--cp-border-subtle)",
|
||||
position: "relative",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={value || "#6c5ce7"}
|
||||
onChange={(e) => onChange && onChange(e.target.value)}
|
||||
style={{ position: "absolute", inset: 0, opacity: 0, cursor: "pointer" }}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange && onChange(e.target.value)}
|
||||
placeholder="#6c5ce7"
|
||||
style={{
|
||||
flex: 1,
|
||||
border: "none",
|
||||
outline: "none",
|
||||
background: "transparent",
|
||||
height: "100%",
|
||||
padding: "0 8px",
|
||||
fontSize: 11.5,
|
||||
fontFamily: CP_MONO,
|
||||
color: "var(--cp-text)",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 작은 enum 용 (size, align, variant 등) — 평면 toolbar 스타일 (외곽/내부 박스 없음)
|
||||
type CPSegmentOption = string | { value: string; label: React.ReactNode };
|
||||
export function CPSegment({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (next: string) => void;
|
||||
options: CPSegmentOption[];
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
rowGap: 4,
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
{options.map((opt, i) => {
|
||||
const v = typeof opt === "string" ? opt : opt.value;
|
||||
const l = typeof opt === "string" ? opt : opt.label;
|
||||
const active = value === v;
|
||||
return (
|
||||
<React.Fragment key={v}>
|
||||
{i > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 1,
|
||||
height: 12,
|
||||
background: "var(--cp-border-subtle)",
|
||||
margin: "0 6px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange && onChange(v)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: active ? "var(--cp-text)" : "var(--cp-text-muted)",
|
||||
fontSize: 11.5,
|
||||
fontWeight: active ? 700 : 500,
|
||||
padding: "2px 4px",
|
||||
borderRadius: 0,
|
||||
cursor: "pointer",
|
||||
fontFamily: CP_FONT,
|
||||
whiteSpace: "nowrap",
|
||||
transition: "color .12s, font-weight .12s",
|
||||
letterSpacing: "-0.005em",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active) (e.currentTarget as HTMLButtonElement).style.color = "var(--cp-text-sec)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!active) (e.currentTarget as HTMLButtonElement).style.color = "var(--cp-text-muted)";
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 작은 아이콘 버튼
|
||||
export function CPIconBtn({
|
||||
children,
|
||||
onClick,
|
||||
title,
|
||||
tone = "default",
|
||||
size = 24,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
title?: string;
|
||||
tone?: "default" | "primary" | "danger";
|
||||
size?: number;
|
||||
}) {
|
||||
const tones = {
|
||||
default: { bg: "transparent", color: "var(--cp-text-sec)" },
|
||||
primary: { bg: "rgba(var(--v5-primary-rgb),0.08)", color: "var(--v5-primary)" },
|
||||
danger: { bg: "transparent", color: "var(--v5-red)" },
|
||||
} as const;
|
||||
const t = tones[tone];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
border: "1px solid var(--cp-border)",
|
||||
background: t.bg,
|
||||
color: t.color,
|
||||
borderRadius: 5,
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// re-export 편의용
|
||||
export { CP_ICONS };
|
||||
@@ -0,0 +1,517 @@
|
||||
/* ===================================================================
|
||||
ConfigPanel 공통 스타일 (cp-*)
|
||||
- 시안 panel-input-new 의 v4-* 패턴을 cp-* 로 일반화
|
||||
- cp 전용 중성(회색) 토큰 — v5 의 보라 tint border 대신 사용
|
||||
=================================================================== */
|
||||
|
||||
:root {
|
||||
/* 라이트: 사이트 토큰(--background/--card/--foreground)과 정렬.
|
||||
hsl alias 로 쓰면 라이트/다크 자동 따라감. */
|
||||
--cp-surface: hsl(var(--card)); /* 패널 카드면 = 사이트 카드 */
|
||||
--cp-bg-subtle: hsl(var(--background)); /* 약간 더 어두운 영역 = 사이트 배경 */
|
||||
--cp-surface-hover: hsl(var(--accent));
|
||||
--cp-text: hsl(var(--foreground));
|
||||
--cp-text-sec: hsl(var(--foreground) / 0.78); /* 라벨용 — 밝게 */
|
||||
--cp-text-muted: hsl(var(--foreground) / 0.55); /* 캡션용 — 가독 OK */
|
||||
--cp-border: hsl(var(--border));
|
||||
--cp-border-subtle: hsl(var(--foreground) / 0.06);
|
||||
--cp-border-strong: hsl(var(--foreground) / 0.18);
|
||||
}
|
||||
|
||||
/* 다크 — 우측 패널만 luminance 살짝 분리. 카드 박싱은 안 함. */
|
||||
.dark .inv-right-panel,
|
||||
html.dark .inv-right-panel {
|
||||
--cp-surface: hsl(220 6% 17%); /* 카드/입력칸 */
|
||||
--cp-bg-subtle: hsl(220 6% 11%); /* 패널 외곽 — soft dark */
|
||||
--cp-surface-hover: hsl(220 6% 24%);
|
||||
--cp-border: hsl(0 0% 100% / 0.16);
|
||||
--cp-border-subtle: hsl(0 0% 100% / 0.15); /* 섹션 구분선 — 한 단 더 진하게 */
|
||||
--cp-border-strong: hsl(0 0% 100% / 0.28);
|
||||
--cp-text-sec: hsl(0 0% 100% / 0.86);
|
||||
--cp-text-muted: hsl(0 0% 100% / 0.62);
|
||||
}
|
||||
|
||||
@keyframes cp-pop {
|
||||
0% { opacity: 0; transform: translateY(-4px) scale(0.98); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes cp-slide-in {
|
||||
0% { opacity: 0; transform: translateX(-8px); }
|
||||
100% { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* ── 헤더 brumb [kind | type ▾] — 좌·우 각자 영역, 외곽 박스 없음 ── */
|
||||
.cp-bar {
|
||||
padding: 4px 0;
|
||||
margin: 2px 0 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 카드 프레임 제거 — split 은 그냥 좌/우 영역 분할용 flex 컨테이너 */
|
||||
.cp-crumb-split {
|
||||
display: flex; align-items: stretch;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
overflow: visible;
|
||||
box-shadow: none;
|
||||
}
|
||||
.cp-crumb-split:hover,
|
||||
.cp-crumb-split:focus-within { box-shadow: none; }
|
||||
.cp-crumb-side { position: relative; display: flex; min-width: 0; }
|
||||
.cp-crumb-kind-slot { flex-shrink: 0; }
|
||||
.cp-crumb-type-slot { flex: 1; min-width: 0; }
|
||||
|
||||
.cp-crumb-divider {
|
||||
width: 1px;
|
||||
background: var(--cp-border);
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* 영역 버튼 공통 */
|
||||
.cp-crumb-side-btn {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 7px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: default;
|
||||
font-family: var(--v5-font-sans);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
transition: background 0.14s;
|
||||
}
|
||||
.cp-crumb-side-btn[data-clickable="true"] { cursor: pointer; }
|
||||
.cp-crumb-side-btn[data-clickable="true"]:hover { background: rgba(var(--v5-primary-rgb), 0.16); border-radius: 3px; }
|
||||
.cp-crumb-type-btn { cursor: pointer; }
|
||||
.cp-crumb-type-btn:hover { background: rgba(var(--v5-primary-rgb), 0.16); border-radius: 3px; }
|
||||
.cp-crumb-type-btn[data-open="true"],
|
||||
.cp-crumb-kind-btn[data-open="true"] { background: rgba(var(--v5-primary-rgb), 0.18); border-radius: 3px; }
|
||||
|
||||
/* 좌측 : kind (회색 톤, 강조 X) */
|
||||
.cp-crumb-kind-icon {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 18px; height: 18px;
|
||||
font-size: 11px; color: var(--cp-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cp-crumb-kind-name {
|
||||
font-size: 11.5px; font-weight: 700;
|
||||
color: var(--cp-text-sec);
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cp-crumb-kind-chev {
|
||||
display: inline-flex; color: var(--cp-text-muted);
|
||||
transition: transform 0.16s;
|
||||
font-size: 8px;
|
||||
}
|
||||
.cp-crumb-kind-btn[data-open="true"] .cp-crumb-kind-chev { transform: rotate(180deg); }
|
||||
|
||||
/* 우측 : type — 평소 회색, hover/active 시에만 primary 강조 */
|
||||
.cp-crumb-type-icon {
|
||||
width: 24px; height: 24px;
|
||||
background: var(--cp-bg-subtle);
|
||||
border: 1px solid var(--cp-border);
|
||||
color: var(--cp-text-sec);
|
||||
border-radius: 5px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 10.5px; font-weight: 700;
|
||||
font-family: var(--v5-font-mono);
|
||||
flex-shrink: 0;
|
||||
transition: background 0.14s, color 0.14s, border-color 0.14s, box-shadow 0.14s;
|
||||
}
|
||||
.cp-crumb-type-btn[data-open="true"] .cp-crumb-type-icon,
|
||||
.cp-crumb-type-btn:hover .cp-crumb-type-icon {
|
||||
background: var(--v5-primary);
|
||||
color: #fff;
|
||||
border-color: var(--v5-primary);
|
||||
box-shadow: 0 0 8px rgba(var(--v5-primary-rgb), 0.30);
|
||||
}
|
||||
.cp-crumb-type-text { display: flex; flex-direction: column; min-width: 0; line-height: 1.2; flex: 1; }
|
||||
.cp-crumb-type-name {
|
||||
font-size: 12.5px; font-weight: 700; color: var(--cp-text);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.cp-crumb-type-desc {
|
||||
font-size: 9.5px; color: var(--cp-text-muted); margin-top: 1px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.cp-crumb-type-chev {
|
||||
display: inline-flex; color: var(--cp-text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.16s;
|
||||
margin-left: auto;
|
||||
}
|
||||
.cp-crumb-type-btn[data-open="true"] .cp-crumb-type-chev { transform: rotate(180deg); }
|
||||
|
||||
/* 다크 모드 — split 은 카드 아니므로 별도 보정 불필요. divider 만 살짝 톤다운 */
|
||||
.dark .cp-crumb-divider, html.dark .cp-crumb-divider {
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
}
|
||||
|
||||
/* ── brumb 팝오버 (type 리스트, kind 리스트) — 평면 리스트 ─────────────── */
|
||||
.cp-pop {
|
||||
position: absolute;
|
||||
top: calc(100% - 4px);
|
||||
z-index: 50;
|
||||
background: var(--cp-surface);
|
||||
border: 1px solid var(--cp-border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 8px 22px rgba(0,0,0,0.18);
|
||||
padding: 4px;
|
||||
display: flex; flex-direction: column; gap: 0;
|
||||
animation: cp-pop 0.14s var(--v5-ease-enter);
|
||||
}
|
||||
.dark .cp-pop, html.dark .cp-pop {
|
||||
box-shadow: 0 14px 36px rgba(0,0,0,0.50), 0 2px 8px rgba(0,0,0,0.30);
|
||||
}
|
||||
/* cp-bar 의 padding (8px 12px) 안쪽으로 정렬 */
|
||||
.cp-pop-kind {
|
||||
left: 12px; min-width: 130px;
|
||||
}
|
||||
.cp-pop-type-list {
|
||||
left: 12px; right: 12px; min-width: 0;
|
||||
}
|
||||
|
||||
/* 통합 팝오버 — kinds(좌, 작은 컬럼) + types(우, 1fr) */
|
||||
.cp-pop.cp-pop-combined {
|
||||
left: 0; right: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cp-pop-combined.cp-pop-types-only {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.cp-pop-col {
|
||||
display: flex; flex-direction: column;
|
||||
padding: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.cp-pop-kinds-col {
|
||||
background: var(--cp-bg-subtle);
|
||||
border-right: 1px solid var(--cp-border-subtle);
|
||||
}
|
||||
|
||||
/* kind 팝오버 아이템 — 평면 strip */
|
||||
.cp-pop-kind-item {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
padding: 7px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 2px solid transparent; /* layout 안정용 */
|
||||
border-radius: 0;
|
||||
cursor: pointer; font-family: var(--v5-font-sans); text-align: left;
|
||||
animation: cp-slide-in 0.18s var(--v5-ease-enter) backwards;
|
||||
}
|
||||
.cp-pop-kind-item:hover { background: var(--cp-surface-hover); }
|
||||
.cp-pop-kind-item[data-active="true"] {
|
||||
background: rgba(var(--v5-primary-rgb), 0.10);
|
||||
border-left-color: rgb(var(--v5-primary-rgb));
|
||||
}
|
||||
.cp-pop-kind-item-icon {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 18px; height: 18px;
|
||||
font-size: 11px; color: var(--cp-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cp-pop-kind-item-name {
|
||||
font-size: 12px; font-weight: 700;
|
||||
color: var(--cp-text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.cp-pop-kind-item[data-active="true"] .cp-pop-kind-item-name {
|
||||
color: var(--cp-text);
|
||||
}
|
||||
.cp-pop-kind-item[data-active="true"] .cp-pop-kind-item-icon {
|
||||
color: var(--v5-primary);
|
||||
}
|
||||
.cp-pop-type {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
padding: 8px 9px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
cursor: pointer; font-family: var(--v5-font-sans); text-align: left;
|
||||
animation: cp-slide-in 0.18s var(--v5-ease-enter) backwards;
|
||||
}
|
||||
.cp-pop-type:hover { background: var(--cp-surface-hover); }
|
||||
.cp-pop-type[data-active="true"] {
|
||||
background: rgba(var(--v5-primary-rgb), 0.10);
|
||||
border-left-color: rgb(var(--v5-primary-rgb));
|
||||
}
|
||||
.cp-pop-type-icon {
|
||||
width: 24px; height: 24px;
|
||||
background: var(--cp-bg-subtle);
|
||||
border: 1px solid var(--cp-border);
|
||||
color: var(--cp-text-sec);
|
||||
border-radius: 5px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 10.5px; font-weight: 700; font-family: var(--v5-font-mono);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cp-pop-type[data-active="true"] .cp-pop-type-icon {
|
||||
background: var(--v5-primary); color: #fff;
|
||||
border-color: var(--v5-primary);
|
||||
box-shadow: 0 0 8px rgba(var(--v5-primary-rgb), 0.35);
|
||||
}
|
||||
.cp-pop-type-text { flex: 1; min-width: 0; line-height: 1.25; }
|
||||
.cp-pop-type-name { font-size: 12px; font-weight: 700; color: var(--cp-text); }
|
||||
.cp-pop-type-desc { font-size: 10px; color: var(--cp-text-muted); margin-top: 1px; }
|
||||
.cp-pop-type-col {
|
||||
font-size: 8.5px; color: var(--cp-text-muted); font-family: var(--v5-font-mono);
|
||||
padding: 1px 4px; background: var(--cp-bg-subtle); border-radius: 3px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── 형식 트리거 (본문 안 [형식 · ▾]) ──────────────────── */
|
||||
.cp-fmt-head {
|
||||
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
|
||||
margin-bottom: 8px; padding-bottom: 6px;
|
||||
border-bottom: 1px dashed var(--cp-border-subtle);
|
||||
}
|
||||
.cp-fmt-head-cap {
|
||||
font-size: 9.5px; font-weight: 700; letter-spacing: 0.08em;
|
||||
color: var(--cp-text-muted); text-transform: uppercase; flex-shrink: 0;
|
||||
}
|
||||
.cp-fmt-head-dot { color: var(--cp-text-muted); flex-shrink: 0; }
|
||||
.cp-fmt-head-desc {
|
||||
font-size: 9.5px; color: var(--cp-text-muted);
|
||||
font-family: var(--v5-font-mono);
|
||||
margin-left: auto;
|
||||
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
/* 좁은 너비에서 desc 자동 숨김 */
|
||||
@media (max-width: 340px) {
|
||||
.cp-fmt-head-desc { display: none; }
|
||||
}
|
||||
.cp-fmt-trigger {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 8px; background: var(--cp-bg-subtle);
|
||||
border: 1px solid var(--cp-border);
|
||||
border-radius: 5px; cursor: pointer;
|
||||
font-family: var(--v5-font-sans);
|
||||
transition: all 0.14s;
|
||||
}
|
||||
.cp-fmt-trigger:hover { border-color: var(--v5-primary); background: var(--cp-surface); }
|
||||
.cp-fmt-trigger[data-open="true"] {
|
||||
border-color: var(--v5-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--v5-primary-rgb), 0.12);
|
||||
background: var(--cp-surface);
|
||||
}
|
||||
.cp-fmt-trigger-icon {
|
||||
width: 18px; height: 18px;
|
||||
background: var(--cp-bg-subtle);
|
||||
border: 1px solid var(--cp-border);
|
||||
color: var(--cp-text-sec);
|
||||
border-radius: 4px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 9px; font-weight: 700; font-family: var(--v5-font-mono);
|
||||
transition: background 0.14s, color 0.14s, border-color 0.14s;
|
||||
}
|
||||
.cp-fmt-trigger[data-open="true"] .cp-fmt-trigger-icon,
|
||||
.cp-fmt-trigger:hover .cp-fmt-trigger-icon {
|
||||
background: var(--v5-primary); color: #fff;
|
||||
border-color: var(--v5-primary);
|
||||
}
|
||||
.cp-fmt-trigger-name { font-size: 11.5px; font-weight: 700; color: var(--cp-text); }
|
||||
.cp-fmt-trigger-chev {
|
||||
color: var(--cp-text-muted); font-size: 8px;
|
||||
transition: transform 0.14s; display: inline-flex;
|
||||
}
|
||||
.cp-fmt-trigger[data-open="true"] .cp-fmt-trigger-chev { transform: rotate(180deg); }
|
||||
|
||||
.cp-fmt-pop {
|
||||
position: absolute; z-index: 40;
|
||||
background: var(--cp-surface);
|
||||
border: 1px solid var(--cp-border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 8px 22px rgba(0,0,0,0.16);
|
||||
padding: 4px; min-width: 220px;
|
||||
animation: cp-pop 0.14s var(--v5-ease-enter);
|
||||
}
|
||||
.cp-fmt-pop-item {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
cursor: pointer; font-family: var(--v5-font-sans); text-align: left;
|
||||
}
|
||||
.cp-fmt-pop-item:hover { background: var(--cp-surface-hover); }
|
||||
.cp-fmt-pop-item[data-active="true"] {
|
||||
background: rgba(var(--v5-primary-rgb), 0.10);
|
||||
border-left-color: rgb(var(--v5-primary-rgb));
|
||||
}
|
||||
.cp-fmt-pop-item-icon {
|
||||
width: 18px; height: 18px;
|
||||
background: var(--cp-bg-subtle);
|
||||
border: 1px solid var(--cp-border);
|
||||
color: var(--cp-text-sec);
|
||||
border-radius: 4px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 9px; font-weight: 700; font-family: var(--v5-font-mono);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cp-fmt-pop-item[data-active="true"] .cp-fmt-pop-item-icon {
|
||||
background: var(--v5-primary); color: #fff;
|
||||
border-color: var(--v5-primary);
|
||||
box-shadow: 0 0 6px rgba(var(--v5-primary-rgb), 0.30);
|
||||
}
|
||||
.cp-fmt-pop-item-text { flex: 1; min-width: 0; line-height: 1.2; }
|
||||
.cp-fmt-pop-item-name { font-size: 11px; font-weight: 700; color: var(--cp-text); }
|
||||
.cp-fmt-pop-item-desc { font-size: 9.5px; color: var(--cp-text-muted); margin-top: 1px; }
|
||||
|
||||
/* ── 푸터 ──────────────────────────────────────────────── */
|
||||
.cp-foot {
|
||||
padding: 8px 14px;
|
||||
border-top: 1px solid var(--cp-border-subtle);
|
||||
background: var(--cp-bg-subtle);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.cp-foot-id {
|
||||
font-family: var(--v5-font-mono); font-size: 10px;
|
||||
color: var(--cp-text-muted); flex: 1;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.cp-foot-btns { display: flex; gap: 6px; }
|
||||
.cp-foot-btn {
|
||||
padding: 5px 10px; font-size: 11px; font-weight: 600; border-radius: 5px;
|
||||
cursor: pointer; font-family: var(--v5-font-sans);
|
||||
border: 1px solid var(--cp-border);
|
||||
background: var(--cp-surface); color: var(--cp-text);
|
||||
}
|
||||
.cp-foot-btn:hover { border-color: var(--v5-primary); }
|
||||
.cp-foot-btn.primary {
|
||||
background: var(--v5-primary); color: #fff;
|
||||
border-color: var(--v5-primary);
|
||||
box-shadow: var(--v5-glow-sm);
|
||||
}
|
||||
.cp-foot-btn.primary:hover { box-shadow: var(--v5-glow-md); }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
외곽 패널 (inv-right-panel) 전체 cp 톤 통일
|
||||
- 사용자 요청: "위/아래 외곽 디자인도 cp 와 같은 톤으로"
|
||||
- shadcn 컴포넌트들의 외관(border, background, font-size, height)을
|
||||
cp 토큰으로 강제 override.
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ① 패널 배경 + 라인 — 사이트 main 톤(더 진함). 안의 카드는 cp-surface(살짝 밝음)로 분리 */
|
||||
.inv-right-panel {
|
||||
background: var(--cp-bg-subtle) !important;
|
||||
font-family: var(--v5-font-sans);
|
||||
color: var(--cp-text);
|
||||
}
|
||||
.inv-right-panel,
|
||||
.inv-right-panel .border-l,
|
||||
.inv-right-panel .border-r,
|
||||
.inv-right-panel .border-t,
|
||||
.inv-right-panel .border-b,
|
||||
.inv-right-panel .border-border,
|
||||
.inv-right-panel .border-border\/50,
|
||||
.inv-right-panel [class*="border-input"] {
|
||||
border-color: var(--cp-border) !important;
|
||||
}
|
||||
|
||||
/* ② 입력 필드 외관 통일 (h-7 = 28px, cp-text 12px, cp-surface bg) */
|
||||
.inv-right-panel input:not([type="checkbox"]):not([type="radio"]):not([type="color"]):not([type="range"]),
|
||||
.inv-right-panel select,
|
||||
.inv-right-panel [role="combobox"],
|
||||
.inv-right-panel [class*="SelectTrigger"] {
|
||||
height: 28px !important;
|
||||
min-height: 28px !important;
|
||||
padding: 0 8px !important;
|
||||
font-size: 12px !important;
|
||||
border: 1px solid var(--cp-border) !important;
|
||||
background: var(--cp-surface) !important;
|
||||
border-radius: 6px !important;
|
||||
color: var(--cp-text) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.inv-right-panel textarea {
|
||||
font-size: 12px !important;
|
||||
border: 1px solid var(--cp-border) !important;
|
||||
background: var(--cp-surface) !important;
|
||||
border-radius: 6px !important;
|
||||
color: var(--cp-text) !important;
|
||||
padding: 8px !important;
|
||||
}
|
||||
.inv-right-panel input:focus-visible,
|
||||
.inv-right-panel select:focus-visible,
|
||||
.inv-right-panel textarea:focus-visible,
|
||||
.inv-right-panel [role="combobox"]:focus-visible,
|
||||
.inv-right-panel [data-state="open"][class*="SelectTrigger"] {
|
||||
border-color: var(--v5-primary) !important;
|
||||
box-shadow: 0 0 0 3px rgba(var(--v5-primary-rgb), 0.12) !important;
|
||||
outline: none !important;
|
||||
}
|
||||
.inv-right-panel input::placeholder,
|
||||
.inv-right-panel textarea::placeholder {
|
||||
color: var(--cp-text-muted) !important;
|
||||
opacity: 0.7 !important;
|
||||
}
|
||||
|
||||
/* ③ 라벨 (Label / 작은 캡션) */
|
||||
.inv-right-panel label {
|
||||
font-size: 11px !important;
|
||||
color: var(--cp-text-sec) !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
/* ④ 섹션 헤더 (h3/h4 — DIMENSIONS / OPTIONS / LABEL / 컴포넌트 스타일 등) */
|
||||
.inv-right-panel h3,
|
||||
.inv-right-panel h4 {
|
||||
font-size: 10.5px !important;
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: 0.12em !important;
|
||||
text-transform: uppercase !important;
|
||||
color: var(--cp-text-muted) !important;
|
||||
font-family: var(--v5-font-sans) !important;
|
||||
}
|
||||
|
||||
/* ⑤ 섹션 좌측 컬러 아이콘 (조건부 표시 ⚡, 컴포넌트 스타일 🎨)
|
||||
primary 보라 → 중성 회색으로 다운 */
|
||||
.inv-right-panel summary svg,
|
||||
.inv-right-panel summary [class*="lucide"],
|
||||
.inv-right-panel details > summary svg {
|
||||
color: var(--cp-text-muted) !important;
|
||||
}
|
||||
|
||||
/* ⑥ 버튼 외관 통일 (Collapsible trigger 등) */
|
||||
.inv-right-panel button[class*="rounded-lg"],
|
||||
.inv-right-panel [data-state][role="button"] {
|
||||
border-color: var(--cp-border) !important;
|
||||
background: var(--cp-bg-subtle) !important;
|
||||
}
|
||||
|
||||
/* ⑦ Collapsible chevron 작게 + muted */
|
||||
.inv-right-panel [data-state="closed"] svg.lucide-chevron-down,
|
||||
.inv-right-panel [data-state="open"] svg.lucide-chevron-down,
|
||||
.inv-right-panel button svg.lucide-chevron-down {
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
color: var(--cp-text-muted) !important;
|
||||
}
|
||||
|
||||
/* ⑧ shadcn Select 의 dropdown (열렸을 때 하단 카드) */
|
||||
[role="listbox"][class*="SelectContent"],
|
||||
[data-radix-select-content] {
|
||||
border-color: var(--cp-border) !important;
|
||||
background: var(--cp-surface) !important;
|
||||
}
|
||||
[data-radix-select-item][data-highlighted] {
|
||||
background: var(--cp-surface-hover) !important;
|
||||
}
|
||||
|
||||
/* ⑨ Separator 색 통일 */
|
||||
.inv-right-panel [role="separator"],
|
||||
.inv-right-panel hr {
|
||||
background-color: var(--cp-border-subtle) !important;
|
||||
border-color: var(--cp-border-subtle) !important;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* ConfigPanel 전용 미니 SVG 아이콘 셋
|
||||
* - 시안 panel-input-new 의 I = {...} 그대로
|
||||
* - 본 프로젝트의 lucide-react 와 별개. 패널 안에서만 사용
|
||||
*/
|
||||
import React from "react";
|
||||
|
||||
export const CP_ICONS = {
|
||||
chevron: (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
|
||||
<path d="M2 3.5l3 3 3-3" />
|
||||
</svg>
|
||||
),
|
||||
plus: (
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
|
||||
<path d="M5.5 2v7M2 5.5h7" />
|
||||
</svg>
|
||||
),
|
||||
trash: (
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 3h7M4 3V2h3v1M3 3l.5 6h4L8 3M4.5 5v3M6.5 5v3" />
|
||||
</svg>
|
||||
),
|
||||
grip: (
|
||||
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor">
|
||||
<circle cx="2" cy="2" r="1.1" />
|
||||
<circle cx="7" cy="2" r="1.1" />
|
||||
<circle cx="2" cy="6.5" r="1.1" />
|
||||
<circle cx="7" cy="6.5" r="1.1" />
|
||||
<circle cx="2" cy="11" r="1.1" />
|
||||
<circle cx="7" cy="11" r="1.1" />
|
||||
</svg>
|
||||
),
|
||||
reset: (
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<path d="M2 5.5a3.5 3.5 0 103.5-3.5M2 2v3.5h3.5" />
|
||||
</svg>
|
||||
),
|
||||
help: (
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<circle cx="5.5" cy="5.5" r="4" />
|
||||
<path d="M4 4.3c0-.8.7-1.5 1.5-1.5s1.5.7 1.5 1.5c0 .8-1.5 1-1.5 2" />
|
||||
<circle cx="5.5" cy="8" r=".4" fill="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
link: (
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<path d="M4.5 6.5l2-2M3.5 4.5L2 6a1.5 1.5 0 002 2l1.5-1.5M7.5 6.5L9 5a1.5 1.5 0 00-2-2L5.5 4.5" />
|
||||
</svg>
|
||||
),
|
||||
dot: (
|
||||
<svg width="7" height="7" viewBox="0 0 7 7">
|
||||
<circle cx="3.5" cy="3.5" r="2.5" fill="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
code: (
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 3L1.5 5.5 4 8M7 3l2.5 2.5L7 8" />
|
||||
</svg>
|
||||
),
|
||||
close: (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
|
||||
<path d="M2 2l6 6M8 2l-6 6" />
|
||||
</svg>
|
||||
),
|
||||
chevRight: (
|
||||
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
|
||||
<path d="M3 2l3 2.5-3 2.5" />
|
||||
</svg>
|
||||
),
|
||||
alert: (
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<path d="M5.5 1.5L1 9.5h9L5.5 1.5zM5.5 4.5v2M5.5 8v.01" />
|
||||
</svg>
|
||||
),
|
||||
} as const;
|
||||
|
||||
export type CPIconName = keyof typeof CP_ICONS;
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* ConfigPanel 공통 프리미티브 (CP*) — barrel export
|
||||
* - 새 패널은 `import { CPSection, CPRow, ... } from "../_shared/cp"` 한 줄로 사용
|
||||
* - 사용하기 전에 `import "./_shared/cp/cp.css"` 한 번 필요 (패널 파일 상단)
|
||||
*/
|
||||
export { CP_ICONS } from "./icons";
|
||||
export type { CPIconName } from "./icons";
|
||||
|
||||
export { CPSection, CPRow, CPStacked, CPText, CPSelect, CPTextarea, CPSwitch, CPNumber, CPColor, CPSegment, CPIconBtn } from "./CPPrimitives";
|
||||
|
||||
export {
|
||||
CPHeader,
|
||||
CPTabs,
|
||||
CPGroup,
|
||||
CPBindChip,
|
||||
CPCrumb,
|
||||
CPFormatTrigger,
|
||||
} from "./CPChrome";
|
||||
export type { CPTabItem, CPCrumbType, CPFormatItem } from "./CPChrome";
|
||||
@@ -82,9 +82,33 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
...fromProps,
|
||||
} as InputConfig;
|
||||
|
||||
const type: InputFieldType = isValidType(componentConfig.type)
|
||||
? componentConfig.type
|
||||
: "text";
|
||||
// DB 저장본의 type 은 'v2-input'/'v2-select'/'v2-date' 같은 컴포넌트 ID 라서
|
||||
// VALID_TYPES 와 직접 매칭 안 됨. 별칭 + web_type 폴백으로 InputFieldType 추정.
|
||||
const rawType: any = componentConfig.type;
|
||||
const webType: any = (componentConfig as any).web_type;
|
||||
const type: InputFieldType = (() => {
|
||||
if (isValidType(rawType)) return rawType;
|
||||
// v2-select 는 entity 든 code 든 dropdown 이든 전부 native select 로 통일
|
||||
if (rawType === "v2-select") return "select";
|
||||
if (rawType === "v2-date") {
|
||||
if (webType === "datetime") return "datetime";
|
||||
return "date";
|
||||
}
|
||||
if (
|
||||
webType === "select" ||
|
||||
webType === "code" ||
|
||||
webType === "dropdown" ||
|
||||
webType === "radio" ||
|
||||
webType === "entity"
|
||||
) return "select";
|
||||
if (webType === "number" || webType === "decimal") return "number";
|
||||
if (webType === "date") return "date";
|
||||
if (webType === "datetime") return "datetime";
|
||||
if (webType === "textarea") return "textarea";
|
||||
if (webType === "checkbox" || webType === "boolean") return "checkbox";
|
||||
if (webType === "file" || webType === "image") return "file";
|
||||
return "text";
|
||||
})();
|
||||
|
||||
const label = componentConfig.label;
|
||||
const placeholder = componentConfig.placeholder ?? "";
|
||||
@@ -95,13 +119,55 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
const disabled = componentConfig.disabled ?? false;
|
||||
const rows = componentConfig.rows ?? 3;
|
||||
|
||||
const [value, setValue] = useState<unknown>(
|
||||
componentConfig.defaultValue ?? "",
|
||||
// 부모가 formData[columnName] 또는 직접 value 를 넘겨주면 controlled. 없으면 로컬 state.
|
||||
// 입력 변경 시 부모 콜백(onFormDataChange / onChange) 둘 다 시도해서 BlockRenderer 의
|
||||
// formRow 갱신 경로를 끊김 없이 잇는다.
|
||||
const formDataProp = (props as any).formData ?? (props as any).form_data;
|
||||
const onFormDataChangeProp = (props as any).onFormDataChange;
|
||||
const onChangeProp = (props as any).onChange;
|
||||
const columnName: string | undefined =
|
||||
(component as any).columnName ?? (component as any).column_name;
|
||||
|
||||
const controlledValue =
|
||||
formDataProp && columnName
|
||||
? formDataProp[columnName]
|
||||
: (props as any).value !== undefined
|
||||
? (props as any).value
|
||||
: undefined;
|
||||
|
||||
const [localValue, setLocalValue] = useState<unknown>(
|
||||
controlledValue !== undefined ? controlledValue : (componentConfig.defaultValue ?? ""),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(componentConfig.defaultValue ?? "");
|
||||
}, [componentConfig.defaultValue]);
|
||||
if (controlledValue !== undefined) {
|
||||
setLocalValue((prev: unknown) => (prev === controlledValue ? prev : controlledValue));
|
||||
} else if (componentConfig.defaultValue !== undefined) {
|
||||
setLocalValue((prev: unknown) =>
|
||||
prev === componentConfig.defaultValue ? prev : (componentConfig.defaultValue ?? ""),
|
||||
);
|
||||
}
|
||||
}, [controlledValue, componentConfig.defaultValue]);
|
||||
|
||||
const value: unknown = controlledValue !== undefined ? controlledValue : localValue;
|
||||
|
||||
const propagate = (v: unknown) => {
|
||||
// [DEBUG] 입력 흐름 진단용 임시 로그. 동작 확인 후 제거 예정.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[InputComponent] propagate", {
|
||||
columnName,
|
||||
v,
|
||||
hasOnFormDataChange: typeof onFormDataChangeProp === "function",
|
||||
hasOnChange: typeof onChangeProp === "function",
|
||||
controlledValue,
|
||||
type,
|
||||
rawType,
|
||||
webType,
|
||||
});
|
||||
setLocalValue(v);
|
||||
if (typeof onFormDataChangeProp === "function" && columnName) onFormDataChangeProp(columnName, v);
|
||||
if (typeof onChangeProp === "function") onChangeProp(v);
|
||||
};
|
||||
|
||||
// ─── DOM props filter (React warning 방지) ────────────────────────────
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
@@ -236,7 +302,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
<input
|
||||
type="number"
|
||||
value={typeof value === "number" || typeof value === "string" ? (value as any) : ""}
|
||||
onChange={(e) => setValue(e.target.valueAsNumber)}
|
||||
onChange={(e) => propagate(e.target.valueAsNumber)}
|
||||
min={componentConfig.min}
|
||||
max={componentConfig.max}
|
||||
step={componentConfig.step}
|
||||
@@ -248,7 +314,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
<input
|
||||
type="date"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onChange={(e) => propagate(e.target.value)}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
@@ -257,7 +323,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onChange={(e) => propagate(e.target.value)}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
@@ -265,7 +331,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
return (
|
||||
<textarea
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onChange={(e) => propagate(e.target.value)}
|
||||
rows={rows}
|
||||
minLength={componentConfig.minLength}
|
||||
maxLength={componentConfig.maxLength}
|
||||
@@ -278,7 +344,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
return (
|
||||
<select
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onChange={(e) => propagate(e.target.value)}
|
||||
{...common}
|
||||
>
|
||||
<option value="">{placeholder || "선택하세요"}</option>
|
||||
@@ -313,7 +379,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!value}
|
||||
onChange={(e) => setValue(e.target.checked)}
|
||||
onChange={(e) => propagate(e.target.checked)}
|
||||
disabled={disabled || isDesignMode}
|
||||
readOnly={readonly}
|
||||
/>
|
||||
@@ -326,7 +392,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
<input
|
||||
type="text"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onChange={(e) => propagate(e.target.value)}
|
||||
{...common}
|
||||
readOnly
|
||||
placeholder={placeholder || "검색 팝업에서 선택"}
|
||||
@@ -379,7 +445,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
<input
|
||||
type="text"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onChange={(e) => propagate(e.target.value)}
|
||||
minLength={componentConfig.minLength}
|
||||
maxLength={componentConfig.maxLength}
|
||||
{...common}
|
||||
|
||||
@@ -15,90 +15,63 @@
|
||||
라이트 #f5f5f8 / #ededf2 / #e4e4ec / #d8d8e2 / #1a1a24 / #6c5ce7
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ─── 라이트 모드 ─── */
|
||||
/* ─── 라이트 모드 ─── 사이트 메인(메뉴/회사관리)과 동일 중성 톤 */
|
||||
.ide-builder {
|
||||
/* 배경 e8e8ef — 캔버스. 카드보다 확실히 어둡게 해서 패널이 도드라지게 */
|
||||
--background: 240 13% 92%;
|
||||
/* 카드 (패널) #ffffff — v5 솔리드 흰색 */
|
||||
--background: 240 20% 99%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 17% 12%;
|
||||
/* 팝오버 #ffffff */
|
||||
--card-foreground: 240 10% 5%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 17% 12%;
|
||||
/* 전경 1a1a24 */
|
||||
--foreground: 240 17% 12%;
|
||||
/* muted dedde6 / 5a5a6e — 캔버스와 카드 사이 톤 */
|
||||
--muted: 240 12% 88%;
|
||||
--muted-foreground: 240 10% 39%;
|
||||
/* accent (hover 배경) dedde6 */
|
||||
--accent: 240 12% 88%;
|
||||
--accent-foreground: 240 17% 12%;
|
||||
/* secondary f4f4f8 */
|
||||
--secondary: 240 13% 96%;
|
||||
--secondary-foreground: 240 17% 12%;
|
||||
/* 보더 c9c9d6 — 대비 강화 */
|
||||
--border: 240 13% 81%;
|
||||
--input: 240 13% 81%;
|
||||
/* 액센트 (primary) #6c5ce7 — v5 Cosmic 보라 */
|
||||
--primary: 249 75% 63%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--ring: 249 75% 63%;
|
||||
/* destructive dc2626 */
|
||||
--popover-foreground: 240 10% 5%;
|
||||
--foreground: 240 10% 5%;
|
||||
--muted: 240 5% 95%;
|
||||
--muted-foreground: 240 4% 46%;
|
||||
--accent: 240 5% 95%;
|
||||
--accent-foreground: 240 10% 5%;
|
||||
--secondary: 240 5% 96%;
|
||||
--secondary-foreground: 240 10% 5%;
|
||||
--border: 240 6% 88%;
|
||||
--input: 240 6% 88%;
|
||||
/* --primary / --ring 은 정의하지 않음 → 사이트 :root 토큰 자동 상속,
|
||||
data-color preset (purple/blue/green/orange/pink/cyan) 도 자동 따라감 */
|
||||
--destructive: 0 73% 51%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
/* 사이드바 (패널 안의 보조 영역) */
|
||||
/* 사이드바 — 사이트 카드 톤 (사이드바 primary/ring 도 미정의 → 사이트 토큰 상속) */
|
||||
--sidebar-background: 0 0% 100%;
|
||||
--sidebar-foreground: 240 17% 12%;
|
||||
--sidebar-primary: 249 75% 63%;
|
||||
--sidebar-foreground: 240 10% 5%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 12% 88%;
|
||||
--sidebar-accent-foreground: 240 17% 12%;
|
||||
--sidebar-border: 240 13% 81%;
|
||||
--sidebar-ring: 249 75% 63%;
|
||||
--sidebar-accent: 240 5% 95%;
|
||||
--sidebar-accent-foreground: 240 10% 5%;
|
||||
--sidebar-border: 240 6% 88%;
|
||||
/* 반경을 살짝 더 타이트하게 — IDE 느낌 */
|
||||
--radius: 0.375rem;
|
||||
}
|
||||
|
||||
/* ─── 다크 모드 ─── */
|
||||
/* ─── 다크 모드 ─── 사이트 메인(메뉴/회사관리)과 동일 중성 톤 */
|
||||
.dark .ide-builder {
|
||||
/* 배경 121218 */
|
||||
--background: 240 13% 9%;
|
||||
/* 카드 (패널) 1a1a22 */
|
||||
--card: 240 14% 12%;
|
||||
--card-foreground: 240 14% 92%;
|
||||
/* 팝오버 1a1a22 */
|
||||
--popover: 240 14% 12%;
|
||||
--popover-foreground: 240 14% 92%;
|
||||
/* 전경 e8e8ee */
|
||||
--foreground: 240 14% 92%;
|
||||
/* muted 22222c / 78788a */
|
||||
--muted: 240 13% 15%;
|
||||
--muted-foreground: 240 8% 51%;
|
||||
/* accent 2a2a36 */
|
||||
--accent: 240 13% 19%;
|
||||
--accent-foreground: 240 14% 92%;
|
||||
/* secondary 1a1a22 */
|
||||
--secondary: 240 14% 12%;
|
||||
--secondary-foreground: 240 14% 92%;
|
||||
/* 보더 3a3a48 */
|
||||
--border: 240 10% 25%;
|
||||
--input: 240 10% 25%;
|
||||
/* 액센트 #a29bfe — v5 Cosmic 보라 (다크) */
|
||||
--primary: 245 99% 81%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--ring: 245 99% 81%;
|
||||
/* destructive f87171 */
|
||||
--background: 220 6% 5%;
|
||||
--card: 220 6% 9%;
|
||||
--card-foreground: 220 6% 95%;
|
||||
--popover: 220 6% 9%;
|
||||
--popover-foreground: 220 6% 95%;
|
||||
--foreground: 220 6% 95%;
|
||||
--muted: 220 6% 14%;
|
||||
--muted-foreground: 220 4% 56%;
|
||||
--accent: 220 6% 18%;
|
||||
--accent-foreground: 220 6% 95%;
|
||||
--secondary: 220 6% 12%;
|
||||
--secondary-foreground: 220 6% 95%;
|
||||
--border: 220 6% 18%;
|
||||
--input: 220 6% 18%;
|
||||
/* --primary / --ring 미정의 → 사이트 :root.dark 토큰 + data-color preset 자동 상속 */
|
||||
--destructive: 0 91% 71%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
/* 사이드바 (패널 안의 보조 영역) */
|
||||
--sidebar-background: 240 14% 12%;
|
||||
--sidebar-foreground: 240 14% 92%;
|
||||
--sidebar-primary: 245 99% 81%;
|
||||
/* 사이드바 — 사이트 카드 톤 (primary/ring 미정의 → 사이트 토큰 상속) */
|
||||
--sidebar-background: 220 6% 9%;
|
||||
--sidebar-foreground: 220 6% 95%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 13% 19%;
|
||||
--sidebar-accent-foreground: 240 14% 92%;
|
||||
--sidebar-border: 240 10% 25%;
|
||||
--sidebar-ring: 245 99% 81%;
|
||||
--sidebar-accent: 220 6% 18%;
|
||||
--sidebar-accent-foreground: 220 6% 95%;
|
||||
--sidebar-border: 220 6% 18%;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -138,33 +138,50 @@
|
||||
|
||||
/* --- BLUE --- */
|
||||
html[data-color="blue"]{
|
||||
--v5-primary-rgb:59,130,246; --v5-cyan-rgb:14,165,233; --v5-pink-rgb:99,102,241;}
|
||||
--v5-primary-rgb:59,130,246; --v5-cyan-rgb:14,165,233; --v5-pink-rgb:99,102,241;
|
||||
--primary:217 91% 60%; --ring:217 91% 60%; --sidebar-primary:217 91% 60%; --sidebar-ring:217 91% 60%;}
|
||||
html.dark[data-color="blue"]{
|
||||
--v5-primary-rgb:147,197,253; --v5-cyan-rgb:125,211,252; --v5-pink-rgb:129,140,248;}
|
||||
--v5-primary-rgb:147,197,253; --v5-cyan-rgb:125,211,252; --v5-pink-rgb:129,140,248;
|
||||
--primary:213 93% 78%; --ring:213 93% 78%; --sidebar-primary:213 93% 78%; --sidebar-ring:213 93% 78%;}
|
||||
|
||||
/* --- GREEN --- */
|
||||
html[data-color="green"]{
|
||||
--v5-primary-rgb:16,185,129; --v5-cyan-rgb:20,184,166; --v5-pink-rgb:132,204,22;}
|
||||
--v5-primary-rgb:16,185,129; --v5-cyan-rgb:20,184,166; --v5-pink-rgb:132,204,22;
|
||||
--primary:160 84% 39%; --ring:160 84% 39%; --sidebar-primary:160 84% 39%; --sidebar-ring:160 84% 39%;}
|
||||
html.dark[data-color="green"]{
|
||||
--v5-primary-rgb:110,231,183; --v5-cyan-rgb:94,234,212; --v5-pink-rgb:190,242,100;}
|
||||
--v5-primary-rgb:110,231,183; --v5-cyan-rgb:94,234,212; --v5-pink-rgb:190,242,100;
|
||||
--primary:156 73% 67%; --ring:156 73% 67%; --sidebar-primary:156 73% 67%; --sidebar-ring:156 73% 67%;}
|
||||
|
||||
/* --- ORANGE --- */
|
||||
html[data-color="orange"]{
|
||||
--v5-primary-rgb:249,115,22; --v5-cyan-rgb:6,182,212; --v5-pink-rgb:251,146,60;}
|
||||
--v5-primary-rgb:249,115,22; --v5-cyan-rgb:6,182,212; --v5-pink-rgb:251,146,60;
|
||||
--primary:25 95% 53%; --ring:25 95% 53%; --sidebar-primary:25 95% 53%; --sidebar-ring:25 95% 53%;}
|
||||
html.dark[data-color="orange"]{
|
||||
--v5-primary-rgb:253,186,116; --v5-cyan-rgb:103,232,249; --v5-pink-rgb:252,165,165;}
|
||||
--v5-primary-rgb:253,186,116; --v5-cyan-rgb:103,232,249; --v5-pink-rgb:252,165,165;
|
||||
--primary:32 97% 72%; --ring:32 97% 72%; --sidebar-primary:32 97% 72%; --sidebar-ring:32 97% 72%;}
|
||||
|
||||
/* --- PINK --- */
|
||||
html[data-color="pink"]{
|
||||
--v5-primary-rgb:236,72,153; --v5-cyan-rgb:168,85,247; --v5-pink-rgb:244,114,182;}
|
||||
--v5-primary-rgb:236,72,153; --v5-cyan-rgb:168,85,247; --v5-pink-rgb:244,114,182;
|
||||
--primary:330 81% 60%; --ring:330 81% 60%; --sidebar-primary:330 81% 60%; --sidebar-ring:330 81% 60%;}
|
||||
html.dark[data-color="pink"]{
|
||||
--v5-primary-rgb:244,114,182; --v5-cyan-rgb:192,132,252; --v5-pink-rgb:249,168,212;}
|
||||
--v5-primary-rgb:244,114,182; --v5-cyan-rgb:192,132,252; --v5-pink-rgb:249,168,212;
|
||||
--primary:330 86% 70%; --ring:330 86% 70%; --sidebar-primary:330 86% 70%; --sidebar-ring:330 86% 70%;}
|
||||
|
||||
/* --- CYAN --- */
|
||||
html[data-color="cyan"]{
|
||||
--v5-primary-rgb:8,145,178; --v5-cyan-rgb:14,165,233; --v5-pink-rgb:6,182,212;}
|
||||
--v5-primary-rgb:8,145,178; --v5-cyan-rgb:14,165,233; --v5-pink-rgb:6,182,212;
|
||||
--primary:191 91% 36%; --ring:191 91% 36%; --sidebar-primary:191 91% 36%; --sidebar-ring:191 91% 36%;}
|
||||
html.dark[data-color="cyan"]{
|
||||
--v5-primary-rgb:125,211,252; --v5-cyan-rgb:103,232,249; --v5-pink-rgb:165,243,252;}
|
||||
--v5-primary-rgb:125,211,252; --v5-cyan-rgb:103,232,249; --v5-pink-rgb:165,243,252;
|
||||
--primary:200 94% 74%; --ring:200 94% 74%; --sidebar-primary:200 94% 74%; --sidebar-ring:200 94% 74%;}
|
||||
|
||||
/* --- PURPLE (기본) — 사이트 :root 토큰을 그대로 쓰지만 명시적으로 매핑해서
|
||||
다른 preset → purple 로 돌아갈 때 이전 값이 남지 않게 */
|
||||
html[data-color="purple"]{
|
||||
--primary:245 75% 57%; --ring:245 75% 57%; --sidebar-primary:245 75% 57%; --sidebar-ring:245 75% 57%;}
|
||||
html.dark[data-color="purple"]{
|
||||
--primary:245 85% 68%; --ring:245 85% 68%; --sidebar-primary:245 85% 68%; --sidebar-ring:245 85% 68%;}
|
||||
|
||||
/* ===== COSMIC BACKGROUND ===== */
|
||||
.v5-cosmos{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# DataPort / FieldConfig 시스템 감사 (2026-04-27)
|
||||
|
||||
Claude(Opus 4.7) 1차 분석 + Codex(GPT) 교차검증 + 사용자 정정 반영.
|
||||
|
||||
> ⚠️ **2026-04-27 정정**: 초판에서 "DataPort 제거" 를 권고했으나 잘못된 결론이었음. DataPort 는
|
||||
> INVYONE 컴포넌트 규격 v1.0 의 핵심 차별화 요소(vex 대비 강점)로 새로 도입한 통신 프로토콜이며,
|
||||
> 인프라가 먼저 깔린 후 컴포넌트 어댑터/빌더 UI 가 따라붙는 단계임. 현재 props 콜백은 활성화 전
|
||||
> 임시 호환 레이어. 본 문서는 정정 후 버전.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **DataPort 는 INVYONE 의 핵심 통신 규격.** 인프라(타입/Bus/Runtime/메타/DB컬럼) 깔린 상태, 활성화 대기 중.
|
||||
- **현재 props 콜백 + context drilling 은 임시 호환 레이어.** 콜백 시그니처가 DataPort 포트와 1:1 매칭되게 설계됨.
|
||||
- **다음 단계 = 컴포넌트 publish/subscribe 어댑터 + 빌더 시각 연결 편집 UI + `BlockRenderer` 에서 `setupConnections` 호출**.
|
||||
- **FieldConfig 정상 작동.** 단 `v2-table-list` 레거시 `config.columns` fallback 1곳에서 단일 진실 원천 원칙 위반 — 정리 가능.
|
||||
|
||||
---
|
||||
|
||||
## 1. 설계 의도 (진실의 원천)
|
||||
|
||||
`notes/gbpark/2026-04-08-invyone-component-spec.md` §4 / §7 표:
|
||||
|
||||
| | vex | invyone |
|
||||
|---|---|---|
|
||||
| 컴포넌트 간 통신 | 자체 구현, 컴포넌트마다 다름 | **DataPort 표준 프로토콜** |
|
||||
| 데이터 전달 | 컴포넌트별 커스텀 이벤트 | **output → input 자동 매칭** |
|
||||
|
||||
`notes/gbpark/2026-04-09-invyone-architecture.md` §1.2:
|
||||
> 컴포넌트끼리 직접 참조하지 않는다. 대신 표준화된 DataPort로 데이터를 주고받는다.
|
||||
|
||||
`notes/gbpark/2026-04-10-phase2-fieldconfig-components.md` (FcTable/FcForm/FcSearch props 주석):
|
||||
- `onRowSelect` ← // selectedRow(row)
|
||||
- `onRowsSelect` ← // selectedRows(rows)
|
||||
- `onSearch` ← // searchParams(params)
|
||||
- `onSubmit` ← // formData(row), `onSaved` ← // savedRow(row)
|
||||
- `loadRow` ← // DataPort 입력
|
||||
|
||||
→ **현재 콜백 시그니처가 이미 DataPort 포트 이름과 1:1 매칭되도록 설계됨**. props↔DataPort 전환이 어댑터 한 층으로 가능하게 만들어둔 구조.
|
||||
|
||||
---
|
||||
|
||||
## 2. 현재 구현 상태
|
||||
|
||||
| 레이어 | 상태 | 위치 |
|
||||
|---|---|---|
|
||||
| 타입 (`DataPort`/`Connection`/`DataPortType`/`ReservedDataPortName`/`DataPortDef`/`CardConnection`) | ✅ 정의됨 | `frontend/types/invyone-component.ts:240~306, 692~693, 898~905, 939~946` |
|
||||
| Pub/Sub 버스 | ✅ 구현됨 | `frontend/lib/dataPort/DataPortBus.ts` |
|
||||
| Connection → Bus 브리지 (`setupConnections`) | ✅ 구현됨 | `frontend/lib/dataPort/runtime.ts` |
|
||||
| 컴포넌트 메타 (`ComponentDefinition.dataPorts: { inputs, outputs }`) | ✅ 9개 등록 | `v2-table-list, input, v2-button-primary, v2-input, search, table, button, v2-table-search-widget, stats` |
|
||||
| Template DB 컬럼 (`connections jsonb`) | ✅ 생성·저장 경로 | `templateAdapter.ts:93,115` |
|
||||
| 컴포넌트 publish/subscribe 어댑터 | ❌ 미구현 | (다음 단계) |
|
||||
| 빌더 시각 연결 편집 UI | ❌ 미구현 | (다음 단계) |
|
||||
| `BlockRenderer` 에서 `setupConnections` 호출 + cleanup | ❌ 미구현 | (다음 단계) |
|
||||
| Template.connections 인자를 `saveTemplate` 호출처가 전달 | ❌ 미구현 | `ScreenDesigner.tsx:2797,6757`, `builder/page.tsx:359` |
|
||||
|
||||
**현재 작동하는 임시 통신 패턴 (DataPort 활성화 전 1세대):**
|
||||
|
||||
```
|
||||
DashboardCard (state hub)
|
||||
├─ state: searchParams, selectedRow, formRow, page, pageSize
|
||||
├─ handlers: handleSearch, handleRowSelect, handleAdd, handleEdit, handleDelete
|
||||
└─ TemplateRenderer({ template, context })
|
||||
context.{onSearch, onRowSelect, onAdd, onFormSubmit, ...} ← DataPort 와 1:1 매칭되는 콜백
|
||||
└─ BlockRenderer ← 콜백을 props 로 펼쳐서 컴포넌트에 전달
|
||||
└─ 각 v2-* / fc-* 컴포넌트 (props.onSearch, props.onRowSelect, ...)
|
||||
```
|
||||
|
||||
이 구조가 **DataPort 로 전환되었을 때**:
|
||||
|
||||
```
|
||||
DashboardCard
|
||||
├─ DataPortBus 인스턴스 (화면별)
|
||||
└─ TemplateRenderer
|
||||
└─ BlockRenderer
|
||||
├─ setupConnections(template.connections, bus) ← 마운트 시
|
||||
├─ cleanup() ← 언마운트 시
|
||||
└─ 각 컴포넌트
|
||||
├─ outputs 발생 시 bus.publish(`${id}.${portName}`, value)
|
||||
└─ inputs 채널 bus.subscribe(...)
|
||||
```
|
||||
|
||||
콜백 props 와 DataPort 는 **공존 가능**한 구조. 어댑터 한 층(예: `usePortPublish(componentId, portName)` 훅)으로 양쪽 호환 가능.
|
||||
|
||||
---
|
||||
|
||||
## 3. FieldConfig 경로 — 정상 작동
|
||||
|
||||
| 위치 | 사용 |
|
||||
|---|---|
|
||||
| `FcTable.tsx:29`, `FcForm.tsx:16`, `FcSearch.tsx:16` | `FieldConfig[]` 직접 |
|
||||
| `FieldRenderer.tsx:30` | `FieldConfig.type` → 위젯 매핑 (10종) |
|
||||
| `lib/fieldConfig/adapters.ts` | `FieldConfig` → 레거시 columns/formFields/searchFields 변환 (호환 레이어) |
|
||||
| **`v2-table-list/TableListContainerWrapper.tsx:55,70`** | **fallback: `props.fields` 부재 시 `config.columns` 사용 ← 단일 진실 원천 원칙 위반** |
|
||||
|
||||
`TableListComponent.tsx:509,1277` 이 `config.columns` 직접 소비 → 이 경로가 유일하게 FieldConfig 원칙 깸. 빌더에서 columns→FieldConfig 정규화 후 fallback 제거 필요.
|
||||
|
||||
---
|
||||
|
||||
## 4. 모델 이중화 — TemplateComponent vs BlockV2
|
||||
|
||||
`invyone-component.ts` 안에 컴포넌트 모델 3개 공존:
|
||||
|
||||
| 모델 | 위치 | 실제 사용 |
|
||||
|---|---|---|
|
||||
| `Component` (§2) | line 186 | 자체 주석에 "사실상 폐기, 즉시 삭제 가능" — 정리 가능 |
|
||||
| `TemplateComponent` (§6) | line 804 | **빌더(`ScreenDesigner.tsx:122,2988`, `TemplatesPanel`)에서 사용 중** |
|
||||
| `BlockV2` (§7) | line 998 | **런타임 진실** — `TemplateRenderer`/`BlockRenderer` 가 받는 모델 |
|
||||
|
||||
빌더 → `templateAdapter.saveTemplate` → `convertLegacyToV2` → DB 저장 → `ensureV2Views` → BlockV2 처리.
|
||||
이중화는 의도된 구조(빌더 모델 vs 런타임 모델 분리). 어댑터로 정합성 유지. **제거 대상 아님**.
|
||||
|
||||
---
|
||||
|
||||
## 5. 권고
|
||||
|
||||
### A) DataPort 인프라 유지 (절대 건드리지 말 것)
|
||||
타입/Bus/Runtime/메타/DB컬럼 모두 의도해서 깐 인프라. 아직 활성화되지 않은 상태일 뿐.
|
||||
|
||||
### B) DataPort 활성화 작업 순서 (다음 단계)
|
||||
|
||||
1. **`BlockRenderer` (또는 그 상위 `TemplateRenderer`/`DashboardCard`) 에 Bus 라이프사이클**
|
||||
- 화면별 `new DataPortBus()` 또는 `defaultDataPortBus` 사용
|
||||
- `useEffect` 로 `setupConnections(template.connections, bus)` 호출 / cleanup
|
||||
- React Context 로 자식 컴포넌트에 bus 전달
|
||||
|
||||
2. **컴포넌트 어댑터 훅** (props↔Bus 양쪽 호환)
|
||||
```ts
|
||||
// 예
|
||||
function usePortPublish(componentId: string, portName: string) {
|
||||
const bus = useDataPortBus();
|
||||
return useCallback((value: unknown) => {
|
||||
bus.publish(DataPortBus.channelOf(componentId, portName), value);
|
||||
}, [bus, componentId, portName]);
|
||||
}
|
||||
```
|
||||
기존 `onRowSelect` 콜백 호출 시 동시에 `publish('selectedRow', row)` 도 호출하도록 어댑터 추가. 점진 전환.
|
||||
|
||||
3. **빌더에 시각 연결 편집 UI**
|
||||
- 컴포넌트 박스에 `dataPorts.outputs/inputs` 메타 기반으로 포트 핸들 자동 표시
|
||||
- 드래그로 from.output → to.input 연결 → `Template.connections` 누적
|
||||
- `PortHandle.tsx` 가 이미 존재 (제어 모드 노드 에디터용으로) — 패턴 재사용 가능
|
||||
|
||||
4. **`templateAdapter.saveTemplate` 호출처에 connections 전달**
|
||||
- `ScreenDesigner.tsx:2797,6757`, `builder/page.tsx:359` 가 빌더 상태의 connections 를 인자로 넘기게
|
||||
|
||||
### C) DataPort 와 별개로 정리 가능한 항목
|
||||
|
||||
- `Component` (§2 invyone-component.ts:186) 인터페이스 — 자체 주석에 폐기 명시. 참조 0건이면 삭제
|
||||
- `TableListContainerWrapper.tsx:55,70` 의 `config.columns` fallback — 빌더에서 columns→FieldConfig 정규화 후 제거. FieldConfig 단일 원천 회복
|
||||
|
||||
### D) 손대지 말 것
|
||||
|
||||
- `lib/dataPort/` 전체
|
||||
- 각 컴포넌트 definition 의 `dataPorts: { ... }` 선언
|
||||
- `Template.connections`, `Card.inputs/outputs`, `Dashboard.connections` (CardConnection[]) 필드
|
||||
- `templateAdapter.ts` 의 connections 직렬화
|
||||
- `TemplateComponent` (§6) — 빌더 모델로 사용 중
|
||||
|
||||
---
|
||||
|
||||
## 부록: 검증에 사용한 grep
|
||||
|
||||
```bash
|
||||
# DataPort 활성화 경로 (현재 0건 — 다음 단계에서 채워짐)
|
||||
grep -rn "setupConnections\|defaultDataPortBus\|new DataPortBus\|bus\.publish\|bus\.subscribe" \
|
||||
--include="*.ts" --include="*.tsx" frontend/
|
||||
|
||||
# FieldConfig 사용처
|
||||
grep -rn "FieldConfig" --include="*.ts" --include="*.tsx" frontend/
|
||||
|
||||
# dataPorts 메타 등록
|
||||
grep -rn "dataPorts\b" --include="*.ts" --include="*.tsx" frontend/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 부록: 초판의 잘못된 결론
|
||||
|
||||
초판: "DataPort 전체 제거" 권고. 호출처 0건 = dead code 라는 단순 추론에서 나옴.
|
||||
정정: 인프라 유지 + 활성화 작업이 정답. 콜백 시그니처가 DataPort 포트와 1:1 매칭되도록 미리 설계된 점, 컴포넌트 메타에 dataPorts 가 명시적으로 등록된 점, Template DB 에 connections 컬럼이 미리 만들어진 점이 "활성화 대기 중인 인프라" 임을 보여주는 신호였는데 초판에서 놓쳤음.
|
||||
@@ -0,0 +1,283 @@
|
||||
# INVYONE Studio · Config Panel cp 디자인 마이그레이션
|
||||
|
||||
날짜: 2026-04-27
|
||||
작업자: gbpark
|
||||
상태: 진행 중 (입력 필드 패널 + 외곽 + 사이트 preset 시스템 정리까지 완료, 추가 패널 미작업)
|
||||
|
||||
---
|
||||
|
||||
## 0. 배경 / 컨텍스트
|
||||
|
||||
- 디자이너 시안: `/Users/gbpark/Downloads/ui_kits/app/cli_export/standalone/`
|
||||
- `panel-input-new` (입력 필드)
|
||||
- `panel-table-new` (표)
|
||||
- `panel-lookup-new` (조회)
|
||||
- `_shared/config-panel.css` + `_shared/config-panel-fields.jsx` (CP* 프리미티브 14종)
|
||||
- 시안의 핵심 컨셉:
|
||||
- **3-레벨 분류**: `kind ▸ type ▸ format`
|
||||
- kind = 행위 (입력/선택/자동/첨부)
|
||||
- type = 저장 모양 (글자/숫자/금액/날짜 등) → DB 컬럼 타입 박힘
|
||||
- format = 검증·마스킹·UI variant
|
||||
- **헤더 brumb**: `[kind ▸ type ▾]` 클릭 시 type 팝오버
|
||||
- **format trigger**: type 안에서 format 동적 변경 (자유/이메일/전화 등)
|
||||
- **카드 = 회색 라인 + 작은 폰트(0.55~0.85rem)**, 보라는 active 강조에만
|
||||
- 라이트/다크 모두 동작, v5 토큰만 사용
|
||||
|
||||
---
|
||||
|
||||
## 1. 작업 범위
|
||||
|
||||
| 영역 | 파일 | 처리 |
|
||||
|---|---|---|
|
||||
| **CP 프리미티브 신설** | `frontend/components/v2/config-panels/_shared/cp/` | 신규 5개 파일 |
|
||||
| **빌더 입력 필드 본체** | `V2FieldConfigPanel.tsx` (914줄) | cp 패턴으로 재작성 |
|
||||
| **빌더 외곽 패널** | `V2PropertiesPanel.tsx` (1351줄) | DIMENSIONS/OPTIONS/LABEL/조건부 표시/컴포넌트 스타일 cp 패턴화 |
|
||||
| **빌더 컴포넌트 스타일 카드** | `StyleEditor.tsx` (테두리/배경/텍스트) | cp 톤 정렬 |
|
||||
| **빌더 우측 패널 컨테이너** | `ScreenDesigner.tsx` 8772 라인 | 너비 드래그 핸들 + cp 톤 헤더 |
|
||||
| **빌더 IDE 토큰** | `frontend/styles/builder-ide.css` | 사이트 메인 톤(`220 6%`)으로 정렬 |
|
||||
| **사이트 컬러 preset** | `frontend/styles/v5-layout.css` 라인 139~167 | shadcn `--primary/--ring/--sidebar-primary/-ring` 도 preset 따라감 |
|
||||
| (사용 안 됨) | `V2InputConfigPanel.tsx` (832줄) | 잘못 수정한 옛 파일. 폐기 보류 (사용처 없음) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 만든 / 수정한 파일
|
||||
|
||||
### 2.1 신설 (CP 프리미티브)
|
||||
|
||||
```
|
||||
frontend/components/v2/config-panels/_shared/cp/
|
||||
├── cp.css # cp 토큰 + 모든 cp-* 클래스 + 외곽 패널 override
|
||||
├── icons.tsx # CP_ICONS 12종 SVG (chevron/plus/trash/grip/reset/help/link/dot/code/close/chevRight/alert)
|
||||
├── CPPrimitives.tsx # CPSection / CPRow / CPStacked / CPText / CPSelect / CPTextarea
|
||||
│ # CPSwitch / CPNumber / CPColor / CPSegment / CPIconBtn
|
||||
├── CPChrome.tsx # CPHeader / CPTabs / CPGroup / CPBindChip / CPCrumb / CPFormatTrigger
|
||||
└── index.ts # barrel export
|
||||
```
|
||||
|
||||
핵심 import:
|
||||
```ts
|
||||
import "./_shared/cp/cp.css";
|
||||
import { CPSection, CPRow, CPText, CPNumber, CPSwitch, CPGroup, CPCrumb, CPFormatTrigger } from "./_shared/cp";
|
||||
```
|
||||
|
||||
### 2.2 수정
|
||||
|
||||
- `frontend/components/v2/config-panels/V2FieldConfigPanel.tsx` — 거의 전부 재작성
|
||||
- `frontend/components/screen/panels/V2PropertiesPanel.tsx` — 외곽 섹션들 cp 패턴화
|
||||
- `frontend/components/screen/StyleEditor.tsx` — Collapsible trigger cp 박스 톤
|
||||
- `frontend/components/screen/ScreenDesigner.tsx` — 우측 패널 너비 state + 드래그 핸들 + 헤더 톤
|
||||
- `frontend/styles/builder-ide.css` — 빌더 토큰 → 사이트 중성 회색 톤
|
||||
- `frontend/styles/v5-layout.css` — preset 분기에 shadcn `--primary` / `--ring` 추가
|
||||
|
||||
---
|
||||
|
||||
## 3. 적용된 디자인 컨셉
|
||||
|
||||
### 3.1 cp 토큰 (cp.css 맨 위)
|
||||
|
||||
```css
|
||||
:root {
|
||||
--cp-surface: hsl(var(--card)); /* 카드면 = 사이트 카드 */
|
||||
--cp-bg-subtle: hsl(var(--background)); /* 외곽 = 사이트 background, 더 진함 */
|
||||
--cp-surface-hover: hsl(var(--accent));
|
||||
--cp-text: hsl(var(--foreground));
|
||||
--cp-text-sec: hsl(var(--foreground) / 0.78); /* 라벨 — 밝음 */
|
||||
--cp-text-muted: hsl(var(--foreground) / 0.55); /* 캡션 — 가독 OK */
|
||||
--cp-border: hsl(var(--border));
|
||||
--cp-border-subtle: hsl(var(--foreground) / 0.06);
|
||||
--cp-border-strong: hsl(var(--foreground) / 0.18);
|
||||
}
|
||||
```
|
||||
|
||||
**핵심**: cp 토큰을 직접 hex 가 아닌 사이트 shadcn 토큰의 **alias**.
|
||||
- 라이트/다크 자동 분기 (사이트 토큰이 분기하니까)
|
||||
- 메뉴/회사관리/빌더 어디서든 동일 색감
|
||||
- preset 변경 시도 자동 (사이트 toplevel 토큰이 preset 따라가도록 v5-layout.css 도 손봄 — 3.5 참조)
|
||||
|
||||
### 3.2 V2FieldConfigPanel 분류 체계
|
||||
|
||||
```
|
||||
kind = 입력 / 선택 (type 의 group 으로 자동 결정)
|
||||
type
|
||||
입력 : 글자(text) · 숫자(number) · 여러 줄(textarea) · 채번(numbering)
|
||||
선택 : 직접 목록(select) · 카테고리(category) · 테이블 참조(entity)
|
||||
format = text 안에서만 자유/이메일/전화/URL/사업자번호/통화
|
||||
```
|
||||
|
||||
화면 구조:
|
||||
```
|
||||
CPCrumb (입력 | 글자 ▾) ← 좌/우 분할 brumb, 양쪽 클릭 가능
|
||||
├ CPSection "① 데이터 소속" ← primary/reference/child + 테이블/컬럼
|
||||
├ CPSection "② 유형별 설정" ← CPFormatTrigger + 형식별 동적 옵션
|
||||
├ CPSection "③ 데이터 필터" ← entity/category 일 때만
|
||||
└ CPGroup "고급 설정" ← 접이식 (선택형: 모드/복수/검색, 입력형: autoGen/마스크)
|
||||
```
|
||||
|
||||
데이터 모델 100% 호환 — 기존 config (`fieldType, source, dataRole, options, autoGeneration` 등) 그대로 사용. `resolveFieldType()` 로 추론 + `handleFieldTypeChange()` 로 전환 시 source/inputType 동시 갱신.
|
||||
|
||||
### 3.3 V2PropertiesPanel 외곽 cp 패턴
|
||||
|
||||
**위에서 아래로 같은 그리드** (`80px 라벨 | 1fr 컨트롤`):
|
||||
|
||||
| 섹션 | cp 컴포넌트 |
|
||||
|---|---|
|
||||
| 크기 | `CPSection` + `CPRow × 3` (너비/높이/Z-Index) |
|
||||
| 내용 (group/area) | `CPSection` + `CPRow × 2` (제목/설명) |
|
||||
| 옵션 | `CPSection` + `CPRow + CPSwitch × 3` (필수/읽기/숨김) |
|
||||
| 라벨 | `CPGroup` 접이 + `CPRow × 7` (텍스트/위치/간격/크기/색상/굵기/표시) |
|
||||
| 조건부 표시 | `CPGroup` 접이 + ConditionalConfigPanel |
|
||||
| 컴포넌트 스타일 | `CPGroup` 접이 + StyleEditor (안의 테두리/배경/텍스트도 cp 박스 톤) |
|
||||
|
||||
영문 헤더 → 한글 (DIMENSIONS→크기, OPTIONS→옵션, LABEL→라벨, CONTENT→내용).
|
||||
|
||||
### 3.4 빌더 토큰 정렬 (builder-ide.css)
|
||||
|
||||
기존 빌더는 `240 13%` 보라/파랑 tint 토큰 사용 → 메뉴·회사관리(`220 6%` 중성 회색)와 다른 톤.
|
||||
|
||||
수정:
|
||||
- `.ide-builder` 의 `--background / --card / --foreground / --border / --accent / --muted / --secondary / --sidebar-*` 모두 사이트 중성 톤으로 정렬
|
||||
- `--primary / --ring / --sidebar-primary / --sidebar-ring` 정의 **삭제** → 사이트 `:root` 토큰 자동 상속
|
||||
|
||||
### 3.5 사이트 preset 시스템 확장 (v5-layout.css)
|
||||
|
||||
기존 — preset 변경 시 `--v5-primary-rgb` 만 바뀜. shadcn `--primary` 는 라이트/다크 보라 고정.
|
||||
|
||||
추가 — 6개 preset (`purple/blue/green/orange/pink/cyan`) 라이트/다크 분기에 모두 다음 토큰 같이 정의:
|
||||
```css
|
||||
html[data-color="orange"]{
|
||||
--v5-primary-rgb:249,115,22; ...
|
||||
--primary:25 95% 53%;
|
||||
--ring:25 95% 53%;
|
||||
--sidebar-primary:25 95% 53%;
|
||||
--sidebar-ring:25 95% 53%;
|
||||
}
|
||||
```
|
||||
|
||||
→ 이제 preset 변경 시 **사이트 전체** (메뉴/회사관리/빌더의 검색 버튼·탭·input focus·라디오·체크) 가 그 색으로 통일.
|
||||
|
||||
### 3.6 우측 패널 너비 드래그 핸들 (ScreenDesigner.tsx)
|
||||
|
||||
```
|
||||
좌측 모서리 4px 핸들
|
||||
├ drag → 240~600px 범위 너비 조절
|
||||
├ 더블클릭 → 320px 리셋
|
||||
└ localStorage `inv-right-panel-width` 자동 저장
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 알려진 버그 / 함정 (다음 작업자 주의)
|
||||
|
||||
### 4.1 V2InputConfigPanel.tsx 는 사용 안 됨
|
||||
|
||||
- `frontend/components/v2/registerV2Components.ts` 에 등록되어 있지만 실제 빌더가 쓰는 registry (`lib/registry/components/v2-input/index.ts`) 는 `V2FieldConfigPanel` 을 가리킴
|
||||
- 처음에 잘못 V2InputConfigPanel 을 cp 패턴으로 재작성했는데 화면 변화 없어 한참 헤맸음
|
||||
- 폐기할지 보류 — 시그니처/매핑 함수 일부는 향후 단독 입력 패널에 재활용 가능
|
||||
|
||||
### 4.2 cp-pop 위치 — overflow:hidden 에 잘림
|
||||
|
||||
- `cp-crumb-split` 가 `overflow: hidden` 라 그 안에 absolute 떨어지는 팝오버는 잘림
|
||||
- 해결: CPCrumb 의 jsx 에서 cp-pop 을 cp-bar 직속으로 (split 박스 밖) 이동
|
||||
- 추가로 V2FieldConfigPanel wrapper 에서도 `overflow: hidden` 제거함
|
||||
|
||||
### 4.3 V2PropertiesPanel 안 `bg-white` 박힌 곳
|
||||
|
||||
- 라인 170, 1180 에 `bg-white` 가 박혀있어 다크 모드 cp 톤을 덮음
|
||||
- 둘 다 제거함 (`flex h-full flex-col`)
|
||||
|
||||
### 4.4 빌더 외곽 톤 ≠ 패널 톤 우려
|
||||
|
||||
- 우측 패널 `inv-right-panel` 배경 = `cp-bg-subtle` (사이트 background, 검정)
|
||||
- 안의 카드 (cp-crumb-split, input) = `cp-surface` (사이트 card, 약간 밝음)
|
||||
- → 카드가 살짝 떠 보이는 입체감
|
||||
- 배경 차이가 너무 약하면 사용자가 "구분 안 된다" 할 수 있으니 주의
|
||||
|
||||
---
|
||||
|
||||
## 5. 미완료 / 다음 작업 후보
|
||||
|
||||
### 5.1 다른 V2 패널 cp 마이그레이션 (시안의 표/조회 적용)
|
||||
|
||||
`components/v2/config-panels/` 에 40개 V2 패널 더 있음. 사용 빈도 높은 것부터:
|
||||
- `V2TableListConfigPanel.tsx` (1497줄) — 시안 `panel-table-new` 컨셉 적용 가능
|
||||
- `V2SelectConfigPanel.tsx` (847줄) — 입력에서 분리된 옛 패널 (V2FieldConfigPanel 이 이미 흡수했지만 여전히 등록되어 있을 수 있음 — 확인 필요)
|
||||
- `V2DateConfigPanel.tsx` 등 type 별 패널 — 단일 패널 (V2FieldConfigPanel) 로 통합할지 결정
|
||||
|
||||
### 5.2 시안의 lookup (조회) 패턴 도입
|
||||
|
||||
시안 `panel-lookup-new` 의 핵심 = "③ 결과 매핑" (M21 다컬럼 매핑). 본 프로젝트 `V2SelectConfigPanel.tsx` 또는 새 `entity` type 안에 적용.
|
||||
|
||||
### 5.3 V2InputConfigPanel.tsx 처리
|
||||
|
||||
- 옵션 A: 폐기 (rm)
|
||||
- 옵션 B: 그대로 두기
|
||||
- 옵션 C: registerV2Components.ts 에서 V2FieldConfigPanel 로 교체
|
||||
|
||||
### 5.4 입력 필드 `format` 확장
|
||||
|
||||
현재 V2FieldConfigPanel 의 text format 은 6개 (자유/이메일/전화/URL/사업자번호/통화). 시안의 V2InputConfigPanel 에는 7개 (+ 마스킹, 여러 줄). textarea 는 별도 type. 통합/분리 전략 재검토 가능.
|
||||
|
||||
### 5.5 한 번에 더 많은 검증
|
||||
|
||||
- 라이트/다크 둘 다 빌더 + 메뉴/회사관리에서 시각 검증
|
||||
- 6개 preset (purple/blue/green/orange/pink/cyan) 모두 회사관리·빌더 검색 버튼 색이 정확히 따라가는지
|
||||
- 빌더에서 입력 필드 외 컴포넌트 (버튼, 테이블, 셀렉트 등) 클릭 시 패널이 cp 톤으로 잘 그려지는지
|
||||
|
||||
---
|
||||
|
||||
## 6. 검증 방법
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit 2>&1 | grep -E "v2/config-panels/(_shared|V2Field|V2Input)|V2PropertiesPanel|StyleEditor"
|
||||
```
|
||||
- 우리 파일 에러 = 0 이어야 함
|
||||
- 다른 파일 에러는 기존부터 있던 것 (test-scenarios, ddl.ts, ButtonConfigPanel-fixed 등) → 무시
|
||||
|
||||
브라우저 검증:
|
||||
1. `https://localhost:9772/admin/builder` 또는 화면관리 → 템플릿 편집
|
||||
2. 컴포넌트 (입력 필드) 클릭 → 우측 패널 cp 톤으로 뜨는지
|
||||
3. brumb 좌측 "입력/선택" 클릭 → kind 팝오버
|
||||
4. brumb 우측 "글자 ▾" 클릭 → type 팝오버
|
||||
5. 형식 trigger 클릭 → format 팝오버 (글자 type 일 때만)
|
||||
6. 데이터 소속 segment (주/참조/하위) 작동 확인
|
||||
7. 우측 패널 좌측 모서리 hover → ew-resize 커서, 드래그로 너비 조절
|
||||
8. 우상단 Tweaks → 컬러 preset 변경 → 전체 사이트 색 통일 확인 (검색 버튼 등)
|
||||
9. 라이트/다크 토글 둘 다 자연스러운지
|
||||
|
||||
---
|
||||
|
||||
## 7. 관련 파일 맵 (다음 세션이 빠르게 컨텍스트 잡기)
|
||||
|
||||
```
|
||||
[디자인 시안 — 읽기 전용 참고]
|
||||
~/Downloads/ui_kits/app/cli_export/standalone/
|
||||
├── _shared/config-panel.css
|
||||
├── _shared/config-panel-fields.jsx ← CP* 프리미티브 14종 원본
|
||||
├── panel-input-new.{html,jsx} ← 입력 패널 시안
|
||||
├── panel-table-new.{html,jsx} ← 표 패널 시안 (다음 작업)
|
||||
└── panel-lookup-new.{html,jsx} ← 조회 패널 시안 (다음 작업)
|
||||
|
||||
[프로젝트 — 작업 산출물]
|
||||
frontend/components/v2/config-panels/
|
||||
├── _shared/cp/ ← 신설
|
||||
│ ├── cp.css ← cp 토큰 + 외곽 패널 override
|
||||
│ ├── icons.tsx
|
||||
│ ├── CPPrimitives.tsx
|
||||
│ ├── CPChrome.tsx
|
||||
│ └── index.ts
|
||||
├── V2FieldConfigPanel.tsx ← 본체 (입력+선택 통합)
|
||||
├── V2InputConfigPanel.tsx ← 사용 안 됨, 폐기 보류
|
||||
└── (V2TableListConfigPanel.tsx 등 39개 — 미작업)
|
||||
|
||||
frontend/components/screen/
|
||||
├── panels/V2PropertiesPanel.tsx ← 외곽 (DIMENSIONS/OPTIONS/LABEL 등)
|
||||
├── StyleEditor.tsx ← 컴포넌트 스타일 (테두리/배경/텍스트)
|
||||
└── ScreenDesigner.tsx ← 빌더 셸 + 우측 패널 wrap + 너비 드래그
|
||||
|
||||
frontend/styles/
|
||||
├── builder-ide.css ← 빌더 토큰 (사이트 중성 톤으로 정렬)
|
||||
└── v5-layout.css ← v5 토큰 + preset 분기 (shadcn --primary 도 preset 따라감)
|
||||
|
||||
frontend/lib/registry/components/v2-input/index.ts
|
||||
└── config_panel: V2FieldConfigPanel ← 빌더가 실제 쓰는 등록처 (기억해둘 것)
|
||||
```
|
||||
Reference in New Issue
Block a user