blogs crud

This commit is contained in:
hamid zarghami
2025-09-25 12:18:04 +03:30
parent edf6bcfc3d
commit 475a881f2f
17 changed files with 807 additions and 2 deletions
+105
View File
@@ -0,0 +1,105 @@
import { type FC, useState } from 'react'
import { useFormik } from 'formik'
import { type BlogFormType, type BlogCategory } from './types/Types'
import * as Yup from 'yup'
import { useCreateBlog, useGetBlogCategories } from './hooks/useBlogsData'
import { useNavigate } from 'react-router-dom'
import Button from '../../components/Button'
import { TickCircle } from 'iconsax-react'
import { useUploadSingle } from '../uploader/hooks/useUploaderData'
import { toast } from 'react-toastify'
import { extractErrorMessage } from '@/helpers/utils'
import { BlogFormFields, FileUploadSection } from './components'
import { Pages } from '@/config/Pages'
const CreateBlog: FC = () => {
const navigate = useNavigate()
const { data: categoriesData } = useGetBlogCategories()
const uploadSingle = useUploadSingle()
const createBlogMutation = useCreateBlog()
const [imageFile, setImageFile] = useState<File[]>([])
const formik = useFormik<BlogFormType>({
initialValues: {
title_fa: '',
slug: '',
category: '',
description: '',
content: '',
tags: [],
imageUrl: '',
},
validationSchema: Yup.object({
title_fa: Yup.string().required('عنوان فارسی بلاگ الزامی است'),
slug: Yup.string().required('اسلاگ بلاگ الزامی است'),
category: Yup.string().required('دسته بندی بلاگ الزامی است'),
description: Yup.string().required('توضیحات بلاگ الزامی است'),
content: Yup.string().required('محتوای بلاگ الزامی است'),
}),
onSubmit: async (values) => {
let imageUrl = values.imageUrl
if (imageFile.length > 0) {
const imageResponse = await uploadSingle.mutateAsync(imageFile[0])
imageUrl = imageResponse.results?.url?.url || ''
}
const submitData = {
...values,
imageUrl
}
createBlogMutation.mutate(submitData, {
onSuccess: () => {
toast.success('بلاگ با موفقیت ایجاد شد')
navigate(Pages.blog.list)
},
onError: (error) => {
toast.error(extractErrorMessage(error))
}
})
},
})
const categoryOptions = categoriesData?.results?.categories?.map((category: BlogCategory) => ({
value: category._id,
label: category.title_fa
})) || []
return (
<div className='mt-4'>
<div className='flex justify-between items-center'>
<div className='flex items-center gap-3'>
<h1>ساخت بلاگ جدید</h1>
</div>
<div>
<Button
className='px-6'
onClick={() => formik.handleSubmit()}
isLoading={createBlogMutation.isPending || uploadSingle.isPending}
disabled={!formik.isValid || createBlogMutation.isPending || uploadSingle.isPending}
>
<div className='flex gap-2 items-center'>
<TickCircle size={16} color='#fff' />
<div>ثبت بلاگ</div>
</div>
</Button>
</div>
</div>
<div className='flex gap-6 xl:mt-8 mt-6'>
{/* اطلاعات اصلی */}
<div className='flex-1 text-sm bg-white py-8 xl:px-10 px-6 rounded-3xl'>
<h2 className='text-lg font-medium mb-6'>اطلاعات اصلی</h2>
<BlogFormFields formik={formik} categoryOptions={categoryOptions} />
</div>
{/* فایل‌ها */}
<FileUploadSection onFileChange={setImageFile} />
</div>
</div>
)
}
export default CreateBlog
+136
View File
@@ -0,0 +1,136 @@
import { type FC, useState } from 'react'
import { useDeleteBlog, useGetBlogs } from './hooks/useBlogsData'
import { useQueryClient } from '@tanstack/react-query'
import { type Blog } from './types/Types'
import PageLoading from '../../components/PageLoading'
import Error from '../../components/Error'
import PaginationUi from '../../components/PaginationUi'
import Td from '../../components/Td'
import TrashWithConfrim from '../../components/TrashWithConfrim'
import { Edit, Eye } from 'iconsax-react'
import { Link, useNavigate } from 'react-router-dom'
import { Pages } from '@/config/Pages'
import Button from '@/components/Button'
const BlogsList: FC = () => {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [currentPage, setCurrentPage] = useState(1)
const deleteBlog = useDeleteBlog()
const { data: blogsData, isLoading, error } = useGetBlogs(currentPage)
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<PageLoading />
</div>
)
}
if (error) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<Error errorText="خطا در بارگذاری بلاگ‌ها" />
</div>
)
}
const blogs = blogsData?.blogs || []
const pager = blogsData?.pager
return (
<div>
<div className='flex justify-end mt-5'>
<Button
label='افزودن بلاگ'
onClick={() => navigate(`${Pages.blog.create}`)}
className='w-fit'
/>
</div>
<div className='relative overflow-x-auto rounded-3xl mt-5 w-full'>
<table className='w-full text-sm'>
<thead className='thead'>
<tr>
<Td text={'تصویر'} />
<Td text={'عنوان'} />
<Td text={'دسته‌بندی'} />
<Td text={'نویسنده'} />
<Td text={'بازدید'} />
<Td text={'تاریخ ایجاد'} />
<Td text={'عملیات'} />
</tr>
</thead>
<tbody>
{blogs.length === 0 ? (
<tr className='tr'>
<td colSpan={7} className="text-center py-8 text-gray-500">
هیچ بلاگی یافت نشد
</td>
</tr>
) : (
blogs.map((blog: Blog) => (
<tr key={blog._id} className='tr'>
<Td text="">
<div className="w-12 h-12 rounded-lg overflow-hidden">
<img
src={blog.imageUrl || '/placeholder-image.png'}
alt={blog.title_fa}
className="w-full h-full object-cover"
/>
</div>
</Td>
<Td text="">
<div>
<div className="font-medium text-gray-900">
{blog.title_fa}
</div>
<div className="text-gray-500 text-xs">
{blog.slug}
</div>
</div>
</Td>
<Td text={blog.category.title_fa} />
<Td text={blog.author.fullName} />
<Td text="">
<div className="flex items-center gap-1">
<Eye size={14} className="text-gray-400" />
<span>{blog.view.toLocaleString('fa-IR')}</span>
</div>
</Td>
<Td text={blog.createdAt} />
<Td text="">
<div className="flex items-center gap-2">
<Link to={`${Pages.blog.detail}${blog._id}`}>
<Edit color='#8C90A3' size={16} className="cursor-pointer hover:text-blue-500" />
</Link>
<TrashWithConfrim
onDelete={() => {
deleteBlog.mutate(blog._id, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['blogs'] })
}
})
}}
/>
</div>
</Td>
</tr>
))
)}
</tbody>
</table>
</div>
<PaginationUi
pager={pager}
onPageChange={(page) => {
setCurrentPage(page)
}}
/>
</div>
)
}
export default BlogsList
+134
View File
@@ -0,0 +1,134 @@
import { type FC, useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { useFormik } from 'formik'
import { type BlogFormType } from './types/Types'
import * as Yup from 'yup'
import { useGetBlogDetail, useGetBlogCategories, useUpdateBlog } from './hooks/useBlogsData'
import { useNavigate } from 'react-router-dom'
import Button from '../../components/Button'
import { TickCircle } from 'iconsax-react'
import { useUploadSingle } from '../uploader/hooks/useUploaderData'
import { toast } from 'react-toastify'
import { extractErrorMessage } from '@/helpers/utils'
import { BlogFormFields, FileUploadSection } from './components'
import { Pages } from '@/config/Pages'
const UpdateBlog: FC = () => {
const navigate = useNavigate()
const { id } = useParams<{ id: string }>()
const { data: blogDetail, isLoading: blogDetailLoading, error: blogDetailError } = useGetBlogDetail(id!)
const { data: categoriesData } = useGetBlogCategories()
const uploadSingle = useUploadSingle()
const updateBlogMutation = useUpdateBlog()
const [imageFile, setImageFile] = useState<File[]>([])
const [blogId, setBlogId] = useState<string>('')
const formik = useFormik<BlogFormType>({
initialValues: {
title_fa: '',
slug: '',
category: '',
description: '',
content: '',
tags: [],
imageUrl: '',
},
validationSchema: Yup.object({
title_fa: Yup.string().required('عنوان فارسی بلاگ الزامی است'),
slug: Yup.string().required('اسلاگ بلاگ الزامی است'),
category: Yup.string().required('دسته بندی بلاگ الزامی است'),
description: Yup.string().required('توضیحات بلاگ الزامی است'),
content: Yup.string().required('محتوای بلاگ الزامی است'),
}),
onSubmit: async (values) => {
let imageUrl = values.imageUrl
if (imageFile.length > 0) {
const imageResponse = await uploadSingle.mutateAsync(imageFile[0])
imageUrl = imageResponse.results?.url?.url || ''
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { slug: _slug, ...valuesWithoutSlug } = values
const submitData = {
...valuesWithoutSlug,
imageUrl
}
updateBlogMutation.mutate({ _id: blogId, ...submitData }, {
onSuccess: () => {
toast.success('بلاگ با موفقیت ویرایش شد')
navigate(Pages.blog.list)
},
onError: (error) => {
toast.error(extractErrorMessage(error))
}
})
},
})
useEffect(() => {
if (blogDetail) {
setBlogId(blogDetail._id)
formik.setValues({
title_fa: blogDetail.title_fa,
slug: blogDetail.slug,
category: blogDetail.category._id,
description: blogDetail.description,
content: blogDetail.content,
tags: blogDetail.tags,
imageUrl: blogDetail.imageUrl,
})
}
}, [blogDetail, formik])
const categoryOptions = categoriesData?.results?.categories?.map((category) => ({
value: category._id,
label: category.title_fa
})) || []
if (blogDetailLoading) {
return <div>در حال بارگذاری...</div>
}
if (blogDetailError) {
return <div>خطا در بارگذاری دادهها</div>
}
return (
<div className='mt-4'>
<div className='flex justify-between items-center'>
<div className='flex items-center gap-3'>
<h1>ویرایش بلاگ</h1>
</div>
<div>
<Button
className='px-6'
onClick={() => formik.handleSubmit()}
isLoading={updateBlogMutation.isPending || uploadSingle.isPending}
disabled={!formik.isValid || updateBlogMutation.isPending || uploadSingle.isPending}
>
<div className='flex gap-2 items-center'>
<TickCircle size={16} color='#fff' />
<div>ویرایش بلاگ</div>
</div>
</Button>
</div>
</div>
<div className='flex gap-6 xl:mt-8 mt-6'>
{/* اطلاعات اصلی */}
<div className='flex-1 text-sm bg-white py-8 xl:px-10 px-6 rounded-3xl'>
<h2 className='text-lg font-medium mb-6'>اطلاعات اصلی</h2>
<BlogFormFields formik={formik} categoryOptions={categoryOptions} />
</div>
{/* فایل‌ها */}
<FileUploadSection onFileChange={setImageFile} />
</div>
</div>
)
}
export default UpdateBlog
@@ -0,0 +1,66 @@
import { type FC } from 'react'
import { type FormikProps } from 'formik'
import Input from '../../../components/Input'
import Select from '../../../components/Select'
import Textarea from '../../../components/Textarea'
import { type BlogFormType } from '../types/Types'
import { TagManager } from './TagManager'
interface BlogFormFieldsProps {
formik: FormikProps<BlogFormType>
categoryOptions: { value: string; label: string }[]
}
export const BlogFormFields: FC<BlogFormFieldsProps> = ({ formik, categoryOptions }) => {
const getFieldError = (field: keyof BlogFormType) => {
const error = formik.touched[field] && formik.errors[field]
return error ? (Array.isArray(error) ? error[0] : error) : ''
}
return (
<div className='space-y-6'>
<div className='grid grid-cols-1 md:grid-cols-2 gap-6'>
<Input
label='عنوان فارسی بلاگ'
placeholder='مثال: راهنمای خرید گوشی'
{...formik.getFieldProps('title_fa')}
error_text={getFieldError('title_fa')}
/>
<Input
label='اسلاگ بلاگ'
placeholder='مثال: rahnamad-kharid-goshi'
{...formik.getFieldProps('slug')}
error_text={getFieldError('slug')}
/>
</div>
<Select
label='دسته‌بندی'
placeholder='انتخاب دسته‌بندی بلاگ'
items={categoryOptions}
{...formik.getFieldProps('category')}
error_text={getFieldError('category')}
/>
<Textarea
label='توضیحات بلاگ'
placeholder='توضیحات کوتاه بلاگ را وارد کنید...'
{...formik.getFieldProps('description')}
error_text={getFieldError('description')}
/>
<Textarea
label='محتوای بلاگ'
placeholder='محتوای کامل بلاگ را وارد کنید...'
rows={10}
{...formik.getFieldProps('content')}
error_text={getFieldError('content')}
/>
<TagManager
tags={formik.values.tags}
onTagsChange={(tags) => formik.setFieldValue('tags', tags)}
/>
</div>
)
}
@@ -0,0 +1,26 @@
import { type FC } from 'react'
import UploadBoxDraggble from '@/components/UploadBoxDraggble'
interface FileUploadSectionProps {
onFileChange: (files: File[]) => void
}
export const FileUploadSection: FC<FileUploadSectionProps> = ({ onFileChange }) => {
return (
<div className='w-80 space-y-6'>
<div className='text-sm bg-white py-8 px-6 rounded-3xl'>
<h2 className='text-lg font-medium mb-6'>تصویر شاخص</h2>
<div className='space-y-6'>
<div>
<UploadBoxDraggble
label='تصویر شاخص بلاگ'
isMultiple={false}
onChange={onFileChange}
/>
</div>
</div>
</div>
</div>
)
}
+62
View File
@@ -0,0 +1,62 @@
import { type FC } from 'react'
import { CloseCircle } from 'iconsax-react'
import Input from '../../../components/Input'
import { useTagManager } from '../hooks/useTagManager'
interface TagManagerProps {
tags: string[]
onTagsChange: (tags: string[]) => void
}
export const TagManager: FC<TagManagerProps> = ({ tags, onTagsChange }) => {
const { tagInput, setTagInput, removeTag } = useTagManager(tags)
const handleAddTag = () => {
const trimmedTag = tagInput.trim()
if (trimmedTag && !tags.includes(trimmedTag)) {
onTagsChange([...tags, trimmedTag])
setTagInput('')
}
}
const handleRemoveTag = (tagToRemove: string) => {
removeTag(tagToRemove)
onTagsChange(tags.filter(tag => tag !== tagToRemove))
}
const handleKeyDownWithUpdate = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddTag()
}
}
return (
<div>
<label className="block text-sm font-medium mb-2">برچسبها</label>
<div className="flex flex-wrap gap-2 mb-2">
{tags.map((tag, index) => (
<span
key={index}
className="inline-flex items-center gap-2 bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm"
>
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="text-blue-600 hover:text-blue-800"
>
<CloseCircle color='red' size={16} />
</button>
</span>
))}
</div>
<Input
placeholder='برچسب را تایپ کنید و Enter بزنید...'
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleKeyDownWithUpdate}
/>
</div>
)
}
+3
View File
@@ -0,0 +1,3 @@
export { BlogFormFields } from "./BlogFormFields";
export { FileUploadSection } from "./FileUploadSection";
export { TagManager } from "./TagManager";
+49
View File
@@ -0,0 +1,49 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import * as api from "../service/BlogsService";
import type {
GetBlogsResult,
Blog,
CreateBlogType,
UpdateBlogType,
BlogCategoriesResponse,
} from "../types/Types";
export const useGetBlogs = (page: number = 1) => {
return useQuery<GetBlogsResult>({
queryKey: ["blogs", page],
queryFn: () => api.getBlogs(page),
});
};
export const useGetBlogCategories = () => {
return useQuery<BlogCategoriesResponse>({
queryKey: ["blog-categories"],
queryFn: api.getBlogCategories,
});
};
export const useGetBlogDetail = (id: string) => {
return useQuery<Blog>({
queryKey: ["blog-detail", id],
queryFn: () => api.getBlogDetail(id),
enabled: !!id,
});
};
export const useCreateBlog = () => {
return useMutation({
mutationFn: (params: CreateBlogType) => api.createBlog(params),
});
};
export const useUpdateBlog = () => {
return useMutation({
mutationFn: (params: UpdateBlogType) => api.updateBlog(params),
});
};
export const useDeleteBlog = () => {
return useMutation({
mutationFn: (id: string) => api.deleteBlog(id),
});
};
+39
View File
@@ -0,0 +1,39 @@
import { useState } from "react";
export const useTagManager = (initialTags: string[] = []) => {
const [tagInput, setTagInput] = useState("");
const [tags, setTags] = useState<string[]>(initialTags);
const addTag = () => {
const trimmedTag = tagInput.trim();
if (trimmedTag && !tags.includes(trimmedTag)) {
setTags((prev) => [...prev, trimmedTag]);
setTagInput("");
}
};
const removeTag = (tagToRemove: string) => {
setTags((prev) => prev.filter((tag) => tag !== tagToRemove));
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
}
};
const setTagsValue = (newTags: string[]) => {
setTags(newTags);
};
return {
tagInput,
setTagInput,
tags,
addTag,
removeTag,
handleKeyDown,
setTags: setTagsValue,
};
};
+41
View File
@@ -0,0 +1,41 @@
import axios from "../../../config/axios";
import type {
BlogsResponse,
GetBlogsResult,
Blog,
CreateBlogType,
UpdateBlogType,
BlogCategoriesResponse,
BlogDetailResponse,
} from "../types/Types";
export const getBlogs = async (page: number = 1): Promise<GetBlogsResult> => {
const { data } = await axios.get<BlogsResponse>(`/blogs?page=${page}`);
return data.results;
};
export const getBlogCategories = async (): Promise<BlogCategoriesResponse> => {
const { data } = await axios.get<BlogCategoriesResponse>("/blogs/categories");
return data;
};
export const getBlogDetail = async (id: string): Promise<Blog> => {
const { data } = await axios.get<BlogDetailResponse>(`/blogs/${id}`);
return data.results.blog;
};
export const createBlog = async (params: CreateBlogType) => {
const { data } = await axios.post(`/admin/blog`, params);
return data;
};
export const updateBlog = async (params: UpdateBlogType) => {
const { _id, ...updateData } = params;
const { data } = await axios.post(`/admin/blog/${_id}`, updateData);
return data;
};
export const deleteBlog = async (id: string) => {
const { data } = await axios.delete(`/admin/blog/${id}`);
return data;
};
+101
View File
@@ -0,0 +1,101 @@
export interface BlogCategory {
_id: string;
title_fa: string;
slug: string;
description: string;
parent: string | null;
leaf: boolean;
createdAt: string;
updatedAt: string;
children: BlogCategory[];
}
export interface Blog {
_id: string;
title_fa: string;
slug: string;
category: {
_id: string;
title_fa: string;
slug: string;
description: string;
};
description: string;
content: string;
author: {
fullName: string;
};
view: number;
tags: string[];
imageUrl: string;
createdAt: string;
}
export interface Pager {
page: number;
limit: number;
totalItems: number;
totalPages: number;
prevPage: boolean | null;
nextPage: string | null;
}
export interface BlogsResponse {
status: number;
success: boolean;
results: {
pager: Pager;
blogs: Blog[];
};
}
export interface GetBlogsResult {
pager: Pager;
blogs: Blog[];
}
export type CreateBlogType = {
title_fa: string;
slug: string;
category: string;
description: string;
content: string;
tags: string[];
imageUrl: string;
};
export type UpdateBlogType = {
_id: string;
title_fa: string;
category: string;
description: string;
content: string;
tags: string[];
imageUrl: string;
};
export type BlogFormType = {
title_fa: string;
slug: string;
category: string;
description: string;
content: string;
tags: string[];
imageUrl: string;
};
export interface BlogCategoriesResponse {
status: number;
success: boolean;
results: {
categories: BlogCategory[];
};
}
export interface BlogDetailResponse {
status: number;
success: boolean;
results: {
blog: Blog;
};
}
+17
View File
@@ -0,0 +1,17 @@
import { type FC } from 'react'
// import { useParams } from 'react-router-dom'
// import { useGetOrderDetailUser } from './hooks/useOrderData'
const OrderDetail: FC = () => {
// const { id } = useParams()
// const { data, isLoading, error } = useGetOrderDetailUser(id!)
return (
<div>
asd
</div>
)
}
export default OrderDetail
+2 -2
View File
@@ -16,7 +16,7 @@ const STATUS_CONFIG = {
process_by_sellers: { label: 'در حال پردازش توسط فروشنده', color: 'bg-blue-100 text-blue-800' },
cancelled_system: { label: 'کنسل شده توسط سیستم', color: 'bg-red-100 text-red-800' },
Delivered: { label: 'تحویل داده شده', color: 'bg-green-100 text-green-800' },
} as const
}
const getStatusLabel = (status: string): string => STATUS_CONFIG[status as keyof typeof STATUS_CONFIG]?.label || status
@@ -92,7 +92,7 @@ const Native: FC = () => {
<Td text={order.createdAt || 'نامشخص'} />
<Td text="">
<div className="flex items-center gap-2">
<Link to={`${Pages.orders.detail}${order._id}?status=${status}`}>
<Link to={`${Pages.orders.detailUser}${order._id}`}>
<Eye color='#8C90A3' size={16} className="cursor-pointer hover:text-blue-500 transition-colors" />
</Link>
</div>
+8
View File
@@ -22,3 +22,11 @@ export const useGetSellerOrders = (
});
return { data, isLoading, error };
};
export const useGetOrderDetailUser = (id: string) => {
return useQuery({
queryKey: ["order-detail-user", id],
queryFn: () => api.getOrderDetailUser(id),
enabled: !!id,
});
};
+5
View File
@@ -20,3 +20,8 @@ export const getSellerOrders = async (
});
return data;
};
export const getOrderDetailUser = async (id: string) => {
const { data } = await axios.get(`/order/${id}/user`);
return data;
};