723 lines
22 KiB
TypeScript
723 lines
22 KiB
TypeScript
import React, { useState, useEffect } from 'react'
|
||
import Card from '@/components/ui/Card'
|
||
import Button from '@/components/ui/Button'
|
||
import Table from '@/components/ui/Table'
|
||
import Tag from '@/components/ui/Tag'
|
||
import Dialog from '@/components/ui/Dialog'
|
||
import { FormContainer, FormItem } from '@/components/ui/Form'
|
||
import Input from '@/components/ui/Input'
|
||
import Select from '@/components/ui/Select'
|
||
import Switcher from '@/components/ui/Switcher'
|
||
import { HiPlus, HiPencil, HiTrash, HiEye } from 'react-icons/hi'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import {
|
||
blogService,
|
||
BlogPost,
|
||
BlogCategory,
|
||
CreateUpdateBlogPostDto,
|
||
CreateUpdateBlogCategoryDto,
|
||
} from '@/services/blog.service'
|
||
import { format } from 'date-fns'
|
||
import { tr } from 'date-fns/locale'
|
||
import { Field, FieldProps, Form, Formik } from 'formik'
|
||
import * as Yup from 'yup'
|
||
import toast from '@/components/ui/toast'
|
||
import Notification from '@/components/ui/Notification'
|
||
import ReactQuill from 'react-quill'
|
||
import 'react-quill/dist/quill.snow.css'
|
||
import Tr from '@/components/ui/Table/Tr'
|
||
import Th from '@/components/ui/Table/Th'
|
||
import THead from '@/components/ui/Table/THead'
|
||
import TBody from '@/components/ui/Table/TBody'
|
||
import Td from '@/components/ui/Table/Td'
|
||
import { SelectBoxOption } from '@/shared/types'
|
||
import { CheckBox } from 'devextreme-react'
|
||
import { Checkbox } from '@/components/ui'
|
||
import { Helmet } from 'react-helmet'
|
||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||
|
||
const validationSchema = Yup.object().shape({
|
||
title: Yup.string().required(),
|
||
summary: Yup.string().required(),
|
||
categoryId: Yup.string().required(),
|
||
content: Yup.string(),
|
||
tags: Yup.string(),
|
||
coverImage: Yup.string(),
|
||
isPublished: Yup.bool(),
|
||
})
|
||
|
||
const categoryValidationSchema = Yup.object().shape({
|
||
name: Yup.string().required(),
|
||
slug: Yup.string().required(),
|
||
description: Yup.string(),
|
||
icon: Yup.string(),
|
||
displayOrder: Yup.number(),
|
||
isActive: Yup.bool(),
|
||
})
|
||
|
||
const BlogManagement = () => {
|
||
const { translate } = useLocalization()
|
||
const navigate = useNavigate()
|
||
const [activeTab, setActiveTab] = useState<'posts' | 'categories'>('posts')
|
||
const [posts, setPosts] = useState<BlogPost[]>([])
|
||
const [categories, setCategories] = useState<BlogCategory[]>([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [modalVisible, setModalVisible] = useState(false)
|
||
const [categoryModalVisible, setCategoryModalVisible] = useState(false)
|
||
const [editingPost, setEditingPost] = useState<BlogPost | null>(null)
|
||
const [editingCategory, setEditingCategory] = useState<BlogCategory | null>(null)
|
||
const categoryItems = categories?.map((cat) => ({
|
||
value: cat.id,
|
||
label: cat.name,
|
||
}))
|
||
|
||
useEffect(() => {
|
||
loadData()
|
||
}, [])
|
||
|
||
const loadData = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const [postsData, categoriesData] = await Promise.all([
|
||
blogService.getPosts({ pageSize: 100 }),
|
||
blogService.getCategories(),
|
||
])
|
||
setPosts(postsData.items)
|
||
setCategories(categoriesData)
|
||
} catch (error) {
|
||
toast.push(
|
||
<Notification title="Hata" type="danger">
|
||
Veriler yüklenirken hata oluştu
|
||
</Notification>,
|
||
{
|
||
placement: 'top-center',
|
||
},
|
||
)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleCreate = () => {
|
||
setEditingPost(null)
|
||
setModalVisible(true)
|
||
}
|
||
|
||
const handleEdit = (post: BlogPost) => {
|
||
setEditingPost(post)
|
||
setModalVisible(true)
|
||
}
|
||
|
||
const handleDelete = async (id: string) => {
|
||
try {
|
||
await blogService.deletePost(id)
|
||
toast.push(
|
||
<Notification title="Başarılı" type="success">
|
||
Blog yazısı silindi
|
||
</Notification>,
|
||
{
|
||
placement: 'top-center',
|
||
},
|
||
)
|
||
loadData()
|
||
} catch (error) {
|
||
toast.push(
|
||
<Notification title="Hata" type="danger">
|
||
Silme işlemi başarısız
|
||
</Notification>,
|
||
{
|
||
placement: 'top-center',
|
||
},
|
||
)
|
||
}
|
||
}
|
||
|
||
const handleSubmit = async (values: any, { setSubmitting }: any) => {
|
||
try {
|
||
const data: CreateUpdateBlogPostDto = {
|
||
title: values.title,
|
||
slug: values.slug,
|
||
content: values.content,
|
||
summary: values.summary,
|
||
categoryId: values.categoryId,
|
||
tags: values.tags ? values.tags.split(',').map((t: string) => t.trim()) : [],
|
||
coverImage: values.coverImage,
|
||
isPublished: values.isPublished,
|
||
}
|
||
|
||
if (editingPost) {
|
||
await blogService.updatePost(editingPost.id, data)
|
||
toast.push(
|
||
<Notification title="Başarılı" type="success">
|
||
Blog yazısı güncellendi
|
||
</Notification>,
|
||
{
|
||
placement: 'top-center',
|
||
},
|
||
)
|
||
} else {
|
||
await blogService.createPost(data)
|
||
toast.push(
|
||
<Notification title="Başarılı" type="success">
|
||
Blog yazısı oluşturuldu
|
||
</Notification>,
|
||
{
|
||
placement: 'top-center',
|
||
},
|
||
)
|
||
}
|
||
|
||
setModalVisible(false)
|
||
loadData()
|
||
} catch (error) {
|
||
toast.push(
|
||
<Notification title="Hata" type="danger">
|
||
İşlem başarısız
|
||
</Notification>,
|
||
{
|
||
placement: 'top-center',
|
||
},
|
||
)
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
const handlePublish = async (post: BlogPost) => {
|
||
try {
|
||
if (post.isPublished) {
|
||
await blogService.unpublishPost(post.id)
|
||
toast.push(
|
||
<Notification title="Başarılı" type="success">
|
||
Yayından kaldırıldı
|
||
</Notification>,
|
||
{
|
||
placement: 'top-center',
|
||
},
|
||
)
|
||
} else {
|
||
await blogService.publishPost(post.id)
|
||
toast.push(
|
||
<Notification title="Başarılı" type="success">
|
||
Yayınlandı
|
||
</Notification>,
|
||
{
|
||
placement: 'top-center',
|
||
},
|
||
)
|
||
}
|
||
loadData()
|
||
} catch (error) {
|
||
toast.push(
|
||
<Notification title="Hata" type="danger">
|
||
İşlem başarısız
|
||
</Notification>,
|
||
{
|
||
placement: 'top-center',
|
||
},
|
||
)
|
||
}
|
||
}
|
||
|
||
// Category functions
|
||
const handleCreateCategory = () => {
|
||
setEditingCategory(null)
|
||
setCategoryModalVisible(true)
|
||
}
|
||
|
||
const handleEditCategory = (category: BlogCategory) => {
|
||
setEditingCategory(category)
|
||
console.log(category)
|
||
setCategoryModalVisible(true)
|
||
}
|
||
|
||
const handleDeleteCategory = async (id: string) => {
|
||
try {
|
||
await blogService.deleteCategory(id)
|
||
toast.push(
|
||
<Notification title="Başarılı" type="success">
|
||
Kategori silindi
|
||
</Notification>,
|
||
{
|
||
placement: 'top-center',
|
||
},
|
||
)
|
||
loadData()
|
||
} catch (error) {
|
||
toast.push(
|
||
<Notification title="Hata" type="danger">
|
||
Silme işlemi başarısız
|
||
</Notification>,
|
||
{
|
||
placement: 'top-center',
|
||
},
|
||
)
|
||
}
|
||
}
|
||
|
||
const handleSubmitCategory = async (values: any, { setSubmitting }: any) => {
|
||
try {
|
||
const data: CreateUpdateBlogCategoryDto = {
|
||
name: values.name,
|
||
slug: values.slug,
|
||
description: values.description,
|
||
icon: values.icon,
|
||
displayOrder: values.displayOrder,
|
||
isActive: values.isActive,
|
||
}
|
||
|
||
if (editingCategory) {
|
||
await blogService.updateCategory(editingCategory.id, data)
|
||
toast.push(
|
||
<Notification title="Başarılı" type="success">
|
||
Kategori güncellendi
|
||
</Notification>,
|
||
{
|
||
placement: 'top-center',
|
||
},
|
||
)
|
||
} else {
|
||
await blogService.createCategory(data)
|
||
toast.push(
|
||
<Notification title="Başarılı" type="success">
|
||
Kategori oluşturuldu
|
||
</Notification>,
|
||
{
|
||
placement: 'top-center',
|
||
},
|
||
)
|
||
}
|
||
|
||
setCategoryModalVisible(false)
|
||
loadData()
|
||
} catch (error) {
|
||
toast.push(
|
||
<Notification title="Hata" type="danger">
|
||
İşlem başarısız
|
||
</Notification>,
|
||
{
|
||
placement: 'top-center',
|
||
},
|
||
)
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
const initialValues = editingPost
|
||
? {
|
||
title: editingPost.title,
|
||
slug: editingPost.slug,
|
||
summary: editingPost.summary,
|
||
content: editingPost.content,
|
||
categoryId: editingPost.category.id,
|
||
tags: editingPost.tags.join(', '),
|
||
coverImage: editingPost.coverImage || '',
|
||
isPublished: editingPost.isPublished,
|
||
}
|
||
: {
|
||
title: '',
|
||
slug: '',
|
||
summary: '',
|
||
content: '',
|
||
categoryId: '',
|
||
tags: '',
|
||
coverImage: '',
|
||
isPublished: false,
|
||
}
|
||
|
||
const initialCategoryValues = editingCategory
|
||
? {
|
||
name: editingCategory.name,
|
||
slug: editingCategory.slug,
|
||
description: editingCategory.description || '',
|
||
icon: editingCategory.icon,
|
||
displayOrder: editingCategory.displayOrder,
|
||
isActive: editingCategory.isActive,
|
||
}
|
||
: {
|
||
name: '',
|
||
slug: '',
|
||
description: '',
|
||
icon: '',
|
||
displayOrder: 0,
|
||
isActive: true,
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<Helmet
|
||
titleTemplate="%s | Kurs Platform"
|
||
title={translate('::' + 'Blog Management')}
|
||
defaultTitle="Kurs Platform"
|
||
></Helmet>
|
||
<Card>
|
||
<div className="mb-4">
|
||
<div className="flex gap-4 border-b">
|
||
<button
|
||
className={`pb-2 px-1 ${activeTab === 'posts' ? 'border-b-2 border-blue-600 text-blue-600' : 'text-gray-600'}`}
|
||
onClick={() => setActiveTab('posts')}
|
||
>
|
||
<b>Blog Yazıları</b>
|
||
</button>
|
||
<button
|
||
className={`pb-2 px-1 ${activeTab === 'categories' ? 'border-b-2 border-blue-600 text-blue-600' : 'text-gray-600'}`}
|
||
onClick={() => setActiveTab('categories')}
|
||
>
|
||
<b>Kategoriler</b>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{activeTab === 'posts' ? (
|
||
<Table compact>
|
||
<THead>
|
||
<Tr>
|
||
<Th>Başlık</Th>
|
||
<Th>Slug</Th>
|
||
<Th>Kategori</Th>
|
||
<Th>Yazar</Th>
|
||
<Th>Yayın Tarihi</Th>
|
||
<Th>Durum</Th>
|
||
<Th>
|
||
{' '}
|
||
<Button variant="solid" size="xs" icon={<HiPlus />} onClick={handleCreate}>
|
||
Yeni
|
||
</Button>
|
||
</Th>
|
||
</Tr>
|
||
</THead>
|
||
<TBody>
|
||
{loading ? (
|
||
<Tr>
|
||
<Td colSpan={7} className="text-center">
|
||
Yükleniyor...
|
||
</Td>
|
||
</Tr>
|
||
) : (
|
||
posts.map((post) => (
|
||
<Tr key={post.id}>
|
||
<Td className="font-medium">{post.title}</Td>
|
||
<Td>{post.slug}</Td>
|
||
<Td>{post.category?.name}</Td>
|
||
<Td>{post.author?.name}</Td>
|
||
<Td>
|
||
{post.publishedAt
|
||
? format(new Date(post.publishedAt), 'dd MMM yyyy', { locale: tr })
|
||
: '-'}
|
||
</Td>
|
||
<Td>
|
||
<Switcher checked={post.isPublished} onChange={() => handlePublish(post)} />
|
||
</Td>
|
||
<Td>
|
||
<div className="flex gap-2">
|
||
<Button size="sm" icon={<HiPencil />} onClick={() => handleEdit(post)} />
|
||
<Button
|
||
size="sm"
|
||
variant="solid"
|
||
color="red-600"
|
||
icon={<HiTrash />}
|
||
onClick={() => handleDelete(post.id)}
|
||
/>
|
||
</div>
|
||
</Td>
|
||
</Tr>
|
||
))
|
||
)}
|
||
</TBody>
|
||
</Table>
|
||
) : (
|
||
<Table compact>
|
||
<THead>
|
||
<Tr>
|
||
<Th>İsim</Th>
|
||
<Th>Slug</Th>
|
||
<Th>Açıklama</Th>
|
||
<Th>Yazı Sayısı</Th>
|
||
<Th>Durum</Th>
|
||
<Th>
|
||
<Button
|
||
variant="solid"
|
||
size="xs"
|
||
icon={<HiPlus />}
|
||
onClick={handleCreateCategory}
|
||
>
|
||
Yeni
|
||
</Button>
|
||
</Th>
|
||
</Tr>
|
||
</THead>
|
||
<TBody>
|
||
{loading ? (
|
||
<Tr>
|
||
<Td colSpan={5} className="text-center">
|
||
Yükleniyor...
|
||
</Td>
|
||
</Tr>
|
||
) : (
|
||
categories.map((category) => (
|
||
<Tr key={category.id}>
|
||
<Td className="font-medium">{category.name}</Td>
|
||
<Td>{category.slug}</Td>
|
||
<Td>{category.description}</Td>
|
||
<Td>{category.postCount}</Td>
|
||
<Td>
|
||
<Tag
|
||
className={
|
||
category.isActive
|
||
? 'bg-green-100 text-green-800'
|
||
: 'bg-orange-100 text-orange-800'
|
||
}
|
||
>
|
||
{category.isActive ? 'Aktif' : 'Pasif'}
|
||
</Tag>
|
||
</Td>
|
||
<Td>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
size="sm"
|
||
icon={<HiPencil />}
|
||
onClick={() => handleEditCategory(category)}
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
variant="solid"
|
||
color="red-600"
|
||
icon={<HiTrash />}
|
||
onClick={() => handleDeleteCategory(category.id)}
|
||
/>
|
||
</div>
|
||
</Td>
|
||
</Tr>
|
||
))
|
||
)}
|
||
</TBody>
|
||
</Table>
|
||
)}
|
||
</Card>
|
||
|
||
{/* Post Modal */}
|
||
<Dialog
|
||
isOpen={modalVisible}
|
||
onClose={() => setModalVisible(false)}
|
||
onRequestClose={() => setModalVisible(false)}
|
||
width={1000}
|
||
>
|
||
<h5 className="mb-4">{editingPost ? 'Blog Yazısını Düzenle' : 'Yeni Blog Yazısı'}</h5>
|
||
|
||
<Formik
|
||
initialValues={initialValues}
|
||
validationSchema={validationSchema}
|
||
onSubmit={handleSubmit}
|
||
enableReinitialize={true}
|
||
>
|
||
{({ values, touched, errors, isSubmitting, setFieldValue }) => (
|
||
<Form>
|
||
<FormContainer>
|
||
<FormItem
|
||
asterisk
|
||
label="Başlık"
|
||
invalid={errors.title && touched.title}
|
||
errorMessage={errors.title}
|
||
>
|
||
<Field
|
||
type="text"
|
||
name="title"
|
||
placeholder="Blog başlığı"
|
||
component={Input}
|
||
autoFocus={true}
|
||
/>
|
||
</FormItem>
|
||
|
||
<FormItem
|
||
asterisk
|
||
label="Slug"
|
||
invalid={errors.title && touched.title}
|
||
errorMessage={errors.title}
|
||
>
|
||
<Field type="text" name="slug" placeholder="Slug" component={Input} />
|
||
</FormItem>
|
||
|
||
<FormItem
|
||
asterisk
|
||
label="Özet"
|
||
invalid={errors.summary && touched.summary}
|
||
errorMessage={errors.summary}
|
||
>
|
||
<Field
|
||
name="summary"
|
||
placeholder="Kısa açıklama"
|
||
component={Input}
|
||
textArea={true}
|
||
rows={3}
|
||
/>
|
||
</FormItem>
|
||
|
||
<FormItem
|
||
asterisk
|
||
label="Kategori"
|
||
invalid={errors.categoryId && touched.categoryId}
|
||
errorMessage={errors.categoryId}
|
||
>
|
||
<Field name="categoryId">
|
||
{({ field, form }: FieldProps<SelectBoxOption>) => (
|
||
<Select
|
||
field={field}
|
||
form={form}
|
||
options={categoryItems}
|
||
isClearable={true}
|
||
value={categoryItems.filter((option) => option.value === values.categoryId)}
|
||
onChange={(option) => form.setFieldValue(field.name, option?.value)}
|
||
/>
|
||
)}
|
||
</Field>
|
||
</FormItem>
|
||
|
||
<FormItem label="Etiketler - Virgül ile ayırarak yazınız">
|
||
<Field
|
||
type="text"
|
||
name="tags"
|
||
placeholder="örn: react, javascript, web"
|
||
component={Input}
|
||
/>
|
||
</FormItem>
|
||
|
||
<FormItem label="Kapak Görseli">
|
||
<Field
|
||
type="text"
|
||
name="coverImage"
|
||
placeholder="Görsel URL'si"
|
||
component={Input}
|
||
/>
|
||
</FormItem>
|
||
|
||
<FormItem
|
||
label="İçerik"
|
||
asterisk
|
||
invalid={!!errors.content}
|
||
errorMessage={errors.content}
|
||
>
|
||
<ReactQuill
|
||
theme="snow"
|
||
value={values.content}
|
||
onChange={(val: string) => setFieldValue('content', val)}
|
||
style={{ height: '300px', marginBottom: '50px' }}
|
||
/>
|
||
</FormItem>
|
||
|
||
<FormItem
|
||
label="Durum"
|
||
invalid={errors.isPublished && touched.isPublished}
|
||
errorMessage={errors.isPublished}
|
||
>
|
||
<Field name="isPublished" component={Switcher} />
|
||
</FormItem>
|
||
|
||
<FormItem>
|
||
<div className="flex gap-2">
|
||
<Button variant="solid" type="submit" loading={isSubmitting}>
|
||
{editingPost ? 'Güncelle' : 'Oluştur'}
|
||
</Button>
|
||
<Button variant="plain" onClick={() => setModalVisible(false)}>
|
||
İptal
|
||
</Button>
|
||
</div>
|
||
</FormItem>
|
||
</FormContainer>
|
||
</Form>
|
||
)}
|
||
</Formik>
|
||
</Dialog>
|
||
|
||
{/* Category Modal */}
|
||
<Dialog
|
||
isOpen={categoryModalVisible}
|
||
onClose={() => setCategoryModalVisible(false)}
|
||
onRequestClose={() => setCategoryModalVisible(false)}
|
||
width={600}
|
||
>
|
||
<h5 className="mb-4">{editingCategory ? 'Kategoriyi Düzenle' : 'Yeni Kategori'}</h5>
|
||
|
||
<Formik
|
||
initialValues={initialCategoryValues}
|
||
validationSchema={categoryValidationSchema}
|
||
onSubmit={handleSubmitCategory}
|
||
enableReinitialize
|
||
>
|
||
{({ values, touched, errors, isSubmitting }) => (
|
||
<Form>
|
||
<FormContainer>
|
||
<FormItem
|
||
asterisk
|
||
label="İsim"
|
||
invalid={errors.name && touched.name}
|
||
errorMessage={errors.name}
|
||
>
|
||
<Field
|
||
autoFocus={true}
|
||
type="text"
|
||
name="name"
|
||
placeholder="Kategori ismi"
|
||
component={Input}
|
||
/>
|
||
</FormItem>
|
||
|
||
<FormItem
|
||
asterisk
|
||
label="Slug"
|
||
invalid={errors.slug && touched.slug}
|
||
errorMessage={errors.slug}
|
||
>
|
||
<Field type="text" name="slug" placeholder="kategori-slug" component={Input} />
|
||
</FormItem>
|
||
|
||
<FormItem
|
||
label="Açıklama"
|
||
invalid={errors.description && touched.description}
|
||
errorMessage={errors.description}
|
||
>
|
||
<Field
|
||
name="description"
|
||
placeholder="Kategori açıklaması"
|
||
component={Input}
|
||
textArea={true}
|
||
rows={3}
|
||
/>
|
||
</FormItem>
|
||
|
||
<FormItem label="İkon (Emoji)">
|
||
<Field type="text" name="icon" placeholder="📚" component={Input} />
|
||
</FormItem>
|
||
|
||
<FormItem label="Sıralama">
|
||
<Field type="number" name="displayOrder" placeholder="0" component={Input} />
|
||
</FormItem>
|
||
|
||
<FormItem
|
||
label="Durum"
|
||
invalid={errors.isActive && touched.isActive}
|
||
errorMessage={errors.isActive}
|
||
>
|
||
<Field name="isActive" component={Checkbox} />
|
||
</FormItem>
|
||
|
||
<FormItem>
|
||
<div className="flex gap-2">
|
||
<Button variant="solid" type="submit" loading={isSubmitting}>
|
||
{editingCategory ? 'Güncelle' : 'Oluştur'}
|
||
</Button>
|
||
<Button variant="plain" onClick={() => setCategoryModalVisible(false)}>
|
||
İptal
|
||
</Button>
|
||
</div>
|
||
</FormItem>
|
||
</FormContainer>
|
||
</Form>
|
||
)}
|
||
</Formik>
|
||
</Dialog>
|
||
</>
|
||
)
|
||
}
|
||
|
||
export default BlogManagement
|