1037 lines
43 KiB
TypeScript
1037 lines
43 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
import { Check, Plus, X, Info, RotateCcw } from "lucide-react";
|
|
import { icons as allLucideIcons } from "lucide-react";
|
|
import DOMPurify from "isomorphic-dompurify";
|
|
import { cn } from "@/lib/utils";
|
|
import { ComponentData } from "@/types/screen";
|
|
import { ColorPickerWithTransparent } from "../../common/ColorPickerWithTransparent";
|
|
import {
|
|
actionIconMap,
|
|
noIconActions,
|
|
NO_ICON_MESSAGE,
|
|
iconSizePresets,
|
|
getLucideIcon,
|
|
addToIconMap,
|
|
getDefaultIconForAction,
|
|
} from "@/lib/button-icon-map";
|
|
import type { ButtonTabProps } from "./types";
|
|
|
|
export const BasicTab: React.FC<ButtonTabProps> = ({
|
|
component,
|
|
onUpdateProperty,
|
|
}) => {
|
|
const config = component.componentConfig || {};
|
|
|
|
// 표시 모드, 버튼 텍스트, 액션 타입
|
|
const [displayMode, setDisplayMode] = useState<"text" | "icon" | "icon-text">(
|
|
config.displayMode || "text"
|
|
);
|
|
const [localText, setLocalText] = useState(
|
|
config.text !== undefined ? config.text : "버튼"
|
|
);
|
|
const [localActionType, setLocalActionType] = useState(
|
|
String(config.action?.type || "save")
|
|
);
|
|
|
|
// 아이콘 설정 상태
|
|
const [selectedIcon, setSelectedIcon] = useState<string>(config.icon?.name || "");
|
|
const [selectedIconType, setSelectedIconType] = useState<"lucide" | "svg">(
|
|
config.icon?.type || "lucide"
|
|
);
|
|
const [iconSize, setIconSize] = useState<string>(config.icon?.size || "보통");
|
|
const [iconColor, setIconColor] = useState<string>(config.icon?.color || "");
|
|
const [iconGap, setIconGap] = useState<number>(config.iconGap ?? 6);
|
|
const [iconTextPosition, setIconTextPosition] = useState<
|
|
"right" | "left" | "bottom" | "top"
|
|
>(config.iconTextPosition || "right");
|
|
|
|
// 커스텀 아이콘 UI 상태
|
|
const [lucideSearchOpen, setLucideSearchOpen] = useState(false);
|
|
const [lucideSearchTerm, setLucideSearchTerm] = useState("");
|
|
const [svgPasteOpen, setSvgPasteOpen] = useState(false);
|
|
const [svgInput, setSvgInput] = useState("");
|
|
const [svgName, setSvgName] = useState("");
|
|
const [svgError, setSvgError] = useState("");
|
|
|
|
// 컴포넌트 prop 변경 시 로컬 상태 동기화
|
|
useEffect(() => {
|
|
const latestConfig = component.componentConfig || {};
|
|
const latestAction = latestConfig.action || {};
|
|
|
|
setLocalText(latestConfig.text !== undefined ? latestConfig.text : "버튼");
|
|
setLocalActionType(String(latestAction.type || "save"));
|
|
setDisplayMode((latestConfig.displayMode as "text" | "icon" | "icon-text") || "text");
|
|
setSelectedIcon(latestConfig.icon?.name || "");
|
|
setSelectedIconType((latestConfig.icon?.type as "lucide" | "svg") || "lucide");
|
|
setIconSize(latestConfig.icon?.size || "보통");
|
|
setIconColor(latestConfig.icon?.color || "");
|
|
setIconGap(latestConfig.iconGap ?? 6);
|
|
setIconTextPosition(
|
|
(latestConfig.iconTextPosition as "right" | "left" | "bottom" | "top") || "right"
|
|
);
|
|
}, [component.id, component.componentConfig?.action?.type]);
|
|
|
|
// 현재 액션의 추천 아이콘 목록
|
|
const currentActionIcons = actionIconMap[localActionType] || [];
|
|
const isNoIconAction = noIconActions.has(localActionType);
|
|
const customIcons: string[] = config.customIcons || [];
|
|
const customSvgIcons: Array<{ name: string; svg: string }> =
|
|
config.customSvgIcons || [];
|
|
const showIconSettings = displayMode === "icon" || displayMode === "icon-text";
|
|
|
|
// 아이콘 선택 핸들러
|
|
const handleSelectIcon = (iconName: string, iconType: "lucide" | "svg" = "lucide") => {
|
|
setSelectedIcon(iconName);
|
|
setSelectedIconType(iconType);
|
|
onUpdateProperty("componentConfig.icon", {
|
|
name: iconName,
|
|
type: iconType,
|
|
size: iconSize,
|
|
...(iconColor ? { color: iconColor } : {}),
|
|
});
|
|
};
|
|
|
|
// 선택 중인 아이콘이 삭제되었을 때 디폴트 아이콘으로 복귀
|
|
const revertToDefaultIcon = () => {
|
|
const def = getDefaultIconForAction(localActionType);
|
|
setSelectedIcon(def.name);
|
|
setSelectedIconType(def.type);
|
|
handleSelectIcon(def.name, def.type);
|
|
};
|
|
|
|
// 표시 모드 변경 핸들러
|
|
const handleDisplayModeChange = (mode: "text" | "icon" | "icon-text") => {
|
|
setDisplayMode(mode);
|
|
onUpdateProperty("componentConfig.displayMode", mode);
|
|
|
|
if ((mode === "icon" || mode === "icon-text") && !selectedIcon) {
|
|
revertToDefaultIcon();
|
|
}
|
|
};
|
|
|
|
// 아이콘 크기 프리셋 변경
|
|
const handleIconSizePreset = (preset: string) => {
|
|
setIconSize(preset);
|
|
if (selectedIcon) {
|
|
onUpdateProperty("componentConfig.icon.size", preset);
|
|
}
|
|
};
|
|
|
|
// 아이콘 색상 변경
|
|
const handleIconColorChange = (color: string | undefined) => {
|
|
const val = color || "";
|
|
setIconColor(val);
|
|
if (selectedIcon) {
|
|
if (val) {
|
|
onUpdateProperty("componentConfig.icon.color", val);
|
|
} else {
|
|
onUpdateProperty("componentConfig.icon.color", undefined);
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 표시 모드 선택 */}
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs sm:text-sm">표시 모드</Label>
|
|
<div className="flex rounded-md border">
|
|
{(
|
|
[
|
|
{ value: "text", label: "텍스트" },
|
|
{ value: "icon", label: "아이콘" },
|
|
{ value: "icon-text", label: "아이콘+텍스트" },
|
|
] as const
|
|
).map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
onClick={() => handleDisplayModeChange(opt.value)}
|
|
className={cn(
|
|
"flex-1 px-2 py-1.5 text-xs font-medium transition-colors first:rounded-l-md last:rounded-r-md",
|
|
displayMode === opt.value
|
|
? "bg-primary text-primary-foreground"
|
|
: "hover:bg-muted text-muted-foreground"
|
|
)}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 아이콘 모드 레이아웃 안내 */}
|
|
{displayMode === "icon" && (
|
|
<div className="flex items-start gap-2 rounded-md bg-muted p-2.5 text-xs text-muted-foreground">
|
|
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
<span>
|
|
아이콘만 표시할 때는 버튼 영역의 가로 폭을 줄여 정사각형에 가깝게
|
|
만들면 더 깔끔합니다.
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 버튼 텍스트 (텍스트 / 아이콘+텍스트 모드에서 표시) */}
|
|
{(displayMode === "text" || displayMode === "icon-text") && (
|
|
<div>
|
|
<Label htmlFor="button-text">버튼 텍스트</Label>
|
|
<Input
|
|
id="button-text"
|
|
value={localText}
|
|
onChange={(e) => {
|
|
const newValue = e.target.value;
|
|
setLocalText(newValue);
|
|
onUpdateProperty("componentConfig.text", newValue);
|
|
}}
|
|
placeholder="버튼 텍스트를 입력하세요"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 버튼 액션 */}
|
|
<div>
|
|
<Label htmlFor="button-action" className="mb-1.5 block">
|
|
버튼 액션
|
|
</Label>
|
|
<Select
|
|
key={`action-${component.id}`}
|
|
value={localActionType}
|
|
onValueChange={(value) => {
|
|
setLocalActionType(value);
|
|
onUpdateProperty("componentConfig.action.type", value);
|
|
|
|
// 액션 변경 시: 선택된 아이콘이 새 액션의 추천 목록에 없으면 초기화
|
|
const newActionIcons = actionIconMap[value] || [];
|
|
if (
|
|
selectedIcon &&
|
|
selectedIconType === "lucide" &&
|
|
!newActionIcons.includes(selectedIcon) &&
|
|
!customIcons.includes(selectedIcon)
|
|
) {
|
|
setSelectedIcon("");
|
|
onUpdateProperty("componentConfig.icon", undefined);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
const newColor = value === "delete" ? "#ef4444" : "#212121";
|
|
onUpdateProperty("style.labelColor", newColor);
|
|
}, 100);
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="버튼 액션 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{/* 핵심 액션 */}
|
|
<SelectItem value="save">저장</SelectItem>
|
|
<SelectItem value="delete">삭제</SelectItem>
|
|
<SelectItem value="edit">편집</SelectItem>
|
|
<SelectItem value="navigate">페이지 이동</SelectItem>
|
|
<SelectItem value="modal">모달 열기</SelectItem>
|
|
<SelectItem value="transferData">데이터 전달</SelectItem>
|
|
|
|
{/* 엑셀 관련 */}
|
|
<SelectItem value="excel_download">엑셀 다운로드</SelectItem>
|
|
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
|
<SelectItem value="multi_table_excel_upload">
|
|
다중 테이블 엑셀 업로드
|
|
</SelectItem>
|
|
|
|
{/* 고급 기능 */}
|
|
<SelectItem value="quickInsert">즉시 저장</SelectItem>
|
|
<SelectItem value="control">제어 흐름</SelectItem>
|
|
<SelectItem value="approval">결재 요청</SelectItem>
|
|
|
|
{/* 특수 기능 (필요 시 사용) */}
|
|
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
|
<SelectItem value="operation_control">운행알림 및 종료</SelectItem>
|
|
|
|
{/* 이벤트 버스 */}
|
|
<SelectItem value="event">이벤트 발송</SelectItem>
|
|
|
|
{/* 복사 */}
|
|
<SelectItem value="copy">복사 (품목코드 초기화)</SelectItem>
|
|
|
|
{/* 숨김 처리 - 기존 시스템 호환성 유지, UI에서만 숨김 */}
|
|
{/* <SelectItem value="view_table_history">테이블 이력 보기</SelectItem> */}
|
|
{/* <SelectItem value="openRelatedModal">연관 데이터 버튼 모달 열기</SelectItem> */}
|
|
{/* <SelectItem value="openModalWithData">(deprecated) 데이터 전달 + 모달 열기</SelectItem> */}
|
|
{/* <SelectItem value="code_merge">코드 병합</SelectItem> */}
|
|
{/* <SelectItem value="empty_vehicle">공차등록</SelectItem> */}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 아이콘 설정 영역 */}
|
|
{showIconSettings && (
|
|
<div className="space-y-4">
|
|
{/* 추천 아이콘 / 안내 문구 */}
|
|
{isNoIconAction ? (
|
|
<div>
|
|
<div className="text-muted-foreground rounded-md border border-dashed p-3 text-center text-xs">
|
|
{NO_ICON_MESSAGE}
|
|
</div>
|
|
|
|
{/* 커스텀 아이콘이 있으면 표시 */}
|
|
{(customIcons.length > 0 || customSvgIcons.length > 0) && (
|
|
<>
|
|
<div className="my-2 flex items-center gap-2">
|
|
<div className="bg-border h-px flex-1" />
|
|
<span className="text-muted-foreground text-[10px]">
|
|
커스텀 아이콘
|
|
</span>
|
|
<div className="bg-border h-px flex-1" />
|
|
</div>
|
|
<div className="grid grid-cols-4 gap-1.5">
|
|
{customIcons.map((iconName) => {
|
|
const Icon = getLucideIcon(iconName);
|
|
if (!Icon) return null;
|
|
return (
|
|
<div key={`custom-${iconName}`} className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleSelectIcon(iconName, "lucide")}
|
|
className={cn(
|
|
"hover:bg-muted flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors",
|
|
selectedIcon === iconName &&
|
|
selectedIconType === "lucide"
|
|
? "border-primary ring-primary/30 bg-primary/5 ring-2"
|
|
: "border-transparent"
|
|
)}
|
|
>
|
|
<Icon className="h-6 w-6" />
|
|
<span className="text-muted-foreground truncate text-[10px]">
|
|
{iconName}
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const next = customIcons.filter(
|
|
(n) => n !== iconName
|
|
);
|
|
onUpdateProperty(
|
|
"componentConfig.customIcons",
|
|
next
|
|
);
|
|
if (selectedIcon === iconName)
|
|
revertToDefaultIcon();
|
|
}}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/80 absolute -top-1 -right-1 rounded-full p-0.5"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
{customSvgIcons.map((svgIcon) => (
|
|
<div
|
|
key={`svg-${svgIcon.name}`}
|
|
className="relative"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
handleSelectIcon(svgIcon.name, "svg")
|
|
}
|
|
className={cn(
|
|
"hover:bg-muted flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors",
|
|
selectedIcon === svgIcon.name &&
|
|
selectedIconType === "svg"
|
|
? "border-primary ring-primary/30 bg-primary/5 ring-2"
|
|
: "border-transparent"
|
|
)}
|
|
>
|
|
<span
|
|
className="flex h-6 w-6 items-center justify-center [&>svg]:h-full [&>svg]:w-full"
|
|
dangerouslySetInnerHTML={{
|
|
__html: DOMPurify.sanitize(svgIcon.svg, {
|
|
USE_PROFILES: { svg: true },
|
|
}),
|
|
}}
|
|
/>
|
|
<span className="text-muted-foreground truncate text-[10px]">
|
|
{svgIcon.name}
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const next = customSvgIcons.filter(
|
|
(s) => s.name !== svgIcon.name
|
|
);
|
|
onUpdateProperty(
|
|
"componentConfig.customSvgIcons",
|
|
next
|
|
);
|
|
if (selectedIcon === svgIcon.name)
|
|
revertToDefaultIcon();
|
|
}}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/80 absolute -top-1 -right-1 rounded-full p-0.5"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* 커스텀 아이콘 추가 버튼 */}
|
|
<div className="mt-2 flex gap-2">
|
|
<Popover
|
|
open={lucideSearchOpen}
|
|
onOpenChange={setLucideSearchOpen}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 flex-1 text-xs"
|
|
>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
lucide 검색
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-64 p-0" align="start">
|
|
<Command>
|
|
<CommandInput
|
|
placeholder="아이콘 이름 검색..."
|
|
value={lucideSearchTerm}
|
|
onValueChange={setLucideSearchTerm}
|
|
className="text-xs"
|
|
/>
|
|
<CommandList className="max-h-48">
|
|
<CommandEmpty className="py-3 text-xs">
|
|
아이콘을 찾을 수 없습니다.
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{Object.keys(allLucideIcons)
|
|
.filter((name) =>
|
|
name
|
|
.toLowerCase()
|
|
.includes(lucideSearchTerm.toLowerCase())
|
|
)
|
|
.slice(0, 30)
|
|
.map((iconName) => {
|
|
const Icon =
|
|
allLucideIcons[
|
|
iconName as keyof typeof allLucideIcons
|
|
];
|
|
return (
|
|
<CommandItem
|
|
key={iconName}
|
|
value={iconName}
|
|
onSelect={() => {
|
|
const next = [...customIcons];
|
|
if (!next.includes(iconName)) {
|
|
next.push(iconName);
|
|
onUpdateProperty(
|
|
"componentConfig.customIcons",
|
|
next
|
|
);
|
|
if (Icon)
|
|
addToIconMap(iconName, Icon);
|
|
}
|
|
setLucideSearchOpen(false);
|
|
setLucideSearchTerm("");
|
|
}}
|
|
className="flex items-center gap-2 text-xs"
|
|
>
|
|
{Icon ? (
|
|
<Icon className="h-4 w-4" />
|
|
) : (
|
|
<span className="h-4 w-4" />
|
|
)}
|
|
{iconName}
|
|
{customIcons.includes(iconName) && (
|
|
<Check className="text-primary ml-auto h-3 w-3" />
|
|
)}
|
|
</CommandItem>
|
|
);
|
|
})}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
<Popover open={svgPasteOpen} onOpenChange={setSvgPasteOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 flex-1 text-xs"
|
|
>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
SVG 붙여넣기
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-72 space-y-2 p-3" align="start">
|
|
<Label className="text-xs">아이콘 이름</Label>
|
|
<Input
|
|
value={svgName}
|
|
onChange={(e) => setSvgName(e.target.value)}
|
|
placeholder="예: 회사로고"
|
|
className="h-7 text-xs"
|
|
/>
|
|
<Label className="text-xs">SVG 코드</Label>
|
|
<textarea
|
|
value={svgInput}
|
|
onChange={(e) => {
|
|
setSvgInput(e.target.value);
|
|
setSvgError("");
|
|
}}
|
|
onPaste={(e) => {
|
|
e.stopPropagation();
|
|
const text = e.clipboardData.getData("text/plain");
|
|
if (text) {
|
|
e.preventDefault();
|
|
setSvgInput(text);
|
|
setSvgError("");
|
|
}
|
|
}}
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
placeholder={'<svg xmlns="http://www.w3.org/2000/svg" ...>...</svg>'}
|
|
className="bg-background focus:ring-ring h-20 w-full rounded-md border px-2 py-1.5 text-xs focus:ring-2 focus:outline-none"
|
|
/>
|
|
{svgInput && (
|
|
<div className="bg-muted/50 flex items-center justify-center rounded border p-2">
|
|
<span
|
|
className="flex h-8 w-8 items-center justify-center [&>svg]:h-full [&>svg]:w-full"
|
|
dangerouslySetInnerHTML={{
|
|
__html: DOMPurify.sanitize(svgInput, {
|
|
USE_PROFILES: { svg: true },
|
|
}),
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
{svgError && (
|
|
<p className="text-destructive text-xs">{svgError}</p>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
className="h-7 w-full text-xs"
|
|
onClick={() => {
|
|
if (!svgName.trim()) {
|
|
setSvgError("아이콘 이름을 입력하세요.");
|
|
return;
|
|
}
|
|
if (!svgInput.trim().includes("<svg")) {
|
|
setSvgError("유효한 SVG 코드가 아닙니다.");
|
|
return;
|
|
}
|
|
const sanitized = DOMPurify.sanitize(svgInput, {
|
|
USE_PROFILES: { svg: true },
|
|
});
|
|
let finalName = svgName.trim();
|
|
const existingNames = new Set(
|
|
customSvgIcons.map((s) => s.name)
|
|
);
|
|
if (existingNames.has(finalName)) {
|
|
let counter = 2;
|
|
while (
|
|
existingNames.has(`${svgName.trim()}(${counter})`)
|
|
)
|
|
counter++;
|
|
finalName = `${svgName.trim()}(${counter})`;
|
|
}
|
|
const next = [
|
|
...customSvgIcons,
|
|
{ name: finalName, svg: sanitized },
|
|
];
|
|
onUpdateProperty(
|
|
"componentConfig.customSvgIcons",
|
|
next
|
|
);
|
|
setSvgInput("");
|
|
setSvgName("");
|
|
setSvgError("");
|
|
setSvgPasteOpen(false);
|
|
}}
|
|
>
|
|
추가
|
|
</Button>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs sm:text-sm">
|
|
아이콘 선택
|
|
</Label>
|
|
<div className="grid grid-cols-4 gap-1.5">
|
|
{currentActionIcons.map((iconName) => {
|
|
const Icon = getLucideIcon(iconName);
|
|
if (!Icon) return null;
|
|
return (
|
|
<button
|
|
key={iconName}
|
|
type="button"
|
|
onClick={() => handleSelectIcon(iconName, "lucide")}
|
|
className={cn(
|
|
"hover:bg-muted flex flex-col items-center gap-1 rounded-md border p-2 transition-colors",
|
|
selectedIcon === iconName &&
|
|
selectedIconType === "lucide"
|
|
? "border-primary ring-primary/30 bg-primary/5 ring-2"
|
|
: "border-transparent"
|
|
)}
|
|
>
|
|
<Icon className="h-6 w-6" />
|
|
<span className="text-muted-foreground truncate text-[10px]">
|
|
{iconName}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 커스텀 아이콘 영역 */}
|
|
{(customIcons.length > 0 || customSvgIcons.length > 0) && (
|
|
<>
|
|
<div className="my-2 flex items-center gap-2">
|
|
<div className="bg-border h-px flex-1" />
|
|
<span className="text-muted-foreground text-[10px]">
|
|
커스텀 아이콘
|
|
</span>
|
|
<div className="bg-border h-px flex-1" />
|
|
</div>
|
|
<div className="grid grid-cols-4 gap-1.5">
|
|
{customIcons.map((iconName) => {
|
|
const Icon = getLucideIcon(iconName);
|
|
if (!Icon) return null;
|
|
return (
|
|
<div key={`custom-${iconName}`} className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleSelectIcon(iconName, "lucide")}
|
|
className={cn(
|
|
"hover:bg-muted flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors",
|
|
selectedIcon === iconName &&
|
|
selectedIconType === "lucide"
|
|
? "border-primary ring-primary/30 bg-primary/5 ring-2"
|
|
: "border-transparent"
|
|
)}
|
|
>
|
|
<Icon className="h-6 w-6" />
|
|
<span className="text-muted-foreground truncate text-[10px]">
|
|
{iconName}
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const next = customIcons.filter(
|
|
(n) => n !== iconName
|
|
);
|
|
onUpdateProperty(
|
|
"componentConfig.customIcons",
|
|
next
|
|
);
|
|
if (selectedIcon === iconName)
|
|
revertToDefaultIcon();
|
|
}}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/80 absolute -top-1 -right-1 rounded-full p-0.5"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
{customSvgIcons.map((svgIcon) => (
|
|
<div
|
|
key={`svg-${svgIcon.name}`}
|
|
className="relative"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
handleSelectIcon(svgIcon.name, "svg")
|
|
}
|
|
className={cn(
|
|
"hover:bg-muted flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors",
|
|
selectedIcon === svgIcon.name &&
|
|
selectedIconType === "svg"
|
|
? "border-primary ring-primary/30 bg-primary/5 ring-2"
|
|
: "border-transparent"
|
|
)}
|
|
>
|
|
<span
|
|
className="flex h-6 w-6 items-center justify-center [&>svg]:h-full [&>svg]:w-full"
|
|
dangerouslySetInnerHTML={{
|
|
__html: DOMPurify.sanitize(svgIcon.svg, {
|
|
USE_PROFILES: { svg: true },
|
|
}),
|
|
}}
|
|
/>
|
|
<span className="text-muted-foreground truncate text-[10px]">
|
|
{svgIcon.name}
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const next = customSvgIcons.filter(
|
|
(s) => s.name !== svgIcon.name
|
|
);
|
|
onUpdateProperty(
|
|
"componentConfig.customSvgIcons",
|
|
next
|
|
);
|
|
if (selectedIcon === svgIcon.name)
|
|
revertToDefaultIcon();
|
|
}}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/80 absolute -top-1 -right-1 rounded-full p-0.5"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* 커스텀 아이콘 추가 버튼 */}
|
|
<div className="mt-2 flex gap-2">
|
|
<Popover
|
|
open={lucideSearchOpen}
|
|
onOpenChange={setLucideSearchOpen}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 flex-1 text-xs"
|
|
>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
lucide 검색
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-64 p-0" align="start">
|
|
<Command>
|
|
<CommandInput
|
|
placeholder="아이콘 이름 검색..."
|
|
value={lucideSearchTerm}
|
|
onValueChange={setLucideSearchTerm}
|
|
className="text-xs"
|
|
/>
|
|
<CommandList className="max-h-48">
|
|
<CommandEmpty className="py-3 text-xs">
|
|
아이콘을 찾을 수 없습니다.
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{Object.keys(allLucideIcons)
|
|
.filter((name) =>
|
|
name
|
|
.toLowerCase()
|
|
.includes(lucideSearchTerm.toLowerCase())
|
|
)
|
|
.slice(0, 30)
|
|
.map((iconName) => {
|
|
const Icon =
|
|
allLucideIcons[
|
|
iconName as keyof typeof allLucideIcons
|
|
];
|
|
return (
|
|
<CommandItem
|
|
key={iconName}
|
|
value={iconName}
|
|
onSelect={() => {
|
|
const next = [...customIcons];
|
|
if (!next.includes(iconName)) {
|
|
next.push(iconName);
|
|
onUpdateProperty(
|
|
"componentConfig.customIcons",
|
|
next
|
|
);
|
|
if (Icon)
|
|
addToIconMap(iconName, Icon);
|
|
}
|
|
setLucideSearchOpen(false);
|
|
setLucideSearchTerm("");
|
|
}}
|
|
className="flex items-center gap-2 text-xs"
|
|
>
|
|
{Icon ? (
|
|
<Icon className="h-4 w-4" />
|
|
) : (
|
|
<span className="h-4 w-4" />
|
|
)}
|
|
{iconName}
|
|
{customIcons.includes(iconName) && (
|
|
<Check className="text-primary ml-auto h-3 w-3" />
|
|
)}
|
|
</CommandItem>
|
|
);
|
|
})}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
<Popover open={svgPasteOpen} onOpenChange={setSvgPasteOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 flex-1 text-xs"
|
|
>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
SVG 붙여넣기
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-72 space-y-2 p-3" align="start">
|
|
<Label className="text-xs">아이콘 이름</Label>
|
|
<Input
|
|
value={svgName}
|
|
onChange={(e) => setSvgName(e.target.value)}
|
|
placeholder="예: 회사로고"
|
|
className="h-7 text-xs"
|
|
/>
|
|
<Label className="text-xs">SVG 코드</Label>
|
|
<textarea
|
|
value={svgInput}
|
|
onChange={(e) => {
|
|
setSvgInput(e.target.value);
|
|
setSvgError("");
|
|
}}
|
|
onPaste={(e) => {
|
|
e.stopPropagation();
|
|
const text = e.clipboardData.getData("text/plain");
|
|
if (text) {
|
|
e.preventDefault();
|
|
setSvgInput(text);
|
|
setSvgError("");
|
|
}
|
|
}}
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
placeholder={'<svg xmlns="http://www.w3.org/2000/svg" ...>...</svg>'}
|
|
className="bg-background focus:ring-ring h-20 w-full rounded-md border px-2 py-1.5 text-xs focus:ring-2 focus:outline-none"
|
|
/>
|
|
{svgInput && (
|
|
<div className="bg-muted/50 flex items-center justify-center rounded border p-2">
|
|
<span
|
|
className="flex h-8 w-8 items-center justify-center [&>svg]:h-full [&>svg]:w-full"
|
|
dangerouslySetInnerHTML={{
|
|
__html: DOMPurify.sanitize(svgInput, {
|
|
USE_PROFILES: { svg: true },
|
|
}),
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
{svgError && (
|
|
<p className="text-destructive text-xs">{svgError}</p>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
className="h-7 w-full text-xs"
|
|
onClick={() => {
|
|
if (!svgName.trim()) {
|
|
setSvgError("아이콘 이름을 입력하세요.");
|
|
return;
|
|
}
|
|
if (!svgInput.trim().includes("<svg")) {
|
|
setSvgError("유효한 SVG 코드가 아닙니다.");
|
|
return;
|
|
}
|
|
const sanitized = DOMPurify.sanitize(svgInput, {
|
|
USE_PROFILES: { svg: true },
|
|
});
|
|
let finalName = svgName.trim();
|
|
const existingNames = new Set(
|
|
customSvgIcons.map((s) => s.name)
|
|
);
|
|
if (existingNames.has(finalName)) {
|
|
let counter = 2;
|
|
while (
|
|
existingNames.has(`${svgName.trim()}(${counter})`)
|
|
)
|
|
counter++;
|
|
finalName = `${svgName.trim()}(${counter})`;
|
|
}
|
|
const next = [
|
|
...customSvgIcons,
|
|
{ name: finalName, svg: sanitized },
|
|
];
|
|
onUpdateProperty(
|
|
"componentConfig.customSvgIcons",
|
|
next
|
|
);
|
|
setSvgInput("");
|
|
setSvgName("");
|
|
setSvgError("");
|
|
setSvgPasteOpen(false);
|
|
}}
|
|
>
|
|
추가
|
|
</Button>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 아이콘 크기 비율 */}
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs sm:text-sm">
|
|
아이콘 크기 비율
|
|
</Label>
|
|
<div className="flex rounded-md border">
|
|
{Object.keys(iconSizePresets).map((preset) => (
|
|
<button
|
|
key={preset}
|
|
type="button"
|
|
onClick={() => handleIconSizePreset(preset)}
|
|
className={cn(
|
|
"flex-1 px-1 py-1 text-xs font-medium whitespace-nowrap transition-colors first:rounded-l-md last:rounded-r-md",
|
|
iconSize === preset
|
|
? "bg-primary text-primary-foreground"
|
|
: "hover:bg-muted text-muted-foreground"
|
|
)}
|
|
>
|
|
{preset}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 텍스트 위치 (icon-text 모드 전용) */}
|
|
{displayMode === "icon-text" && (
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs sm:text-sm">
|
|
텍스트 위치
|
|
</Label>
|
|
<div className="flex rounded-md border">
|
|
{(
|
|
[
|
|
{ value: "left", label: "왼쪽" },
|
|
{ value: "right", label: "오른쪽" },
|
|
{ value: "top", label: "위쪽" },
|
|
{ value: "bottom", label: "아래쪽" },
|
|
] as const
|
|
).map((pos) => (
|
|
<button
|
|
key={pos.value}
|
|
type="button"
|
|
onClick={() => {
|
|
setIconTextPosition(pos.value);
|
|
onUpdateProperty(
|
|
"componentConfig.iconTextPosition",
|
|
pos.value
|
|
);
|
|
}}
|
|
className={cn(
|
|
"flex-1 px-2 py-1 text-xs font-medium transition-colors first:rounded-l-md last:rounded-r-md",
|
|
iconTextPosition === pos.value
|
|
? "bg-primary text-primary-foreground"
|
|
: "hover:bg-muted text-muted-foreground"
|
|
)}
|
|
>
|
|
{pos.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 아이콘-텍스트 간격 (icon-text 모드 전용) */}
|
|
{displayMode === "icon-text" && (
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs sm:text-sm">
|
|
아이콘-텍스트 간격
|
|
</Label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={32}
|
|
step={1}
|
|
value={Math.min(iconGap, 32)}
|
|
onChange={(e) => {
|
|
const val = Number(e.target.value);
|
|
setIconGap(val);
|
|
onUpdateProperty("componentConfig.iconGap", val);
|
|
}}
|
|
className="accent-primary h-1.5 flex-1 cursor-pointer"
|
|
/>
|
|
<div className="flex items-center gap-1">
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
value={iconGap}
|
|
onChange={(e) => {
|
|
const val = Math.max(
|
|
0,
|
|
Number(e.target.value) || 0
|
|
);
|
|
setIconGap(val);
|
|
onUpdateProperty("componentConfig.iconGap", val);
|
|
}}
|
|
className="h-7 w-14 text-center text-xs"
|
|
/>
|
|
<span className="text-muted-foreground text-xs">px</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 아이콘 색상 */}
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs sm:text-sm">
|
|
아이콘 색상
|
|
</Label>
|
|
<div className="flex items-center gap-2">
|
|
<ColorPickerWithTransparent
|
|
value={iconColor || undefined}
|
|
onChange={handleIconColorChange}
|
|
placeholder="텍스트 색상 상속"
|
|
className="flex-1"
|
|
/>
|
|
{iconColor && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 shrink-0 text-xs"
|
|
onClick={() => handleIconColorChange(undefined)}
|
|
>
|
|
<RotateCcw className="mr-1 h-3 w-3" />
|
|
텍스트 색상과 동일
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|