2026-02-04 14:14:48 +09:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import React from "react";
|
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
import {
|
|
|
|
|
|
PopComponentDefinitionV4,
|
|
|
|
|
|
PopSizeConstraintV4,
|
|
|
|
|
|
PopContainerV4,
|
|
|
|
|
|
PopComponentType,
|
|
|
|
|
|
} from "../types/pop-layout";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Settings,
|
|
|
|
|
|
Database,
|
|
|
|
|
|
Link2,
|
|
|
|
|
|
MoveHorizontal,
|
|
|
|
|
|
MoveVertical,
|
|
|
|
|
|
Square,
|
|
|
|
|
|
Maximize2,
|
|
|
|
|
|
AlignCenter,
|
2026-02-04 18:23:59 +09:00
|
|
|
|
Eye,
|
2026-02-04 14:14:48 +09:00
|
|
|
|
} from "lucide-react";
|
|
|
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Select,
|
|
|
|
|
|
SelectContent,
|
|
|
|
|
|
SelectItem,
|
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
|
SelectValue,
|
|
|
|
|
|
} from "@/components/ui/select";
|
|
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
// Props 정의
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
|
|
interface ComponentEditorPanelV4Props {
|
|
|
|
|
|
/** 선택된 컴포넌트 */
|
|
|
|
|
|
component: PopComponentDefinitionV4 | null;
|
|
|
|
|
|
/** 선택된 컨테이너 */
|
|
|
|
|
|
container: PopContainerV4 | null;
|
2026-02-04 18:23:59 +09:00
|
|
|
|
/** 현재 뷰포트 모드 */
|
|
|
|
|
|
currentViewportMode?: "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
|
2026-02-04 14:14:48 +09:00
|
|
|
|
/** 컴포넌트 업데이트 */
|
|
|
|
|
|
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV4>) => void;
|
|
|
|
|
|
/** 컨테이너 업데이트 */
|
|
|
|
|
|
onUpdateContainer?: (updates: Partial<PopContainerV4>) => void;
|
|
|
|
|
|
/** 추가 className */
|
|
|
|
|
|
className?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
// 컴포넌트 타입별 라벨
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
|
|
|
|
|
"pop-field": "필드",
|
|
|
|
|
|
"pop-button": "버튼",
|
|
|
|
|
|
"pop-list": "리스트",
|
|
|
|
|
|
"pop-indicator": "인디케이터",
|
|
|
|
|
|
"pop-scanner": "스캐너",
|
|
|
|
|
|
"pop-numpad": "숫자패드",
|
2026-02-04 18:23:59 +09:00
|
|
|
|
"pop-spacer": "스페이서",
|
|
|
|
|
|
"pop-break": "줄바꿈",
|
2026-02-04 14:14:48 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
// v4 컴포넌트 편집 패널
|
|
|
|
|
|
//
|
|
|
|
|
|
// 핵심:
|
|
|
|
|
|
// - 크기 제약 편집 (fixed/fill/hug)
|
|
|
|
|
|
// - 반응형 숨김 설정
|
|
|
|
|
|
// - 개별 정렬 설정
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
|
|
export function ComponentEditorPanelV4({
|
|
|
|
|
|
component,
|
|
|
|
|
|
container,
|
2026-02-04 18:23:59 +09:00
|
|
|
|
currentViewportMode = "tablet_landscape",
|
2026-02-04 14:14:48 +09:00
|
|
|
|
onUpdateComponent,
|
|
|
|
|
|
onUpdateContainer,
|
|
|
|
|
|
className,
|
|
|
|
|
|
}: ComponentEditorPanelV4Props) {
|
|
|
|
|
|
// 아무것도 선택되지 않은 경우
|
|
|
|
|
|
if (!component && !container) {
|
|
|
|
|
|
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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
|
|
|
|
|
|
컴포넌트 또는 컨테이너를 선택하세요
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 컨테이너가 선택된 경우
|
|
|
|
|
|
if (container) {
|
2026-02-04 18:23:59 +09:00
|
|
|
|
const isNonDefaultMode = currentViewportMode !== "tablet_landscape";
|
|
|
|
|
|
|
2026-02-04 14:14:48 +09:00
|
|
|
|
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>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">{container.id}</p>
|
2026-02-04 18:23:59 +09:00
|
|
|
|
{isNonDefaultMode && (
|
|
|
|
|
|
<p className="text-xs text-amber-600 mt-1">
|
|
|
|
|
|
다른 모드에서는 드래그로 배치 변경 후 '고정' 버튼을 사용하세요
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
2026-02-04 14:14:48 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex-1 overflow-auto p-4">
|
|
|
|
|
|
<ContainerSettingsForm
|
|
|
|
|
|
container={container}
|
2026-02-04 18:23:59 +09:00
|
|
|
|
currentViewportMode={currentViewportMode}
|
2026-02-04 14:14:48 +09:00
|
|
|
|
onUpdate={onUpdateContainer}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 컴포넌트가 선택된 경우
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className={cn("flex h-full flex-col", className)}>
|
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
|
<div className="border-b px-4 py-3">
|
|
|
|
|
|
<h3 className="text-sm font-medium">
|
|
|
|
|
|
{component!.label || COMPONENT_TYPE_LABELS[component!.type]}
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">{component!.type}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 탭 컨텐츠 */}
|
|
|
|
|
|
<Tabs defaultValue="size" className="flex-1">
|
|
|
|
|
|
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2">
|
|
|
|
|
|
<TabsTrigger value="size" className="gap-1 text-xs">
|
|
|
|
|
|
<Maximize2 className="h-3 w-3" />
|
|
|
|
|
|
크기
|
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
|
<TabsTrigger value="settings" className="gap-1 text-xs">
|
|
|
|
|
|
<Settings className="h-3 w-3" />
|
|
|
|
|
|
설정
|
|
|
|
|
|
</TabsTrigger>
|
2026-02-04 18:23:59 +09:00
|
|
|
|
<TabsTrigger value="visibility" className="gap-1 text-xs">
|
|
|
|
|
|
<Eye className="h-3 w-3" />
|
|
|
|
|
|
표시
|
|
|
|
|
|
</TabsTrigger>
|
2026-02-04 14:14:48 +09:00
|
|
|
|
<TabsTrigger value="data" className="gap-1 text-xs">
|
|
|
|
|
|
<Database className="h-3 w-3" />
|
|
|
|
|
|
데이터
|
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
|
</TabsList>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 크기 제약 탭 */}
|
|
|
|
|
|
<TabsContent value="size" className="flex-1 overflow-auto p-4">
|
|
|
|
|
|
<SizeConstraintForm
|
|
|
|
|
|
component={component!}
|
|
|
|
|
|
onUpdate={onUpdateComponent}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 기본 설정 탭 */}
|
|
|
|
|
|
<TabsContent value="settings" className="flex-1 overflow-auto p-4">
|
|
|
|
|
|
<ComponentSettingsForm
|
|
|
|
|
|
component={component!}
|
|
|
|
|
|
onUpdate={onUpdateComponent}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
2026-02-04 18:23:59 +09:00
|
|
|
|
{/* 모드별 표시 탭 */}
|
|
|
|
|
|
<TabsContent value="visibility" className="flex-1 overflow-auto p-4">
|
|
|
|
|
|
<VisibilityForm
|
|
|
|
|
|
component={component!}
|
|
|
|
|
|
onUpdate={onUpdateComponent}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
2026-02-04 14:14:48 +09:00
|
|
|
|
{/* 데이터 바인딩 탭 */}
|
|
|
|
|
|
<TabsContent value="data" className="flex-1 overflow-auto p-4">
|
|
|
|
|
|
<DataBindingPlaceholder />
|
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
</Tabs>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
// 크기 제약 폼
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
|
|
interface SizeConstraintFormProps {
|
|
|
|
|
|
component: PopComponentDefinitionV4;
|
|
|
|
|
|
onUpdate?: (updates: Partial<PopComponentDefinitionV4>) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SizeConstraintForm({ component, onUpdate }: SizeConstraintFormProps) {
|
|
|
|
|
|
const { size } = component;
|
|
|
|
|
|
|
|
|
|
|
|
const handleSizeChange = (
|
|
|
|
|
|
field: keyof PopSizeConstraintV4,
|
|
|
|
|
|
value: string | number | undefined
|
|
|
|
|
|
) => {
|
|
|
|
|
|
onUpdate?.({
|
|
|
|
|
|
size: {
|
|
|
|
|
|
...size,
|
|
|
|
|
|
[field]: value,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
{/* 너비 설정 */}
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<Label className="text-xs font-medium flex items-center gap-1">
|
|
|
|
|
|
<MoveHorizontal className="h-3 w-3" />
|
|
|
|
|
|
너비
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-1">
|
|
|
|
|
|
<SizeButton
|
|
|
|
|
|
active={size.width === "fixed"}
|
|
|
|
|
|
onClick={() => handleSizeChange("width", "fixed")}
|
|
|
|
|
|
label="고정"
|
|
|
|
|
|
description="px"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<SizeButton
|
|
|
|
|
|
active={size.width === "fill"}
|
|
|
|
|
|
onClick={() => handleSizeChange("width", "fill")}
|
|
|
|
|
|
label="채움"
|
|
|
|
|
|
description="flex"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<SizeButton
|
|
|
|
|
|
active={size.width === "hug"}
|
|
|
|
|
|
onClick={() => handleSizeChange("width", "hug")}
|
|
|
|
|
|
label="맞춤"
|
|
|
|
|
|
description="auto"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 고정 너비 입력 */}
|
|
|
|
|
|
{size.width === "fixed" && (
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
className="h-8 w-24 text-xs"
|
|
|
|
|
|
value={size.fixedWidth || ""}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
handleSizeChange(
|
|
|
|
|
|
"fixedWidth",
|
|
|
|
|
|
e.target.value ? Number(e.target.value) : undefined
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
placeholder="너비"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-xs text-muted-foreground">px</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 채움일 때 최소/최대 */}
|
|
|
|
|
|
{size.width === "fill" && (
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
className="h-8 w-20 text-xs"
|
|
|
|
|
|
value={size.minWidth || ""}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
handleSizeChange(
|
|
|
|
|
|
"minWidth",
|
|
|
|
|
|
e.target.value ? Number(e.target.value) : undefined
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
placeholder="최소"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-xs text-muted-foreground">~</span>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
className="h-8 w-20 text-xs"
|
|
|
|
|
|
value={size.maxWidth || ""}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
handleSizeChange(
|
|
|
|
|
|
"maxWidth",
|
|
|
|
|
|
e.target.value ? Number(e.target.value) : undefined
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
placeholder="최대"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-xs text-muted-foreground">px</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 높이 설정 */}
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<Label className="text-xs font-medium flex items-center gap-1">
|
|
|
|
|
|
<MoveVertical className="h-3 w-3" />
|
|
|
|
|
|
높이
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-1">
|
|
|
|
|
|
<SizeButton
|
|
|
|
|
|
active={size.height === "fixed"}
|
|
|
|
|
|
onClick={() => handleSizeChange("height", "fixed")}
|
|
|
|
|
|
label="고정"
|
|
|
|
|
|
description="px"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<SizeButton
|
|
|
|
|
|
active={size.height === "fill"}
|
|
|
|
|
|
onClick={() => handleSizeChange("height", "fill")}
|
|
|
|
|
|
label="채움"
|
|
|
|
|
|
description="flex"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<SizeButton
|
|
|
|
|
|
active={size.height === "hug"}
|
|
|
|
|
|
onClick={() => handleSizeChange("height", "hug")}
|
|
|
|
|
|
label="맞춤"
|
|
|
|
|
|
description="auto"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 고정 높이 입력 */}
|
|
|
|
|
|
{size.height === "fixed" && (
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
className="h-8 w-24 text-xs"
|
|
|
|
|
|
value={size.fixedHeight || ""}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
handleSizeChange(
|
|
|
|
|
|
"fixedHeight",
|
|
|
|
|
|
e.target.value ? Number(e.target.value) : undefined
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
placeholder="높이"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-xs text-muted-foreground">px</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 채움일 때 최소 */}
|
|
|
|
|
|
{size.height === "fill" && (
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
className="h-8 w-24 text-xs"
|
|
|
|
|
|
value={size.minHeight || ""}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
handleSizeChange(
|
|
|
|
|
|
"minHeight",
|
|
|
|
|
|
e.target.value ? Number(e.target.value) : undefined
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
placeholder="최소 높이"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-xs text-muted-foreground">px</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 개별 정렬 */}
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<Label className="text-xs font-medium flex items-center gap-1">
|
|
|
|
|
|
<AlignCenter className="h-3 w-3" />
|
|
|
|
|
|
개별 정렬
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={component.alignSelf || "none"}
|
|
|
|
|
|
onValueChange={(value) =>
|
|
|
|
|
|
onUpdate?.({
|
|
|
|
|
|
alignSelf: value === "none" ? undefined : (value as any),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger className="h-8 text-xs">
|
|
|
|
|
|
<SelectValue placeholder="컨테이너 설정 따름" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="none">컨테이너 설정 따름</SelectItem>
|
|
|
|
|
|
<SelectItem value="start">시작</SelectItem>
|
|
|
|
|
|
<SelectItem value="center">가운데</SelectItem>
|
|
|
|
|
|
<SelectItem value="end">끝</SelectItem>
|
|
|
|
|
|
<SelectItem value="stretch">늘이기</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
// 크기 버튼 컴포넌트
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
|
|
interface SizeButtonProps {
|
|
|
|
|
|
active: boolean;
|
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
description: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SizeButton({ active, onClick, label, description }: SizeButtonProps) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex-1 flex flex-col items-center gap-0.5 rounded-md border p-2 text-xs transition-colors",
|
|
|
|
|
|
active
|
|
|
|
|
|
? "border-primary bg-primary/10 text-primary"
|
|
|
|
|
|
: "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
|
|
|
|
|
|
)}
|
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="font-medium">{label}</span>
|
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">{description}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
// 컨테이너 설정 폼
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
|
|
interface ContainerSettingsFormProps {
|
|
|
|
|
|
container: PopContainerV4;
|
2026-02-04 18:23:59 +09:00
|
|
|
|
currentViewportMode?: "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
|
2026-02-04 14:14:48 +09:00
|
|
|
|
onUpdate?: (updates: Partial<PopContainerV4>) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ContainerSettingsForm({
|
|
|
|
|
|
container,
|
2026-02-04 18:23:59 +09:00
|
|
|
|
currentViewportMode = "tablet_landscape",
|
2026-02-04 14:14:48 +09:00
|
|
|
|
onUpdate,
|
|
|
|
|
|
}: ContainerSettingsFormProps) {
|
2026-02-04 18:23:59 +09:00
|
|
|
|
const isNonDefaultMode = currentViewportMode !== "tablet_landscape";
|
|
|
|
|
|
|
2026-02-04 14:14:48 +09:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
{/* 방향 */}
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<Label className="text-xs font-medium">방향</Label>
|
|
|
|
|
|
<div className="flex gap-1">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant={container.direction === "horizontal" ? "default" : "outline"}
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="flex-1 h-8 text-xs"
|
|
|
|
|
|
onClick={() => onUpdate?.({ direction: "horizontal" })}
|
2026-02-04 18:23:59 +09:00
|
|
|
|
disabled={isNonDefaultMode}
|
2026-02-04 14:14:48 +09:00
|
|
|
|
>
|
|
|
|
|
|
가로
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant={container.direction === "vertical" ? "default" : "outline"}
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="flex-1 h-8 text-xs"
|
|
|
|
|
|
onClick={() => onUpdate?.({ direction: "vertical" })}
|
2026-02-04 18:23:59 +09:00
|
|
|
|
disabled={isNonDefaultMode}
|
2026-02-04 14:14:48 +09:00
|
|
|
|
>
|
|
|
|
|
|
세로
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-02-04 18:23:59 +09:00
|
|
|
|
{isNonDefaultMode && (
|
|
|
|
|
|
<p className="text-[10px] text-amber-600">
|
|
|
|
|
|
드래그로 배치 변경 후 '고정' 버튼 클릭
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
2026-02-04 14:14:48 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 줄바꿈 */}
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<Label className="text-xs font-medium">줄바꿈</Label>
|
|
|
|
|
|
<div className="flex gap-1">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant={container.wrap ? "default" : "outline"}
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="flex-1 h-8 text-xs"
|
|
|
|
|
|
onClick={() => onUpdate?.({ wrap: true })}
|
2026-02-04 18:23:59 +09:00
|
|
|
|
disabled={isNonDefaultMode}
|
2026-02-04 14:14:48 +09:00
|
|
|
|
>
|
|
|
|
|
|
허용
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant={!container.wrap ? "default" : "outline"}
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="flex-1 h-8 text-xs"
|
|
|
|
|
|
onClick={() => onUpdate?.({ wrap: false })}
|
2026-02-04 18:23:59 +09:00
|
|
|
|
disabled={isNonDefaultMode}
|
2026-02-04 14:14:48 +09:00
|
|
|
|
>
|
|
|
|
|
|
금지
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 간격 */}
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<Label className="text-xs font-medium">간격 (gap)</Label>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
className="h-8 w-24 text-xs"
|
|
|
|
|
|
value={container.gap}
|
|
|
|
|
|
onChange={(e) => onUpdate?.({ gap: Number(e.target.value) || 0 })}
|
2026-02-04 18:23:59 +09:00
|
|
|
|
disabled={isNonDefaultMode}
|
2026-02-04 14:14:48 +09:00
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-xs text-muted-foreground">px</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 패딩 */}
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<Label className="text-xs font-medium">패딩</Label>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
className="h-8 w-24 text-xs"
|
|
|
|
|
|
value={container.padding || 0}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
onUpdate?.({ padding: Number(e.target.value) || undefined })
|
|
|
|
|
|
}
|
2026-02-04 18:23:59 +09:00
|
|
|
|
disabled={isNonDefaultMode}
|
2026-02-04 14:14:48 +09:00
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-xs text-muted-foreground">px</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 정렬 */}
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<Label className="text-xs font-medium">교차축 정렬</Label>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={container.alignItems}
|
|
|
|
|
|
onValueChange={(value) => onUpdate?.({ alignItems: value as any })}
|
2026-02-04 18:23:59 +09:00
|
|
|
|
disabled={isNonDefaultMode}
|
2026-02-04 14:14:48 +09:00
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger className="h-8 text-xs">
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="start">시작</SelectItem>
|
|
|
|
|
|
<SelectItem value="center">가운데</SelectItem>
|
|
|
|
|
|
<SelectItem value="end">끝</SelectItem>
|
|
|
|
|
|
<SelectItem value="stretch">늘이기</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<Label className="text-xs font-medium">주축 정렬</Label>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={container.justifyContent}
|
|
|
|
|
|
onValueChange={(value) => onUpdate?.({ justifyContent: value as any })}
|
2026-02-04 18:23:59 +09:00
|
|
|
|
disabled={isNonDefaultMode}
|
2026-02-04 14:14:48 +09:00
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger className="h-8 text-xs">
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="start">시작</SelectItem>
|
|
|
|
|
|
<SelectItem value="center">가운데</SelectItem>
|
|
|
|
|
|
<SelectItem value="end">끝</SelectItem>
|
|
|
|
|
|
<SelectItem value="space-between">균등 배치</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
// 컴포넌트 설정 폼
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
|
|
interface ComponentSettingsFormProps {
|
|
|
|
|
|
component: PopComponentDefinitionV4;
|
|
|
|
|
|
onUpdate?: (updates: Partial<PopComponentDefinitionV4>) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
className="h-8 text-xs"
|
|
|
|
|
|
value={component.label || ""}
|
|
|
|
|
|
onChange={(e) => onUpdate?.({ label: e.target.value })}
|
|
|
|
|
|
placeholder="컴포넌트 라벨"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</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">
|
|
|
|
|
|
{COMPONENT_TYPE_LABELS[component.type]} 상세 설정
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="mt-1 text-center text-xs text-muted-foreground">
|
|
|
|
|
|
(추후 구현 예정)
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 18:23:59 +09:00
|
|
|
|
// ========================================
|
|
|
|
|
|
// 모드별 표시/숨김 폼
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
|
|
interface VisibilityFormProps {
|
|
|
|
|
|
component: PopComponentDefinitionV4;
|
|
|
|
|
|
onUpdate?: (updates: Partial<PopComponentDefinitionV4>) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
|
|
|
|
|
|
const modes = [
|
|
|
|
|
|
{ key: "tablet_landscape" as const, label: "태블릿 가로 (1024×768)" },
|
|
|
|
|
|
{ key: "tablet_portrait" as const, label: "태블릿 세로 (768×1024)" },
|
|
|
|
|
|
{ key: "mobile_landscape" as const, label: "모바일 가로 (667×375)" },
|
|
|
|
|
|
{ key: "mobile_portrait" as const, label: "모바일 세로 (375×667)" },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<Label className="text-xs font-medium flex items-center gap-1">
|
|
|
|
|
|
<Eye className="h-3 w-3" />
|
|
|
|
|
|
모드별 표시 설정
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
|
체크 해제하면 해당 모드에서 컴포넌트가 숨겨집니다
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2 rounded-lg border p-3">
|
|
|
|
|
|
{modes.map(({ key, label }) => {
|
|
|
|
|
|
const isChecked = component.visibility?.[key] !== false;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={key} className="flex items-center gap-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
id={`visibility-${key}`}
|
|
|
|
|
|
checked={isChecked}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
onUpdate?.({
|
|
|
|
|
|
visibility: {
|
|
|
|
|
|
...component.visibility,
|
|
|
|
|
|
[key]: e.target.checked,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="h-4 w-4 rounded border-gray-300"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<label
|
|
|
|
|
|
htmlFor={`visibility-${key}`}
|
|
|
|
|
|
className="text-xs cursor-pointer flex-1"
|
|
|
|
|
|
>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
{!isChecked && (
|
|
|
|
|
|
<span className="text-xs text-muted-foreground">(숨김)</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 반응형 숨김 (픽셀 기반) */}
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<Label className="text-xs font-medium">반응형 숨김 (픽셀 기반)</Label>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
className="h-8 w-24 text-xs"
|
|
|
|
|
|
value={component.hideBelow || ""}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
onUpdate?.({
|
|
|
|
|
|
hideBelow: e.target.value ? Number(e.target.value) : undefined,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
placeholder="없음"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-xs text-muted-foreground">px 이하에서 숨김</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
|
예: 500 입력 시 화면 너비가 500px 이하면 자동 숨김
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 14:14:48 +09:00
|
|
|
|
// ========================================
|
|
|
|
|
|
// 데이터 바인딩 플레이스홀더
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default ComponentEditorPanelV4;
|