icon selected

This commit is contained in:
hamid zarghami
2025-12-16 15:26:19 +03:30
parent afa0bbf3fb
commit 2ab830bd48
7 changed files with 276 additions and 44 deletions
+30 -25
View File
@@ -1,4 +1,5 @@
import { type FC, Fragment, type ReactNode, useEffect } from 'react'
import { createPortal } from 'react-dom'
import HeaderModal from './HeaderModal'
interface Props {
@@ -21,37 +22,41 @@ const DefaulModal: FC<Props> = (props: Props) => {
}, [props.open])
return (
const modalContent = props.open ? (
<Fragment>
{
props.open && (
<Fragment>
<div style={{ maxWidth: props.width }} className='xl:justify-center xl:items-center items-end flex overflow-x-hidden overflow-y-auto fixed inset-0 z-[60] h-auto top-0 bottom-0 m-auto outline-none focus:outline-none xl:max-w-xl mx-auto'>
<div className='relative xl:h-full h-[80%] bottom-0 left-0 flex xl:items-center sm:h-auto w-full xl:my-6 xl:p-2'>
<div className='border-0 h-auto p-5 lg:min-w-full overflow-y-auto rounded-3xl rounded-b-none xl:rounded-b-3xl relative flex flex-col w-full bg-white outline-none focus:outline-none'>
<div
onClick={props.close}
className='fixed size-full top-0 bottom-0 right-0 left-0 bg-black/40 inset-0 z-[9998]'
/>
<div
style={{ maxWidth: props.width }}
className='xl:justify-center xl:items-center items-end flex overflow-x-hidden overflow-y-auto fixed inset-0 z-[9999] h-auto top-0 bottom-0 m-auto outline-none focus:outline-none xl:max-w-xl mx-auto pointer-events-none'
>
<div
className='relative xl:h-full h-[80%] bottom-0 left-0 flex xl:items-center sm:h-auto w-full xl:my-6 xl:p-2 pointer-events-auto'
onClick={(e) => e.stopPropagation()}
>
<div className='border-0 h-auto p-5 lg:min-w-full overflow-y-auto rounded-3xl rounded-b-none xl:rounded-b-3xl relative flex flex-col w-full bg-white outline-none focus:outline-none shadow-2xl'>
{
props.isHeader && props.title_header &&
<div className='pb-3 border-b border-border'>
<div className='h-[5px] w-[200px] mx-auto bg-[#D1D3D7] rounded-full mb-4 xl:hidden'></div>
<HeaderModal close={props.close} label={props.title_header} />
</div>
}
{props.children}
</div>
{
props.isHeader && props.title_header &&
<div className='pb-3 border-b border-border'>
<div className='h-[5px] w-[200px] mx-auto bg-[#D1D3D7] rounded-full mb-4 xl:hidden'></div>
<HeaderModal close={props.close} label={props.title_header} />
</div>
</div>
}
<div onClick={props.close} className='fixed size-full top-0 bottom-0 right-0 bg-black/40 inset-0 z-50 '></div>
</Fragment>
)
}
{props.children}
</div>
</div>
</div>
</Fragment>
)
) : null
return createPortal(modalContent, document.body)
}
export default DefaulModal
+21 -3
View File
@@ -25,6 +25,7 @@ type CategoryFormValues = {
const CategoryFood: FC = () => {
const [isActive, setIsActive] = useState<boolean>(true)
const [iconFiles, setIconFiles] = useState<File[]>([])
const [selectedIconUrl, setSelectedIconUrl] = useState<string>('')
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
const [filters, setFilters] = useState<{ search?: string | null }>({})
@@ -56,7 +57,7 @@ const CategoryFood: FC = () => {
const categoryData: CreateCategoryType = {
title: values.title,
isActive,
avatarUrl: avatarUrl || editingCategory?.avatarUrl || ''
avatarUrl: avatarUrl || selectedIconUrl || editingCategory?.avatarUrl || ''
}
if (editingCategory) {
@@ -85,7 +86,9 @@ const CategoryFood: FC = () => {
}
}
if (iconFiles.length > 0) {
if (selectedIconUrl) {
submitCategory(selectedIconUrl)
} else if (iconFiles.length > 0) {
singleUpload(iconFiles[0], {
onSuccess: (response) => {
const avatarUrl = response?.data?.url || ''
@@ -105,6 +108,7 @@ const CategoryFood: FC = () => {
formik.resetForm()
setIsActive(true)
setIconFiles([])
setSelectedIconUrl('')
setEditingCategory(null)
}
@@ -115,9 +119,21 @@ const CategoryFood: FC = () => {
})
setIsActive(category.isActive)
setIconFiles([])
setSelectedIconUrl(category.avatarUrl || '')
setEditingCategory(category)
}
const handleIconUrlChange = (url: string) => {
setSelectedIconUrl(url)
setIconFiles([])
formik.setFieldValue('icon', [])
}
const handleIconFileChange = (files: File[]) => {
setIconFiles(files)
setSelectedIconUrl('')
}
const handleDelete = (id: string) => {
deleteCategory(id, {
onSuccess: () => {
@@ -189,10 +205,12 @@ const CategoryFood: FC = () => {
formik={formik}
isActive={isActive}
onActiveChange={setIsActive}
onIconChange={setIconFiles}
onIconChange={handleIconFileChange}
iconFiles={iconFiles}
isSubmitting={isCreating || isUpdating || isUploading}
isEditing={!!editingCategory}
onIconUrlChange={handleIconUrlChange}
selectedIconUrl={selectedIconUrl}
/>
</div>
)
+75 -16
View File
@@ -1,10 +1,11 @@
import { type FC } from 'react'
import { Add } from 'iconsax-react'
import { type FC, useState } from 'react'
import { Add, Image } from 'iconsax-react'
import Input from '@/components/Input'
import UploadBox from '@/components/UploadBox'
import SwitchComponent from '@/components/Switch'
import Button from '@/components/Button'
import type { FormikProps } from 'formik'
import { useGetIcons } from '../hooks/useFoodData'
import IconSelectModal from './IconSelectModal'
type CategoryFormValues = {
title: string
@@ -15,21 +16,34 @@ type Props = {
formik: FormikProps<CategoryFormValues>
isActive: boolean
onActiveChange: (value: boolean) => void
onIconChange: (files: File[]) => void
iconFiles: File[]
onIconChange?: (files: File[]) => void
iconFiles?: File[]
isSubmitting: boolean
isEditing?: boolean
onIconUrlChange?: (url: string) => void
selectedIconUrl?: string
}
const CategoryForm: FC<Props> = ({
formik,
isActive,
onActiveChange,
onIconChange,
iconFiles,
isSubmitting,
isEditing = false
isEditing = false,
onIconUrlChange,
selectedIconUrl
}) => {
const { data: iconsData } = useGetIcons()
const icons = iconsData?.data || []
const [isIconModalOpen, setIsIconModalOpen] = useState(false)
const handleIconSelect = (url: string) => {
if (onIconUrlChange) {
onIconUrlChange(url)
}
setIsIconModalOpen(false)
}
return (
<div className="w-[300px] bg-white rounded-3xl p-6 h-fit sticky top-6">
<h2 className="font-light mb-6">{isEditing ? 'ویرایش دسته بندی' : 'اضافه کردن دسته بندی'}</h2>
@@ -53,16 +67,61 @@ const CategoryForm: FC<Props> = ({
</div>
<div className="mb-6">
<UploadBox
label="آیکون دسته بندی"
onChange={(files) => {
onIconChange(files)
formik.setFieldValue('icon', files)
}}
isReset={iconFiles.length === 0 && formik.values.icon.length === 0}
/>
<div className="text-sm mb-2 font-medium">آیکون دسته بندی</div>
<Button
type="button"
onClick={() => setIsIconModalOpen(true)}
className="w-full h-12 flex items-center justify-center gap-2"
>
<Image size={20} color="#fff" />
<span>انتخاب آیکون</span>
</Button>
{selectedIconUrl && (
<div className="mt-3 flex items-center gap-3 p-3 bg-gray-50 rounded-xl border border-gray-200">
<div className="w-12 h-12 flex items-center justify-center bg-white rounded-lg border border-gray-200 p-2 flex-shrink-0">
<img
src={selectedIconUrl}
alt="selected icon"
className="w-full h-full object-contain max-w-full max-h-full"
style={{
imageRendering: 'auto',
objectFit: 'contain'
}}
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-500 mb-1">آیکون انتخاب شده</div>
<div className="text-xs text-gray-400 truncate">
{selectedIconUrl}
</div>
</div>
<button
type="button"
onClick={() => {
if (onIconUrlChange) {
onIconUrlChange('')
}
}}
className="text-gray-400 hover:text-red-500 transition-colors text-xl leading-none flex-shrink-0 w-6 h-6 flex items-center justify-center"
title="حذف آیکون"
>
×
</button>
</div>
)}
</div>
<IconSelectModal
open={isIconModalOpen}
onClose={() => setIsIconModalOpen(false)}
icons={icons}
onSelect={handleIconSelect}
selectedUrl={selectedIconUrl}
/>
<Button
onClick={() => formik.handleSubmit()}
className="w-full"
@@ -0,0 +1,117 @@
import { type FC, useState, useEffect } from 'react'
import DefaulModal from '@/components/DefaulModal'
import type { Icon, IconGroup } from '../types/Types'
import { TickCircle } from 'iconsax-react'
type Props = {
open: boolean
onClose: () => void
icons: IconGroup[]
onSelect: (url: string) => void
selectedUrl?: string
}
const IconSelectModal: FC<Props> = ({
open,
onClose,
icons,
onSelect,
selectedUrl
}) => {
const [selectedIconUrl, setSelectedIconUrl] = useState<string | undefined>(
selectedUrl
)
useEffect(() => {
setSelectedIconUrl(selectedUrl)
}, [selectedUrl, open])
const handleSelect = (url: string) => {
setSelectedIconUrl(url)
onSelect(url)
onClose()
}
return (
<DefaulModal
open={open}
close={onClose}
isHeader
title_header="انتخاب آیکون"
width={700}
>
<div className="max-h-[65vh] overflow-y-auto py-2">
{icons.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<div className="text-base mb-2">آیکونی یافت نشد</div>
<div className="text-sm text-gray-400">
لطفاً بعداً تلاش کنید
</div>
</div>
) : (
<div className="space-y-8">
{icons.map((group) => (
<div key={group.id} className="border-b border-gray-100 last:border-0 pb-6 last:pb-0">
<div className="flex items-center gap-2 mb-4">
<h3 className="text-base font-semibold text-gray-800">
{group.name}
</h3>
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-1 rounded-full">
{group.icons.length} آیکون
</span>
</div>
{group.icons.length === 0 ? (
<div className="text-sm text-gray-400 bg-gray-50 rounded-lg p-4 text-center">
این گروه آیکونی ندارد
</div>
) : (
<div className="grid px-2 grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-3">
{group.icons.map((icon: Icon) => (
<button
key={icon.id}
onClick={() => handleSelect(icon.url)}
className={`group relative aspect-square border-2 rounded-xl p-2.5 transition-all duration-200 hover:scale-105 hover:shadow-md flex items-center justify-center ${selectedIconUrl === icon.url
? 'border-primary scale-105'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<img
src={icon.url}
alt="icon"
className="w-full h-full object-contain transition-opacity duration-200 max-w-full max-h-full"
style={{
imageRendering: 'auto',
objectFit: 'contain'
}}
onError={(e) => {
e.currentTarget.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"%3E%3Cpath fill="%23ccc" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/%3E%3C/svg%3E'
}}
/>
{selectedIconUrl === icon.url && (
<div className="absolute -top-1 -right-1 bg-primary rounded-full z-10">
<TickCircle
size={18}
color="#fff"
variant="Bold"
/>
</div>
)}
<div className={`absolute inset-0 rounded-xl transition-opacity duration-200 ${selectedIconUrl === icon.url
? 'opacity-0'
: 'opacity-0 group-hover:opacity-100 bg-primary/5'
}`} />
</button>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
</DefaulModal>
)
}
export default IconSelectModal
+7
View File
@@ -80,3 +80,10 @@ export const useUpdateCategory = () => {
},
});
};
export const useGetIcons = () => {
return useQuery({
queryKey: ["icons"],
queryFn: api.getIcons,
});
};
+6
View File
@@ -6,6 +6,7 @@ import type {
GetFoodDetailsResponseType,
GetFoodsParams,
GetFoodsResponseType,
GetIconsResponseType,
} from "../types/Types";
export const createFood = async (params: CreateFoodType) => {
@@ -65,3 +66,8 @@ export const updateCategory = async (
const { data } = await axios.patch(`/admin/categories/${id}`, params);
return data;
};
export const getIcons = async (): Promise<GetIconsResponseType> => {
const { data } = await axios.get<GetIconsResponseType>(`/admin/groups/icons`);
return data;
};
+20
View File
@@ -129,3 +129,23 @@ export type CreateCategoryType = {
isActive: boolean;
avatarUrl: string;
};
export type Icon = {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
url: string;
group: string;
};
export type IconGroup = {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
name: string;
icons: Icon[];
};
export type GetIconsResponseType = IResponse<IconGroup[]>;