diff --git a/src/components/customs/Select.tsx b/src/components/customs/Select.tsx index 8c02f5b..64891f9 100644 --- a/src/components/customs/Select.tsx +++ b/src/components/customs/Select.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { createPortal } from "react-dom"; +import { Search, X, Check, ChevronDown } from "lucide-react"; import { cn } from "../../lib/utils"; interface Option { @@ -18,6 +19,7 @@ interface SelectProps { placeholder?: string; children?: React.ReactNode; disabled?: boolean; + searchable?: boolean; } export function Select({ @@ -31,11 +33,14 @@ export function Select({ placeholder = "Select...", children, disabled, + searchable, }: SelectProps) { const [isOpen, setIsOpen] = React.useState(false); + const [searchQuery, setSearchQuery] = React.useState(""); const [rect, setRect] = React.useState(null); const buttonRef = React.useRef(null); const dropdownRef = React.useRef(null); + const searchInputRef = React.useRef(null); const options = React.useMemo(() => { const opts: Option[] = []; @@ -53,6 +58,19 @@ export function Select({ const selectedOption = options.find((opt) => opt.value === value); + // By default, enable search if there are more than 5 options or if explicitly requested + const isSearchEnabled = searchable !== undefined ? searchable : options.length > 5; + + const filteredOptions = React.useMemo(() => { + if (!isSearchEnabled || !searchQuery.trim()) { + return options; + } + const q = searchQuery.toLowerCase().trim(); + return options.filter((opt) => + opt.label.toLowerCase().includes(q) || opt.value.toLowerCase().includes(q) + ); + }, [options, isSearchEnabled, searchQuery]); + // Recalculate position on scroll/resize while open React.useEffect(() => { if (!isOpen) return; @@ -68,6 +86,17 @@ export function Select({ }; }, [isOpen]); + // Auto-focus search input when opened + React.useEffect(() => { + if (isOpen && isSearchEnabled) { + setTimeout(() => { + searchInputRef.current?.focus(); + }, 50); + } else if (!isOpen) { + setSearchQuery(""); + } + }, [isOpen, isSearchEnabled]); + // Close on outside click React.useEffect(() => { if (!isOpen) return; @@ -77,6 +106,7 @@ export function Select({ dropdownRef.current?.contains(e.target as Node) ) return; setIsOpen(false); + setSearchQuery(""); onBlur?.({ target: { name } } as any); }; document.addEventListener("mousedown", handleClickOutside); @@ -94,10 +124,11 @@ export function Select({ const handleSelect = (val: string) => { onChange?.({ target: { name, value: val } }); setIsOpen(false); + setSearchQuery(""); }; return ( -
+
{isOpen && rect && createPortal( @@ -136,30 +161,60 @@ export function Select({ position: "fixed", top: rect.bottom + 4, left: rect.left, - width: rect.width, + width: Math.max(rect.width, 220), zIndex: 9999, }} - className="bg-surface text-foreground border border-border shadow-lg rounded-xl p-1 max-h-60 overflow-y-auto" + className="bg-surface text-foreground border border-border shadow-xl rounded-xl overflow-hidden flex flex-col max-h-64 font-sans animate-in fade-in zoom-in-95 duration-100" > - {options.map((opt) => ( -
{ e.preventDefault(); handleSelect(opt.value); }} - className={cn( - "cursor-pointer rounded-lg px-3 py-2 text-sm select-none transition-colors flex items-center justify-between", - opt.value === value - ? "bg-primary/10 text-primary font-medium" - : "hover:bg-primary/5 hover:text-primary text-foreground" - )} - > - {opt.label} - {opt.value === value && ( - - - + {isSearchEnabled && ( +
+ + setSearchQuery(e.target.value)} + placeholder="Search..." + className="w-full px-2 py-1 text-xs border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary bg-background text-foreground font-sans" + onKeyDown={(e) => e.stopPropagation()} + /> + {searchQuery && ( + )}
- ))} + )} + +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((opt) => ( +
{ e.preventDefault(); handleSelect(opt.value); }} + className={cn( + "cursor-pointer rounded-lg px-3 py-2 text-xs select-none transition-colors flex items-center justify-between", + opt.value === value + ? "bg-primary/10 text-primary font-medium" + : "hover:bg-background text-foreground" + )} + > + {opt.label} + {opt.value === value && ( + + )} +
+ )) + ) : ( +
+ No options found. +
+ )} +
, document.body )} diff --git a/src/features/asset-types/components/CreateAssetTypeModal.tsx b/src/features/asset-types/components/CreateAssetTypeModal.tsx new file mode 100644 index 0000000..b45a5da --- /dev/null +++ b/src/features/asset-types/components/CreateAssetTypeModal.tsx @@ -0,0 +1,605 @@ +import { useState, useEffect } from "react"; +import { useFormik } from "formik"; +import { + X, Image as ImageIcon, Video, FileText, Award, Megaphone, + HelpCircle, Plus, AlertCircle, Check, Loader2, Save +} from "lucide-react"; +import { Input } from "../../../components/customs/Input"; +import { TextArea } from "../../../components/customs/TextArea"; +import { Select } from "../../../components/customs/Select"; +import { Button } from "../../../components/customs/Button"; +import { useAssetType } from "../hook/useAssetType"; +import { assetTypeSchema } from "../validation/asset-types.schema"; +import { notify } from "../../../services/toast"; +import type { AssetType, AssetTypeCreateRequest } from "../types/asset-types.types"; + +const CATEGORIES = [ + { + id: 'image', + label: 'Image', + icon: ImageIcon, + desc: 'jpg, jpeg, png...', + color: 'text-blue-600', + bg: 'bg-blue-50', + bgSelected: 'bg-blue-600', + borderSelected: 'border-blue-500', + ringSelected: 'ring-blue-500', + bgSelectedCard: 'bg-blue-50/70', + hoverBorder: 'hover:border-blue-300', + hoverBg: 'hover:bg-blue-50/50' + }, + { + id: 'video', + label: 'Video', + icon: Video, + desc: 'mp4, mov, avi...', + color: 'text-orange-600', + bg: 'bg-orange-50', + bgSelected: 'bg-orange-600', + borderSelected: 'border-orange-500', + ringSelected: 'ring-orange-500', + bgSelectedCard: 'bg-orange-50/70', + hoverBorder: 'hover:border-orange-300', + hoverBg: 'hover:bg-orange-50/50' + }, + { + id: 'document', + label: 'Document', + icon: FileText, + desc: 'pdf, docx, doc...', + color: 'text-red-600', + bg: 'bg-red-50', + bgSelected: 'bg-red-600', + borderSelected: 'border-red-500', + ringSelected: 'ring-red-500', + bgSelectedCard: 'bg-red-50/70', + hoverBorder: 'hover:border-red-300', + hoverBg: 'hover:bg-red-50/50' + }, + { + id: 'certificate', + label: 'Certificate', + icon: Award, + desc: 'pdf, jpg, png', + color: 'text-emerald-600', + bg: 'bg-emerald-50', + bgSelected: 'bg-emerald-600', + borderSelected: 'border-emerald-500', + ringSelected: 'ring-emerald-500', + bgSelectedCard: 'bg-emerald-50/70', + hoverBorder: 'hover:border-emerald-300', + hoverBg: 'hover:bg-emerald-50/50' + }, + { + id: 'marketing', + label: 'Marketing', + icon: Megaphone, + desc: 'jpg, png, svg...', + color: 'text-purple-600', + bg: 'bg-purple-50', + bgSelected: 'bg-purple-600', + borderSelected: 'border-purple-500', + ringSelected: 'ring-purple-500', + bgSelectedCard: 'bg-purple-50/70', + hoverBorder: 'hover:border-purple-300', + hoverBg: 'hover:bg-purple-50/50' + }, + { + id: 'other', + label: 'Other', + icon: HelpCircle, + desc: 'pdf, zip, csv...', + color: 'text-muted-foreground', + bg: 'bg-background', + bgSelected: 'bg-surface-active', + borderSelected: 'border-border', + ringSelected: 'ring-ring', + bgSelectedCard: 'bg-background', + hoverBorder: 'hover:border-border', + hoverBg: 'hover:bg-background/50' + }, +] as const; + +const POPULAR_EXTENSIONS = [ + // Images + { ext: 'jpg', category: 'image', label: 'JPG' }, + { ext: 'jpeg', category: 'image', label: 'JPEG' }, + { ext: 'png', category: 'image', label: 'PNG' }, + { ext: 'webp', category: 'image', label: 'WEBP' }, + { ext: 'gif', category: 'image', label: 'GIF' }, + { ext: 'svg', category: 'image', label: 'SVG' }, + // Videos + { ext: 'mp4', category: 'video', label: 'MP4' }, + { ext: 'mov', category: 'video', label: 'MOV' }, + { ext: 'avi', category: 'video', label: 'AVI' }, + { ext: 'webm', category: 'video', label: 'WEBM' }, + // Documents + { ext: 'pdf', category: 'document', label: 'PDF' }, + { ext: 'doc', category: 'document', label: 'DOC' }, + { ext: 'docx', category: 'document', label: 'DOCX' }, + { ext: 'xls', category: 'document', label: 'XLS' }, + { ext: 'xlsx', category: 'document', label: 'XLSX' }, + { ext: 'ppt', category: 'document', label: 'PPT' }, + { ext: 'pptx', category: 'document', label: 'PPTX' }, + { ext: 'txt', category: 'document', label: 'TXT' }, + // Other + { ext: 'zip', category: 'other', label: 'ZIP' }, + { ext: 'rar', category: 'other', label: 'RAR' }, + { ext: 'csv', category: 'other', label: 'CSV' }, + { ext: 'json', category: 'other', label: 'JSON' }, +]; + +const STEPS = [ + { id: 'basic', label: 'Basic Info', step: 1 }, + { id: 'category', label: 'Asset Category', step: 2 }, + { id: 'validation', label: 'Validation Rules', step: 3 }, + { id: 'preview', label: 'Preview', step: 4 }, +]; + +const labelClass = 'block text-xs font-semibold text-foreground mb-1.5'; +const errorClass = 'text-xs text-red-500 mt-1'; + +interface CreateAssetTypeModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: (created: AssetType) => void; +} + +export function CreateAssetTypeModal({ isOpen, onClose, onSuccess }: CreateAssetTypeModalProps) { + const { createItem, loading } = useAssetType(); + const [newFileType, setNewFileType] = useState(''); + const [activeStep, setActiveStep] = useState('basic'); + + const formik = useFormik({ + initialValues: { + name: "", + code: "", + description: "", + status: "active" as "active" | "inactive", + isRequired: false, + category: "" as typeof CATEGORIES[number]['id'] | "", + validation: { + allowedFileTypes: [] as string[], + maxFileSize: 10, + minUploadCount: 0, + maxUploadCount: 1, + } + }, + validationSchema: assetTypeSchema, + onSubmit: async (values, { setSubmitting }) => { + try { + const created = await createItem(values as AssetTypeCreateRequest); + if (created) { + onSuccess(created); + onClose(); + } + } catch (err: any) { + // Handled in hook notify + } finally { + setSubmitting(false); + } + }, + }); + + // Sync validation errors and switch steps if submit is attempted with errors + useEffect(() => { + if (formik.submitCount > 0 && !formik.isSubmitting) { + const errors = formik.errors; + const errorKeys = Object.keys(errors); + + if (errorKeys.length > 0) { + const messages: string[] = []; + + if (errors.name) messages.push(errors.name); + if (errors.code) messages.push(errors.code); + if (errors.category) { + messages.push(errors.category); + setActiveStep('category'); + } else if (errors.name || errors.code) { + setActiveStep('basic'); + } else if (errors.validation) { + setActiveStep('validation'); + const valErrors = errors.validation as any; + if (valErrors?.maxFileSize) messages.push(valErrors.maxFileSize); + if (valErrors?.allowedFileTypes) messages.push(valErrors.allowedFileTypes); + } + + notify.error(`Please resolve validation errors: ${messages.join('; ')}`); + formik.setSubmitting(false); + } + } + }, [formik.submitCount, formik.isSubmitting, formik.errors]); + + if (!isOpen) return null; + + const handleNameChange = (e: React.ChangeEvent) => { + formik.handleChange(e); + if (!formik.touched.code) { + const generatedCode = e.target.value.toLowerCase().replace(/[^a-z0-9]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, ''); + formik.setFieldValue('code', generatedCode); + } + }; + + const handleAddFileType = () => { + const trimmed = newFileType.trim().toLowerCase().replace(/^\./, ''); + if (trimmed && !formik.values.validation.allowedFileTypes.includes(trimmed)) { + formik.setFieldValue('validation.allowedFileTypes', [...formik.values.validation.allowedFileTypes, trimmed]); + setNewFileType(''); + } + }; + + const removeFileType = (type: string) => { + formik.setFieldValue('validation.allowedFileTypes', formik.values.validation.allowedFileTypes.filter(t => t !== type)); + }; + + const activeIndex = STEPS.findIndex(s => s.id === activeStep); + + return ( +
+
+ + {/* Modal Header */} +
+
+

Create New Asset Type

+

Configure media classifications and validation rules

+
+ +
+ + {/* Step Indicator Bar */} +
+ {STEPS.map((s, idx) => { + const isActive = activeStep === s.id; + const isDone = idx < activeIndex; + return ( + + ); + })} +
+ + {/* Modal Body */} +
+ + {/* Step 1 — Basic Information */} + {activeStep === 'basic' && ( +
+
+
+ + + {formik.touched.name && formik.errors.name &&

{formik.errors.name}

} +
+
+ + +

Unique identifier (slug)

+ {formik.touched.code && formik.errors.code &&

{formik.errors.code}

} +
+
+ +
+ +