feat: Implement button iconization feature for screen designer

- Added a comprehensive plan for expanding button display modes in the screen designer, allowing for text, icon, and icon+text modes.
- Introduced a new `ButtonIconRenderer` component to handle dynamic rendering of buttons based on selected display modes and actions.
- Enhanced the `ButtonConfigPanel` to include UI for selecting display modes and managing icon settings, including size, color, and position.
- Implemented functionality for custom icon addition via lucide search and SVG paste, ensuring flexibility for users.
- Updated relevant components to utilize the new button rendering logic, improving the overall user experience and visual consistency.

Made-with: Cursor
This commit is contained in:
2026-03-04 16:30:05 +09:00
parent a0cf9db6e8
commit 6ceed2acd0
12 changed files with 1793 additions and 63 deletions

View File

@@ -10,7 +10,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Check, ChevronsUpDown, Search, Plus, X, ChevronUp, ChevronDown, Type, Database } from "lucide-react";
import { Check, ChevronsUpDown, Search, Plus, X, ChevronUp, ChevronDown, Type, Database, Info, RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
import { ComponentData } from "@/types/screen";
import { apiClient } from "@/lib/api/client";
@@ -18,6 +18,18 @@ import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
import { QuickInsertConfigSection } from "./QuickInsertConfigSection";
import DOMPurify from "isomorphic-dompurify";
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
import { icons as allLucideIcons } from "lucide-react";
import {
actionIconMap,
noIconActions,
NO_ICON_MESSAGE,
iconSizePresets,
getLucideIcon,
addToIconMap,
getDefaultIconForAction,
} from "@/lib/button-icon-map";
// 🆕 제목 블록 타입
interface TitleBlock {
@@ -70,6 +82,29 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
groupByColumn: String(config.action?.groupByColumns?.[0] || ""),
});
// 아이콘 설정 상태
const [displayMode, setDisplayMode] = useState<"text" | "icon" | "icon-text">(
config.displayMode || "text",
);
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("");
const [screens, setScreens] = useState<ScreenOption[]>([]);
const [screensLoading, setScreensLoading] = useState(false);
const [modalScreenOpen, setModalScreenOpen] = useState(false);
@@ -778,38 +813,144 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
);
};
// 아이콘 선택 핸들러
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(localInputs.actionType);
setSelectedIcon(def.name);
setSelectedIconType(def.type);
handleSelectIcon(def.name, def.type);
};
// 표시 모드 변경 핸들러 — icon/icon-text 전환 시 아이콘 미선택이면 디폴트 부여
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);
}
}
};
// 현재 액션의 추천 아이콘 목록
const currentActionIcons = actionIconMap[localInputs.actionType] || [];
const isNoIconAction = noIconActions.has(localInputs.actionType);
const customIcons: string[] = config.customIcons || [];
const customSvgIcons: Array<{ name: string; svg: string }> = config.customSvgIcons || [];
const showIconSettings = displayMode === "icon" || displayMode === "icon-text";
return (
<div className="space-y-4">
{/* 표시 모드 선택 */}
<div>
<Label htmlFor="button-text"> </Label>
<Input
id="button-text"
value={localInputs.text}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, text: newValue }));
onUpdateProperty("componentConfig.text", newValue);
}}
placeholder="버튼 텍스트를 입력하세요"
/>
<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-blue-50 p-2.5 text-xs text-blue-700 dark:bg-blue-950/20 dark:text-blue-300">
<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={localInputs.text}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, text: newValue }));
onUpdateProperty("componentConfig.text", newValue);
}}
placeholder="버튼 텍스트를 입력하세요"
/>
</div>
)}
<div>
<Label htmlFor="button-action"> </Label>
<Label htmlFor="button-action" className="mb-1.5 block"> </Label>
<Select
key={`action-${component.id}`}
value={localInputs.actionType}
onValueChange={(value) => {
// 🔥 로컬 상태 먼저 업데이트
setLocalInputs((prev) => ({ ...prev, actionType: value }));
// 🔥 action.type 업데이트
onUpdateProperty("componentConfig.action.type", value);
// 🔥 색상 업데이트는 충분히 지연 (React 리렌더링 완료 후)
// 액션 변경 시: 선택된 아이콘이 새 액션의 추천 목록에 없으면 초기화
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); // 0 → 100ms로 증가
}, 100);
}}
>
<SelectTrigger>
@@ -842,10 +983,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
{/* 복사 */}
<SelectItem value="copy"> ( )</SelectItem>
{/* 🔒 숨김 처리 - 기존 시스템 호환성 유지, UI에서만 숨김
{/* 🔒 숨김 처리 - 기존 시스템 호환성 유지, UI에서만 숨김 */}
{/* 테스트용 임시 노출 */}
<SelectItem value="view_table_history"> </SelectItem>
{/*
<SelectItem value="openRelatedModal">연관 데이터 버튼 모달 열기</SelectItem>
<SelectItem value="openModalWithData">(deprecated) 데이터 전달 + 모달 열기</SelectItem>
<SelectItem value="view_table_history">테이블 이력 보기</SelectItem>
<SelectItem value="code_merge">코드 병합</SelectItem>
<SelectItem value="empty_vehicle">공차등록</SelectItem>
*/}
@@ -853,6 +996,584 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</Select>
</div>
{/* ────────────────── 아이콘 설정 영역 ────────────────── */}
{showIconSettings && (
<div className="space-y-4">
{/* 추천 아이콘 / 안내 문구 */}
{isNoIconAction ? (
<div>
<div className="rounded-md border border-dashed p-3 text-center text-xs text-muted-foreground">
{NO_ICON_MESSAGE}
</div>
{/* 커스텀 아이콘이 있으면 표시 */}
{(customIcons.length > 0 || customSvgIcons.length > 0) && (
<>
<div className="my-2 flex items-center gap-2">
<div className="h-px flex-1 bg-border" />
<span className="text-[10px] text-muted-foreground"> </span>
<div className="h-px flex-1 bg-border" />
</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(
"flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors hover:bg-muted",
selectedIcon === iconName && selectedIconType === "lucide"
? "border-primary ring-2 ring-primary/30 bg-primary/5"
: "border-transparent",
)}
>
<Icon className="h-6 w-6" />
<span className="truncate text-[10px] text-muted-foreground">{iconName}</span>
</button>
<button
type="button"
onClick={() => {
const next = customIcons.filter((n) => n !== iconName);
onUpdateProperty("componentConfig.customIcons", next);
if (selectedIcon === iconName) revertToDefaultIcon();
}}
className="absolute -top-1 -right-1 rounded-full bg-destructive p-0.5 text-destructive-foreground hover:bg-destructive/80"
>
<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(
"flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors hover:bg-muted",
selectedIcon === svgIcon.name && selectedIconType === "svg"
? "border-primary ring-2 ring-primary/30 bg-primary/5"
: "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="truncate text-[10px] text-muted-foreground">{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="absolute -top-1 -right-1 rounded-full bg-destructive p-0.5 text-destructive-foreground hover:bg-destructive/80"
>
<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="ml-auto h-3 w-3 text-primary" />}
</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="h-20 w-full rounded-md border bg-background px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
/>
{svgInput && (
<div className="flex items-center justify-center rounded border bg-muted/50 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-xs text-destructive">{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(
"flex flex-col items-center gap-1 rounded-md border p-2 transition-colors hover:bg-muted",
selectedIcon === iconName && selectedIconType === "lucide"
? "border-primary ring-2 ring-primary/30 bg-primary/5"
: "border-transparent",
)}
>
<Icon className="h-6 w-6" />
<span className="truncate text-[10px] text-muted-foreground">{iconName}</span>
</button>
);
})}
</div>
{/* 커스텀 아이콘 영역 */}
{(customIcons.length > 0 || customSvgIcons.length > 0) && (
<>
<div className="my-2 flex items-center gap-2">
<div className="h-px flex-1 bg-border" />
<span className="text-[10px] text-muted-foreground"> </span>
<div className="h-px flex-1 bg-border" />
</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(
"flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors hover:bg-muted",
selectedIcon === iconName && selectedIconType === "lucide"
? "border-primary ring-2 ring-primary/30 bg-primary/5"
: "border-transparent",
)}
>
<Icon className="h-6 w-6" />
<span className="truncate text-[10px] text-muted-foreground">{iconName}</span>
</button>
<button
type="button"
onClick={() => {
const next = customIcons.filter((n) => n !== iconName);
onUpdateProperty("componentConfig.customIcons", next);
if (selectedIcon === iconName) revertToDefaultIcon();
}}
className="absolute -top-1 -right-1 rounded-full bg-destructive p-0.5 text-destructive-foreground hover:bg-destructive/80"
>
<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(
"flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors hover:bg-muted",
selectedIcon === svgIcon.name && selectedIconType === "svg"
? "border-primary ring-2 ring-primary/30 bg-primary/5"
: "border-transparent",
)}
>
<span
className="flex h-6 w-6 items-center justify-center [&>svg]:h-full [&>svg]:w-full"
dangerouslySetInnerHTML={{ __html: svgIcon.svg }}
/>
<span className="truncate text-[10px] text-muted-foreground">{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="absolute -top-1 -right-1 rounded-full bg-destructive p-0.5 text-destructive-foreground hover:bg-destructive/80"
>
<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);
// iconMap에 동적 추가 (렌더링용)
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="ml-auto h-3 w-3 text-primary" />}
</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="h-20 w-full rounded-md border bg-background px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
/>
{svgInput && (
<div className="flex items-center justify-center rounded border bg-muted/50 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-xs text-destructive">{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="h-1.5 flex-1 cursor-pointer accent-primary"
/>
<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-xs text-muted-foreground">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>
)}
{/* 모달 열기 액션 설정 */}
{localInputs.actionType === "modal" && (
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">