feat(pop): 아이콘 컴포넌트 추가 및 디자이너 UX 개선
- pop-icon.tsx 신규 추가: 아이콘 컴포넌트 구현 - ComponentPalette: 아이콘 컴포넌트 팔레트 추가 - ComponentEditorPanel: 아이콘 편집 패널 추가 - PopRenderer: 아이콘 렌더링 지원 - pop-layout.ts: 아이콘 타입 정의 추가 - pop-text.tsx: 텍스트 컴포넌트 개선 - next.config.mjs: 설정 업데이트 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1116fb350a
commit
929cfb2b61
|
|
@ -361,7 +361,7 @@ DashboardItem {
|
|||
|
||||
- **한 줄 정의**: 누르면 어딘가로 이동 (돌아오는 값 없음)
|
||||
- **카테고리**: action
|
||||
- **역할**: 네비게이션 (화면 이동, URL 이동, 새로고침)
|
||||
- **역할**: 네비게이션 (화면 이동, URL 이동)
|
||||
- **데이터**: 없음
|
||||
- **이벤트**: 없음 (네비게이션은 이벤트가 아닌 직접 실행)
|
||||
- **설정**: 아이콘 종류(lucide-icon), 라벨, 배경색/그라디언트, 크기, 클릭 액션(PopActionConfig), 뱃지 표시
|
||||
|
|
|
|||
|
|
@ -109,8 +109,8 @@ export default function ComponentEditorPanel({
|
|||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<Tabs defaultValue="position" className="flex flex-1 flex-col">
|
||||
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2">
|
||||
<Tabs defaultValue="position" className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2 flex-shrink-0">
|
||||
<TabsTrigger value="position" className="gap-1 text-xs">
|
||||
<Grid3x3 className="h-3 w-3" />
|
||||
위치
|
||||
|
|
@ -130,7 +130,7 @@ export default function ComponentEditorPanel({
|
|||
</TabsList>
|
||||
|
||||
{/* 위치 탭 */}
|
||||
<TabsContent value="position" className="min-h-0 flex-1 overflow-auto p-4">
|
||||
<TabsContent value="position" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
|
||||
{/* 배치된 컴포넌트 목록 */}
|
||||
{allComponents && allComponents.length > 0 && (
|
||||
<div className="mb-4">
|
||||
|
|
@ -178,7 +178,7 @@ export default function ComponentEditorPanel({
|
|||
</TabsContent>
|
||||
|
||||
{/* 설정 탭 */}
|
||||
<TabsContent value="settings" className="flex-1 overflow-auto p-4">
|
||||
<TabsContent value="settings" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
|
||||
<ComponentSettingsForm
|
||||
component={component}
|
||||
onUpdate={onUpdateComponent}
|
||||
|
|
@ -186,7 +186,7 @@ export default function ComponentEditorPanel({
|
|||
</TabsContent>
|
||||
|
||||
{/* 표시 탭 */}
|
||||
<TabsContent value="visibility" className="flex-1 overflow-auto p-4">
|
||||
<TabsContent value="visibility" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
|
||||
<VisibilityForm
|
||||
component={component}
|
||||
onUpdate={onUpdateComponent}
|
||||
|
|
@ -194,7 +194,7 @@ export default function ComponentEditorPanel({
|
|||
</TabsContent>
|
||||
|
||||
{/* 데이터 탭 */}
|
||||
<TabsContent value="data" className="flex-1 overflow-auto p-4">
|
||||
<TabsContent value="data" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
|
||||
<DataBindingPlaceholder />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useDrag } from "react-dnd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PopComponentType } from "../types/pop-layout";
|
||||
import { Square, FileText } from "lucide-react";
|
||||
import { Square, FileText, MousePointer } from "lucide-react";
|
||||
import { DND_ITEM_TYPES } from "../constants";
|
||||
|
||||
// 컴포넌트 정의
|
||||
|
|
@ -27,6 +27,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||
icon: FileText,
|
||||
description: "텍스트, 시간, 이미지 표시",
|
||||
},
|
||||
{
|
||||
type: "pop-icon",
|
||||
label: "아이콘",
|
||||
icon: MousePointer,
|
||||
description: "네비게이션 아이콘 (화면 이동, URL, 뒤로가기)",
|
||||
},
|
||||
];
|
||||
|
||||
// 드래그 가능한 컴포넌트 아이템
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ interface PopRendererProps {
|
|||
overridePadding?: number;
|
||||
/** 추가 className */
|
||||
className?: string;
|
||||
/** 현재 편집 중인 화면 ID (아이콘 네비게이션용) */
|
||||
currentScreenId?: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
|
@ -83,6 +85,7 @@ export default function PopRenderer({
|
|||
overrideGap,
|
||||
overridePadding,
|
||||
className,
|
||||
currentScreenId,
|
||||
}: PopRendererProps) {
|
||||
const { gridConfig, components, overrides } = layout;
|
||||
|
||||
|
|
@ -511,9 +514,19 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
|
|||
|
||||
// 실제 컴포넌트가 등록되어 있으면 실제 데이터로 렌더링 (대시보드 등)
|
||||
if (ActualComp) {
|
||||
// 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용
|
||||
const needsPointerEvents = component.type === "pop-icon";
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden pointer-events-none">
|
||||
<ActualComp config={component.config} label={component.label} />
|
||||
<div className={cn(
|
||||
"h-full w-full overflow-hidden",
|
||||
!needsPointerEvents && "pointer-events-none"
|
||||
)}>
|
||||
<ActualComp
|
||||
config={component.config}
|
||||
label={component.label}
|
||||
isDesignMode={isDesignMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
/**
|
||||
* POP 컴포넌트 타입
|
||||
*/
|
||||
export type PopComponentType = "pop-sample" | "pop-text"; // 테스트용 샘플 박스, 텍스트 컴포넌트
|
||||
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon"; // 테스트용 샘플 박스, 텍스트 컴포넌트, 아이콘 컴포넌트
|
||||
|
||||
/**
|
||||
* 데이터 흐름 정의
|
||||
|
|
@ -342,6 +342,7 @@ export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => {
|
|||
export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
|
||||
"pop-sample": { colSpan: 2, rowSpan: 1 },
|
||||
"pop-text": { colSpan: 3, rowSpan: 1 },
|
||||
"pop-icon": { colSpan: 1, rowSpan: 2 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export * from "./types";
|
|||
|
||||
// POP 컴포넌트 등록
|
||||
import "./pop-text";
|
||||
import "./pop-icon";
|
||||
|
||||
// 향후 추가될 컴포넌트들:
|
||||
// import "./pop-field";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,974 @@
|
|||
"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"],
|
||||
});
|
||||
|
|
@ -391,6 +391,19 @@ interface PopTextConfigPanelProps {
|
|||
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({
|
||||
config,
|
||||
onUpdate,
|
||||
|
|
@ -398,10 +411,10 @@ export function PopTextConfigPanel({
|
|||
const textType = config?.textType || "text";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 overflow-y-auto pr-1 pb-32">
|
||||
{/* 텍스트 타입 선택 */}
|
||||
<SectionDivider label="텍스트 타입" />
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">텍스트 타입</Label>
|
||||
<Select
|
||||
value={textType}
|
||||
onValueChange={(v) =>
|
||||
|
|
@ -424,8 +437,8 @@ export function PopTextConfigPanel({
|
|||
{/* 서브타입별 설정 */}
|
||||
{textType === "text" && (
|
||||
<>
|
||||
<SectionDivider label="내용 설정" />
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">내용</Label>
|
||||
<Textarea
|
||||
value={config?.content || ""}
|
||||
onChange={(e) => onUpdate({ ...config, content: e.target.value })}
|
||||
|
|
@ -434,6 +447,7 @@ export function PopTextConfigPanel({
|
|||
className="text-xs resize-none"
|
||||
/>
|
||||
</div>
|
||||
<SectionDivider label="스타일 설정" />
|
||||
<FontSizeSelect config={config} onUpdate={onUpdate} />
|
||||
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
||||
</>
|
||||
|
|
@ -441,6 +455,7 @@ export function PopTextConfigPanel({
|
|||
|
||||
{textType === "datetime" && (
|
||||
<>
|
||||
<SectionDivider label="날짜/시간 설정" />
|
||||
{/* 포맷 빌더 UI */}
|
||||
<DateTimeFormatBuilder config={config} onUpdate={onUpdate} />
|
||||
|
||||
|
|
@ -453,6 +468,7 @@ export function PopTextConfigPanel({
|
|||
<Label className="text-xs">실시간 업데이트</Label>
|
||||
</div>
|
||||
|
||||
<SectionDivider label="스타일 설정" />
|
||||
<FontSizeSelect config={config} onUpdate={onUpdate} />
|
||||
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
||||
</>
|
||||
|
|
@ -460,6 +476,7 @@ export function PopTextConfigPanel({
|
|||
|
||||
{textType === "image" && (
|
||||
<>
|
||||
<SectionDivider label="이미지 설정" />
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">이미지 URL</Label>
|
||||
<Input
|
||||
|
|
@ -507,14 +524,15 @@ export function PopTextConfigPanel({
|
|||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<SectionDivider label="정렬 설정" />
|
||||
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{textType === "title" && (
|
||||
<>
|
||||
<SectionDivider label="제목 설정" />
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">제목 텍스트</Label>
|
||||
<Input
|
||||
value={config?.content || ""}
|
||||
onChange={(e) => onUpdate({ ...config, content: e.target.value })}
|
||||
|
|
@ -522,6 +540,7 @@ export function PopTextConfigPanel({
|
|||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<SectionDivider label="스타일 설정" />
|
||||
<FontSizeSelect config={config} onUpdate={onUpdate} />
|
||||
<FontWeightSelect config={config} onUpdate={onUpdate} />
|
||||
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
||||
|
|
|
|||
|
|
@ -20,10 +20,11 @@ const nextConfig = {
|
|||
},
|
||||
|
||||
// API 프록시 설정 - 백엔드로 요청 전달
|
||||
// Docker 환경: SERVER_API_URL=http://backend:3001 사용
|
||||
// 로컬 개발: http://localhost:8080 사용
|
||||
// Docker 환경: 컨테이너 이름(pms-backend-mac) 또는 SERVER_API_URL 사용
|
||||
// 로컬 개발: http://127.0.0.1:8080 사용
|
||||
async rewrites() {
|
||||
const backendUrl = process.env.SERVER_API_URL || "http://localhost:8080";
|
||||
// Docker 컨테이너 내부에서는 컨테이너 이름으로 통신
|
||||
const backendUrl = process.env.SERVER_API_URL || "http://pms-backend-mac:8080";
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
|
|
@ -48,8 +49,8 @@ const nextConfig = {
|
|||
|
||||
// 환경 변수 (런타임에 읽기)
|
||||
env: {
|
||||
// 항상 명시적으로 백엔드 포트(8080)를 지정
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080/api",
|
||||
// Docker 컨테이너 내부에서는 컨테이너 이름으로 통신
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://pms-backend-mac:8080/api",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue