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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user