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
+18 -13
View File
@@ -1,4 +1,5 @@
import { type FC, Fragment, type ReactNode, useEffect } from 'react' import { type FC, Fragment, type ReactNode, useEffect } from 'react'
import { createPortal } from 'react-dom'
import HeaderModal from './HeaderModal' import HeaderModal from './HeaderModal'
interface Props { interface Props {
@@ -21,14 +22,21 @@ const DefaulModal: FC<Props> = (props: Props) => {
}, [props.open]) }, [props.open])
return ( const modalContent = props.open ? (
<Fragment> <Fragment>
{ <div
props.open && ( onClick={props.close}
<Fragment> 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-[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
<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'> 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'>
{ {
@@ -45,13 +53,10 @@ const DefaulModal: FC<Props> = (props: Props) => {
</div> </div>
</div> </div>
</div> </div>
</Fragment>
) : null
<div onClick={props.close} className='fixed size-full top-0 bottom-0 right-0 bg-black/40 inset-0 z-50 '></div> return createPortal(modalContent, document.body)
</Fragment>
)
}
</Fragment>
)
} }
export default DefaulModal export default DefaulModal
+21 -3
View File
@@ -25,6 +25,7 @@ type CategoryFormValues = {
const CategoryFood: FC = () => { const CategoryFood: FC = () => {
const [isActive, setIsActive] = useState<boolean>(true) const [isActive, setIsActive] = useState<boolean>(true)
const [iconFiles, setIconFiles] = useState<File[]>([]) const [iconFiles, setIconFiles] = useState<File[]>([])
const [selectedIconUrl, setSelectedIconUrl] = useState<string>('')
const [editingCategory, setEditingCategory] = useState<Category | null>(null) const [editingCategory, setEditingCategory] = useState<Category | null>(null)
const [filters, setFilters] = useState<{ search?: string | null }>({}) const [filters, setFilters] = useState<{ search?: string | null }>({})
@@ -56,7 +57,7 @@ const CategoryFood: FC = () => {
const categoryData: CreateCategoryType = { const categoryData: CreateCategoryType = {
title: values.title, title: values.title,
isActive, isActive,
avatarUrl: avatarUrl || editingCategory?.avatarUrl || '' avatarUrl: avatarUrl || selectedIconUrl || editingCategory?.avatarUrl || ''
} }
if (editingCategory) { 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], { singleUpload(iconFiles[0], {
onSuccess: (response) => { onSuccess: (response) => {
const avatarUrl = response?.data?.url || '' const avatarUrl = response?.data?.url || ''
@@ -105,6 +108,7 @@ const CategoryFood: FC = () => {
formik.resetForm() formik.resetForm()
setIsActive(true) setIsActive(true)
setIconFiles([]) setIconFiles([])
setSelectedIconUrl('')
setEditingCategory(null) setEditingCategory(null)
} }
@@ -115,9 +119,21 @@ const CategoryFood: FC = () => {
}) })
setIsActive(category.isActive) setIsActive(category.isActive)
setIconFiles([]) setIconFiles([])
setSelectedIconUrl(category.avatarUrl || '')
setEditingCategory(category) setEditingCategory(category)
} }
const handleIconUrlChange = (url: string) => {
setSelectedIconUrl(url)
setIconFiles([])
formik.setFieldValue('icon', [])
}
const handleIconFileChange = (files: File[]) => {
setIconFiles(files)
setSelectedIconUrl('')
}
const handleDelete = (id: string) => { const handleDelete = (id: string) => {
deleteCategory(id, { deleteCategory(id, {
onSuccess: () => { onSuccess: () => {
@@ -189,10 +205,12 @@ const CategoryFood: FC = () => {
formik={formik} formik={formik}
isActive={isActive} isActive={isActive}
onActiveChange={setIsActive} onActiveChange={setIsActive}
onIconChange={setIconFiles} onIconChange={handleIconFileChange}
iconFiles={iconFiles} iconFiles={iconFiles}
isSubmitting={isCreating || isUpdating || isUploading} isSubmitting={isCreating || isUpdating || isUploading}
isEditing={!!editingCategory} isEditing={!!editingCategory}
onIconUrlChange={handleIconUrlChange}
selectedIconUrl={selectedIconUrl}
/> />
</div> </div>
) )
+73 -14
View File
@@ -1,10 +1,11 @@
import { type FC } from 'react' import { type FC, useState } from 'react'
import { Add } from 'iconsax-react' import { Add, Image } from 'iconsax-react'
import Input from '@/components/Input' import Input from '@/components/Input'
import UploadBox from '@/components/UploadBox'
import SwitchComponent from '@/components/Switch' import SwitchComponent from '@/components/Switch'
import Button from '@/components/Button' import Button from '@/components/Button'
import type { FormikProps } from 'formik' import type { FormikProps } from 'formik'
import { useGetIcons } from '../hooks/useFoodData'
import IconSelectModal from './IconSelectModal'
type CategoryFormValues = { type CategoryFormValues = {
title: string title: string
@@ -15,21 +16,34 @@ type Props = {
formik: FormikProps<CategoryFormValues> formik: FormikProps<CategoryFormValues>
isActive: boolean isActive: boolean
onActiveChange: (value: boolean) => void onActiveChange: (value: boolean) => void
onIconChange: (files: File[]) => void onIconChange?: (files: File[]) => void
iconFiles: File[] iconFiles?: File[]
isSubmitting: boolean isSubmitting: boolean
isEditing?: boolean isEditing?: boolean
onIconUrlChange?: (url: string) => void
selectedIconUrl?: string
} }
const CategoryForm: FC<Props> = ({ const CategoryForm: FC<Props> = ({
formik, formik,
isActive, isActive,
onActiveChange, onActiveChange,
onIconChange,
iconFiles,
isSubmitting, 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 ( return (
<div className="w-[300px] bg-white rounded-3xl p-6 h-fit sticky top-6"> <div className="w-[300px] bg-white rounded-3xl p-6 h-fit sticky top-6">
<h2 className="font-light mb-6">{isEditing ? 'ویرایش دسته بندی' : 'اضافه کردن دسته بندی'}</h2> <h2 className="font-light mb-6">{isEditing ? 'ویرایش دسته بندی' : 'اضافه کردن دسته بندی'}</h2>
@@ -53,15 +67,60 @@ const CategoryForm: FC<Props> = ({
</div> </div>
<div className="mb-6"> <div className="mb-6">
<UploadBox <div className="text-sm mb-2 font-medium">آیکون دسته بندی</div>
label="آیکون دسته بندی" <Button
onChange={(files) => { type="button"
onIconChange(files) onClick={() => setIsIconModalOpen(true)}
formik.setFieldValue('icon', files) 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'
}} }}
isReset={iconFiles.length === 0 && formik.values.icon.length === 0}
/> />
</div> </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 <Button
onClick={() => formik.handleSubmit()} onClick={() => formik.handleSubmit()}
@@ -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, GetFoodDetailsResponseType,
GetFoodsParams, GetFoodsParams,
GetFoodsResponseType, GetFoodsResponseType,
GetIconsResponseType,
} from "../types/Types"; } from "../types/Types";
export const createFood = async (params: CreateFoodType) => { export const createFood = async (params: CreateFoodType) => {
@@ -65,3 +66,8 @@ export const updateCategory = async (
const { data } = await axios.patch(`/admin/categories/${id}`, params); const { data } = await axios.patch(`/admin/categories/${id}`, params);
return data; 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; isActive: boolean;
avatarUrl: string; 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[]>;