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:
shin 2026-02-11 10:41:30 +09:00
parent 1116fb350a
commit 929cfb2b61
9 changed files with 1035 additions and 20 deletions

View File

@ -361,7 +361,7 @@ DashboardItem {
- **한 줄 정의**: 누르면 어딘가로 이동 (돌아오는 값 없음)
- **카테고리**: action
- **역할**: 네비게이션 (화면 이동, URL 이동, 새로고침)
- **역할**: 네비게이션 (화면 이동, URL 이동)
- **데이터**: 없음
- **이벤트**: 없음 (네비게이션은 이벤트가 아닌 직접 실행)
- **설정**: 아이콘 종류(lucide-icon), 라벨, 배경색/그라디언트, 크기, 클릭 액션(PopActionConfig), 뱃지 표시

View File

@ -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>

View File

@ -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, 뒤로가기)",
},
];
// 드래그 가능한 컴포넌트 아이템

View File

@ -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>
);
}

View File

@ -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 },
};
/**

View File

@ -13,6 +13,7 @@ export * from "./types";
// POP 컴포넌트 등록
import "./pop-text";
import "./pop-icon";
// 향후 추가될 컴포넌트들:
// import "./pop-field";

View File

@ -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"],
});

View File

@ -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} />

View File

@ -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",
},
};