- Enhanced the menu management functionality by adding a new `menu_icon` field in the database schema, allowing for the storage of menu icons. - Updated the `saveMenu` and `updateMenu` functions in the admin controller to handle the new `menu_icon` field during menu creation and updates. - Modified the `AdminService` to include `MENU_ICON` in various queries, ensuring that the icon data is retrieved and processed correctly. - Integrated the `MenuIconPicker` component in the frontend to allow users to select and display menu icons in the `MenuFormModal`. - Updated the sidebar and layout components to utilize the new icon data, enhancing the visual representation of menus across the application.
554 lines
21 KiB
TypeScript
554 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useMemo, useRef, useEffect, useCallback } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Label } from "@/components/ui/label";
|
|
import { cn } from "@/lib/utils";
|
|
import { Search, X, ChevronDown } from "lucide-react";
|
|
import * as LucideIcons from "lucide-react";
|
|
|
|
type IconComponent = React.FC<{ className?: string }>;
|
|
|
|
// lucide-react에서 아이콘 컴포넌트만 필터링 (유틸 함수, 타입 등 제외)
|
|
const EXCLUDED_EXPORTS = new Set([
|
|
"createLucideIcon",
|
|
"defaultAttributes",
|
|
"Icon",
|
|
"icons",
|
|
"default",
|
|
]);
|
|
|
|
// PascalCase인지 확인 (아이콘 컴포넌트는 모두 PascalCase)
|
|
const isPascalCase = (str: string): boolean => /^[A-Z][a-zA-Z0-9]*$/.test(str);
|
|
|
|
// 한글 키워드 매핑 (자주 쓰는 아이콘에 한글 검색어 추가)
|
|
const KOREAN_KEYWORDS: Record<string, string[]> = {
|
|
Home: ["홈", "메인", "대시보드"],
|
|
FileText: ["문서", "파일", "텍스트"],
|
|
Users: ["사용자", "회원", "인사", "팀"],
|
|
User: ["사용자", "회원", "개인"],
|
|
Settings: ["설정", "관리", "시스템"],
|
|
Shield: ["보안", "권한", "관리자"],
|
|
Package: ["제품", "품목", "패키지", "상품"],
|
|
BarChart3: ["통계", "차트", "분석", "리포트"],
|
|
BarChart2: ["통계", "차트", "분석"],
|
|
BarChart: ["통계", "차트"],
|
|
Building2: ["회사", "조직", "건물", "부서"],
|
|
Building: ["회사", "건물"],
|
|
ShoppingCart: ["영업", "판매", "주문", "장바구니"],
|
|
ShoppingBag: ["쇼핑", "가방", "구매"],
|
|
Truck: ["물류", "배송", "운송", "출하"],
|
|
Warehouse: ["창고", "재고", "입고"],
|
|
Factory: ["생산", "공장", "제조"],
|
|
Wrench: ["설비", "유지보수", "수리", "도구"],
|
|
ClipboardCheck: ["품질", "검사", "체크리스트"],
|
|
ClipboardList: ["작업지시", "지시서", "할일"],
|
|
Clipboard: ["클립보드", "복사"],
|
|
DollarSign: ["회계", "금액", "비용", "가격"],
|
|
Receipt: ["영수증", "청구", "전표"],
|
|
Calendar: ["일정", "캘린더", "날짜"],
|
|
CalendarDays: ["일정", "캘린더", "날짜", "일"],
|
|
Clock: ["시간", "이력", "히스토리"],
|
|
FolderOpen: ["폴더", "분류", "카테고리"],
|
|
Folder: ["폴더", "분류", "그룹"],
|
|
FolderPlus: ["폴더추가", "분류추가"],
|
|
Database: ["데이터", "DB", "저장소"],
|
|
Globe: ["글로벌", "다국어", "웹", "세계"],
|
|
Mail: ["메일", "이메일"],
|
|
Bell: ["알림", "벨", "통지"],
|
|
BellRing: ["알림", "벨", "울림"],
|
|
Search: ["검색", "조회", "찾기"],
|
|
ListOrdered: ["목록", "리스트", "순서"],
|
|
List: ["목록", "리스트"],
|
|
LayoutGrid: ["그리드", "레이아웃", "화면"],
|
|
LayoutDashboard: ["대시보드", "레이아웃"],
|
|
Tag: ["태그", "라벨", "분류"],
|
|
Tags: ["태그", "라벨", "분류", "복수"],
|
|
BookOpen: ["문서", "매뉴얼", "가이드"],
|
|
Book: ["책", "문서"],
|
|
Boxes: ["BOM", "자재", "부품", "구성"],
|
|
Box: ["박스", "상자", "제품"],
|
|
GitBranch: ["흐름", "분기", "프로세스"],
|
|
Workflow: ["워크플로우", "플로우", "프로세스"],
|
|
ArrowRightLeft: ["이동", "전환", "교환"],
|
|
ArrowRight: ["오른쪽", "다음", "진행"],
|
|
ArrowLeft: ["왼쪽", "이전", "뒤로"],
|
|
ArrowUp: ["위", "상승", "업"],
|
|
ArrowDown: ["아래", "하강", "다운"],
|
|
Layers: ["레이어", "계층", "구조"],
|
|
PieChart: ["파이차트", "통계", "비율"],
|
|
TrendingUp: ["추세", "성장", "상승"],
|
|
TrendingDown: ["추세", "하락", "하강"],
|
|
AlertTriangle: ["경고", "주의"],
|
|
AlertCircle: ["경고", "주의", "원"],
|
|
CheckCircle: ["완료", "승인", "확인"],
|
|
CheckCircle2: ["완료", "승인", "확인"],
|
|
Check: ["확인", "체크"],
|
|
Cog: ["톱니바퀴", "설정", "옵션"],
|
|
Map: ["지도", "위치", "경로"],
|
|
MapPin: ["지도핀", "위치", "장소"],
|
|
Printer: ["프린터", "인쇄", "출력"],
|
|
UserCog: ["사용자설정", "계정", "프로필"],
|
|
UserPlus: ["사용자추가", "회원가입"],
|
|
UserCheck: ["사용자확인", "인증"],
|
|
Key: ["키", "권한", "인증", "보안"],
|
|
Lock: ["잠금", "보안", "비밀번호"],
|
|
LockOpen: ["잠금해제", "열기"],
|
|
Unlock: ["잠금해제"],
|
|
Hammer: ["작업", "공구", "수리"],
|
|
Ruler: ["측정", "규격", "사양"],
|
|
Scan: ["스캔", "바코드", "QR"],
|
|
QrCode: ["QR코드", "큐알"],
|
|
ScrollText: ["계약", "문서", "스크롤"],
|
|
HandCoins: ["구매", "발주", "거래"],
|
|
CircleDollarSign: ["매출", "수익", "원가"],
|
|
FileSpreadsheet: ["엑셀", "스프레드시트", "표"],
|
|
FilePlus2: ["신규", "추가", "등록"],
|
|
FilePlus: ["파일추가", "신규"],
|
|
FileCheck2: ["승인", "결재", "확인"],
|
|
FileCheck: ["파일확인"],
|
|
Zap: ["전기", "에너지", "빠른"],
|
|
Gauge: ["게이지", "성능", "속도"],
|
|
HardDrive: ["저장", "서버", "디스크"],
|
|
Monitor: ["모니터", "화면", "디스플레이"],
|
|
Smartphone: ["모바일", "스마트폰", "앱"],
|
|
Lightbulb: ["아이디어", "제안", "개선"],
|
|
Star: ["별", "즐겨찾기", "중요"],
|
|
Heart: ["좋아요", "관심", "찜"],
|
|
Bookmark: ["북마크", "저장", "즐겨찾기"],
|
|
Flag: ["플래그", "깃발", "표시"],
|
|
Award: ["수상", "인증", "포상"],
|
|
Trophy: ["트로피", "우승", "성과"],
|
|
Target: ["목표", "타겟", "대상"],
|
|
Crosshair: ["크로스헤어", "조준", "정확"],
|
|
Eye: ["보기", "조회", "미리보기"],
|
|
EyeOff: ["숨기기", "비공개"],
|
|
Image: ["이미지", "사진", "그림"],
|
|
Camera: ["카메라", "사진", "촬영"],
|
|
Video: ["비디오", "영상", "동영상"],
|
|
Music: ["음악", "오디오", "사운드"],
|
|
Mic: ["마이크", "음성", "녹음"],
|
|
Phone: ["전화", "연락", "콜"],
|
|
PhoneCall: ["통화", "전화"],
|
|
MessageSquare: ["메시지", "채팅", "대화"],
|
|
MessageCircle: ["메시지", "채팅"],
|
|
Send: ["보내기", "전송", "발송"],
|
|
Share2: ["공유", "전달"],
|
|
Link: ["링크", "연결", "URL"],
|
|
ExternalLink: ["외부링크", "새창"],
|
|
Download: ["다운로드", "내려받기"],
|
|
Upload: ["업로드", "올리기"],
|
|
CloudUpload: ["클라우드업로드", "올리기"],
|
|
CloudDownload: ["클라우드다운로드", "내려받기"],
|
|
Cloud: ["클라우드", "구름"],
|
|
Server: ["서버", "시스템"],
|
|
Cpu: ["CPU", "프로세서", "처리"],
|
|
Wifi: ["와이파이", "네트워크", "무선"],
|
|
Activity: ["활동", "모니터링", "심박"],
|
|
Thermometer: ["온도", "온도계", "측정"],
|
|
Droplets: ["물", "수질", "액체"],
|
|
Wind: ["바람", "공기", "환기"],
|
|
Sun: ["태양", "밝기", "낮"],
|
|
Moon: ["달", "야간", "다크모드"],
|
|
Umbrella: ["우산", "보호", "보험"],
|
|
Compass: ["나침반", "방향", "가이드"],
|
|
Navigation: ["네비게이션", "안내"],
|
|
RotateCcw: ["되돌리기", "새로고침", "초기화"],
|
|
RefreshCw: ["새로고침", "갱신", "동기화"],
|
|
Repeat: ["반복", "되풀이"],
|
|
Shuffle: ["셔플", "무작위", "랜덤"],
|
|
Filter: ["필터", "거르기", "조건"],
|
|
SlidersHorizontal: ["슬라이더", "조정", "필터"],
|
|
Maximize2: ["최대화", "전체화면"],
|
|
Minimize2: ["최소화", "축소"],
|
|
Move: ["이동", "옮기기"],
|
|
Copy: ["복사", "복제"],
|
|
Scissors: ["가위", "잘라내기"],
|
|
Trash2: ["삭제", "쓰레기통", "휴지통"],
|
|
Trash: ["삭제", "쓰레기"],
|
|
Archive: ["보관", "아카이브", "저장"],
|
|
ArchiveRestore: ["복원", "복구"],
|
|
Plus: ["추가", "더하기", "플러스"],
|
|
Minus: ["빼기", "마이너스", "제거"],
|
|
PlusCircle: ["추가", "원형추가"],
|
|
MinusCircle: ["제거", "원형제거"],
|
|
XCircle: ["닫기", "취소", "제거"],
|
|
Info: ["정보", "안내", "도움말"],
|
|
HelpCircle: ["도움말", "질문", "안내"],
|
|
CircleAlert: ["경고", "주의", "원형경고"],
|
|
Ban: ["금지", "차단", "비허용"],
|
|
ShieldCheck: ["보안확인", "인증완료"],
|
|
ShieldAlert: ["보안경고", "위험"],
|
|
LogIn: ["로그인", "접속"],
|
|
LogOut: ["로그아웃", "종료"],
|
|
Power: ["전원", "켜기/끄기"],
|
|
ToggleLeft: ["토글", "스위치", "끄기"],
|
|
ToggleRight: ["토글", "스위치", "켜기"],
|
|
Percent: ["퍼센트", "비율", "할인"],
|
|
Hash: ["해시", "번호", "코드"],
|
|
AtSign: ["앳", "이메일", "골뱅이"],
|
|
Code: ["코드", "개발", "프로그래밍"],
|
|
Terminal: ["터미널", "명령어", "콘솔"],
|
|
Table: ["테이블", "표", "데이터"],
|
|
Table2: ["테이블", "표"],
|
|
Columns: ["컬럼", "열", "항목"],
|
|
Rows: ["행", "줄"],
|
|
Grid3x3: ["그리드", "격자", "표"],
|
|
PanelLeft: ["패널", "사이드바", "왼쪽"],
|
|
PanelRight: ["패널", "사이드바", "오른쪽"],
|
|
Split: ["분할", "나누기"],
|
|
Combine: ["결합", "합치기"],
|
|
Network: ["네트워크", "연결망"],
|
|
Radio: ["라디오", "옵션"],
|
|
CircleDot: ["원형점", "선택"],
|
|
SquareCheck: ["체크박스", "선택"],
|
|
Square: ["사각형", "상자"],
|
|
Circle: ["원", "동그라미"],
|
|
Triangle: ["삼각형", "세모"],
|
|
Hexagon: ["육각형", "벌집"],
|
|
Diamond: ["다이아몬드", "마름모"],
|
|
Pen: ["펜", "작성", "편집"],
|
|
Pencil: ["연필", "수정", "편집"],
|
|
PenLine: ["펜라인", "서명"],
|
|
Eraser: ["지우개", "삭제", "초기화"],
|
|
Palette: ["팔레트", "색상", "디자인"],
|
|
Paintbrush: ["브러시", "페인트", "디자인"],
|
|
Figma: ["피그마", "디자인"],
|
|
Type: ["타입", "글꼴", "폰트"],
|
|
Bold: ["굵게", "볼드"],
|
|
Italic: ["기울임", "이탤릭"],
|
|
AlignLeft: ["왼쪽정렬"],
|
|
AlignCenter: ["가운데정렬"],
|
|
AlignRight: ["오른쪽정렬"],
|
|
Footprints: ["발자국", "추적", "이력"],
|
|
Fingerprint: ["지문", "인증", "보안"],
|
|
ScanLine: ["스캔라인", "인식"],
|
|
Barcode: ["바코드"],
|
|
CreditCard: ["신용카드", "결제", "카드"],
|
|
Wallet: ["지갑", "결제", "자금"],
|
|
Banknote: ["지폐", "현금", "돈"],
|
|
Coins: ["동전", "코인"],
|
|
PiggyBank: ["저금통", "저축", "예산"],
|
|
Landmark: ["랜드마크", "은행", "기관"],
|
|
Store: ["매장", "상점", "가게"],
|
|
GraduationCap: ["졸업", "교육", "학습"],
|
|
School: ["학교", "교육", "훈련"],
|
|
Library: ["도서관", "라이브러리"],
|
|
BookMarked: ["북마크", "표시된책"],
|
|
Notebook: ["노트북", "공책", "메모"],
|
|
NotebookPen: ["노트작성", "메모"],
|
|
FileArchive: ["압축파일", "아카이브"],
|
|
FileAudio: ["오디오파일", "음악파일"],
|
|
FileVideo: ["비디오파일", "영상파일"],
|
|
FileImage: ["이미지파일", "사진파일"],
|
|
FileCode: ["코드파일", "소스파일"],
|
|
FileJson: ["JSON파일", "데이터파일"],
|
|
FileCog: ["파일설정", "환경설정"],
|
|
FileSearch: ["파일검색", "문서검색"],
|
|
FileWarning: ["파일경고", "주의파일"],
|
|
FileX: ["파일삭제", "파일제거"],
|
|
Files: ["파일들", "다중파일"],
|
|
FolderSearch: ["폴더검색"],
|
|
FolderCog: ["폴더설정"],
|
|
FolderInput: ["입력폴더", "수신"],
|
|
FolderOutput: ["출력폴더", "발신"],
|
|
FolderSync: ["폴더동기화"],
|
|
FolderTree: ["폴더트리", "계층구조"],
|
|
Inbox: ["받은편지함", "수신"],
|
|
MailOpen: ["메일열기", "읽음"],
|
|
MailPlus: ["메일추가", "새메일"],
|
|
CalendarCheck: ["일정확인", "예약확인"],
|
|
CalendarPlus: ["일정추가", "새일정"],
|
|
CalendarX: ["일정취소", "일정삭제"],
|
|
Timer: ["타이머", "시간측정"],
|
|
Hourglass: ["모래시계", "대기", "로딩"],
|
|
AlarmClock: ["알람", "시계"],
|
|
Watch: ["시계", "손목시계"],
|
|
Rocket: ["로켓", "출시", "배포"],
|
|
Plane: ["비행기", "항공", "운송"],
|
|
Ship: ["배", "선박", "해운"],
|
|
Car: ["자동차", "차량"],
|
|
Bus: ["버스", "대중교통"],
|
|
Train: ["기차", "열차", "철도"],
|
|
Bike: ["자전거", "이동"],
|
|
Fuel: ["연료", "주유"],
|
|
Construction: ["공사", "건설", "설치"],
|
|
HardHat: ["안전모", "건설", "안전"],
|
|
Shovel: ["삽", "건설", "시공"],
|
|
Drill: ["드릴", "공구"],
|
|
Nut: ["너트", "부품", "볼트"],
|
|
Plug: ["플러그", "전원", "연결"],
|
|
Cable: ["케이블", "선", "연결"],
|
|
Battery: ["배터리", "충전"],
|
|
BatteryCharging: ["충전중", "배터리"],
|
|
Signal: ["신호", "강도"],
|
|
Antenna: ["안테나", "수신"],
|
|
Bluetooth: ["블루투스", "무선"],
|
|
Usb: ["USB", "연결"],
|
|
SquareStack: ["스택", "쌓기", "레이어"],
|
|
Component: ["컴포넌트", "부품", "구성요소"],
|
|
Puzzle: ["퍼즐", "조각", "모듈"],
|
|
Blocks: ["블록", "구성요소"],
|
|
GitCommit: ["커밋", "변경"],
|
|
GitMerge: ["병합", "머지"],
|
|
GitPullRequest: ["풀리퀘스트", "요청"],
|
|
GitCompare: ["비교", "차이"],
|
|
CirclePlay: ["재생", "플레이"],
|
|
CirclePause: ["일시정지", "멈춤"],
|
|
CircleStop: ["정지", "중지"],
|
|
SkipForward: ["다음", "건너뛰기"],
|
|
SkipBack: ["이전", "뒤로"],
|
|
Volume2: ["볼륨", "소리"],
|
|
VolumeX: ["음소거"],
|
|
Headphones: ["헤드폰", "오디오"],
|
|
Speaker: ["스피커", "소리"],
|
|
Projector: ["프로젝터", "발표"],
|
|
Presentation: ["프레젠테이션", "발표"],
|
|
GanttChart: ["간트차트", "일정관리", "프로젝트"],
|
|
KanbanSquare: ["칸반", "보드", "프로젝트"],
|
|
ListTodo: ["할일목록", "체크리스트"],
|
|
ListChecks: ["체크목록", "확인목록"],
|
|
ListFilter: ["필터목록", "조건목록"],
|
|
ListTree: ["트리목록", "계층목록"],
|
|
StretchHorizontal: ["가로확장"],
|
|
StretchVertical: ["세로확장"],
|
|
Maximize: ["최대화"],
|
|
Minimize: ["최소화"],
|
|
Expand: ["확장", "펼치기"],
|
|
Shrink: ["축소", "줄이기"],
|
|
ZoomIn: ["확대"],
|
|
ZoomOut: ["축소"],
|
|
Focus: ["포커스", "집중"],
|
|
Crosshairs: ["조준", "대상"],
|
|
Locate: ["위치찾기", "현재위치"],
|
|
LocateFixed: ["위치고정"],
|
|
LocateOff: ["위치끄기"],
|
|
Spline: ["스플라인", "곡선"],
|
|
BrainCircuit: ["AI", "인공지능", "두뇌"],
|
|
Brain: ["두뇌", "지능", "생각"],
|
|
Bot: ["봇", "로봇", "자동화"],
|
|
Sparkles: ["반짝", "AI", "마법"],
|
|
Wand2: ["마법봉", "자동", "AI"],
|
|
FlaskConical: ["실험", "연구", "시험"],
|
|
TestTube: ["시험관", "검사", "테스트"],
|
|
Microscope: ["현미경", "분석", "연구"],
|
|
Stethoscope: ["청진기", "의료", "진단"],
|
|
Syringe: ["주사기", "의료"],
|
|
Pill: ["약", "의약품"],
|
|
HeartPulse: ["심박", "건강", "의료"],
|
|
Dna: ["DNA", "유전", "생명과학"],
|
|
Atom: ["원자", "과학", "화학"],
|
|
Beaker: ["비커", "실험", "화학"],
|
|
Scale: ["저울", "무게", "측정"],
|
|
Weight: ["무게", "중량"],
|
|
Ratio: ["비율", "비교"],
|
|
Calculator: ["계산기", "계산"],
|
|
Binary: ["이진수", "코드"],
|
|
Regex: ["정규식", "패턴"],
|
|
Variable: ["변수", "값"],
|
|
FunctionSquare: ["함수", "기능"],
|
|
Braces: ["중괄호", "코드"],
|
|
Brackets: ["대괄호", "배열"],
|
|
Parentheses: ["소괄호", "그룹"],
|
|
Tally5: ["집계", "카운트", "합계"],
|
|
Sigma: ["시그마", "합계", "총합"],
|
|
Infinity: ["무한", "반복"],
|
|
Pi: ["파이", "수학"],
|
|
Omega: ["오메가", "마지막"],
|
|
};
|
|
|
|
interface IconEntry {
|
|
name: string;
|
|
component: IconComponent;
|
|
keywords: string[];
|
|
}
|
|
|
|
// 모든 Lucide 아이콘을 동적으로 가져오기
|
|
const ALL_ICONS: IconEntry[] = (() => {
|
|
const entries: IconEntry[] = [];
|
|
for (const [name, maybeComponent] of Object.entries(LucideIcons)) {
|
|
if (EXCLUDED_EXPORTS.has(name)) continue;
|
|
if (!isPascalCase(name)) continue;
|
|
// lucide-react 아이콘은 forwardRef + memo로 감싸진 React 컴포넌트 (object)
|
|
const comp = maybeComponent as any;
|
|
const isReactComponent =
|
|
typeof comp === "function" ||
|
|
(typeof comp === "object" && comp !== null && comp.$$typeof);
|
|
if (!isReactComponent) continue;
|
|
|
|
const koreanKw = KOREAN_KEYWORDS[name] || [];
|
|
entries.push({
|
|
name,
|
|
component: comp as IconComponent,
|
|
keywords: [...koreanKw, name.toLowerCase()],
|
|
});
|
|
}
|
|
return entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
})();
|
|
|
|
export function getIconComponent(iconName: string | null | undefined): IconComponent | null {
|
|
if (!iconName) return null;
|
|
const entry = ALL_ICONS.find((e) => e.name === iconName);
|
|
return entry?.component || null;
|
|
}
|
|
|
|
interface MenuIconPickerProps {
|
|
value: string;
|
|
onChange: (iconName: string) => void;
|
|
label?: string;
|
|
}
|
|
|
|
export const MenuIconPicker: React.FC<MenuIconPickerProps> = ({
|
|
value,
|
|
onChange,
|
|
label = "메뉴 아이콘",
|
|
}) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [searchText, setSearchText] = useState("");
|
|
const [visibleCount, setVisibleCount] = useState(120);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
setSearchText("");
|
|
}
|
|
};
|
|
if (isOpen) {
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
}
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [isOpen]);
|
|
|
|
// 드롭다운 열릴 때 표시 개수 초기화
|
|
useEffect(() => {
|
|
if (isOpen) setVisibleCount(120);
|
|
}, [isOpen]);
|
|
|
|
// 검색어 변경 시 표시 개수 초기화
|
|
useEffect(() => {
|
|
setVisibleCount(120);
|
|
}, [searchText]);
|
|
|
|
const filteredIcons = useMemo(() => {
|
|
if (!searchText) return ALL_ICONS;
|
|
const lower = searchText.toLowerCase();
|
|
return ALL_ICONS.filter(
|
|
(entry) =>
|
|
entry.name.toLowerCase().includes(lower) ||
|
|
entry.keywords.some((kw) => kw.includes(lower))
|
|
);
|
|
}, [searchText]);
|
|
|
|
// 스크롤 끝에 도달하면 더 로드
|
|
const handleScroll = useCallback(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 40) {
|
|
setVisibleCount((prev) => Math.min(prev + 120, filteredIcons.length));
|
|
}
|
|
}, [filteredIcons.length]);
|
|
|
|
const selectedIcon = ALL_ICONS.find((e) => e.name === value);
|
|
const SelectedIconComponent = selectedIcon?.component;
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<Label>{label}</Label>
|
|
<div className="relative" ref={containerRef}>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className="h-10 w-full justify-between text-sm"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{SelectedIconComponent ? (
|
|
<>
|
|
<SelectedIconComponent className="h-4 w-4" />
|
|
<span>{selectedIcon?.name}</span>
|
|
</>
|
|
) : (
|
|
<span className="text-muted-foreground">아이콘을 선택하세요 (선택사항)</span>
|
|
)}
|
|
</div>
|
|
{value ? (
|
|
<X
|
|
className="h-4 w-4 opacity-50 hover:opacity-100"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onChange("");
|
|
setIsOpen(false);
|
|
}}
|
|
/>
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
|
)}
|
|
</Button>
|
|
|
|
{isOpen && (
|
|
<div className="bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 w-full rounded-md border shadow-lg">
|
|
<div className="border-b p-2">
|
|
<div className="relative">
|
|
<Search className="text-muted-foreground absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2" />
|
|
<Input
|
|
placeholder="아이콘 검색 (한글/영문)..."
|
|
value={searchText}
|
|
onChange={(e) => setSearchText(e.target.value)}
|
|
className="h-8 pl-8 text-sm"
|
|
onClick={(e) => e.stopPropagation()}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
ref={scrollRef}
|
|
onScroll={handleScroll}
|
|
className="max-h-72 overflow-y-auto p-2"
|
|
>
|
|
{!searchText && (
|
|
<p className="text-muted-foreground mb-2 text-center text-xs">
|
|
총 {ALL_ICONS.length}개 아이콘
|
|
</p>
|
|
)}
|
|
<div className="grid grid-cols-6 gap-1">
|
|
{filteredIcons.slice(0, visibleCount).map((entry) => {
|
|
const IconComp = entry.component;
|
|
return (
|
|
<button
|
|
key={entry.name}
|
|
type="button"
|
|
title={entry.name}
|
|
onClick={() => {
|
|
onChange(entry.name);
|
|
setIsOpen(false);
|
|
setSearchText("");
|
|
}}
|
|
className={cn(
|
|
"flex flex-col items-center justify-center rounded-md p-2 transition-colors hover:bg-accent",
|
|
value === entry.name && "bg-primary/10 ring-primary ring-1"
|
|
)}
|
|
>
|
|
<IconComp className="h-5 w-5" />
|
|
<span className="mt-1 max-w-full truncate text-[9px] leading-tight">{entry.name}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
{filteredIcons.length === 0 && (
|
|
<p className="text-muted-foreground py-4 text-center text-sm">
|
|
검색 결과가 없습니다.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|