diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index efcc2c8e..8bcc8f3a 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -12,6 +12,9 @@ import { } from "@/components/ui/resizable"; import { toast } from "sonner"; +// POP 컴포넌트 자동 등록 (반드시 다른 import보다 먼저) +import "@/lib/registry/pop-components"; + import PopCanvas from "./PopCanvas"; import ComponentEditorPanel from "./panels/ComponentEditorPanel"; import ComponentPalette from "./panels/ComponentPalette"; diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index 4998a67d..ddb7ac79 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -21,6 +21,7 @@ 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"; +import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; // ======================================== // Props @@ -315,6 +316,15 @@ interface ComponentSettingsFormProps { } function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormProps) { + // PopComponentRegistry에서 configPanel 가져오기 + const registeredComp = PopComponentRegistry.getComponent(component.type); + const ConfigPanel = registeredComp?.configPanel; + + // config 업데이트 핸들러 + const handleConfigUpdate = (newConfig: any) => { + onUpdate?.({ config: newConfig }); + }; + return (
{/* 라벨 */} @@ -329,12 +339,19 @@ function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormPro />
- {/* 컴포넌트 타입별 설정 (추후 구현) */} -
-

- {component.type} 전용 설정은 Phase 4에서 구현 예정 -

-
+ {/* 컴포넌트 타입별 설정 패널 */} + {ConfigPanel ? ( + + ) : ( +
+

+ {component.type} 전용 설정이 없습니다 +

+
+ )} ); } diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx index d594bbbc..05db0aab 100644 --- a/frontend/components/pop/designer/panels/ComponentPalette.tsx +++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx @@ -3,7 +3,7 @@ import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; import { PopComponentType } from "../types/pop-layout"; -import { Square } from "lucide-react"; +import { Square, FileText } from "lucide-react"; import { DND_ITEM_TYPES } from "../constants"; // 컴포넌트 정의 @@ -21,6 +21,12 @@ const PALETTE_ITEMS: PaletteItem[] = [ icon: Square, description: "크기 조정 테스트용", }, + { + type: "pop-text", + label: "텍스트", + icon: FileText, + description: "텍스트, 시간, 이미지 표시", + }, ]; // 드래그 가능한 컴포넌트 아이템 diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index 301c78f8..b0299813 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -19,6 +19,7 @@ import { isOverlapping, getAllEffectivePositions, } from "../utils/gridUtils"; +import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; // ======================================== // Props @@ -500,7 +501,11 @@ interface ComponentContentProps { function ComponentContent({ component, effectivePosition, isDesignMode, isSelected }: ComponentContentProps) { const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; - // 디자인 모드: 플레이스홀더 표시 + // PopComponentRegistry에서 등록된 컴포넌트 가져오기 + const registeredComp = PopComponentRegistry.getComponent(component.type); + const PreviewComponent = registeredComp?.preview; + + // 디자인 모드: 미리보기 컴포넌트 또는 플레이스홀더 표시 if (isDesignMode) { return (
@@ -519,11 +524,15 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
- {/* 내용 */} -
- - {typeLabel} - + {/* 내용: 등록된 preview 컴포넌트 또는 기본 플레이스홀더 */} +
+ {PreviewComponent ? ( + + ) : ( + + {typeLabel} + + )}
{/* 위치 정보 표시 (유효 위치 사용) */} diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index 146af62c..1a8335ec 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -9,7 +9,7 @@ /** * POP 컴포넌트 타입 */ -export type PopComponentType = "pop-sample"; // 테스트용 샘플 박스 +export type PopComponentType = "pop-sample" | "pop-text"; // 테스트용 샘플 박스, 텍스트 컴포넌트 /** * 데이터 흐름 정의 @@ -341,6 +341,7 @@ export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => { */ export const DEFAULT_COMPONENT_GRID_SIZE: Record = { "pop-sample": { colSpan: 2, rowSpan: 1 }, + "pop-text": { colSpan: 3, rowSpan: 1 }, }; /** diff --git a/frontend/lib/registry/PopComponentRegistry.ts b/frontend/lib/registry/PopComponentRegistry.ts index f9620415..31f9a4e1 100644 --- a/frontend/lib/registry/PopComponentRegistry.ts +++ b/frontend/lib/registry/PopComponentRegistry.ts @@ -13,6 +13,7 @@ export interface PopComponentDefinition { icon?: string; component: React.ComponentType; configPanel?: React.ComponentType; + preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용 defaultProps?: Record; // POP 전용 속성 touchOptimized?: boolean; diff --git a/frontend/lib/registry/pop-components/index.ts b/frontend/lib/registry/pop-components/index.ts index fd98edd8..b604f9e8 100644 --- a/frontend/lib/registry/pop-components/index.ts +++ b/frontend/lib/registry/pop-components/index.ts @@ -1,24 +1,20 @@ +"use client"; + /** - * POP 컴포넌트 인덱스 + * POP 컴포넌트 자동 등록 진입점 * - * POP(모바일/태블릿) 전용 컴포넌트를 export합니다. - * 새로운 POP 컴포넌트 추가 시 여기에 export를 추가하세요. + * [역할] + * - 각 컴포넌트 파일을 import하면 해당 파일 끝의 registerComponent()가 실행되어 자동 등록됨 + * - 예: import "./pop-text" → pop-text.tsx 실행 → PopComponentRegistry.registerComponent() 호출 */ -// ============================================ -// POP 컴포넌트 목록 -// ============================================ -// 4단계에서 추가될 컴포넌트들: -// - pop-card-list: 카드형 리스트 -// - pop-touch-button: 터치 버튼 -// - pop-scanner-input: 스캐너 입력 -// - pop-status-badge: 상태 배지 +// 공통 타입 re-export (외부에서 필요 시 사용 가능) +export * from "./types"; -// 예시: 컴포넌트가 추가되면 다음과 같이 export -// export * from "./pop-card-list"; -// export * from "./pop-touch-button"; -// export * from "./pop-scanner-input"; -// export * from "./pop-status-badge"; +// POP 컴포넌트 등록 +import "./pop-text"; -// 현재는 빈 export (컴포넌트 개발 전) -export { }; +// 향후 추가될 컴포넌트들: +// import "./pop-field"; +// import "./pop-button"; +// import "./pop-list"; diff --git a/frontend/lib/registry/pop-components/pop-text.tsx b/frontend/lib/registry/pop-components/pop-text.tsx new file mode 100644 index 00000000..8cad19ad --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-text.tsx @@ -0,0 +1,831 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { Checkbox } from "@/components/ui/checkbox"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; +import { + FontSize, + FontWeight, + TextAlign, + ObjectFit, + VerticalAlign, + FONT_SIZE_LABELS, + FONT_WEIGHT_LABELS, + OBJECT_FIT_LABELS, + FONT_SIZE_CLASSES, + FONT_WEIGHT_CLASSES, + TEXT_ALIGN_CLASSES, + VERTICAL_ALIGN_LABELS, + VERTICAL_ALIGN_CLASSES, + JUSTIFY_CLASSES, +} from "./types"; + +// ======================================== +// 타입 정의 +// ======================================== +export type PopTextType = "text" | "datetime" | "image" | "title"; + +// datetime 빌더 설정 타입 +export interface DateTimeBuilderConfig { + // 날짜 요소 + showYear?: boolean; + showMonth?: boolean; + showDay?: boolean; + showWeekday?: boolean; + // 시간 요소 + showHour?: boolean; + showMinute?: boolean; + showSecond?: boolean; + // 표기 방식 + useKorean?: boolean; // true: 한글 (02월 04일), false: 숫자 (02/04) + // 구분자 + dateSeparator?: string; // "-", "/", "." +} + +export interface PopTextConfig { + textType: PopTextType; + content?: string; + dateFormat?: string; // 기존 호환용 (deprecated) + dateTimeConfig?: DateTimeBuilderConfig; // 새로운 빌더 설정 + isRealtime?: boolean; + imageUrl?: string; + objectFit?: ObjectFit; + imageScale?: number; // 이미지 크기 조정 (10-100%) + fontSize?: FontSize; + fontWeight?: FontWeight; + textAlign?: TextAlign; + verticalAlign?: VerticalAlign; // 상하 정렬 +} + +const TEXT_TYPE_LABELS: Record = { + text: "일반 텍스트", + datetime: "시간/날짜", + image: "이미지", + title: "제목", +}; + +// ======================================== +// datetime 포맷 빌드 함수 +// ======================================== +function buildDateTimeFormat(config?: DateTimeBuilderConfig): string { + // 설정이 없으면 기본값 (시:분:초) + if (!config) return "HH:mm:ss"; + + const sep = config.dateSeparator || "-"; + const parts: string[] = []; + + // 날짜 부분 조합 + const hasDateParts = config.showYear || config.showMonth || config.showDay; + if (hasDateParts) { + const dateParts: string[] = []; + if (config.showYear) dateParts.push(config.useKorean ? "yyyy년" : "yyyy"); + if (config.showMonth) dateParts.push(config.useKorean ? "MM월" : "MM"); + if (config.showDay) dateParts.push(config.useKorean ? "dd일" : "dd"); + + // 한글 모드: 공백으로 연결, 숫자 모드: 구분자로 연결 + parts.push(config.useKorean ? dateParts.join(" ") : dateParts.join(sep)); + } + + // 요일 + if (config.showWeekday) { + parts.push(config.useKorean ? "(EEEE)" : "(EEE)"); + } + + // 시간 부분 조합 + const timeParts: string[] = []; + if (config.showHour) timeParts.push(config.useKorean ? "HH시" : "HH"); + if (config.showMinute) timeParts.push(config.useKorean ? "mm분" : "mm"); + if (config.showSecond) timeParts.push(config.useKorean ? "ss초" : "ss"); + + if (timeParts.length > 0) { + // 한글 모드: 공백으로 연결, 숫자 모드: 콜론으로 연결 + parts.push(config.useKorean ? timeParts.join(" ") : timeParts.join(":")); + } + + // 아무것도 선택 안 했으면 기본값 + return parts.join(" ") || "HH:mm:ss"; +} + +// ======================================== +// 메인 컴포넌트 +// ======================================== +interface PopTextComponentProps { + config?: PopTextConfig; + label?: string; + isDesignMode?: boolean; +} + +export function PopTextComponent({ + config, + label, + isDesignMode, +}: PopTextComponentProps) { + const textType = config?.textType || "text"; + + if (isDesignMode) { + return ( +
+ +
+ ); + } + + // 실제 렌더링 + switch (textType) { + case "datetime": + return ; + case "image": + return ; + case "title": + return ; + default: + return ; + } +} + +// 디자인 모드 미리보기 (실제 설정값 표시) +function DesignModePreview({ + config, + label, +}: { + config?: PopTextConfig; + label?: string; +}) { + const textType = config?.textType || "text"; + + // 공통 정렬 래퍼 클래스 (상하좌우 정렬) + const alignWrapperClass = cn( + "flex w-full h-full", + VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"], + JUSTIFY_CLASSES[config?.textAlign || "left"] + ); + + switch (textType) { + case "datetime": + // 실시간 시간 미리보기 + return ( +
+ +
+ ); + case "image": + // 이미지 미리보기 + if (!config?.imageUrl) { + return ( +
+ 이미지 URL 없음 +
+ ); + } + // 이미지도 정렬 래퍼 적용 + return ( +
+ +
+ ); + case "title": + // 제목 미리보기 + return ( +
+ + {config?.content || label || "제목"} + +
+ ); + default: + // 일반 텍스트 미리보기 + return ( +
+ + {config?.content || label || "텍스트"} + +
+ ); + } +} + +// 디자인 모드용 시간 미리보기 (실시간) +function DateTimePreview({ config }: { config?: PopTextConfig }) { + const [now, setNow] = useState(new Date()); + + useEffect(() => { + // 디자인 모드에서도 실시간 업데이트 (간격 늘림) + const timer = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(timer); + }, []); + + // 빌더 설정 또는 기존 dateFormat 사용 (하위 호환) + const dateFormat = config?.dateTimeConfig + ? buildDateTimeFormat(config.dateTimeConfig) + : config?.dateFormat || "HH:mm:ss"; + + return ( + + {format(now, dateFormat, { locale: ko })} + + ); +} + +// 시간/날짜 (실시간 지원) +function DateTimeDisplay({ config }: { config?: PopTextConfig }) { + const [now, setNow] = useState(new Date()); + + useEffect(() => { + if (!config?.isRealtime) return; + const timer = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(timer); + }, [config?.isRealtime]); + + // 빌더 설정 또는 기존 dateFormat 사용 (하위 호환) + const dateFormat = config?.dateTimeConfig + ? buildDateTimeFormat(config.dateTimeConfig) + : config?.dateFormat || "HH:mm:ss"; + + // 정렬 래퍼 클래스 + const alignWrapperClass = cn( + "flex w-full h-full", + VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"], + JUSTIFY_CLASSES[config?.textAlign || "left"] + ); + + return ( +
+ + {format(now, dateFormat, { locale: ko })} + +
+ ); +} + +// 이미지 +function ImageDisplay({ config }: { config?: PopTextConfig }) { + if (!config?.imageUrl) { + return ( +
+ 이미지 URL 필요 +
+ ); + } + + // 정렬 래퍼 클래스 + const alignWrapperClass = cn( + "flex w-full h-full", + VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"], + JUSTIFY_CLASSES[config?.textAlign || "left"] + ); + + return ( +
+ +
+ ); +} + +// 제목 +function TitleDisplay({ + config, + label, +}: { + config?: PopTextConfig; + label?: string; +}) { + const sizeClass = FONT_SIZE_CLASSES[config?.fontSize || "base"]; + const weightClass = FONT_WEIGHT_CLASSES[config?.fontWeight || "normal"]; + + // 정렬 래퍼 클래스 + const alignWrapperClass = cn( + "flex w-full h-full", + VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"], + JUSTIFY_CLASSES[config?.textAlign || "left"] + ); + + return ( +
+ + {config?.content || label || "제목"} + +
+ ); +} + +// 일반 텍스트 +function TextDisplay({ + config, + label, +}: { + config?: PopTextConfig; + label?: string; +}) { + const sizeClass = FONT_SIZE_CLASSES[config?.fontSize || "base"]; + + // 정렬 래퍼 클래스 + const alignWrapperClass = cn( + "flex w-full h-full", + VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"], + JUSTIFY_CLASSES[config?.textAlign || "left"] + ); + + return ( +
+ + {config?.content || label || "텍스트"} + +
+ ); +} + +// ======================================== +// 설정 패널 +// ======================================== +interface PopTextConfigPanelProps { + config: PopTextConfig; + onUpdate: (config: PopTextConfig) => void; +} + +export function PopTextConfigPanel({ + config, + onUpdate, +}: PopTextConfigPanelProps) { + const textType = config?.textType || "text"; + + return ( +
+ {/* 텍스트 타입 선택 */} +
+ + +
+ + {/* 서브타입별 설정 */} + {textType === "text" && ( + <> +
+ +