- Added new entries to .gitignore for multi-agent MCP task queue and related rules. - Removed "즉시 저장" (quick insert) options from the ScreenSettingModal and BasicTab components to streamline button configurations. - Cleaned up unused event options in the V2ButtonConfigPanel to enhance clarity and maintainability. These changes aim to improve project organization and simplify the user interface by eliminating redundant options.
1021 lines
42 KiB
TypeScript
1021 lines
42 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 { 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="control">제어 흐름</SelectItem>
|
|
<SelectItem value="approval">결재 요청</SelectItem>
|
|
|
|
{/* 특수 기능 (필요 시 사용) */}
|
|
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
|
<SelectItem value="operation_control">운행알림 및 종료</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>
|
|
);
|
|
};
|