POP 디자이너 v5 그리드 시스템 통합 및 그리드 가이드 재설계
레거시 v1~v4 시스템 제거 (6,634줄 순감) GridGuide SVG → PopRenderer CSS Grid 기반으로 전환 행/열 라벨 추가로 배치 위치 명확화 컴포넌트 타입 pop-sample로 단순화 문서 정리 (ARCHITECTURE, SPEC, CHANGELOG, ADR)
This commit is contained in:
@@ -2,50 +2,73 @@
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PopComponentDefinition, PopComponentConfig } from "../types/pop-layout";
|
||||
import { Settings, Database, Link2 } from "lucide-react";
|
||||
import {
|
||||
PopComponentDefinitionV5,
|
||||
PopGridPosition,
|
||||
GridMode,
|
||||
GRID_BREAKPOINTS,
|
||||
PopComponentType,
|
||||
} from "../types/pop-layout";
|
||||
import {
|
||||
Settings,
|
||||
Database,
|
||||
Eye,
|
||||
Grid3x3,
|
||||
MoveHorizontal,
|
||||
MoveVertical,
|
||||
} from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
// ========================================
|
||||
// Props 정의
|
||||
// Props
|
||||
// ========================================
|
||||
|
||||
interface ComponentEditorPanelProps {
|
||||
/** 선택된 컴포넌트 (없으면 null) */
|
||||
component: PopComponentDefinition | null;
|
||||
/** 컴포넌트 설정 변경 시 호출 */
|
||||
onConfigChange?: (config: Partial<PopComponentConfig>) => void;
|
||||
/** 컴포넌트 라벨 변경 시 호출 */
|
||||
onLabelChange?: (label: string) => void;
|
||||
/** 선택된 컴포넌트 */
|
||||
component: PopComponentDefinitionV5 | null;
|
||||
/** 현재 모드 */
|
||||
currentMode: GridMode;
|
||||
/** 컴포넌트 업데이트 */
|
||||
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||
/** 추가 className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 컴포넌트 편집 패널
|
||||
//
|
||||
// 역할:
|
||||
// - 선택된 컴포넌트의 설정을 편집
|
||||
// - 3개 탭: 기본 설정 / 데이터 바인딩 / 데이터 연결
|
||||
//
|
||||
// TODO:
|
||||
// - 타입별 상세 설정 UI 구현
|
||||
// - 데이터 바인딩 UI 구현
|
||||
// - 데이터 플로우 UI 구현
|
||||
// 컴포넌트 타입별 라벨
|
||||
// ========================================
|
||||
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||
"pop-field": "필드",
|
||||
"pop-button": "버튼",
|
||||
"pop-list": "리스트",
|
||||
"pop-indicator": "인디케이터",
|
||||
"pop-scanner": "스캐너",
|
||||
"pop-numpad": "숫자패드",
|
||||
"pop-spacer": "스페이서",
|
||||
"pop-break": "줄바꿈",
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// 컴포넌트 편집 패널 (v5 그리드 시스템)
|
||||
// ========================================
|
||||
|
||||
export function ComponentEditorPanel({
|
||||
export default function ComponentEditorPanel({
|
||||
component,
|
||||
onConfigChange,
|
||||
onLabelChange,
|
||||
currentMode,
|
||||
onUpdateComponent,
|
||||
className,
|
||||
}: ComponentEditorPanelProps) {
|
||||
// 컴포넌트가 선택되지 않은 경우
|
||||
const breakpoint = GRID_BREAKPOINTS[currentMode];
|
||||
|
||||
// 선택된 컴포넌트 없음
|
||||
if (!component) {
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
<div className="border-b px-4 py-3">
|
||||
<h3 className="text-sm font-medium">컴포넌트 편집</h3>
|
||||
<h3 className="text-sm font-medium">속성</h3>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
|
||||
컴포넌트를 선택하세요
|
||||
@@ -54,50 +77,75 @@ export function ComponentEditorPanel({
|
||||
);
|
||||
}
|
||||
|
||||
// 기본 모드 여부
|
||||
const isDefaultMode = currentMode === "tablet_landscape";
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
<div className={cn("flex h-full flex-col bg-white", className)}>
|
||||
{/* 헤더 */}
|
||||
<div className="border-b px-4 py-3">
|
||||
<h3 className="text-sm font-medium">
|
||||
{component.label || getComponentTypeLabel(component.type)}
|
||||
{component.label || COMPONENT_TYPE_LABELS[component.type]}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">{component.type}</p>
|
||||
{!isDefaultMode && (
|
||||
<p className="text-xs text-amber-600 mt-1">
|
||||
기본 모드(태블릿 가로)에서만 위치 편집 가능
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 탭 컨텐츠 */}
|
||||
<Tabs defaultValue="settings" className="flex-1">
|
||||
{/* 탭 */}
|
||||
<Tabs defaultValue="position" className="flex flex-1 flex-col">
|
||||
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2">
|
||||
<TabsTrigger value="position" className="gap-1 text-xs">
|
||||
<Grid3x3 className="h-3 w-3" />
|
||||
위치
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="gap-1 text-xs">
|
||||
<Settings className="h-3 w-3" />
|
||||
설정
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="visibility" className="gap-1 text-xs">
|
||||
<Eye className="h-3 w-3" />
|
||||
표시
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="data" className="gap-1 text-xs">
|
||||
<Database className="h-3 w-3" />
|
||||
데이터
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="flow" className="gap-1 text-xs">
|
||||
<Link2 className="h-3 w-3" />
|
||||
연결
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 기본 설정 탭 */}
|
||||
<TabsContent value="settings" className="flex-1 overflow-auto p-4">
|
||||
<ComponentSettingsForm
|
||||
{/* 위치 탭 */}
|
||||
<TabsContent value="position" className="flex-1 overflow-auto p-4">
|
||||
<PositionForm
|
||||
component={component}
|
||||
onConfigChange={onConfigChange}
|
||||
onLabelChange={onLabelChange}
|
||||
currentMode={currentMode}
|
||||
isDefaultMode={isDefaultMode}
|
||||
columns={breakpoint.columns}
|
||||
onUpdate={onUpdateComponent}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 데이터 바인딩 탭 (뼈대) */}
|
||||
<TabsContent value="data" className="flex-1 overflow-auto p-4">
|
||||
<DataBindingPlaceholder />
|
||||
{/* 설정 탭 */}
|
||||
<TabsContent value="settings" className="flex-1 overflow-auto p-4">
|
||||
<ComponentSettingsForm
|
||||
component={component}
|
||||
onUpdate={onUpdateComponent}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 데이터 연결 탭 (뼈대) */}
|
||||
<TabsContent value="flow" className="flex-1 overflow-auto p-4">
|
||||
<DataFlowPlaceholder />
|
||||
{/* 표시 탭 */}
|
||||
<TabsContent value="visibility" className="flex-1 overflow-auto p-4">
|
||||
<VisibilityForm
|
||||
component={component}
|
||||
onUpdate={onUpdateComponent}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 데이터 탭 */}
|
||||
<TabsContent value="data" className="flex-1 overflow-auto p-4">
|
||||
<DataBindingPlaceholder />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -105,41 +153,186 @@ export function ComponentEditorPanel({
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 컴포넌트 설정 폼
|
||||
// 위치 편집 폼
|
||||
// ========================================
|
||||
|
||||
interface PositionFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
currentMode: GridMode;
|
||||
isDefaultMode: boolean;
|
||||
columns: number;
|
||||
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||
}
|
||||
|
||||
function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate }: PositionFormProps) {
|
||||
const { position } = component;
|
||||
|
||||
const handlePositionChange = (field: keyof PopGridPosition, value: number) => {
|
||||
// 범위 체크
|
||||
let clampedValue = Math.max(1, value);
|
||||
|
||||
if (field === "col" || field === "colSpan") {
|
||||
clampedValue = Math.min(columns, clampedValue);
|
||||
}
|
||||
if (field === "colSpan" && position.col + clampedValue - 1 > columns) {
|
||||
clampedValue = columns - position.col + 1;
|
||||
}
|
||||
|
||||
onUpdate?.({
|
||||
position: {
|
||||
...position,
|
||||
[field]: clampedValue,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 그리드 정보 */}
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
<p className="text-xs font-medium text-gray-700 mb-1">
|
||||
현재 그리드: {GRID_BREAKPOINTS[currentMode].label}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
최대 {columns}칸 × 무제한 행
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 열 위치 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium flex items-center gap-1">
|
||||
<MoveHorizontal className="h-3 w-3" />
|
||||
열 위치 (Col)
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={columns}
|
||||
value={position.col}
|
||||
onChange={(e) => handlePositionChange("col", parseInt(e.target.value) || 1)}
|
||||
disabled={!isDefaultMode}
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(1~{columns})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 행 위치 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium flex items-center gap-1">
|
||||
<MoveVertical className="h-3 w-3" />
|
||||
행 위치 (Row)
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={position.row}
|
||||
onChange={(e) => handlePositionChange("row", parseInt(e.target.value) || 1)}
|
||||
disabled={!isDefaultMode}
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(1~)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-gray-200" />
|
||||
|
||||
{/* 열 크기 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium flex items-center gap-1">
|
||||
<MoveHorizontal className="h-3 w-3" />
|
||||
열 크기 (ColSpan)
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={columns}
|
||||
value={position.colSpan}
|
||||
onChange={(e) => handlePositionChange("colSpan", parseInt(e.target.value) || 1)}
|
||||
disabled={!isDefaultMode}
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
칸 (1~{columns})
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{Math.round((position.colSpan / columns) * 100)}% 너비
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 행 크기 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium flex items-center gap-1">
|
||||
<MoveVertical className="h-3 w-3" />
|
||||
행 크기 (RowSpan)
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={position.rowSpan}
|
||||
onChange={(e) => handlePositionChange("rowSpan", parseInt(e.target.value) || 1)}
|
||||
disabled={!isDefaultMode}
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
행
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
높이: {position.rowSpan * GRID_BREAKPOINTS[currentMode].rowHeight}px
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 비활성화 안내 */}
|
||||
{!isDefaultMode && (
|
||||
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3">
|
||||
<p className="text-xs text-amber-800">
|
||||
위치 편집은 기본 모드(태블릿 가로)에서만 가능합니다.
|
||||
다른 모드에서는 자동으로 변환됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 설정 폼
|
||||
// ========================================
|
||||
|
||||
interface ComponentSettingsFormProps {
|
||||
component: PopComponentDefinition;
|
||||
onConfigChange?: (config: Partial<PopComponentConfig>) => void;
|
||||
onLabelChange?: (label: string) => void;
|
||||
component: PopComponentDefinitionV5;
|
||||
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||
}
|
||||
|
||||
function ComponentSettingsForm({
|
||||
component,
|
||||
onConfigChange,
|
||||
onLabelChange,
|
||||
}: ComponentSettingsFormProps) {
|
||||
function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 라벨 입력 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">라벨</label>
|
||||
<input
|
||||
{/* 라벨 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">라벨</Label>
|
||||
<Input
|
||||
type="text"
|
||||
className="h-8 w-full rounded border border-input bg-background px-2 text-sm"
|
||||
value={component.label || ""}
|
||||
onChange={(e) => onLabelChange?.(e.target.value)}
|
||||
placeholder="컴포넌트 라벨"
|
||||
onChange={(e) => onUpdate?.({ label: e.target.value })}
|
||||
placeholder="컴포넌트 이름"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 타입별 설정 (TODO: 상세 구현) */}
|
||||
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{getComponentTypeLabel(component.type)} 상세 설정
|
||||
</p>
|
||||
<p className="mt-1 text-center text-xs text-muted-foreground">
|
||||
(추후 구현 예정)
|
||||
{/* 컴포넌트 타입별 설정 (추후 구현) */}
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{component.type} 전용 설정은 Phase 4에서 구현 예정
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,69 +340,82 @@ function ComponentSettingsForm({
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 데이터 바인딩 플레이스홀더 (뼈대)
|
||||
// 표시/숨김 폼
|
||||
// ========================================
|
||||
|
||||
interface VisibilityFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||
}
|
||||
|
||||
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
|
||||
const modes: Array<{ key: GridMode; label: string }> = [
|
||||
{ key: "tablet_landscape", label: "태블릿 가로 (12칸)" },
|
||||
{ key: "tablet_portrait", label: "태블릿 세로 (8칸)" },
|
||||
{ key: "mobile_landscape", label: "모바일 가로 (6칸)" },
|
||||
{ key: "mobile_portrait", label: "모바일 세로 (4칸)" },
|
||||
];
|
||||
|
||||
const handleVisibilityChange = (mode: GridMode, visible: boolean) => {
|
||||
onUpdate?.({
|
||||
visibility: {
|
||||
...component.visibility,
|
||||
[mode]: visible,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">모드별 표시 설정</Label>
|
||||
|
||||
{modes.map((mode) => {
|
||||
const isVisible = component.visibility?.[mode.key] !== false;
|
||||
|
||||
return (
|
||||
<div key={mode.key} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`visibility-${mode.key}`}
|
||||
checked={isVisible}
|
||||
onCheckedChange={(checked) =>
|
||||
handleVisibilityChange(mode.key, checked === true)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`visibility-${mode.key}`}
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
{mode.label}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-blue-50 border border-blue-200 p-3">
|
||||
<p className="text-xs text-blue-800">
|
||||
체크 해제하면 해당 모드에서 컴포넌트가 숨겨집니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 데이터 바인딩 플레이스홀더
|
||||
// ========================================
|
||||
|
||||
function DataBindingPlaceholder() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Database className="h-8 w-8 text-gray-400" />
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
데이터 바인딩 설정
|
||||
</p>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
테이블 선택 → 칼럼 선택 → 조인 설정
|
||||
</p>
|
||||
<p className="mt-2 text-center text-xs text-gray-400">
|
||||
(추후 구현 예정)
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-4 text-center">
|
||||
<Database className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-gray-700">데이터 바인딩</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Phase 4에서 구현 예정
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 데이터 플로우 플레이스홀더 (뼈대)
|
||||
// ========================================
|
||||
|
||||
function DataFlowPlaceholder() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Link2 className="h-8 w-8 text-gray-400" />
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
데이터 연결 설정
|
||||
</p>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
컴포넌트 간 / 섹션 간 / 화면 간 연결
|
||||
</p>
|
||||
<p className="mt-2 text-center text-xs text-gray-400">
|
||||
(추후 구현 예정)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 헬퍼 함수
|
||||
// ========================================
|
||||
|
||||
function getComponentTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
"pop-field": "필드",
|
||||
"pop-button": "버튼",
|
||||
"pop-list": "리스트",
|
||||
"pop-indicator": "인디케이터",
|
||||
"pop-scanner": "스캐너",
|
||||
"pop-numpad": "넘패드",
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
export default ComponentEditorPanel;
|
||||
|
||||
Reference in New Issue
Block a user