ERP-node/frontend/components/screen/config-panels/button-config/BasicTab.tsx

1025 lines
43 KiB
TypeScript
Raw Normal View History

"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 { sanitizeSvg } from "@/lib/button-icon-map";
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: sanitizeSvg(svgIcon.svg),
}}
/>
<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: sanitizeSvg(svgInput),
}}
/>
</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 = sanitizeSvg(svgInput);
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: sanitizeSvg(svgIcon.svg),
}}
/>
<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: sanitizeSvg(svgInput),
}}
/>
</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 = sanitizeSvg(svgInput);
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>
);
};