471 lines
17 KiB
TypeScript
471 lines
17 KiB
TypeScript
import React, { useState } from 'react'
|
|
import { Plus, Edit2, Trash2, CheckCircle, Circle, Heart, Loader2 } from 'lucide-react'
|
|
import { ForumPost, ForumTopic } from '@/proxy/forum/forum'
|
|
import { HtmlEditor, ImageUpload, Item, MediaResizing, Toolbar } from 'devextreme-react/html-editor'
|
|
import { useStoreState } from '@/store/store'
|
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
|
import { Formik, Form, Field, FieldProps } from 'formik'
|
|
import * as Yup from 'yup'
|
|
import { FormContainer, FormItem, Button } from '@/components/ui'
|
|
import { ConfirmDialog } from '@/components/shared'
|
|
import {
|
|
fontFamilyOptions,
|
|
fontSizeOptions,
|
|
fontValues,
|
|
headerOptions,
|
|
headerValues,
|
|
sizeValues,
|
|
} from '@/proxy/html-editor/data'
|
|
|
|
interface PostManagementProps {
|
|
posts: ForumPost[]
|
|
topics: ForumTopic[]
|
|
loading: boolean
|
|
onCreatePost: (post: {
|
|
topicId: string
|
|
content: string
|
|
parentPostId?: string
|
|
tenantId?: string
|
|
}) => Promise<void>
|
|
onUpdatePost: (id: string, post: Partial<ForumPost>) => Promise<void>
|
|
onDeletePost: (id: string) => Promise<void>
|
|
onMarkPostAsAcceptedAnswer: (id: string) => Promise<void>
|
|
onUnmarkPostAsAcceptedAnswer: (id: string) => Promise<void>
|
|
}
|
|
|
|
export function PostManagement({
|
|
posts,
|
|
topics,
|
|
loading,
|
|
onCreatePost,
|
|
onUpdatePost,
|
|
onDeletePost,
|
|
onMarkPostAsAcceptedAnswer,
|
|
onUnmarkPostAsAcceptedAnswer,
|
|
}: PostManagementProps) {
|
|
const { translate } = useLocalization()
|
|
const { tenant } = useStoreState((state) => state.auth)
|
|
const [content, setContent] = useState('')
|
|
const [showCreateForm, setShowCreateForm] = useState(false)
|
|
const [editingPost, setEditingPost] = useState<ForumPost | null>(null)
|
|
const [formData, setFormData] = useState({
|
|
topicId: '',
|
|
parentPostId: '',
|
|
tenantId: '',
|
|
isAcceptedAnswer: false,
|
|
})
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [showConfirm, setShowConfirm] = useState(false)
|
|
const [postToDelete, setPostToDelete] = useState<ForumPost | null>(null)
|
|
|
|
const confirmDeletePost = (post: ForumPost) => {
|
|
setPostToDelete(post)
|
|
setShowConfirm(true)
|
|
}
|
|
|
|
const resetForm = () => {
|
|
setFormData({
|
|
topicId: '',
|
|
parentPostId: '',
|
|
tenantId: '',
|
|
isAcceptedAnswer: false,
|
|
})
|
|
setShowCreateForm(false)
|
|
setEditingPost(null)
|
|
}
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (submitting || !content.trim()) return
|
|
|
|
try {
|
|
setSubmitting(true)
|
|
const data = { ...formData, content: content.trim() }
|
|
|
|
if (editingPost) {
|
|
await onUpdatePost(editingPost.id, data)
|
|
} else {
|
|
await onCreatePost(data)
|
|
}
|
|
|
|
resetForm()
|
|
} catch (error) {
|
|
console.error('Error submitting form:', error)
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const handleEdit = (post: ForumPost) => {
|
|
setEditingPost(post)
|
|
setFormData({
|
|
topicId: post.topicId,
|
|
parentPostId: '',
|
|
tenantId: tenant.tenantId ?? '',
|
|
isAcceptedAnswer: post.isAcceptedAnswer,
|
|
})
|
|
setContent(post.content)
|
|
setShowCreateForm(true)
|
|
}
|
|
|
|
const handleToggleAcceptedAnswer = async (post: ForumPost) => {
|
|
try {
|
|
if (post.isAcceptedAnswer) {
|
|
await onUnmarkPostAsAcceptedAnswer(post.id)
|
|
} else {
|
|
await onMarkPostAsAcceptedAnswer(post.id)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error toggling accepted answer:', error)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (confirm('Are you sure you want to delete this post?')) {
|
|
try {
|
|
await onDeletePost(id)
|
|
} catch (error) {
|
|
console.error('Error deleting post:', error)
|
|
}
|
|
}
|
|
}
|
|
|
|
const getTopicTitle = (topicId: string) => {
|
|
const topic = topics.find((t) => t.id === topicId)
|
|
return topic ? topic.title : 'Unknown Topic'
|
|
}
|
|
|
|
const formatDate = (value: string | Date) => {
|
|
const date = value instanceof Date ? value : new Date(value)
|
|
if (isNaN(date.getTime())) return 'Invalid Date'
|
|
|
|
return new Intl.DateTimeFormat('en', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
}).format(date)
|
|
}
|
|
|
|
const postValidationSchema = Yup.object().shape({
|
|
topicId: Yup.string().required('Topic is required'),
|
|
content: Yup.string()
|
|
.test('not-empty', 'Content is required', (value) => {
|
|
const plainText = value?.replace(/<[^>]+>/g, '').trim()
|
|
return !!plainText
|
|
})
|
|
.required(),
|
|
})
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-2xl font-bold text-gray-900">
|
|
{translate('::App.Forum.PostManagement.Title')}
|
|
</h2>
|
|
<button
|
|
onClick={() => setShowCreateForm(true)}
|
|
disabled={loading}
|
|
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
<span>{translate('::App.Forum.PostManagement.AddPost')}</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Create/Edit Form */}
|
|
{showCreateForm && (
|
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
|
{editingPost
|
|
? translate('::App.Forum.PostManagement.EditPost')
|
|
: translate('::App.Forum.PostManagement.AddPost')}
|
|
</h3>
|
|
|
|
<Formik
|
|
initialValues={{
|
|
topicId: editingPost?.topicId || '',
|
|
content: editingPost?.content || '',
|
|
isAcceptedAnswer: editingPost?.isAcceptedAnswer || false,
|
|
parentPostId: '',
|
|
tenantId: tenant.tenantId || '',
|
|
}}
|
|
validationSchema={postValidationSchema}
|
|
enableReinitialize
|
|
onSubmit={async (values, { setSubmitting, resetForm }) => {
|
|
try {
|
|
if (editingPost) {
|
|
await onUpdatePost(editingPost.id, values)
|
|
} else {
|
|
await onCreatePost(values)
|
|
}
|
|
resetForm()
|
|
setShowCreateForm(false)
|
|
setEditingPost(null)
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}}
|
|
>
|
|
{({ values, setFieldValue, errors, touched, isSubmitting }) => (
|
|
<Form>
|
|
<FormContainer className="space-y-4">
|
|
<FormItem
|
|
label="Topic"
|
|
asterisk
|
|
invalid={!!errors.topicId && touched.topicId}
|
|
errorMessage={errors.topicId}
|
|
>
|
|
<Field
|
|
as="select"
|
|
name="topicId"
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
|
>
|
|
<option value="">Select a topic</option>
|
|
{topics.map((topic) => (
|
|
<option key={topic.id} value={topic.id}>
|
|
{topic.title}
|
|
</option>
|
|
))}
|
|
</Field>
|
|
</FormItem>
|
|
|
|
<FormItem
|
|
label="Content"
|
|
asterisk
|
|
invalid={!!errors.content && touched.content}
|
|
errorMessage={errors.content}
|
|
>
|
|
<Field name="content">
|
|
{({ field }: FieldProps) => (
|
|
<HtmlEditor
|
|
value={field.value}
|
|
onValueChanged={(e) => setFieldValue('content', e.value)}
|
|
height={400}
|
|
placeholder="Write your message..."
|
|
>
|
|
<MediaResizing enabled={true} />
|
|
<ImageUpload fileUploadMode="base64" />
|
|
<Toolbar multiline={true}>
|
|
<Item name="undo" />
|
|
<Item name="redo" />
|
|
<Item name="separator" />
|
|
<Item
|
|
name="size"
|
|
acceptedValues={sizeValues}
|
|
options={fontSizeOptions}
|
|
/>
|
|
<Item
|
|
name="font"
|
|
acceptedValues={fontValues}
|
|
options={fontFamilyOptions}
|
|
/>
|
|
<Item name="separator" />
|
|
<Item name="bold" />
|
|
<Item name="italic" />
|
|
<Item name="strike" />
|
|
<Item name="underline" />
|
|
<Item name="separator" />
|
|
<Item name="alignLeft" />
|
|
<Item name="alignCenter" />
|
|
<Item name="alignRight" />
|
|
<Item name="alignJustify" />
|
|
<Item name="separator" />
|
|
<Item name="orderedList" />
|
|
<Item name="bulletList" />
|
|
<Item name="separator" />
|
|
<Item
|
|
name="header"
|
|
acceptedValues={headerValues}
|
|
options={headerOptions}
|
|
/>
|
|
<Item name="separator" />
|
|
<Item name="color" />
|
|
<Item name="background" />
|
|
<Item name="separator" />
|
|
<Item name="link" />
|
|
<Item name="image" />
|
|
<Item name="separator" />
|
|
<Item name="clear" />
|
|
<Item name="codeBlock" />
|
|
<Item name="blockquote" />
|
|
<Item name="separator" />
|
|
<Item name="insertTable" />
|
|
<Item name="deleteTable" />
|
|
<Item name="insertRowAbove" />
|
|
<Item name="insertRowBelow" />
|
|
<Item name="deleteRow" />
|
|
<Item name="insertColumnLeft" />
|
|
<Item name="insertColumnRight" />
|
|
<Item name="deleteColumn" />
|
|
</Toolbar>
|
|
</HtmlEditor>
|
|
)}
|
|
</Field>
|
|
</FormItem>
|
|
|
|
<FormItem label="Accepted Answer">
|
|
<label className="flex items-center">
|
|
<Field type="checkbox" name="isAcceptedAnswer" className="mr-2" />
|
|
Mark as Accepted Answer
|
|
</label>
|
|
</FormItem>
|
|
|
|
<div className="flex justify-end space-x-3 pt-2">
|
|
<Button
|
|
variant="plain"
|
|
type="button"
|
|
onClick={() => {
|
|
setShowCreateForm(false)
|
|
setEditingPost(null)
|
|
}}
|
|
>
|
|
{translate('::Cancel')}
|
|
</Button>
|
|
<Button
|
|
variant="solid"
|
|
type="submit"
|
|
loading={isSubmitting}
|
|
disabled={
|
|
!values.topicId ||
|
|
!values.content ||
|
|
values.content.replace(/<[^>]+>/g, '').trim() === ''
|
|
}
|
|
>
|
|
{editingPost ? 'Update' : 'Create'}
|
|
</Button>
|
|
</div>
|
|
</FormContainer>
|
|
</Form>
|
|
)}
|
|
</Formik>
|
|
</div>
|
|
)}
|
|
|
|
{/* Posts List */}
|
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
|
<div className="px-6 py-4 border-b border-gray-200">
|
|
<h3 className="text-lg font-semibold text-gray-900">Posts ({posts.length})</h3>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="p-8 text-center">
|
|
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4 text-blue-600" />
|
|
<p className="text-gray-500">{translate('::App.Forum.PostManagement.Loadingtopics')}</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-200">
|
|
{posts
|
|
.sort(
|
|
(a, b) => new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime(),
|
|
)
|
|
.map((post) => (
|
|
<div key={post.id} className="p-6 hover:bg-gray-50 transition-colors">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center space-x-2 mb-2">
|
|
<h4 className="text-sm font-semibold text-gray-900">{post.authorName}</h4>
|
|
{post.isAcceptedAnswer && (
|
|
<div className="flex items-center space-x-1 bg-emerald-100 text-emerald-700 px-2 py-1 rounded-full text-xs">
|
|
<CheckCircle className="w-3 h-3" />
|
|
<span>Accepted Answer</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mb-3">
|
|
<p className="text-xs text-gray-500 mb-1">
|
|
Reply to:{' '}
|
|
<span className="font-medium">{getTopicTitle(post.topicId)}</span>
|
|
</p>
|
|
<p
|
|
className="text-gray-700 line-clamp-3"
|
|
dangerouslySetInnerHTML={{ __html: post.content }}
|
|
></p>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between text-sm text-gray-500">
|
|
<div className="flex items-center space-x-4">
|
|
<span>{formatDate(post.creationTime)}</span>
|
|
<div className="flex items-center space-x-1">
|
|
<Heart className="w-4 h-4" />
|
|
<span>{post.likeCount} likes</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2 ml-4">
|
|
<button
|
|
onClick={() => handleToggleAcceptedAnswer(post)}
|
|
className={`p-2 rounded-lg transition-colors ${
|
|
post.isAcceptedAnswer
|
|
? 'text-emerald-600 hover:bg-emerald-100'
|
|
: 'text-gray-400 hover:bg-gray-100'
|
|
}`}
|
|
title={
|
|
post.isAcceptedAnswer
|
|
? 'Remove Accepted Answer'
|
|
: 'Mark as Accepted Answer'
|
|
}
|
|
>
|
|
{post.isAcceptedAnswer ? (
|
|
<CheckCircle className="w-4 h-4" />
|
|
) : (
|
|
<Circle className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => handleEdit(post)}
|
|
className="p-2 text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
|
|
title={translate('::App.Forum.PostManagement.EditPost')}
|
|
>
|
|
<Edit2 className="w-4 h-4" />
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => confirmDeletePost(post)}
|
|
className="p-2 text-red-600 hover:bg-red-100 rounded-lg transition-colors"
|
|
title={translate('::App.Forum.PostManagement.DeletePost')}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{postToDelete && (
|
|
<ConfirmDialog
|
|
isOpen={showConfirm}
|
|
type="danger"
|
|
title={translate('::DeleteConfirmation')}
|
|
confirmText={translate('::Delete')}
|
|
cancelText={translate('::Cancel')}
|
|
confirmButtonColor="red-600"
|
|
onCancel={() => {
|
|
setShowConfirm(false)
|
|
setPostToDelete(null)
|
|
}}
|
|
onConfirm={async () => {
|
|
try {
|
|
if (postToDelete) {
|
|
await onDeletePost(postToDelete.id)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting post:', error)
|
|
} finally {
|
|
setShowConfirm(false)
|
|
setPostToDelete(null)
|
|
}
|
|
}}
|
|
></ConfirmDialog>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|