Compare commits
No commits in common. "bae50ffda15d573ef9168c1c8be7e958229a0357" and "1116fb350aedf55370a8906530b896835e5813a8" have entirely different histories.
bae50ffda1
...
1116fb350a
|
|
@ -361,7 +361,7 @@ DashboardItem {
|
||||||
|
|
||||||
- **한 줄 정의**: 누르면 어딘가로 이동 (돌아오는 값 없음)
|
- **한 줄 정의**: 누르면 어딘가로 이동 (돌아오는 값 없음)
|
||||||
- **카테고리**: action
|
- **카테고리**: action
|
||||||
- **역할**: 네비게이션 (화면 이동, URL 이동)
|
- **역할**: 네비게이션 (화면 이동, URL 이동, 새로고침)
|
||||||
- **데이터**: 없음
|
- **데이터**: 없음
|
||||||
- **이벤트**: 없음 (네비게이션은 이벤트가 아닌 직접 실행)
|
- **이벤트**: 없음 (네비게이션은 이벤트가 아닌 직접 실행)
|
||||||
- **설정**: 아이콘 종류(lucide-icon), 라벨, 배경색/그라디언트, 크기, 클릭 액션(PopActionConfig), 뱃지 표시
|
- **설정**: 아이콘 종류(lucide-icon), 라벨, 배경색/그라디언트, 크기, 클릭 액션(PopActionConfig), 뱃지 표시
|
||||||
|
|
|
||||||
|
|
@ -109,8 +109,8 @@ export default function ComponentEditorPanel({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 탭 */}
|
{/* 탭 */}
|
||||||
<Tabs defaultValue="position" className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
<Tabs defaultValue="position" className="flex flex-1 flex-col">
|
||||||
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2 flex-shrink-0">
|
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2">
|
||||||
<TabsTrigger value="position" className="gap-1 text-xs">
|
<TabsTrigger value="position" className="gap-1 text-xs">
|
||||||
<Grid3x3 className="h-3 w-3" />
|
<Grid3x3 className="h-3 w-3" />
|
||||||
위치
|
위치
|
||||||
|
|
@ -130,7 +130,7 @@ export default function ComponentEditorPanel({
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* 위치 탭 */}
|
{/* 위치 탭 */}
|
||||||
<TabsContent value="position" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
|
<TabsContent value="position" className="min-h-0 flex-1 overflow-auto p-4">
|
||||||
{/* 배치된 컴포넌트 목록 */}
|
{/* 배치된 컴포넌트 목록 */}
|
||||||
{allComponents && allComponents.length > 0 && (
|
{allComponents && allComponents.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
|
|
@ -178,7 +178,7 @@ export default function ComponentEditorPanel({
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 설정 탭 */}
|
{/* 설정 탭 */}
|
||||||
<TabsContent value="settings" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
|
<TabsContent value="settings" className="flex-1 overflow-auto p-4">
|
||||||
<ComponentSettingsForm
|
<ComponentSettingsForm
|
||||||
component={component}
|
component={component}
|
||||||
onUpdate={onUpdateComponent}
|
onUpdate={onUpdateComponent}
|
||||||
|
|
@ -186,7 +186,7 @@ export default function ComponentEditorPanel({
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 표시 탭 */}
|
{/* 표시 탭 */}
|
||||||
<TabsContent value="visibility" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
|
<TabsContent value="visibility" className="flex-1 overflow-auto p-4">
|
||||||
<VisibilityForm
|
<VisibilityForm
|
||||||
component={component}
|
component={component}
|
||||||
onUpdate={onUpdateComponent}
|
onUpdate={onUpdateComponent}
|
||||||
|
|
@ -194,7 +194,7 @@ export default function ComponentEditorPanel({
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 데이터 탭 */}
|
{/* 데이터 탭 */}
|
||||||
<TabsContent value="data" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
|
<TabsContent value="data" className="flex-1 overflow-auto p-4">
|
||||||
<DataBindingPlaceholder />
|
<DataBindingPlaceholder />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useDrag } from "react-dnd";
|
import { useDrag } from "react-dnd";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PopComponentType } from "../types/pop-layout";
|
import { PopComponentType } from "../types/pop-layout";
|
||||||
import { Square, FileText, MousePointer } from "lucide-react";
|
import { Square, FileText } from "lucide-react";
|
||||||
import { DND_ITEM_TYPES } from "../constants";
|
import { DND_ITEM_TYPES } from "../constants";
|
||||||
|
|
||||||
// 컴포넌트 정의
|
// 컴포넌트 정의
|
||||||
|
|
@ -27,12 +27,6 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
description: "텍스트, 시간, 이미지 표시",
|
description: "텍스트, 시간, 이미지 표시",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
type: "pop-icon",
|
|
||||||
label: "아이콘",
|
|
||||||
icon: MousePointer,
|
|
||||||
description: "네비게이션 아이콘 (화면 이동, URL, 뒤로가기)",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 드래그 가능한 컴포넌트 아이템
|
// 드래그 가능한 컴포넌트 아이템
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,6 @@ interface PopRendererProps {
|
||||||
overridePadding?: number;
|
overridePadding?: number;
|
||||||
/** 추가 className */
|
/** 추가 className */
|
||||||
className?: string;
|
className?: string;
|
||||||
/** 현재 편집 중인 화면 ID (아이콘 네비게이션용) */
|
|
||||||
currentScreenId?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -85,7 +83,6 @@ export default function PopRenderer({
|
||||||
overrideGap,
|
overrideGap,
|
||||||
overridePadding,
|
overridePadding,
|
||||||
className,
|
className,
|
||||||
currentScreenId,
|
|
||||||
}: PopRendererProps) {
|
}: PopRendererProps) {
|
||||||
const { gridConfig, components, overrides } = layout;
|
const { gridConfig, components, overrides } = layout;
|
||||||
|
|
||||||
|
|
@ -514,19 +511,9 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
|
||||||
|
|
||||||
// 실제 컴포넌트가 등록되어 있으면 실제 데이터로 렌더링 (대시보드 등)
|
// 실제 컴포넌트가 등록되어 있으면 실제 데이터로 렌더링 (대시보드 등)
|
||||||
if (ActualComp) {
|
if (ActualComp) {
|
||||||
// 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용
|
|
||||||
const needsPointerEvents = component.type === "pop-icon";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className="h-full w-full overflow-hidden pointer-events-none">
|
||||||
"h-full w-full overflow-hidden",
|
<ActualComp config={component.config} label={component.label} />
|
||||||
!needsPointerEvents && "pointer-events-none"
|
|
||||||
)}>
|
|
||||||
<ActualComp
|
|
||||||
config={component.config}
|
|
||||||
label={component.label}
|
|
||||||
isDesignMode={isDesignMode}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
/**
|
/**
|
||||||
* POP 컴포넌트 타입
|
* POP 컴포넌트 타입
|
||||||
*/
|
*/
|
||||||
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon"; // 테스트용 샘플 박스, 텍스트 컴포넌트, 아이콘 컴포넌트
|
export type PopComponentType = "pop-sample" | "pop-text"; // 테스트용 샘플 박스, 텍스트 컴포넌트
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터 흐름 정의
|
* 데이터 흐름 정의
|
||||||
|
|
@ -342,7 +342,6 @@ export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => {
|
||||||
export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
|
export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
|
||||||
"pop-sample": { colSpan: 2, rowSpan: 1 },
|
"pop-sample": { colSpan: 2, rowSpan: 1 },
|
||||||
"pop-text": { colSpan: 3, rowSpan: 1 },
|
"pop-text": { colSpan: 3, rowSpan: 1 },
|
||||||
"pop-icon": { colSpan: 1, rowSpan: 2 },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ export * from "./types";
|
||||||
|
|
||||||
// POP 컴포넌트 등록
|
// POP 컴포넌트 등록
|
||||||
import "./pop-text";
|
import "./pop-text";
|
||||||
import "./pop-icon";
|
|
||||||
|
|
||||||
// 향후 추가될 컴포넌트들:
|
// 향후 추가될 컴포넌트들:
|
||||||
// import "./pop-field";
|
// import "./pop-field";
|
||||||
|
|
|
||||||
|
|
@ -1,974 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useRef } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
|
||||||
import { GridMode } from "@/components/pop/designer/types/pop-layout";
|
|
||||||
import * as LucideIcons from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 타입 정의
|
|
||||||
// ========================================
|
|
||||||
export type IconType = "quick" | "emoji" | "image";
|
|
||||||
export type IconSizeMode = "auto" | "fixed";
|
|
||||||
export type LabelPosition = "bottom" | "right" | "none";
|
|
||||||
export type NavigateMode = "none" | "screen" | "url" | "back";
|
|
||||||
|
|
||||||
export interface IconSizeByMode {
|
|
||||||
mobile_portrait: number;
|
|
||||||
mobile_landscape: number;
|
|
||||||
tablet_portrait: number;
|
|
||||||
tablet_landscape: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GradientConfig {
|
|
||||||
from: string;
|
|
||||||
to: string;
|
|
||||||
direction?: "to-b" | "to-r" | "to-br";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImageConfig {
|
|
||||||
fileObjid?: number;
|
|
||||||
imageUrl?: string;
|
|
||||||
// 임시 저장용 (브라우저 캐시)
|
|
||||||
tempDataUrl?: string;
|
|
||||||
tempFileName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PopIconAction {
|
|
||||||
type: "navigate";
|
|
||||||
navigate: {
|
|
||||||
mode: NavigateMode;
|
|
||||||
screenId?: string;
|
|
||||||
url?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuickSelectItem {
|
|
||||||
type: "lucide" | "emoji";
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
gradient: GradientConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PopIconConfig {
|
|
||||||
iconType: IconType;
|
|
||||||
// 빠른 선택용
|
|
||||||
quickSelectType?: "lucide" | "emoji";
|
|
||||||
quickSelectValue?: string;
|
|
||||||
// 이미지용
|
|
||||||
imageConfig?: ImageConfig;
|
|
||||||
imageScale?: number;
|
|
||||||
// 공통
|
|
||||||
label?: string;
|
|
||||||
labelPosition?: LabelPosition;
|
|
||||||
labelColor?: string;
|
|
||||||
labelFontSize?: number;
|
|
||||||
backgroundColor?: string;
|
|
||||||
gradient?: GradientConfig;
|
|
||||||
borderRadiusPercent?: number;
|
|
||||||
sizeMode: IconSizeMode;
|
|
||||||
fixedSize?: number;
|
|
||||||
sizeByMode?: IconSizeByMode;
|
|
||||||
action: PopIconAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 상수
|
|
||||||
// ========================================
|
|
||||||
export const ICON_TYPE_LABELS: Record<IconType, string> = {
|
|
||||||
quick: "빠른 선택",
|
|
||||||
emoji: "이모지 직접 입력",
|
|
||||||
image: "이미지",
|
|
||||||
};
|
|
||||||
|
|
||||||
// 섹션 구분선 컴포넌트
|
|
||||||
function SectionDivider({ label }: { label: string }) {
|
|
||||||
return (
|
|
||||||
<div className="pt-3 pb-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-px flex-1 bg-gray-300" />
|
|
||||||
<span className="text-xs font-medium text-gray-500">{label}</span>
|
|
||||||
<div className="h-px flex-1 bg-gray-300" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NAVIGATE_MODE_LABELS: Record<NavigateMode, string> = {
|
|
||||||
none: "없음",
|
|
||||||
screen: "POP 화면",
|
|
||||||
url: "외부 URL",
|
|
||||||
back: "뒤로가기",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LABEL_POSITION_LABELS: Record<LabelPosition, string> = {
|
|
||||||
bottom: "아래",
|
|
||||||
right: "오른쪽",
|
|
||||||
none: "없음",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DEFAULT_ICON_SIZE_BY_MODE: IconSizeByMode = {
|
|
||||||
mobile_portrait: 48,
|
|
||||||
mobile_landscape: 56,
|
|
||||||
tablet_portrait: 64,
|
|
||||||
tablet_landscape: 72,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 빠른 선택 아이템 (Lucide 10개 + 이모지)
|
|
||||||
export const QUICK_SELECT_ITEMS: QuickSelectItem[] = [
|
|
||||||
// 기본 아이콘 (Lucide) - 10개
|
|
||||||
{ type: "lucide", value: "Home", label: "홈", gradient: { from: "#0984e3", to: "#0774c4" } },
|
|
||||||
{ type: "lucide", value: "ArrowLeft", label: "뒤로", gradient: { from: "#636e72", to: "#525d61" } },
|
|
||||||
{ type: "lucide", value: "Settings", label: "설정", gradient: { from: "#636e72", to: "#525d61" } },
|
|
||||||
{ type: "lucide", value: "Search", label: "검색", gradient: { from: "#fdcb6e", to: "#f9b93b" } },
|
|
||||||
{ type: "lucide", value: "Plus", label: "추가", gradient: { from: "#00b894", to: "#00a86b" } },
|
|
||||||
{ type: "lucide", value: "Check", label: "확인", gradient: { from: "#00b894", to: "#00a86b" } },
|
|
||||||
{ type: "lucide", value: "X", label: "취소", gradient: { from: "#e17055", to: "#d35845" } },
|
|
||||||
{ type: "lucide", value: "Edit", label: "수정", gradient: { from: "#0984e3", to: "#0774c4" } },
|
|
||||||
{ type: "lucide", value: "Trash2", label: "삭제", gradient: { from: "#e17055", to: "#d35845" } },
|
|
||||||
{ type: "lucide", value: "RefreshCw", label: "새로고침", gradient: { from: "#4ecdc4", to: "#26a69a" } },
|
|
||||||
// 이모지
|
|
||||||
{ type: "emoji", value: "📋", label: "작업지시", gradient: { from: "#ff6b6b", to: "#ee5a5a" } },
|
|
||||||
{ type: "emoji", value: "📊", label: "생산실적", gradient: { from: "#4ecdc4", to: "#26a69a" } },
|
|
||||||
{ type: "emoji", value: "📦", label: "입고", gradient: { from: "#00b894", to: "#00a86b" } },
|
|
||||||
{ type: "emoji", value: "🚚", label: "출고", gradient: { from: "#0984e3", to: "#0774c4" } },
|
|
||||||
{ type: "emoji", value: "📈", label: "재고현황", gradient: { from: "#6c5ce7", to: "#5b4cdb" } },
|
|
||||||
{ type: "emoji", value: "🔍", label: "품질검사", gradient: { from: "#fdcb6e", to: "#f9b93b" } },
|
|
||||||
{ type: "emoji", value: "⚠️", label: "불량관리", gradient: { from: "#e17055", to: "#d35845" } },
|
|
||||||
{ type: "emoji", value: "⚙️", label: "설비관리", gradient: { from: "#636e72", to: "#525d61" } },
|
|
||||||
{ type: "emoji", value: "🦺", label: "안전관리", gradient: { from: "#f39c12", to: "#e67e22" } },
|
|
||||||
{ type: "emoji", value: "🏭", label: "외주", gradient: { from: "#6c5ce7", to: "#5b4cdb" } },
|
|
||||||
{ type: "emoji", value: "↩️", label: "반품", gradient: { from: "#e17055", to: "#d35845" } },
|
|
||||||
{ type: "emoji", value: "🤝", label: "사급자재", gradient: { from: "#fdcb6e", to: "#f9b93b" } },
|
|
||||||
{ type: "emoji", value: "🔄", label: "교환", gradient: { from: "#4ecdc4", to: "#26a69a" } },
|
|
||||||
{ type: "emoji", value: "📍", label: "재고이동", gradient: { from: "#4ecdc4", to: "#26a69a" } },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 헬퍼 함수
|
|
||||||
// ========================================
|
|
||||||
function getIconSizeForMode(config: PopIconConfig | undefined, gridMode: GridMode): number {
|
|
||||||
if (!config) return DEFAULT_ICON_SIZE_BY_MODE[gridMode];
|
|
||||||
if (config.sizeMode === "fixed" && config.fixedSize) {
|
|
||||||
return config.fixedSize;
|
|
||||||
}
|
|
||||||
const sizes = config.sizeByMode || DEFAULT_ICON_SIZE_BY_MODE;
|
|
||||||
return sizes[gridMode];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildGradientStyle(gradient?: GradientConfig): React.CSSProperties {
|
|
||||||
if (!gradient) return {};
|
|
||||||
const direction = gradient.direction || "to-b";
|
|
||||||
const dirMap: Record<string, string> = {
|
|
||||||
"to-b": "to bottom",
|
|
||||||
"to-r": "to right",
|
|
||||||
"to-br": "to bottom right"
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
background: `linear-gradient(${dirMap[direction]}, ${gradient.from}, ${gradient.to})`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getImageUrl(imageConfig?: ImageConfig): string | undefined {
|
|
||||||
if (!imageConfig) return undefined;
|
|
||||||
// 임시 저장된 이미지 우선
|
|
||||||
if (imageConfig.tempDataUrl) return imageConfig.tempDataUrl;
|
|
||||||
if (imageConfig.fileObjid) return `/api/files/preview/${imageConfig.fileObjid}`;
|
|
||||||
return imageConfig.imageUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lucide 아이콘 동적 렌더링
|
|
||||||
function DynamicLucideIcon({ name, size, className }: { name: string; size: number; className?: string }) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const IconComponent = (LucideIcons as any)[name];
|
|
||||||
if (!IconComponent) return null;
|
|
||||||
return <IconComponent size={size} className={className} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// screenId에서 실제 ID만 추출 (URL이 입력된 경우 처리)
|
|
||||||
function extractScreenId(input: string): string {
|
|
||||||
if (!input) return "";
|
|
||||||
|
|
||||||
// URL 형태인 경우 (/pop/screens/123 또는 http://...pop/screens/123)
|
|
||||||
const urlMatch = input.match(/\/pop\/screens\/(\d+)/);
|
|
||||||
if (urlMatch) {
|
|
||||||
return urlMatch[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// http:// 또는 https://로 시작하는 경우 (다른 URL 형태)
|
|
||||||
if (input.startsWith("http://") || input.startsWith("https://")) {
|
|
||||||
// URL에서 마지막 숫자 부분 추출 시도
|
|
||||||
const lastNumberMatch = input.match(/\/(\d+)\/?$/);
|
|
||||||
if (lastNumberMatch) {
|
|
||||||
return lastNumberMatch[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 숫자만 있는 경우 그대로 반환
|
|
||||||
if (/^\d+$/.test(input.trim())) {
|
|
||||||
return input.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 그 외의 경우 원본 반환 (에러 처리는 호출부에서)
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 메인 컴포넌트
|
|
||||||
// ========================================
|
|
||||||
interface PopIconComponentProps {
|
|
||||||
config?: PopIconConfig;
|
|
||||||
label?: string;
|
|
||||||
isDesignMode?: boolean;
|
|
||||||
gridMode?: GridMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PopIconComponent({
|
|
||||||
config,
|
|
||||||
label,
|
|
||||||
isDesignMode,
|
|
||||||
gridMode = "tablet_landscape"
|
|
||||||
}: PopIconComponentProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const iconType = config?.iconType || "quick";
|
|
||||||
const iconSize = getIconSizeForMode(config, gridMode);
|
|
||||||
|
|
||||||
// 디자인 모드 확인 다이얼로그 상태
|
|
||||||
const [showNavigateDialog, setShowNavigateDialog] = useState(false);
|
|
||||||
const [pendingNavigate, setPendingNavigate] = useState<{ mode: string; target: string } | null>(null);
|
|
||||||
|
|
||||||
// 클릭 핸들러
|
|
||||||
const handleClick = () => {
|
|
||||||
const navigate = config?.action?.navigate;
|
|
||||||
if (!navigate || navigate.mode === "none") return;
|
|
||||||
|
|
||||||
// 디자인 모드: 확인 다이얼로그 표시
|
|
||||||
if (isDesignMode) {
|
|
||||||
if (navigate.mode === "screen") {
|
|
||||||
if (!navigate.screenId) {
|
|
||||||
toast.error("화면 ID가 설정되지 않았습니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cleanScreenId = extractScreenId(navigate.screenId);
|
|
||||||
setPendingNavigate({ mode: "screen", target: cleanScreenId });
|
|
||||||
setShowNavigateDialog(true);
|
|
||||||
} else if (navigate.mode === "url") {
|
|
||||||
if (!navigate.url) {
|
|
||||||
toast.error("URL이 설정되지 않았습니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPendingNavigate({ mode: "url", target: navigate.url });
|
|
||||||
setShowNavigateDialog(true);
|
|
||||||
} else if (navigate.mode === "back") {
|
|
||||||
toast.warning("뒤로가기는 실제 화면에서 테스트해주세요.");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 실제 모드: 직접 실행
|
|
||||||
switch (navigate.mode) {
|
|
||||||
case "screen":
|
|
||||||
if (navigate.screenId) {
|
|
||||||
const cleanScreenId = extractScreenId(navigate.screenId);
|
|
||||||
window.location.href = `/pop/screens/${cleanScreenId}`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "url":
|
|
||||||
if (navigate.url) window.location.href = navigate.url;
|
|
||||||
break;
|
|
||||||
case "back":
|
|
||||||
router.back();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 확인 후 이동 실행
|
|
||||||
const handleConfirmNavigate = () => {
|
|
||||||
if (!pendingNavigate) return;
|
|
||||||
|
|
||||||
if (pendingNavigate.mode === "screen") {
|
|
||||||
const targetUrl = `/pop/screens/${pendingNavigate.target}`;
|
|
||||||
console.log("[PopIcon] 화면 이동:", { target: pendingNavigate.target, url: targetUrl });
|
|
||||||
window.location.href = targetUrl;
|
|
||||||
} else if (pendingNavigate.mode === "url") {
|
|
||||||
console.log("[PopIcon] URL 이동:", pendingNavigate.target);
|
|
||||||
window.location.href = pendingNavigate.target;
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowNavigateDialog(false);
|
|
||||||
setPendingNavigate(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 배경 스타일 (이미지 타입일 때는 배경 없음)
|
|
||||||
const backgroundStyle: React.CSSProperties = iconType === "image"
|
|
||||||
? { backgroundColor: "transparent" }
|
|
||||||
: config?.gradient
|
|
||||||
? buildGradientStyle(config.gradient)
|
|
||||||
: { backgroundColor: config?.backgroundColor || "#e0e0e0" };
|
|
||||||
|
|
||||||
// 테두리 반경 (0% = 사각형, 100% = 원형)
|
|
||||||
const radiusPercent = config?.borderRadiusPercent ?? 20;
|
|
||||||
const borderRadius = iconType === "image" ? "0%" : `${radiusPercent / 2}%`;
|
|
||||||
|
|
||||||
// 라벨 위치에 따른 레이아웃
|
|
||||||
const isLabelRight = config?.labelPosition === "right";
|
|
||||||
const showLabel = config?.labelPosition !== "none" && (config?.label || label);
|
|
||||||
|
|
||||||
// 아이콘 렌더링
|
|
||||||
const renderIcon = () => {
|
|
||||||
// 빠른 선택
|
|
||||||
if (iconType === "quick") {
|
|
||||||
if (config?.quickSelectType === "lucide" && config?.quickSelectValue) {
|
|
||||||
return (
|
|
||||||
<DynamicLucideIcon
|
|
||||||
name={config.quickSelectValue}
|
|
||||||
size={iconSize * 0.5}
|
|
||||||
className="text-white"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (config?.quickSelectType === "emoji" && config?.quickSelectValue) {
|
|
||||||
return <span style={{ fontSize: iconSize * 0.5 }}>{config.quickSelectValue}</span>;
|
|
||||||
}
|
|
||||||
// 기본값
|
|
||||||
return <span style={{ fontSize: iconSize * 0.5 }}>📦</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이모지 직접 입력
|
|
||||||
if (iconType === "emoji") {
|
|
||||||
if (config?.quickSelectValue) {
|
|
||||||
return <span style={{ fontSize: iconSize * 0.5 }}>{config.quickSelectValue}</span>;
|
|
||||||
}
|
|
||||||
return <span style={{ fontSize: iconSize * 0.5 }}>📦</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이미지 (배경 없이 이미지만 표시)
|
|
||||||
if (iconType === "image" && config?.imageConfig) {
|
|
||||||
const scale = config?.imageScale || 100;
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={getImageUrl(config.imageConfig)}
|
|
||||||
alt=""
|
|
||||||
style={{
|
|
||||||
width: `${scale}%`,
|
|
||||||
height: `${scale}%`,
|
|
||||||
objectFit: "contain"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <span style={{ fontSize: iconSize * 0.5 }}>📦</span>;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-center cursor-pointer transition-transform hover:scale-105",
|
|
||||||
isLabelRight ? "flex-row gap-2" : "flex-col"
|
|
||||||
)}
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
{/* 아이콘 컨테이너 */}
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-center"
|
|
||||||
style={{
|
|
||||||
...backgroundStyle,
|
|
||||||
borderRadius,
|
|
||||||
width: iconSize,
|
|
||||||
height: iconSize,
|
|
||||||
minWidth: iconSize,
|
|
||||||
minHeight: iconSize,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderIcon()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 라벨 */}
|
|
||||||
{showLabel && (
|
|
||||||
<span
|
|
||||||
className={cn("truncate max-w-full", !isLabelRight && "mt-1")}
|
|
||||||
style={{
|
|
||||||
color: config?.labelColor || "#000000",
|
|
||||||
fontSize: config?.labelFontSize || 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{config?.label || label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 디자인 모드 네비게이션 확인 다이얼로그 */}
|
|
||||||
<AlertDialog open={showNavigateDialog} onOpenChange={setShowNavigateDialog}>
|
|
||||||
<AlertDialogContent className="max-w-sm">
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>페이지 이동 확인</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{pendingNavigate?.mode === "screen"
|
|
||||||
? "POP 화면으로 이동합니다."
|
|
||||||
: "외부 URL로 이동합니다."
|
|
||||||
}
|
|
||||||
<br />
|
|
||||||
<span className="text-red-600 font-medium">
|
|
||||||
※ 저장하지 않은 변경사항은 사라집니다.
|
|
||||||
</span>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={handleConfirmNavigate}
|
|
||||||
className="text-white"
|
|
||||||
style={{ backgroundColor: "#0984e3" }}
|
|
||||||
>
|
|
||||||
확인 후 이동
|
|
||||||
</AlertDialogAction>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setShowNavigateDialog(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 설정 패널
|
|
||||||
// ========================================
|
|
||||||
interface PopIconConfigPanelProps {
|
|
||||||
config: PopIconConfig;
|
|
||||||
onUpdate: (config: PopIconConfig) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PopIconConfigPanel({ config, onUpdate }: PopIconConfigPanelProps) {
|
|
||||||
const iconType = config?.iconType || "quick";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2 overflow-y-auto pr-1 pb-32">
|
|
||||||
{/* 아이콘 타입 선택 */}
|
|
||||||
<SectionDivider label="아이콘 타입" />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Select
|
|
||||||
value={iconType}
|
|
||||||
onValueChange={(v) => onUpdate({ ...config, iconType: v as IconType })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{Object.entries(ICON_TYPE_LABELS).map(([key, label]) => (
|
|
||||||
<SelectItem key={key} value={key} className="text-xs">{label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 타입별 설정 */}
|
|
||||||
{iconType === "quick" && <QuickSelectGrid config={config} onUpdate={onUpdate} />}
|
|
||||||
{iconType === "emoji" && <EmojiInput config={config} onUpdate={onUpdate} />}
|
|
||||||
{iconType === "image" && <ImageUpload config={config} onUpdate={onUpdate} />}
|
|
||||||
|
|
||||||
{/* 라벨 설정 */}
|
|
||||||
<SectionDivider label="라벨 설정" />
|
|
||||||
<LabelSettings config={config} onUpdate={onUpdate} />
|
|
||||||
|
|
||||||
{/* 스타일 설정 (이미지 타입 제외) */}
|
|
||||||
{iconType !== "image" && (
|
|
||||||
<>
|
|
||||||
<SectionDivider label="스타일 설정" />
|
|
||||||
<StyleSettings config={config} onUpdate={onUpdate} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 액션 설정 */}
|
|
||||||
<SectionDivider label="클릭 액션" />
|
|
||||||
<ActionSettings config={config} onUpdate={onUpdate} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 빠른 선택 그리드
|
|
||||||
function QuickSelectGrid({ config, onUpdate }: PopIconConfigPanelProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs">빠른 선택</Label>
|
|
||||||
<div className="grid grid-cols-5 gap-1 max-h-48 overflow-y-auto p-1">
|
|
||||||
{QUICK_SELECT_ITEMS.map((item, idx) => (
|
|
||||||
<button
|
|
||||||
key={idx}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onUpdate({
|
|
||||||
...config,
|
|
||||||
quickSelectType: item.type,
|
|
||||||
quickSelectValue: item.value,
|
|
||||||
label: item.label,
|
|
||||||
gradient: item.gradient,
|
|
||||||
})}
|
|
||||||
className={cn(
|
|
||||||
"p-2 rounded border hover:border-primary transition-colors flex items-center justify-center",
|
|
||||||
config?.quickSelectValue === item.value && "border-primary bg-primary/10"
|
|
||||||
)}
|
|
||||||
title={item.label}
|
|
||||||
>
|
|
||||||
{item.type === "lucide" ? (
|
|
||||||
<DynamicLucideIcon name={item.value} size={18} className="text-gray-700" />
|
|
||||||
) : (
|
|
||||||
<span className="text-base">{item.value}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이모지 직접 입력
|
|
||||||
function EmojiInput({ config, onUpdate }: PopIconConfigPanelProps) {
|
|
||||||
const [customEmoji, setCustomEmoji] = useState(config?.quickSelectValue || "");
|
|
||||||
|
|
||||||
const handleEmojiChange = (value: string) => {
|
|
||||||
setCustomEmoji(value);
|
|
||||||
// 이모지가 입력되면 바로 적용
|
|
||||||
if (value.trim()) {
|
|
||||||
onUpdate({
|
|
||||||
...config,
|
|
||||||
quickSelectType: "emoji",
|
|
||||||
quickSelectValue: value,
|
|
||||||
gradient: config?.gradient || { from: "#6c5ce7", to: "#5b4cdb" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs">이모지 입력</Label>
|
|
||||||
<Input
|
|
||||||
value={customEmoji}
|
|
||||||
onChange={(e) => handleEmojiChange(e.target.value)}
|
|
||||||
placeholder="이모지를 입력하세요 (예: 📦, 🚀)"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
maxLength={4}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
Windows: Win + . / Mac: Ctrl + Cmd + Space
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 배경 그라디언트 설정 */}
|
|
||||||
<div className="flex gap-2 mt-2">
|
|
||||||
<div className="flex-1 space-y-1">
|
|
||||||
<Label className="text-xs">시작색</Label>
|
|
||||||
<Input
|
|
||||||
type="color"
|
|
||||||
value={config?.gradient?.from || "#6c5ce7"}
|
|
||||||
onChange={(e) => onUpdate({
|
|
||||||
...config,
|
|
||||||
gradient: { ...config?.gradient, from: e.target.value, to: config?.gradient?.to || "#5b4cdb" }
|
|
||||||
})}
|
|
||||||
className="h-8 w-full p-1 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-1">
|
|
||||||
<Label className="text-xs">끝색</Label>
|
|
||||||
<Input
|
|
||||||
type="color"
|
|
||||||
value={config?.gradient?.to || "#5b4cdb"}
|
|
||||||
onChange={(e) => onUpdate({
|
|
||||||
...config,
|
|
||||||
gradient: { ...config?.gradient, from: config?.gradient?.from || "#6c5ce7", to: e.target.value }
|
|
||||||
})}
|
|
||||||
className="h-8 w-full p-1 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 미리보기 */}
|
|
||||||
{customEmoji && (
|
|
||||||
<div
|
|
||||||
className="mt-2 p-4 rounded flex items-center justify-center"
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(to bottom, ${config?.gradient?.from || "#6c5ce7"}, ${config?.gradient?.to || "#5b4cdb"})`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text-3xl">{customEmoji}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이미지 업로드
|
|
||||||
function ImageUpload({ config, onUpdate }: PopIconConfigPanelProps) {
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// 파일 선택 시 브라우저 캐시에 임시 저장 (DB 업로드 X)
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// 이미지 파일 검증
|
|
||||||
if (!file.type.startsWith("image/")) {
|
|
||||||
setError("이미지 파일만 선택할 수 있습니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 파일 크기 제한 (5MB)
|
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
|
||||||
setError("파일 크기는 5MB 이하여야 합니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileReader로 Base64 변환 (브라우저 캐시)
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
onUpdate({
|
|
||||||
...config,
|
|
||||||
imageConfig: {
|
|
||||||
tempDataUrl: reader.result as string,
|
|
||||||
tempFileName: file.name,
|
|
||||||
// 기존 DB 파일 정보 제거
|
|
||||||
fileObjid: undefined,
|
|
||||||
imageUrl: undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
reader.onerror = () => {
|
|
||||||
setError("파일을 읽는 중 오류가 발생했습니다.");
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
|
|
||||||
// input 초기화 (같은 파일 다시 선택 가능하도록)
|
|
||||||
e.target.value = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
// 이미지 삭제
|
|
||||||
const handleDelete = () => {
|
|
||||||
onUpdate({
|
|
||||||
...config,
|
|
||||||
imageConfig: undefined,
|
|
||||||
imageScale: undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 미리보기 URL 가져오기
|
|
||||||
const getPreviewUrl = (): string | undefined => {
|
|
||||||
if (config?.imageConfig?.tempDataUrl) return config.imageConfig.tempDataUrl;
|
|
||||||
if (config?.imageConfig?.fileObjid) return `/api/files/preview/${config.imageConfig.fileObjid}`;
|
|
||||||
return config?.imageConfig?.imageUrl;
|
|
||||||
};
|
|
||||||
|
|
||||||
const previewUrl = getPreviewUrl();
|
|
||||||
const hasImage = !!previewUrl;
|
|
||||||
const isTemp = !!config?.imageConfig?.tempDataUrl;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs">이미지</Label>
|
|
||||||
|
|
||||||
{/* 파일 선택 + 삭제 버튼 */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleFileSelect}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className={hasImage ? "flex-1 h-8 text-xs" : "w-full h-8 text-xs"}
|
|
||||||
>
|
|
||||||
파일 선택
|
|
||||||
</Button>
|
|
||||||
{hasImage && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleDelete}
|
|
||||||
className="flex-1 h-8 text-xs"
|
|
||||||
>
|
|
||||||
삭제
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 에러 메시지 */}
|
|
||||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
|
||||||
|
|
||||||
{/* 또는 URL 직접 입력 */}
|
|
||||||
<Input
|
|
||||||
value={config?.imageConfig?.imageUrl || ""}
|
|
||||||
onChange={(e) => onUpdate({
|
|
||||||
...config,
|
|
||||||
imageConfig: {
|
|
||||||
imageUrl: e.target.value,
|
|
||||||
// URL 입력 시 임시 파일 제거
|
|
||||||
tempDataUrl: undefined,
|
|
||||||
tempFileName: undefined,
|
|
||||||
fileObjid: undefined,
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
placeholder="또는 URL 직접 입력..."
|
|
||||||
className="h-8 text-xs"
|
|
||||||
disabled={isTemp}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 현재 이미지 미리보기 + 크기 조절 */}
|
|
||||||
{hasImage && (
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
<div className="relative p-2 border rounded bg-gray-50">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={previewUrl}
|
|
||||||
alt="미리보기"
|
|
||||||
className="max-h-20 mx-auto object-contain"
|
|
||||||
/>
|
|
||||||
{isTemp && (
|
|
||||||
<span className="absolute top-1 right-1 text-[10px] bg-yellow-100 text-yellow-700 px-1.5 py-0.5 rounded">
|
|
||||||
임시
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{config?.imageConfig?.tempFileName && (
|
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
|
||||||
{config.imageConfig.tempFileName}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Label className="text-xs">이미지 크기: {config?.imageScale || 100}%</Label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={20}
|
|
||||||
max={100}
|
|
||||||
step={5}
|
|
||||||
value={config?.imageScale || 100}
|
|
||||||
onChange={(e) => onUpdate({ ...config, imageScale: Number(e.target.value) })}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
{isTemp && (
|
|
||||||
<p className="text-xs text-yellow-600">
|
|
||||||
※ 화면 저장 시 서버에 업로드됩니다.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 라벨 설정
|
|
||||||
function LabelSettings({ config, onUpdate }: PopIconConfigPanelProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Input
|
|
||||||
value={config?.label || ""}
|
|
||||||
onChange={(e) => onUpdate({ ...config, label: e.target.value })}
|
|
||||||
placeholder="라벨 텍스트"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<Select
|
|
||||||
value={config?.labelPosition || "bottom"}
|
|
||||||
onValueChange={(v) => onUpdate({ ...config, labelPosition: v as LabelPosition })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs flex-1">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{Object.entries(LABEL_POSITION_LABELS).map(([key, label]) => (
|
|
||||||
<SelectItem key={key} value={key} className="text-xs">{label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Input
|
|
||||||
type="color"
|
|
||||||
value={config?.labelColor || "#000000"}
|
|
||||||
onChange={(e) => onUpdate({ ...config, labelColor: e.target.value })}
|
|
||||||
className="h-8 w-12 p-1 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* 글자 크기 슬라이더 */}
|
|
||||||
<Label className="text-xs">글자 크기: {config?.labelFontSize || 12}px</Label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={8}
|
|
||||||
max={24}
|
|
||||||
step={1}
|
|
||||||
value={config?.labelFontSize || 12}
|
|
||||||
onChange={(e) => onUpdate({ ...config, labelFontSize: Number(e.target.value) })}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 스타일 설정
|
|
||||||
function StyleSettings({ config, onUpdate }: PopIconConfigPanelProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs">
|
|
||||||
모서리: {config?.borderRadiusPercent ?? 20}%
|
|
||||||
</Label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={5}
|
|
||||||
value={config?.borderRadiusPercent ?? 20}
|
|
||||||
onChange={(e) => onUpdate({
|
|
||||||
...config,
|
|
||||||
borderRadiusPercent: Number(e.target.value)
|
|
||||||
})}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 액션 설정
|
|
||||||
function ActionSettings({ config, onUpdate }: PopIconConfigPanelProps) {
|
|
||||||
const navigate = config?.action?.navigate || { mode: "none" as NavigateMode };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Select
|
|
||||||
value={navigate.mode}
|
|
||||||
onValueChange={(v) => onUpdate({
|
|
||||||
...config,
|
|
||||||
action: { type: "navigate", navigate: { ...navigate, mode: v as NavigateMode } }
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{Object.entries(NAVIGATE_MODE_LABELS).map(([key, label]) => (
|
|
||||||
<SelectItem key={key} value={key} className="text-xs">{label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* 없음이 아닐 때만 추가 설정 표시 */}
|
|
||||||
{navigate.mode !== "none" && (
|
|
||||||
<>
|
|
||||||
{navigate.mode === "screen" && (
|
|
||||||
<Input
|
|
||||||
value={navigate.screenId || ""}
|
|
||||||
onChange={(e) => onUpdate({
|
|
||||||
...config,
|
|
||||||
action: { type: "navigate", navigate: { ...navigate, screenId: e.target.value } }
|
|
||||||
})}
|
|
||||||
placeholder="화면 ID"
|
|
||||||
className="h-8 text-xs mt-2"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{navigate.mode === "url" && (
|
|
||||||
<Input
|
|
||||||
value={navigate.url || ""}
|
|
||||||
onChange={(e) => onUpdate({
|
|
||||||
...config,
|
|
||||||
action: { type: "navigate", navigate: { ...navigate, url: e.target.value } }
|
|
||||||
})}
|
|
||||||
placeholder="https://..."
|
|
||||||
className="h-8 text-xs mt-2"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 테스트 버튼 */}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full h-8 text-xs mt-3"
|
|
||||||
onClick={() => {
|
|
||||||
if (navigate.mode === "screen" && navigate.screenId) {
|
|
||||||
const cleanScreenId = extractScreenId(navigate.screenId);
|
|
||||||
window.open(`/pop/screens/${cleanScreenId}`, "_blank");
|
|
||||||
} else if (navigate.mode === "url" && navigate.url) {
|
|
||||||
window.open(navigate.url, "_blank");
|
|
||||||
} else if (navigate.mode === "back") {
|
|
||||||
alert("뒤로가기는 실제 화면에서 테스트해주세요.");
|
|
||||||
} else {
|
|
||||||
alert("먼저 액션을 설정해주세요.");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🧪 액션 테스트 (새 탭에서 열기)
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 미리보기 컴포넌트
|
|
||||||
// ========================================
|
|
||||||
function PopIconPreviewComponent({ config }: { config?: PopIconConfig }) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center overflow-hidden">
|
|
||||||
<PopIconComponent config={config} isDesignMode={true} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 레지스트리 등록
|
|
||||||
// ========================================
|
|
||||||
PopComponentRegistry.registerComponent({
|
|
||||||
id: "pop-icon",
|
|
||||||
name: "아이콘",
|
|
||||||
description: "네비게이션 아이콘 (화면 이동, URL, 뒤로가기)",
|
|
||||||
category: "action",
|
|
||||||
icon: "MousePointer",
|
|
||||||
component: PopIconComponent,
|
|
||||||
configPanel: PopIconConfigPanel,
|
|
||||||
preview: PopIconPreviewComponent,
|
|
||||||
defaultProps: {
|
|
||||||
iconType: "quick",
|
|
||||||
quickSelectType: "emoji",
|
|
||||||
quickSelectValue: "📦",
|
|
||||||
label: "아이콘",
|
|
||||||
labelPosition: "bottom",
|
|
||||||
labelColor: "#000000",
|
|
||||||
labelFontSize: 12,
|
|
||||||
borderRadiusPercent: 20,
|
|
||||||
sizeMode: "auto",
|
|
||||||
action: { type: "navigate", navigate: { mode: "none" } },
|
|
||||||
} as PopIconConfig,
|
|
||||||
touchOptimized: true,
|
|
||||||
supportedDevices: ["mobile", "tablet"],
|
|
||||||
});
|
|
||||||
|
|
@ -265,14 +265,15 @@ function DateTimePreview({ config }: { config?: PopTextConfig }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 시간/날짜 (항상 실시간)
|
// 시간/날짜 (실시간 지원)
|
||||||
function DateTimeDisplay({ config }: { config?: PopTextConfig }) {
|
function DateTimeDisplay({ config }: { config?: PopTextConfig }) {
|
||||||
const [now, setNow] = useState(new Date());
|
const [now, setNow] = useState(new Date());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!config?.isRealtime) return;
|
||||||
const timer = setInterval(() => setNow(new Date()), 1000);
|
const timer = setInterval(() => setNow(new Date()), 1000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, []);
|
}, [config?.isRealtime]);
|
||||||
|
|
||||||
// 빌더 설정 또는 기존 dateFormat 사용 (하위 호환)
|
// 빌더 설정 또는 기존 dateFormat 사용 (하위 호환)
|
||||||
const dateFormat = config?.dateTimeConfig
|
const dateFormat = config?.dateTimeConfig
|
||||||
|
|
@ -390,19 +391,6 @@ interface PopTextConfigPanelProps {
|
||||||
onUpdate: (config: PopTextConfig) => void;
|
onUpdate: (config: PopTextConfig) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 섹션 구분선 컴포넌트
|
|
||||||
function SectionDivider({ label }: { label: string }) {
|
|
||||||
return (
|
|
||||||
<div className="pt-3 pb-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-px flex-1 bg-gray-300" />
|
|
||||||
<span className="text-xs font-medium text-gray-500">{label}</span>
|
|
||||||
<div className="h-px flex-1 bg-gray-300" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PopTextConfigPanel({
|
export function PopTextConfigPanel({
|
||||||
config,
|
config,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
|
@ -410,10 +398,10 @@ export function PopTextConfigPanel({
|
||||||
const textType = config?.textType || "text";
|
const textType = config?.textType || "text";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 overflow-y-auto pr-1 pb-32">
|
<div className="space-y-4">
|
||||||
{/* 텍스트 타입 선택 */}
|
{/* 텍스트 타입 선택 */}
|
||||||
<SectionDivider label="텍스트 타입" />
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">텍스트 타입</Label>
|
||||||
<Select
|
<Select
|
||||||
value={textType}
|
value={textType}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
|
|
@ -436,8 +424,8 @@ export function PopTextConfigPanel({
|
||||||
{/* 서브타입별 설정 */}
|
{/* 서브타입별 설정 */}
|
||||||
{textType === "text" && (
|
{textType === "text" && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider label="내용 설정" />
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">내용</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={config?.content || ""}
|
value={config?.content || ""}
|
||||||
onChange={(e) => onUpdate({ ...config, content: e.target.value })}
|
onChange={(e) => onUpdate({ ...config, content: e.target.value })}
|
||||||
|
|
@ -446,7 +434,6 @@ export function PopTextConfigPanel({
|
||||||
className="text-xs resize-none"
|
className="text-xs resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SectionDivider label="스타일 설정" />
|
|
||||||
<FontSizeSelect config={config} onUpdate={onUpdate} />
|
<FontSizeSelect config={config} onUpdate={onUpdate} />
|
||||||
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
||||||
</>
|
</>
|
||||||
|
|
@ -454,11 +441,18 @@ export function PopTextConfigPanel({
|
||||||
|
|
||||||
{textType === "datetime" && (
|
{textType === "datetime" && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider label="시간/날짜 설정" />
|
|
||||||
{/* 포맷 빌더 UI */}
|
{/* 포맷 빌더 UI */}
|
||||||
<DateTimeFormatBuilder config={config} onUpdate={onUpdate} />
|
<DateTimeFormatBuilder config={config} onUpdate={onUpdate} />
|
||||||
|
|
||||||
<SectionDivider label="스타일 설정" />
|
{/* 실시간 업데이트 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={config?.isRealtime ?? true}
|
||||||
|
onCheckedChange={(v) => onUpdate({ ...config, isRealtime: v })}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs">실시간 업데이트</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FontSizeSelect config={config} onUpdate={onUpdate} />
|
<FontSizeSelect config={config} onUpdate={onUpdate} />
|
||||||
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
||||||
</>
|
</>
|
||||||
|
|
@ -466,7 +460,6 @@ export function PopTextConfigPanel({
|
||||||
|
|
||||||
{textType === "image" && (
|
{textType === "image" && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider label="이미지 설정" />
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">이미지 URL</Label>
|
<Label className="text-xs">이미지 URL</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -514,15 +507,14 @@ export function PopTextConfigPanel({
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SectionDivider label="정렬 설정" />
|
|
||||||
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{textType === "title" && (
|
{textType === "title" && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider label="제목 설정" />
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">제목 텍스트</Label>
|
||||||
<Input
|
<Input
|
||||||
value={config?.content || ""}
|
value={config?.content || ""}
|
||||||
onChange={(e) => onUpdate({ ...config, content: e.target.value })}
|
onChange={(e) => onUpdate({ ...config, content: e.target.value })}
|
||||||
|
|
@ -530,7 +522,6 @@ export function PopTextConfigPanel({
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SectionDivider label="스타일 설정" />
|
|
||||||
<FontSizeSelect config={config} onUpdate={onUpdate} />
|
<FontSizeSelect config={config} onUpdate={onUpdate} />
|
||||||
<FontWeightSelect config={config} onUpdate={onUpdate} />
|
<FontWeightSelect config={config} onUpdate={onUpdate} />
|
||||||
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,10 @@ const nextConfig = {
|
||||||
},
|
},
|
||||||
|
|
||||||
// API 프록시 설정 - 백엔드로 요청 전달
|
// API 프록시 설정 - 백엔드로 요청 전달
|
||||||
// Docker 환경: 컨테이너 이름(pms-backend-mac) 또는 SERVER_API_URL 사용
|
// Docker 환경: SERVER_API_URL=http://backend:3001 사용
|
||||||
// 로컬 개발: http://127.0.0.1:8080 사용
|
// 로컬 개발: http://localhost:8080 사용
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
// Docker 컨테이너 내부에서는 컨테이너 이름으로 통신
|
const backendUrl = process.env.SERVER_API_URL || "http://localhost:8080";
|
||||||
const backendUrl = process.env.SERVER_API_URL || "http://pms-backend-mac:8080";
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: "/api/:path*",
|
source: "/api/:path*",
|
||||||
|
|
@ -49,8 +48,8 @@ const nextConfig = {
|
||||||
|
|
||||||
// 환경 변수 (런타임에 읽기)
|
// 환경 변수 (런타임에 읽기)
|
||||||
env: {
|
env: {
|
||||||
// Docker 컨테이너 내부에서는 컨테이너 이름으로 통신
|
// 항상 명시적으로 백엔드 포트(8080)를 지정
|
||||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://pms-backend-mac:8080/api",
|
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080/api",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue