Claude güncellemesi Forum güncellemesi
This commit is contained in:
parent
c1a74bc245
commit
cedf788787
21 changed files with 1764 additions and 1323 deletions
|
|
@ -20424,6 +20424,30 @@
|
|||
"en": "{done}/{total} assets",
|
||||
"tr": "{done}/{total} bileşen"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.Update.ChangeLogTitle",
|
||||
"en": "What's new in v{version}",
|
||||
"tr": "v{version} ile gelen yenilikler"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.Update.Countdown",
|
||||
"en": "({seconds} s)",
|
||||
"tr": "({seconds} sn)"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.Update.DurationMinutes",
|
||||
"en": "{value} min",
|
||||
"tr": "{value} dk"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.Update.DurationSeconds",
|
||||
"en": "{value} s",
|
||||
"tr": "{value} sn"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.UpdateDescription.Activating",
|
||||
|
|
@ -25649,6 +25673,150 @@
|
|||
"key": "App.ListFormEditWorkflow.CriteriaStart",
|
||||
"en": "Start",
|
||||
"tr": "Başlat"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.Platform.Locked",
|
||||
"en": "Locked",
|
||||
"tr": "Kilitli"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.Platform.Replies",
|
||||
"en": "Replies",
|
||||
"tr": "Yanıtlar"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumCategoryManagement.ShowCategory",
|
||||
"en": "Show category",
|
||||
"tr": "Kategoriyi göster"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumCategoryManagement.HideCategory",
|
||||
"en": "Hide category",
|
||||
"tr": "Kategoriyi gizle"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumCategoryManagement.LockCategory",
|
||||
"en": "Lock category",
|
||||
"tr": "Kategoriyi kilitle"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumCategoryManagement.UnlockCategory",
|
||||
"en": "Unlock category",
|
||||
"tr": "Kategori kilidini aç"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumTopicManagement.PinTopic",
|
||||
"en": "Pin topic",
|
||||
"tr": "Konuyu sabitle"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumTopicManagement.UnpinTopic",
|
||||
"en": "Unpin topic",
|
||||
"tr": "Konu sabitlemesini kaldır"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumTopicManagement.LockTopic",
|
||||
"en": "Lock topic",
|
||||
"tr": "Konuyu kilitle"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumTopicManagement.UnlockTopic",
|
||||
"en": "Unlock topic",
|
||||
"tr": "Konu kilidini aç"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumTopicManagement.MarkAsSolved",
|
||||
"en": "Mark as solved",
|
||||
"tr": "Çözüldü olarak işaretle"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumTopicManagement.MarkAsUnsolved",
|
||||
"en": "Mark as unsolved",
|
||||
"tr": "Çözülmedi olarak işaretle"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumTopicManagement.SelectCategory",
|
||||
"en": "Select a category",
|
||||
"tr": "Kategori seçin"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumTopicManagement.Pinned",
|
||||
"en": "Pinned",
|
||||
"tr": "Sabitlenmiş"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumTopicManagement.Solved",
|
||||
"en": "Solved",
|
||||
"tr": "Çözüldü"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumPostManagement.MarkAsAcceptedAnswer",
|
||||
"en": "Mark as accepted answer",
|
||||
"tr": "Kabul edilen cevap olarak işaretle"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumPostManagement.RemoveAcceptedAnswer",
|
||||
"en": "Remove accepted answer",
|
||||
"tr": "Kabul edilen cevabı kaldır"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumPostManagement.SelectTopic",
|
||||
"en": "Select a topic",
|
||||
"tr": "Konu seçin"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumPostManagement.ReplyTo",
|
||||
"en": "Reply to",
|
||||
"tr": "Yanıtlanan"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumDashboard.ActiveCategories",
|
||||
"en": "{0} active",
|
||||
"tr": "{0} aktif"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumDashboard.SolvedTopics",
|
||||
"en": "{0} solved",
|
||||
"tr": "{0} çözüldü"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumDashboard.AcceptedAnswers",
|
||||
"en": "{0} accepted answers",
|
||||
"tr": "{0} kabul edilen cevap"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumDashboard.PostsPerTopic",
|
||||
"en": "posts per topic",
|
||||
"tr": "konu başına gönderi"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumDashboard.NewCategoryCreated",
|
||||
"en": "New category created",
|
||||
"tr": "Yeni kategori oluşturuldu"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { ForumCategory, ForumTopic } from '@/proxy/forum/forum'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useForumData } from './useForumData'
|
||||
import { ForumView } from './forum/ForumView'
|
||||
import { ForumView, ForumViewState } from './forum/ForumView'
|
||||
import { ForumErrorBanner } from './ForumErrorBanner'
|
||||
import { Container } from '@/components/shared'
|
||||
import PageTitle from '@/components/shared/PageTitle'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { Button } from '@/components/ui'
|
||||
|
||||
export function Forum() {
|
||||
const { translate } = useLocalization()
|
||||
|
|
@ -25,57 +25,32 @@ export function Forum() {
|
|||
|
||||
const [selectedCategory, setSelectedCategory] = useState<ForumCategory | null>(null)
|
||||
const [selectedTopic, setSelectedTopic] = useState<ForumTopic | null>(null)
|
||||
const [forumViewState, setForumViewState] = useState<'categories' | 'topics' | 'posts'>(
|
||||
'categories',
|
||||
)
|
||||
const [forumViewState, setForumViewState] = useState<ForumViewState>('categories')
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const timer = setTimeout(() => {
|
||||
clearError()
|
||||
}, 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
if (!error) return
|
||||
const timer = setTimeout(clearError, 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [error, clearError])
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<PageTitle title={translate('::' + 'App.Forum')} />
|
||||
<PageTitle title={translate('::App.Forum')} />
|
||||
|
||||
{error && (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-200 px-4 py-3 rounded relative">
|
||||
<strong className="font-bold">Error: </strong>
|
||||
<span className="block sm:inline">{error}</span>
|
||||
<Button onClick={clearError} className="absolute top-0 bottom-0 right-0 px-4 py-3 text-red-700 dark:text-red-200">
|
||||
<span className="sr-only">Dismiss</span>×
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ForumErrorBanner error={error} onDismiss={clearError} />
|
||||
|
||||
<ForumView
|
||||
categories={categories}
|
||||
topics={topics}
|
||||
posts={posts}
|
||||
loading={loading}
|
||||
onCreateTopic={(topicData) => createTopic(topicData).then(() => {})}
|
||||
onCreatePost={(postData) => createPost(postData).then(() => {})}
|
||||
onLikePost={(id) => likePost(id).then(() => {})}
|
||||
onUnlikePost={(id) => unlikePost(id).then(() => {})}
|
||||
selectedCategory={selectedCategory}
|
||||
selectedTopic={selectedTopic}
|
||||
viewState={forumViewState}
|
||||
onCreateTopic={createTopic}
|
||||
onCreatePost={createPost}
|
||||
onLikePost={likePost}
|
||||
onUnlikePost={unlikePost}
|
||||
onCategorySelect={setSelectedCategory}
|
||||
onTopicSelect={setSelectedTopic}
|
||||
onViewStateChange={setForumViewState}
|
||||
|
|
|
|||
38
ui/src/views/forum/ForumErrorBanner.tsx
Normal file
38
ui/src/views/forum/ForumErrorBanner.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { FaTimes } from 'react-icons/fa'
|
||||
import { Button } from '@/components/ui'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
|
||||
interface ForumErrorBannerProps {
|
||||
error?: string | null
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export function ForumErrorBanner({ error, onDismiss }: ForumErrorBannerProps) {
|
||||
const { translate } = useLocalization()
|
||||
|
||||
if (!error) return null
|
||||
|
||||
return (
|
||||
<div className="py-4" role="alert">
|
||||
<div className="relative flex items-start gap-3 rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700 dark:border-red-700 dark:bg-red-900 dark:text-red-200">
|
||||
<div className="flex-1">
|
||||
<strong className="font-bold">{translate('::App.Platform.Error')}: </strong>
|
||||
<span className="block sm:inline">{error}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="plain"
|
||||
shape="circle"
|
||||
size="xs"
|
||||
icon={<FaTimes />}
|
||||
aria-label={translate('::App.Platform.Close')}
|
||||
title={translate('::App.Platform.Close')}
|
||||
className="!bg-transparent text-red-700 hover:!bg-red-200 dark:text-red-200 dark:hover:!bg-red-800"
|
||||
onClick={onDismiss}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ForumErrorBanner
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useForumData } from './useForumData'
|
||||
import { AdminView } from './admin/AdminView'
|
||||
import { ForumErrorBanner } from './ForumErrorBanner'
|
||||
import { Container } from '@/components/shared'
|
||||
import PageTitle from '@/components/shared/PageTitle'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { Button } from '@/components/ui'
|
||||
|
||||
export function Management() {
|
||||
const {
|
||||
|
|
@ -38,40 +38,16 @@ export function Management() {
|
|||
const { translate } = useLocalization()
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
// Search modal will be opened by Header component
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const timer = setTimeout(() => {
|
||||
clearError()
|
||||
}, 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
if (!error) return
|
||||
const timer = setTimeout(clearError, 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [error, clearError])
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<PageTitle title={translate('::' + 'App.ForumManagement')} />
|
||||
<PageTitle title={translate('::App.ForumManagement')} />
|
||||
|
||||
{error && (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-200 px-4 py-3 rounded relative">
|
||||
<strong className="font-bold">Error: </strong>
|
||||
<span className="block sm:inline">{error}</span>
|
||||
<Button onClick={clearError} className="absolute top-0 bottom-0 right-0 px-4 py-3 text-red-700 dark:text-red-200">
|
||||
<span className="sr-only">Dismiss</span>×
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ForumErrorBanner error={error} onDismiss={clearError} />
|
||||
|
||||
<AdminView
|
||||
categories={categories}
|
||||
|
|
@ -79,25 +55,25 @@ export function Management() {
|
|||
posts={posts}
|
||||
totalCounts={totalCounts}
|
||||
loading={loading}
|
||||
onCreateCategory={(data) => createCategory(data).then(() => {})}
|
||||
onUpdateCategory={(id, data) => updateCategory(id, data).then(() => {})}
|
||||
onUpdateCategoryLockState={(id) => updateCategoryLockState(id).then(() => {})}
|
||||
onUpdateCategoryActiveState={(id) => updateCategoryActiveState(id).then(() => {})}
|
||||
onDeleteCategory={(id) => deleteCategory(id).then(() => {})}
|
||||
onCreateTopic={(data) => createTopic(data).then(() => {})}
|
||||
onUpdateTopic={(id, data) => updateTopic(id, data).then(() => {})}
|
||||
onDeleteTopic={(id) => deleteTopic(id).then(() => {})}
|
||||
onPinTopic={(id) => pinTopic(id).then(() => {})}
|
||||
onUnpinTopic={(id) => unpinTopic(id).then(() => {})}
|
||||
onLockTopic={(id) => lockTopic(id).then(() => {})}
|
||||
onUnlockTopic={(id) => unlockTopic(id).then(() => {})}
|
||||
onMarkTopicAsSolved={(id) => solvedTopic(id).then(() => {})}
|
||||
onMarkTopicAsUnsolved={(id) => unsolvedTopic(id).then(() => {})}
|
||||
onCreatePost={(data) => createPost(data).then(() => {})}
|
||||
onUpdatePost={(id, data) => updatePost(id, data).then(() => {})}
|
||||
onDeletePost={(id) => deletePost(id).then(() => {})}
|
||||
onMarkPostAsAcceptedAnswer={(id) => markPostAsAcceptedAnswer(id).then(() => {})}
|
||||
onUnmarkPostAsAcceptedAnswer={(id) => unmarkPostAsAcceptedAnswer(id).then(() => {})}
|
||||
onCreateCategory={createCategory}
|
||||
onUpdateCategory={updateCategory}
|
||||
onUpdateCategoryLockState={updateCategoryLockState}
|
||||
onUpdateCategoryActiveState={updateCategoryActiveState}
|
||||
onDeleteCategory={deleteCategory}
|
||||
onCreateTopic={createTopic}
|
||||
onUpdateTopic={updateTopic}
|
||||
onDeleteTopic={deleteTopic}
|
||||
onPinTopic={pinTopic}
|
||||
onUnpinTopic={unpinTopic}
|
||||
onLockTopic={lockTopic}
|
||||
onUnlockTopic={unlockTopic}
|
||||
onMarkTopicAsSolved={solvedTopic}
|
||||
onMarkTopicAsUnsolved={unsolvedTopic}
|
||||
onCreatePost={createPost}
|
||||
onUpdatePost={updatePost}
|
||||
onDeletePost={deletePost}
|
||||
onMarkPostAsAcceptedAnswer={markPostAsAcceptedAnswer}
|
||||
onUnmarkPostAsAcceptedAnswer={unmarkPostAsAcceptedAnswer}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ import { CategoryManagement } from './CategoryManagement'
|
|||
import { TopicManagement } from './TopicManagement'
|
||||
import { PostManagement } from './PostManagement'
|
||||
import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum'
|
||||
import {
|
||||
CreateCategoryRequest,
|
||||
CreatePostRequest,
|
||||
CreateTopicRequest,
|
||||
} from '@/services/forum.service'
|
||||
import { AdminStats } from './Dashboard'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { Button } from '@/components/ui'
|
||||
|
|
@ -14,39 +19,25 @@ interface AdminViewProps {
|
|||
posts: ForumPost[]
|
||||
totalCounts: { categories: number; topics: number; posts: number }
|
||||
loading: boolean
|
||||
onCreateCategory: (category: {
|
||||
name: string
|
||||
slug: string
|
||||
description: string
|
||||
icon: string
|
||||
displayOrder: number
|
||||
isActive: boolean
|
||||
isLocked: boolean
|
||||
}) => Promise<void>
|
||||
onUpdateCategory: (id: string, category: Partial<ForumCategory>) => Promise<void>
|
||||
onUpdateCategoryLockState: (id: string) => Promise<void>
|
||||
onUpdateCategoryActiveState: (id: string) => Promise<void>
|
||||
onDeleteCategory: (id: string) => Promise<void>
|
||||
onCreateTopic: (topic: {
|
||||
title: string
|
||||
content: string
|
||||
categoryId: string
|
||||
isPinned?: boolean
|
||||
isLocked?: boolean
|
||||
}) => Promise<void>
|
||||
onUpdateTopic: (id: string, topic: Partial<ForumTopic>) => Promise<void>
|
||||
onDeleteTopic: (id: string) => Promise<void>
|
||||
onPinTopic: (id: string) => Promise<void>
|
||||
onUnpinTopic: (id: string) => Promise<void>
|
||||
onLockTopic: (id: string) => Promise<void>
|
||||
onUnlockTopic: (id: string) => Promise<void>
|
||||
onMarkTopicAsSolved: (id: string) => Promise<void>
|
||||
onMarkTopicAsUnsolved: (id: string) => Promise<void>
|
||||
onCreatePost: (post: { topicId: string; content: string; parentPostId?: 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>
|
||||
onCreateCategory: (category: CreateCategoryRequest) => Promise<unknown>
|
||||
onUpdateCategory: (id: string, category: Partial<ForumCategory>) => Promise<unknown>
|
||||
onUpdateCategoryLockState: (id: string) => Promise<unknown>
|
||||
onUpdateCategoryActiveState: (id: string) => Promise<unknown>
|
||||
onDeleteCategory: (id: string) => Promise<unknown>
|
||||
onCreateTopic: (topic: CreateTopicRequest) => Promise<unknown>
|
||||
onUpdateTopic: (id: string, topic: Partial<ForumTopic>) => Promise<unknown>
|
||||
onDeleteTopic: (id: string) => Promise<unknown>
|
||||
onPinTopic: (id: string) => Promise<unknown>
|
||||
onUnpinTopic: (id: string) => Promise<unknown>
|
||||
onLockTopic: (id: string) => Promise<unknown>
|
||||
onUnlockTopic: (id: string) => Promise<unknown>
|
||||
onMarkTopicAsSolved: (id: string) => Promise<unknown>
|
||||
onMarkTopicAsUnsolved: (id: string) => Promise<unknown>
|
||||
onCreatePost: (post: CreatePostRequest) => Promise<unknown>
|
||||
onUpdatePost: (id: string, post: Partial<ForumPost>) => Promise<unknown>
|
||||
onDeletePost: (id: string) => Promise<unknown>
|
||||
onMarkPostAsAcceptedAnswer: (id: string) => Promise<unknown>
|
||||
onUnmarkPostAsAcceptedAnswer: (id: string) => Promise<unknown>
|
||||
}
|
||||
|
||||
type AdminSection = 'stats' | 'categories' | 'topics' | 'posts'
|
||||
|
|
@ -114,12 +105,13 @@ export function AdminView({
|
|||
return (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant={isActive ? "solid" : "plain"}
|
||||
variant={isActive ? 'solid' : 'plain'}
|
||||
active={isActive}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className="flex w-full items-center space-x-3 px-4 py-3 text-left transition-colors"
|
||||
onClick={() => setActiveSection(item.id)}
|
||||
className={`w-full flex items-center space-x-3 px-4 py-3 text-left transition-colors`}
|
||||
>
|
||||
<Icon className={`w-5 h-5`} />
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</Button>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,31 +10,37 @@ import {
|
|||
FaSpinner,
|
||||
} from 'react-icons/fa'
|
||||
import { ForumCategory } from '@/proxy/forum/forum'
|
||||
import { CreateCategoryRequest } from '@/services/forum.service'
|
||||
import { useStoreState } from '@/store/store'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { Formik, Form, Field } from 'formik'
|
||||
import { Formik, Form, Field, FieldProps } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { FormContainer, FormItem } from '@/components/ui/Form'
|
||||
import { Input, Checkbox, Button } from '@/components/ui'
|
||||
import { Input, Checkbox, Button, Select } from '@/components/ui'
|
||||
import { ConfirmDialog } from '@/components/shared'
|
||||
import { CategoryIconOption, resolveCategoryIconOptions } from './categoryIcons'
|
||||
|
||||
const FIELD_CLASS =
|
||||
'w-full border border-gray-300 dark:border-gray-700 rounded-lg px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500'
|
||||
|
||||
const CategorySchema = Yup.object().shape({
|
||||
name: Yup.string().trim().required(),
|
||||
slug: Yup.string().trim().required(),
|
||||
description: Yup.string().trim().required(),
|
||||
icon: Yup.string(),
|
||||
displayOrder: Yup.number().required(),
|
||||
isActive: Yup.boolean(),
|
||||
isLocked: Yup.boolean(),
|
||||
})
|
||||
|
||||
interface CategoryManagementProps {
|
||||
categories: ForumCategory[]
|
||||
loading: boolean
|
||||
onCreateCategory: (category: {
|
||||
name: string
|
||||
slug: string
|
||||
description: string
|
||||
icon: string
|
||||
displayOrder: number
|
||||
isActive: boolean
|
||||
isLocked: boolean
|
||||
tenantId?: string
|
||||
}) => Promise<void>
|
||||
onUpdateCategory: (id: string, category: Partial<ForumCategory>) => Promise<void>
|
||||
onDeleteCategory: (id: string) => Promise<void>
|
||||
onUpdateCategoryLockState: (id: string) => Promise<void>
|
||||
onUpdateCategoryActiveState: (id: string) => Promise<void>
|
||||
onCreateCategory: (category: CreateCategoryRequest) => Promise<unknown>
|
||||
onUpdateCategory: (id: string, category: Partial<ForumCategory>) => Promise<unknown>
|
||||
onDeleteCategory: (id: string) => Promise<unknown>
|
||||
onUpdateCategoryLockState: (id: string) => Promise<unknown>
|
||||
onUpdateCategoryActiveState: (id: string) => Promise<unknown>
|
||||
}
|
||||
|
||||
export function CategoryManagement({
|
||||
|
|
@ -53,17 +59,6 @@ export function CategoryManagement({
|
|||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
const [categoryToDelete, setCategoryToDelete] = useState<ForumCategory | null>(null)
|
||||
|
||||
// Validation şeması
|
||||
const CategorySchema = Yup.object().shape({
|
||||
name: Yup.string().required(),
|
||||
slug: Yup.string().required(),
|
||||
description: Yup.string().required(),
|
||||
icon: Yup.string(),
|
||||
displayOrder: Yup.number().required(),
|
||||
isActive: Yup.boolean(),
|
||||
isLocked: Yup.boolean(),
|
||||
})
|
||||
|
||||
const confirmDeleteCategory = (category: ForumCategory) => {
|
||||
setCategoryToDelete(category)
|
||||
setShowConfirm(true)
|
||||
|
|
@ -95,7 +90,7 @@ export function CategoryManagement({
|
|||
{/* Create/Edit Form */}
|
||||
{showCreateForm && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{editingCategory
|
||||
? translate('::App.ForumCategoryManagement.EditCategory')
|
||||
: translate('::App.ForumCategoryManagement.AddCategory')}
|
||||
|
|
@ -131,103 +126,116 @@ export function CategoryManagement({
|
|||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting }) => (
|
||||
{({ values, errors, touched, isSubmitting }) => (
|
||||
<Form>
|
||||
<FormContainer className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormItem
|
||||
label="Name"
|
||||
asterisk
|
||||
invalid={errors.name && touched.name}
|
||||
label={translate('::App.Listform.ListformField.Name')}
|
||||
invalid={!!errors.name && !!touched.name}
|
||||
errorMessage={errors.name}
|
||||
>
|
||||
<Field
|
||||
name="name"
|
||||
className="w-full border border-gray-300 dark:border-gray-700 rounded-lg px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500"
|
||||
/>
|
||||
<Field name="name" className={FIELD_CLASS} component={Input} />
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
label="Slug"
|
||||
asterisk
|
||||
invalid={errors.slug && touched.slug}
|
||||
label={translate('::App.Listform.ListformField.Slug')}
|
||||
invalid={!!errors.slug && !!touched.slug}
|
||||
errorMessage={errors.slug}
|
||||
>
|
||||
<Field
|
||||
name="slug"
|
||||
className="w-full border border-gray-300 dark:border-gray-700 rounded-lg px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500"
|
||||
/>
|
||||
<Field name="slug" className={FIELD_CLASS} component={Input} />
|
||||
</FormItem>
|
||||
</div>
|
||||
|
||||
<FormItem
|
||||
label="Description"
|
||||
asterisk
|
||||
invalid={errors.slug && touched.slug}
|
||||
errorMessage={errors.slug}
|
||||
label={translate('::App.Listform.ListformField.Description')}
|
||||
invalid={!!errors.description && !!touched.description}
|
||||
errorMessage={errors.description}
|
||||
>
|
||||
<Field
|
||||
textArea
|
||||
name="description"
|
||||
className="w-full border border-gray-300 dark:border-gray-700 rounded-lg px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500"
|
||||
textArea="true"
|
||||
className={FIELD_CLASS}
|
||||
component={Input}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormItem
|
||||
label="Icon (Emoji)"
|
||||
asterisk
|
||||
invalid={errors.icon && touched.icon}
|
||||
label={translate('::App.MenuField.Icon')}
|
||||
invalid={!!errors.icon && !!touched.icon}
|
||||
errorMessage={errors.icon}
|
||||
>
|
||||
<Field
|
||||
name="icon"
|
||||
className="w-full border border-gray-300 dark:border-gray-700 rounded-lg px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500"
|
||||
placeholder="💬"
|
||||
/>
|
||||
<Field name="icon">
|
||||
{({ field, form }: FieldProps<string>) => {
|
||||
const iconOptions = resolveCategoryIconOptions(values.icon)
|
||||
return (
|
||||
<Select<CategoryIconOption>
|
||||
isClearable
|
||||
field={field}
|
||||
form={form}
|
||||
options={iconOptions}
|
||||
placeholder="💬"
|
||||
value={iconOptions.filter((option) => option.value === values.icon)}
|
||||
formatOptionLabel={(option) => (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-lg">{option.value}</span>
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
)}
|
||||
onChange={(option) =>
|
||||
form.setFieldValue('icon', option?.value ?? '')
|
||||
}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</Field>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
label="Display Order"
|
||||
asterisk
|
||||
invalid={errors.displayOrder && touched.displayOrder}
|
||||
label={translate('::App.Listform.ListformField.DisplayOrder')}
|
||||
invalid={!!errors.displayOrder && !!touched.displayOrder}
|
||||
errorMessage={errors.displayOrder}
|
||||
>
|
||||
<Field
|
||||
name="displayOrder"
|
||||
className="w-full border border-gray-300 dark:border-gray-700 rounded-lg px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500"
|
||||
placeholder="💬"
|
||||
className={FIELD_CLASS}
|
||||
placeholder="0"
|
||||
type="number"
|
||||
component={Input}
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 pt-6">
|
||||
<FormItem label="Status">
|
||||
<FormItem label={translate('::App.Listform.ListformField.Status')}>
|
||||
<Field as={Checkbox} name="isActive" />
|
||||
</FormItem>
|
||||
<FormItem label="Locked">
|
||||
<FormItem label={translate('::App.Platform.Locked')}>
|
||||
<Field as={Checkbox} name="isLocked" />
|
||||
</FormItem>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-4">
|
||||
<div className="flex justify-end space-x-3 pt-2">
|
||||
<Button
|
||||
variant="plain"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false)
|
||||
setEditingCategory(null)
|
||||
}}
|
||||
>
|
||||
{translate('::App.Platform.Cancel')}
|
||||
</Button>
|
||||
<Button variant="solid" type="submit" loading={isSubmitting}>
|
||||
{editingCategory ? translate('::App.Platform.Update') : translate('::App.Platform.Create')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end space-x-3">
|
||||
<Button
|
||||
variant="plain"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false)
|
||||
setEditingCategory(null)
|
||||
}}
|
||||
>
|
||||
{translate('::App.Platform.Cancel')}
|
||||
</Button>
|
||||
<Button variant="solid" type="submit" loading={isSubmitting}>
|
||||
{editingCategory
|
||||
? translate('::App.Platform.Update')
|
||||
: translate('::App.Platform.Create')}
|
||||
</Button>
|
||||
</div>
|
||||
</FormContainer>
|
||||
</Form>
|
||||
|
|
@ -245,9 +253,9 @@ export function CategoryManagement({
|
|||
<Button
|
||||
size="sm"
|
||||
variant="solid"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
disabled={loading}
|
||||
className="flex items-center space-x-2"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
>
|
||||
<FaPlus className="w-4 h-4" />
|
||||
<span>{translate('::App.ForumCategoryManagement.AddCategory')}</span>
|
||||
|
|
@ -263,6 +271,11 @@ export function CategoryManagement({
|
|||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{categories.length === 0 && (
|
||||
<p className="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{translate('::App.Platform.NoDataFound')}
|
||||
</p>
|
||||
)}
|
||||
{[...categories]
|
||||
.sort((a, b) => a.displayOrder - b.displayOrder)
|
||||
.map((category) => (
|
||||
|
|
@ -280,12 +293,12 @@ export function CategoryManagement({
|
|||
</h4>
|
||||
{!category.isActive && (
|
||||
<span className="px-2 py-1 bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300 text-xs rounded-full">
|
||||
Inactive
|
||||
{translate('::App.Platform.Passive')}
|
||||
</span>
|
||||
)}
|
||||
{category.isLocked && (
|
||||
<span className="px-2 py-1 bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300 text-xs rounded-full">
|
||||
Locked
|
||||
{translate('::App.Platform.Locked')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -293,9 +306,15 @@ export function CategoryManagement({
|
|||
{category.description}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>{category.topicCount} topics</span>
|
||||
<span>{category.postCount} posts</span>
|
||||
<span>Order: {category.displayOrder}</span>
|
||||
<span>
|
||||
{category.topicCount} {translate('::App.ForumDashboard.Topics')}
|
||||
</span>
|
||||
<span>
|
||||
{category.postCount} {translate('::App.ForumDashboard.Posts')}
|
||||
</span>
|
||||
<span>
|
||||
{translate('::App.MenuField.Order')}: {category.displayOrder}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -303,13 +322,17 @@ export function CategoryManagement({
|
|||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => handleToggleActive(category)}
|
||||
className={`p-1 rounded-lg transition-colors ${
|
||||
category.isActive
|
||||
? 'text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900'
|
||||
: 'text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900'
|
||||
}`}
|
||||
title={category.isActive ? 'Hide Category' : 'Show Category'}
|
||||
title={
|
||||
category.isActive
|
||||
? translate('::App.ForumCategoryManagement.HideCategory')
|
||||
: translate('::App.ForumCategoryManagement.ShowCategory')
|
||||
}
|
||||
onClick={() => handleToggleActive(category)}
|
||||
>
|
||||
{category.isActive ? (
|
||||
<FaEye className="w-3 h-3" />
|
||||
|
|
@ -320,13 +343,17 @@ export function CategoryManagement({
|
|||
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => handleToggleLocked(category)}
|
||||
className={`p-1 rounded-lg transition-colors ${
|
||||
category.isLocked
|
||||
? 'text-yellow-600 dark:text-yellow-300 hover:bg-yellow-100 dark:hover:bg-yellow-900'
|
||||
: 'text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900'
|
||||
}`}
|
||||
title={category.isLocked ? 'Unlock Category' : 'Lock Category'}
|
||||
title={
|
||||
category.isLocked
|
||||
? translate('::App.ForumCategoryManagement.UnlockCategory')
|
||||
: translate('::App.ForumCategoryManagement.LockCategory')
|
||||
}
|
||||
onClick={() => handleToggleLocked(category)}
|
||||
>
|
||||
{category.isLocked ? (
|
||||
<FaLock className="w-3 h-3" />
|
||||
|
|
@ -337,18 +364,18 @@ export function CategoryManagement({
|
|||
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => handleEdit(category)}
|
||||
className="p-1 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900 rounded-lg transition-colors"
|
||||
title={translate('::App.ForumCategoryManagement.EditCategory')}
|
||||
onClick={() => handleEdit(category)}
|
||||
>
|
||||
<FaEdit className="w-3 h-3" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => confirmDeleteCategory(category)}
|
||||
className="p-1 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900 rounded-lg transition-colors"
|
||||
title={translate('::App.ForumCategoryManagement.DeleteCategory')}
|
||||
onClick={() => confirmDeleteCategory(category)}
|
||||
>
|
||||
<FaTrash className="w-3 h-3" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useMemo } from 'react'
|
||||
import Widget from '@/components/ui/Widget/Widget'
|
||||
import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
|
|
@ -5,6 +6,11 @@ import dayjs from 'dayjs'
|
|||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import 'dayjs/locale/tr'
|
||||
|
||||
// Plugin kaydı modül seviyesinde yapılır; her render'da tekrarlanması gereksiz iştir.
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const MAX_ACTIVITIES = 5
|
||||
|
||||
interface AdminStatsProps {
|
||||
categories: ForumCategory[]
|
||||
topics: ForumTopic[]
|
||||
|
|
@ -13,100 +19,104 @@ interface AdminStatsProps {
|
|||
}
|
||||
|
||||
interface Activity {
|
||||
id: string
|
||||
message: string
|
||||
color: string
|
||||
date: Date
|
||||
}
|
||||
|
||||
export function AdminStats({ categories, topics, posts, totalCounts }: AdminStatsProps) {
|
||||
const { translate } = useLocalization()
|
||||
const totalCategories = totalCounts.categories
|
||||
const { translate, cultureName } = useLocalization()
|
||||
|
||||
const activeCategories = categories.filter((c) => c.isActive).length
|
||||
const totalTopics = totalCounts.topics
|
||||
const solvedTopics = topics.filter((t) => t.isSolved).length
|
||||
const totalPosts = totalCounts.posts
|
||||
const acceptedAnswers = posts.filter((p) => p.isAcceptedAnswer).length
|
||||
|
||||
const stats = [
|
||||
{
|
||||
key: 'categories',
|
||||
title: translate('::App.ForumDashboard.TotalCategories'),
|
||||
value: totalCategories,
|
||||
subTitle: `${activeCategories} active`,
|
||||
value: totalCounts.categories,
|
||||
subTitle: translate('::App.ForumDashboard.ActiveCategories', { 0: activeCategories }),
|
||||
icon: 'FaFolder' as const,
|
||||
color: 'blue' as const,
|
||||
},
|
||||
{
|
||||
key: 'topics',
|
||||
title: translate('::App.ForumDashboard.TotalTopics'),
|
||||
value: totalTopics,
|
||||
subTitle: `${solvedTopics} solved`,
|
||||
value: totalCounts.topics,
|
||||
subTitle: translate('::App.ForumDashboard.SolvedTopics', { 0: solvedTopics }),
|
||||
icon: 'FaCommentDots' as const,
|
||||
color: 'green' as const,
|
||||
},
|
||||
{
|
||||
key: 'posts',
|
||||
title: translate('::App.ForumDashboard.TotalPosts'),
|
||||
value: totalPosts,
|
||||
subTitle: `${acceptedAnswers} accepted answers`,
|
||||
value: totalCounts.posts,
|
||||
subTitle: translate('::App.ForumDashboard.AcceptedAnswers', { 0: acceptedAnswers }),
|
||||
icon: 'FaFileAlt' as const,
|
||||
color: 'orange' as const,
|
||||
},
|
||||
{
|
||||
key: 'engagement',
|
||||
title: translate('::App.ForumDashboard.EngagementRate'),
|
||||
value: totalTopics > 0 ? Math.round((totalPosts / totalTopics) * 100) / 100 : 0,
|
||||
subTitle: 'posts per topic',
|
||||
value:
|
||||
totalCounts.topics > 0
|
||||
? Math.round((totalCounts.posts / totalCounts.topics) * 100) / 100
|
||||
: 0,
|
||||
subTitle: translate('::App.ForumDashboard.PostsPerTopic'),
|
||||
icon: 'FaChartLine' as const,
|
||||
color: 'purple' as const,
|
||||
},
|
||||
]
|
||||
|
||||
const recentActivities: Activity[] = []
|
||||
const latestActivities = useMemo<Activity[]>(() => {
|
||||
const categoryNameById = new Map(categories.map((c) => [c.id, c.name]))
|
||||
const activities: Activity[] = []
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.locale('tr')
|
||||
|
||||
topics.forEach((topic) => {
|
||||
const category = categories.find((c) => c.id === topic.categoryId)
|
||||
if (topic.creationTime) {
|
||||
recentActivities.push({
|
||||
message: `New topic created in ${category?.name ?? 'Unknown Category'}`,
|
||||
topics.forEach((topic) => {
|
||||
if (!topic.creationTime) return
|
||||
activities.push({
|
||||
id: `topic-${topic.id}`,
|
||||
message: `${translate('::App.ForumTopicManagement.NewTopic')} — ${
|
||||
categoryNameById.get(topic.categoryId) ?? translate('::App.Platform.Unknown')
|
||||
}`,
|
||||
color: 'bg-blue-500',
|
||||
date: new Date(topic.creationTime),
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Posts -> "Post marked as accepted answer"
|
||||
posts.forEach((post) => {
|
||||
if (post.isAcceptedAnswer && post.creationTime) {
|
||||
recentActivities.push({
|
||||
message: 'Post marked as accepted answer',
|
||||
posts.forEach((post) => {
|
||||
if (!post.isAcceptedAnswer || !post.creationTime) return
|
||||
activities.push({
|
||||
id: `post-${post.id}`,
|
||||
message: translate('::App.ForumDashboard.Postmarked'),
|
||||
color: 'bg-emerald-500',
|
||||
date: new Date(post.creationTime),
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Categories -> "New category created: {name}"
|
||||
categories.forEach((category) => {
|
||||
if (category.creationTime) {
|
||||
recentActivities.push({
|
||||
message: `New category created: ${category.name}`,
|
||||
categories.forEach((category) => {
|
||||
if (!category.creationTime) return
|
||||
activities.push({
|
||||
id: `category-${category.id}`,
|
||||
message: `${translate('::App.ForumDashboard.NewCategoryCreated')} — ${category.name}`,
|
||||
color: 'bg-orange-500',
|
||||
date: new Date(category.creationTime),
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Tarihe göre sırala, en güncel ilk 3 aktiviteyi al
|
||||
const latestActivities = recentActivities
|
||||
.sort((a, b) => b.date.getTime() - a.date.getTime())
|
||||
.slice(0, 3)
|
||||
return activities
|
||||
.sort((a, b) => b.date.getTime() - a.date.getTime())
|
||||
.slice(0, MAX_ACTIVITIES)
|
||||
}, [categories, posts, topics, translate])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map((stat, index) => (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<Widget
|
||||
key={index}
|
||||
key={stat.key}
|
||||
title={stat.title}
|
||||
value={stat.value}
|
||||
subTitle={stat.subTitle}
|
||||
|
|
@ -117,20 +127,33 @@ export function AdminStats({ categories, topics, posts, totalCounts }: AdminStat
|
|||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{translate('::App.ForumDashboard.RecentActivity')}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{latestActivities.map((activity, index) => (
|
||||
<div key={index} className="flex items-start space-x-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div className={`w-2 h-2 ${activity.color} rounded-full mt-2`} />
|
||||
<div>
|
||||
<p className="text-sm text-gray-900 dark:text-white">{activity.message}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{dayjs(activity.date).fromNow()}</p>
|
||||
{latestActivities.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{translate('::App.Platform.NoDataFound')}
|
||||
</p>
|
||||
) : (
|
||||
latestActivities.map((activity) => (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="flex items-start space-x-3 rounded-lg bg-gray-50 p-3 dark:bg-gray-800"
|
||||
>
|
||||
<div className={`mt-2 h-2 w-2 ${activity.color} rounded-full`} />
|
||||
<div>
|
||||
<p className="text-sm text-gray-900 dark:text-white">{activity.message}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{dayjs(activity.date)
|
||||
.locale(cultureName?.startsWith('tr') ? 'tr' : 'en')
|
||||
.fromNow()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
FaSpinner,
|
||||
} from 'react-icons/fa'
|
||||
import { ForumPost, ForumTopic } from '@/proxy/forum/forum'
|
||||
import { CreatePostRequest } from '@/services/forum.service'
|
||||
import { HtmlEditor, ImageUpload, Item, MediaResizing, Toolbar } from 'devextreme-react/html-editor'
|
||||
import { useStoreState } from '@/store/store'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
|
|
@ -16,7 +17,7 @@ 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 { formatForumDate } from '../forum/utils'
|
||||
import { formatForumDate, sanitizeForumHtml, stripHtml } from '../forum/utils'
|
||||
import {
|
||||
fontFamilyOptions,
|
||||
fontSizeOptions,
|
||||
|
|
@ -26,21 +27,21 @@ import {
|
|||
sizeValues,
|
||||
} from '@/proxy/reports/data'
|
||||
|
||||
const postValidationSchema = Yup.object().shape({
|
||||
topicId: Yup.string().required(),
|
||||
content: Yup.string().test('not-empty', 'Content is required', (value) => !!stripHtml(value)),
|
||||
})
|
||||
|
||||
interface PostManagementProps {
|
||||
posts: ForumPost[]
|
||||
totalCount: number
|
||||
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>
|
||||
onCreatePost: (post: CreatePostRequest) => Promise<unknown>
|
||||
onUpdatePost: (id: string, post: Partial<ForumPost>) => Promise<unknown>
|
||||
onDeletePost: (id: string) => Promise<unknown>
|
||||
onMarkPostAsAcceptedAnswer: (id: string) => Promise<unknown>
|
||||
onUnmarkPostAsAcceptedAnswer: (id: string) => Promise<unknown>
|
||||
}
|
||||
|
||||
export function PostManagement({
|
||||
|
|
@ -83,52 +84,46 @@ export function PostManagement({
|
|||
}
|
||||
}
|
||||
|
||||
const getTopicTitle = (topicId: string) => {
|
||||
const topic = topics.find((t) => t.id === topicId)
|
||||
return topic ? topic.title : 'Unknown Topic'
|
||||
}
|
||||
|
||||
const postValidationSchema = Yup.object().shape({
|
||||
topicId: Yup.string().required(),
|
||||
content: Yup.string()
|
||||
.test('not-empty', 'Content is required', (value) => {
|
||||
const plainText = value?.replace(/<[^>]+>/g, '').trim()
|
||||
return !!plainText
|
||||
})
|
||||
.required(),
|
||||
})
|
||||
const getTopicTitle = (topicId: string) =>
|
||||
topics.find((t) => t.id === topicId)?.title ?? translate('::App.Platform.Unknown')
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Create/Edit Form */}
|
||||
{showCreateForm && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{editingPost
|
||||
? translate('::App.ForumPostManagement.EditPost')
|
||||
: translate('::App.ForumPostManagement.AddPost')}
|
||||
</h3>
|
||||
|
||||
<Formik
|
||||
enableReinitialize
|
||||
initialValues={{
|
||||
topicId: editingPost?.topicId || '',
|
||||
content: editingPost?.content || '',
|
||||
isAcceptedAnswer: editingPost?.isAcceptedAnswer || false,
|
||||
parentPostId: '',
|
||||
tenantId: tenant.tenantId || '',
|
||||
topicId: editingPost?.topicId ?? '',
|
||||
content: editingPost?.content ?? '',
|
||||
isAcceptedAnswer: editingPost?.isAcceptedAnswer ?? false,
|
||||
tenantId: tenant?.tenantId ?? '',
|
||||
}}
|
||||
validationSchema={postValidationSchema}
|
||||
enableReinitialize
|
||||
onSubmit={async (values, { setSubmitting, resetForm }) => {
|
||||
try {
|
||||
if (editingPost) {
|
||||
await onUpdatePost(editingPost.id, values)
|
||||
} else {
|
||||
await onCreatePost(values)
|
||||
// parentPostId gönderilmez: yönetim ekranından açılan post her zaman kök gönderidir.
|
||||
await onCreatePost({
|
||||
topicId: values.topicId,
|
||||
content: values.content,
|
||||
tenantId: values.tenantId || undefined,
|
||||
})
|
||||
}
|
||||
resetForm()
|
||||
setShowCreateForm(false)
|
||||
setEditingPost(null)
|
||||
} catch (error) {
|
||||
console.error('Error submitting post:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
|
@ -138,8 +133,8 @@ export function PostManagement({
|
|||
<Form>
|
||||
<FormContainer className="space-y-4">
|
||||
<FormItem
|
||||
label="Topic"
|
||||
asterisk
|
||||
label={translate('::App.ForumTopicManagement.Baslik')}
|
||||
invalid={!!errors.topicId && touched.topicId}
|
||||
errorMessage={errors.topicId}
|
||||
>
|
||||
|
|
@ -148,7 +143,7 @@ export function PostManagement({
|
|||
name="topicId"
|
||||
className="w-full border border-gray-300 dark:border-gray-700 rounded-lg px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">Select a topic</option>
|
||||
<option value="">{translate('::App.ForumPostManagement.SelectTopic')}</option>
|
||||
{topics.map((topic) => (
|
||||
<option key={topic.id} value={topic.id}>
|
||||
{topic.title}
|
||||
|
|
@ -158,8 +153,8 @@ export function PostManagement({
|
|||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
label="Content"
|
||||
asterisk
|
||||
label={translate('::App.Listform.ListformField.Content')}
|
||||
invalid={!!errors.content && touched.content}
|
||||
errorMessage={errors.content}
|
||||
>
|
||||
|
|
@ -167,9 +162,9 @@ export function PostManagement({
|
|||
{({ field }: FieldProps) => (
|
||||
<HtmlEditor
|
||||
value={field.value}
|
||||
onValueChanged={(e) => setFieldValue('content', e.value)}
|
||||
height={400}
|
||||
placeholder="Write your message..."
|
||||
placeholder={translate('::App.ForumPostManagement.MessageNew')}
|
||||
onValueChanged={(e) => setFieldValue('content', e.value)}
|
||||
>
|
||||
<MediaResizing enabled={true} />
|
||||
<ImageUpload fileUploadMode="base64" />
|
||||
|
|
@ -231,10 +226,10 @@ export function PostManagement({
|
|||
</Field>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="Accepted Answer">
|
||||
<FormItem label={translate('::App.ForumPostManagement.AcceptedAnswer')}>
|
||||
<label className="flex items-center">
|
||||
<Field type="checkbox" name="isAcceptedAnswer" className="mr-2" />
|
||||
Mark as Accepted Answer
|
||||
{translate('::App.ForumPostManagement.MarkAsAcceptedAnswer')}
|
||||
</label>
|
||||
</FormItem>
|
||||
|
||||
|
|
@ -253,13 +248,11 @@ export function PostManagement({
|
|||
variant="solid"
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
disabled={
|
||||
!values.topicId ||
|
||||
!values.content ||
|
||||
values.content.replace(/<[^>]+>/g, '').trim() === ''
|
||||
}
|
||||
disabled={!values.topicId || !stripHtml(values.content)}
|
||||
>
|
||||
{editingPost ? 'Update' : 'Create'}
|
||||
{editingPost
|
||||
? translate('::App.Platform.Update')
|
||||
: translate('::App.Platform.Create')}
|
||||
</Button>
|
||||
</div>
|
||||
</FormContainer>
|
||||
|
|
@ -272,13 +265,15 @@ export function PostManagement({
|
|||
{/* Posts List */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Posts ({totalCount})</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{translate('::App.ForumDashboard.Posts')} ({totalCount})
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="solid"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
disabled={loading}
|
||||
className="flex items-center space-x-2"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
>
|
||||
<FaPlus className="w-4 h-4" />
|
||||
<span>{translate('::App.ForumPostManagement.AddPost')}</span>
|
||||
|
|
@ -292,6 +287,11 @@ export function PostManagement({
|
|||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{posts.length === 0 && (
|
||||
<p className="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{translate('::App.Platform.NoDataFound')}
|
||||
</p>
|
||||
)}
|
||||
{[...posts]
|
||||
.sort(
|
||||
(a, b) => new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime(),
|
||||
|
|
@ -305,20 +305,20 @@ export function PostManagement({
|
|||
{post.isAcceptedAnswer && (
|
||||
<div className="flex items-center space-x-1 bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300 px-2 py-1 rounded-full text-xs">
|
||||
<FaCheckCircle className="w-3 h-3" />
|
||||
<span>Accepted Answer</span>
|
||||
<span>{translate('::App.ForumPostManagement.AcceptedAnswer')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Reply to:{' '}
|
||||
{translate('::App.ForumPostManagement.ReplyTo')}:{' '}
|
||||
<span className="font-medium">{getTopicTitle(post.topicId)}</span>
|
||||
</p>
|
||||
<p
|
||||
className="text-gray-700 dark:text-gray-200 line-clamp-3"
|
||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
||||
></p>
|
||||
<div
|
||||
className="line-clamp-3 break-words text-gray-700 dark:text-gray-200"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeForumHtml(post.content) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||
|
|
@ -326,7 +326,9 @@ export function PostManagement({
|
|||
<span>{formatForumDate(post.creationTime)}</span>
|
||||
<div className="flex items-center space-x-1">
|
||||
<FaHeart className="w-4 h-4" />
|
||||
<span>{post.likeCount} likes</span>
|
||||
<span>
|
||||
{post.likeCount} {translate('::App.Listform.ListformField.Likes')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -335,7 +337,6 @@ export function PostManagement({
|
|||
<div className="flex items-center space-x-2 ml-4">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => handleToggleAcceptedAnswer(post)}
|
||||
className={`p-1 rounded-lg transition-colors ${
|
||||
post.isAcceptedAnswer
|
||||
? 'text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900'
|
||||
|
|
@ -343,9 +344,10 @@ export function PostManagement({
|
|||
}`}
|
||||
title={
|
||||
post.isAcceptedAnswer
|
||||
? 'Remove Accepted Answer'
|
||||
: 'Mark as Accepted Answer'
|
||||
? translate('::App.ForumPostManagement.RemoveAcceptedAnswer')
|
||||
: translate('::App.ForumPostManagement.MarkAsAcceptedAnswer')
|
||||
}
|
||||
onClick={() => handleToggleAcceptedAnswer(post)}
|
||||
>
|
||||
{post.isAcceptedAnswer ? (
|
||||
<FaCheckCircle className="w-3 h-3" />
|
||||
|
|
@ -356,18 +358,18 @@ export function PostManagement({
|
|||
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => handleEdit(post)}
|
||||
className="p-1 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900 rounded-lg transition-colors"
|
||||
title={translate('::App.ForumPostManagement.EditPost')}
|
||||
onClick={() => handleEdit(post)}
|
||||
>
|
||||
<FaEdit className="w-3 h-3" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => confirmDeletePost(post)}
|
||||
className="p-1 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900 rounded-lg transition-colors"
|
||||
title={translate('::App.ForumPostManagement.DeletePost')}
|
||||
onClick={() => confirmDeletePost(post)}
|
||||
>
|
||||
<FaTrashAlt className="w-3 h-3" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -10,38 +10,46 @@ import {
|
|||
FaCircle,
|
||||
FaEye,
|
||||
FaSpinner,
|
||||
FaTree,
|
||||
} from 'react-icons/fa'
|
||||
import { ForumCategory, ForumTopic } from '@/proxy/forum/forum'
|
||||
import { CreateTopicRequest } from '@/services/forum.service'
|
||||
import { useStoreState } from '@/store/store'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { Formik, Form, Field } from 'formik'
|
||||
import { Formik, Form, Field, FieldProps } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { FormContainer, FormItem, Button } from '@/components/ui'
|
||||
import { FormContainer, FormItem, Button, Select } from '@/components/ui'
|
||||
import { ConfirmDialog } from '@/components/shared'
|
||||
import { formatForumDate } from '../forum/utils'
|
||||
|
||||
const FIELD_CLASS =
|
||||
'w-full border border-gray-300 dark:border-gray-700 rounded-lg px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500'
|
||||
|
||||
interface CategoryOption {
|
||||
value: string
|
||||
label: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
const topicValidationSchema = Yup.object().shape({
|
||||
title: Yup.string().trim().required(),
|
||||
content: Yup.string().trim().required(),
|
||||
categoryId: Yup.string().required(),
|
||||
})
|
||||
|
||||
interface TopicManagementProps {
|
||||
topics: ForumTopic[]
|
||||
totalCount: number
|
||||
categories: ForumCategory[]
|
||||
loading: boolean
|
||||
onCreateTopic: (topic: {
|
||||
title: string
|
||||
content: string
|
||||
categoryId: string
|
||||
isPinned?: boolean
|
||||
isLocked?: boolean
|
||||
tenantId?: string
|
||||
}) => Promise<void>
|
||||
onUpdateTopic: (id: string, topic: Partial<ForumTopic>) => Promise<void>
|
||||
onDeleteTopic: (id: string) => Promise<void>
|
||||
onPinTopic: (id: string) => Promise<void>
|
||||
onUnpinTopic: (id: string) => Promise<void>
|
||||
onLockTopic: (id: string) => Promise<void>
|
||||
onUnlockTopic: (id: string) => Promise<void>
|
||||
onMarkTopicAsSolved: (id: string) => Promise<void>
|
||||
onMarkTopicAsUnsolved: (id: string) => Promise<void>
|
||||
onCreateTopic: (topic: CreateTopicRequest) => Promise<unknown>
|
||||
onUpdateTopic: (id: string, topic: Partial<ForumTopic>) => Promise<unknown>
|
||||
onDeleteTopic: (id: string) => Promise<unknown>
|
||||
onPinTopic: (id: string) => Promise<unknown>
|
||||
onUnpinTopic: (id: string) => Promise<unknown>
|
||||
onLockTopic: (id: string) => Promise<unknown>
|
||||
onUnlockTopic: (id: string) => Promise<unknown>
|
||||
onMarkTopicAsSolved: (id: string) => Promise<unknown>
|
||||
onMarkTopicAsUnsolved: (id: string) => Promise<unknown>
|
||||
}
|
||||
|
||||
export function TopicManagement({
|
||||
|
|
@ -112,10 +120,14 @@ export function TopicManagement({
|
|||
}
|
||||
}
|
||||
|
||||
const getCategoryName = (categoryId: string) => {
|
||||
const category = categories.find((c) => c.id === categoryId)
|
||||
return category ? category.name : 'Unknown Category'
|
||||
}
|
||||
const getCategoryName = (categoryId: string) =>
|
||||
categories.find((c) => c.id === categoryId)?.name ?? translate('::App.Platform.Unknown')
|
||||
|
||||
const categoryOptions: CategoryOption[] = categories.map((category) => ({
|
||||
value: category.id,
|
||||
label: category.name,
|
||||
icon: category.icon,
|
||||
}))
|
||||
|
||||
const topicInitialValues = {
|
||||
title: '',
|
||||
|
|
@ -124,27 +136,22 @@ export function TopicManagement({
|
|||
isPinned: false,
|
||||
isLocked: false,
|
||||
isSolved: false,
|
||||
tenantId: tenant.tenantId || '',
|
||||
tenantId: tenant?.tenantId ?? '',
|
||||
}
|
||||
|
||||
const topicValidationSchema = Yup.object().shape({
|
||||
title: Yup.string().required(),
|
||||
content: Yup.string().required(),
|
||||
categoryId: Yup.string().required(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Create/Edit Form */}
|
||||
{showCreateForm && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{editingTopic
|
||||
? translate('::App.ForumTopicManagement.EditTopic')
|
||||
: translate('::App.ForumTopicManagement.AddTopic')}
|
||||
</h3>
|
||||
|
||||
<Formik
|
||||
enableReinitialize
|
||||
initialValues={
|
||||
editingTopic
|
||||
? {
|
||||
|
|
@ -159,7 +166,6 @@ export function TopicManagement({
|
|||
: topicInitialValues
|
||||
}
|
||||
validationSchema={topicValidationSchema}
|
||||
enableReinitialize
|
||||
onSubmit={async (values, { setSubmitting, resetForm }) => {
|
||||
try {
|
||||
if (editingTopic) {
|
||||
|
|
@ -170,49 +176,61 @@ export function TopicManagement({
|
|||
resetForm()
|
||||
setShowCreateForm(false)
|
||||
setEditingTopic(null)
|
||||
} catch (error) {
|
||||
console.error('Error submitting topic:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting, errors, touched }) => (
|
||||
{({ values, isSubmitting, errors, touched }) => (
|
||||
<Form>
|
||||
<FormContainer className="space-y-4">
|
||||
<FormItem
|
||||
label="Title"
|
||||
asterisk
|
||||
label={translate('::App.Listform.ListformField.Title')}
|
||||
invalid={!!errors.title && touched.title}
|
||||
errorMessage={errors.title}
|
||||
>
|
||||
<Field
|
||||
name="title"
|
||||
className="w-full border border-gray-300 dark:border-gray-700 rounded-lg px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500"
|
||||
className={FIELD_CLASS}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
label="Category"
|
||||
asterisk
|
||||
invalid={!!errors.categoryId && touched.categoryId}
|
||||
label={translate('::App.Platform.Category')}
|
||||
invalid={!!errors.categoryId && !!touched.categoryId}
|
||||
errorMessage={errors.categoryId}
|
||||
>
|
||||
<Field
|
||||
as="select"
|
||||
name="categoryId"
|
||||
className="w-full border border-gray-300 dark:border-gray-700 rounded-lg px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">Select a category</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
<Field name="categoryId">
|
||||
{({ field, form }: FieldProps<string>) => (
|
||||
<Select<CategoryOption>
|
||||
field={field}
|
||||
form={form}
|
||||
options={categoryOptions}
|
||||
placeholder={translate('::App.ForumTopicManagement.SelectCategory')}
|
||||
value={categoryOptions.filter(
|
||||
(option) => option.value === values.categoryId,
|
||||
)}
|
||||
formatOptionLabel={(option) => (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-lg">{option.icon}</span>
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
)}
|
||||
onChange={(option) =>
|
||||
form.setFieldValue('categoryId', option?.value ?? '')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
label="Content"
|
||||
asterisk
|
||||
label={translate('::App.Listform.ListformField.Content')}
|
||||
invalid={!!errors.content && touched.content}
|
||||
errorMessage={errors.content}
|
||||
>
|
||||
|
|
@ -220,23 +238,23 @@ export function TopicManagement({
|
|||
as="textarea"
|
||||
name="content"
|
||||
rows={6}
|
||||
className="w-full border border-gray-300 dark:border-gray-700 rounded-lg px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500"
|
||||
className={FIELD_CLASS}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="Options">
|
||||
<FormItem label={translate('::App.Listform.ListformField.Options')}>
|
||||
<div className="flex items-center space-x-6 pt-1">
|
||||
<label className="flex items-center">
|
||||
<Field type="checkbox" name="isPinned" className="mr-2" />
|
||||
Pinned
|
||||
{translate('::App.ForumTopicManagement.Pinned')}
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<Field type="checkbox" name="isLocked" className="mr-2" />
|
||||
Locked
|
||||
{translate('::App.Platform.Locked')}
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<Field type="checkbox" name="isSolved" className="mr-2" />
|
||||
Solved
|
||||
{translate('::App.ForumTopicManagement.Solved')}
|
||||
</label>
|
||||
</div>
|
||||
</FormItem>
|
||||
|
|
@ -272,9 +290,9 @@ export function TopicManagement({
|
|||
<Button
|
||||
size="sm"
|
||||
variant="solid"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
disabled={loading}
|
||||
className="flex items-center space-x-2"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
>
|
||||
<FaPlus className="w-4 h-4" />
|
||||
<span>{translate('::App.ForumTopicManagement.AddTopic')}</span>
|
||||
|
|
@ -290,6 +308,11 @@ export function TopicManagement({
|
|||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{topics.length === 0 && (
|
||||
<p className="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{translate('::App.Platform.NoDataFound')}
|
||||
</p>
|
||||
)}
|
||||
{[...topics]
|
||||
.sort(
|
||||
(a, b) => new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime(),
|
||||
|
|
@ -323,7 +346,7 @@ export function TopicManagement({
|
|||
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="font-medium">{getCategoryName(topic.categoryId)}</span>
|
||||
<span>by {topic.authorName}</span>
|
||||
<span>{topic.authorName}</span>
|
||||
<span>{formatForumDate(topic.creationTime)}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
|
|
@ -331,8 +354,12 @@ export function TopicManagement({
|
|||
<FaEye className="w-4 h-4" />
|
||||
<span>{topic.viewCount}</span>
|
||||
</div>
|
||||
<span>{topic.replyCount} replies</span>
|
||||
<span>{topic.likeCount} likes</span>
|
||||
<span>
|
||||
{topic.replyCount} {translate('::App.Platform.Replies')}
|
||||
</span>
|
||||
<span>
|
||||
{topic.likeCount} {translate('::App.Listform.ListformField.Likes')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -340,30 +367,34 @@ export function TopicManagement({
|
|||
<div className="flex items-center space-x-2 ml-4">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => handlePin(topic)}
|
||||
className={`p-1 rounded-lg transition-colors ${
|
||||
topic.isPinned
|
||||
? 'text-orange-600 dark:text-orange-400 hover:bg-orange-100 dark:hover:bg-orange-900'
|
||||
: 'text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
title={topic.isPinned ? 'Unpin Topic' : 'Pin Topic'}
|
||||
title={
|
||||
topic.isPinned
|
||||
? translate('::App.ForumTopicManagement.UnpinTopic')
|
||||
: translate('::App.ForumTopicManagement.PinTopic')
|
||||
}
|
||||
onClick={() => handlePin(topic)}
|
||||
>
|
||||
{topic.isPinned ? (
|
||||
<FaThumbtack className="w-3 h-3" />
|
||||
) : (
|
||||
<FaTree className="w-3 h-3" />
|
||||
)}
|
||||
<FaThumbtack className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => handleLock(topic)}
|
||||
className={`p-1 rounded-lg transition-colors ${
|
||||
topic.isLocked
|
||||
? 'text-yellow-600 dark:text-yellow-300 hover:bg-yellow-100 dark:hover:bg-yellow-900'
|
||||
: 'text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900'
|
||||
}`}
|
||||
title={topic.isLocked ? 'Unlock Topic' : 'Lock Topic'}
|
||||
title={
|
||||
topic.isLocked
|
||||
? translate('::App.ForumTopicManagement.UnlockTopic')
|
||||
: translate('::App.ForumTopicManagement.LockTopic')
|
||||
}
|
||||
onClick={() => handleLock(topic)}
|
||||
>
|
||||
{topic.isLocked ? (
|
||||
<FaLock className="w-3 h-3" />
|
||||
|
|
@ -374,13 +405,17 @@ export function TopicManagement({
|
|||
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => handleSolved(topic)}
|
||||
className={`p-1 rounded-lg transition-colors ${
|
||||
topic.isSolved
|
||||
? 'text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900'
|
||||
: 'text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
title={topic.isSolved ? 'Mark as Unsolved' : 'Mark as Solved'}
|
||||
title={
|
||||
topic.isSolved
|
||||
? translate('::App.ForumTopicManagement.MarkAsUnsolved')
|
||||
: translate('::App.ForumTopicManagement.MarkAsSolved')
|
||||
}
|
||||
onClick={() => handleSolved(topic)}
|
||||
>
|
||||
{topic.isSolved ? (
|
||||
<FaCheckCircle className="w-3 h-3" />
|
||||
|
|
@ -391,18 +426,18 @@ export function TopicManagement({
|
|||
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => handleEdit(topic)}
|
||||
className="p-1 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900 rounded-lg transition-colors"
|
||||
title={translate('::App.ForumTopicManagement.EditTopic')}
|
||||
onClick={() => handleEdit(topic)}
|
||||
>
|
||||
<FaEdit className="w-3 h-3" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => confirmDeleteTopic(topic)}
|
||||
className="p-1 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900 rounded-lg transition-colors"
|
||||
title={translate('::App.ForumTopicManagement.DeleteTopic')}
|
||||
onClick={() => confirmDeleteTopic(topic)}
|
||||
>
|
||||
<FaTrashAlt className="w-3 h-3" />
|
||||
</Button>
|
||||
|
|
|
|||
52
ui/src/views/forum/admin/categoryIcons.ts
Normal file
52
ui/src/views/forum/admin/categoryIcons.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
export interface CategoryIconOption {
|
||||
/** Kategoride saklanan emoji değeri. */
|
||||
value: string
|
||||
/** Aramada eşleşen etiket. */
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Forum kategorileri için hazır emoji seti. */
|
||||
export const CATEGORY_ICON_OPTIONS: CategoryIconOption[] = [
|
||||
{ value: '💬', label: 'Discussion' },
|
||||
{ value: '📢', label: 'Announcement' },
|
||||
{ value: '❓', label: 'Question' },
|
||||
{ value: '💡', label: 'Idea' },
|
||||
{ value: '🛠️', label: 'Support' },
|
||||
{ value: '🐞', label: 'Bug' },
|
||||
{ value: '🚀', label: 'Release' },
|
||||
{ value: '📚', label: 'Documentation' },
|
||||
{ value: '📝', label: 'Notes' },
|
||||
{ value: '📌', label: 'Pinned' },
|
||||
{ value: '⭐', label: 'Featured' },
|
||||
{ value: '🔥', label: 'Popular' },
|
||||
{ value: '🎯', label: 'Goals' },
|
||||
{ value: '📊', label: 'Reports' },
|
||||
{ value: '📈', label: 'Analytics' },
|
||||
{ value: '⚙️', label: 'Settings' },
|
||||
{ value: '🔒', label: 'Private' },
|
||||
{ value: '🧩', label: 'Integration' },
|
||||
{ value: '🖥️', label: 'Development' },
|
||||
{ value: '📱', label: 'Mobile' },
|
||||
{ value: '🗄️', label: 'Database' },
|
||||
{ value: '🔐', label: 'Security' },
|
||||
{ value: '👥', label: 'Community' },
|
||||
{ value: '🏢', label: 'Company' },
|
||||
{ value: '🧾', label: 'Finance' },
|
||||
{ value: '🛒', label: 'Sales' },
|
||||
{ value: '🚚', label: 'Logistics' },
|
||||
{ value: '🏭', label: 'Production' },
|
||||
{ value: '🎓', label: 'Training' },
|
||||
{ value: '🎉', label: 'Events' },
|
||||
{ value: '☕', label: 'Off topic' },
|
||||
{ value: '🗳️', label: 'Poll' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Kayıtlı ikon hazır sette yoksa kaybolmasın diye listenin başına eklenir.
|
||||
*/
|
||||
export function resolveCategoryIconOptions(current?: string): CategoryIconOption[] {
|
||||
if (!current || CATEGORY_ICON_OPTIONS.some((option) => option.value === current)) {
|
||||
return CATEGORY_ICON_OPTIONS
|
||||
}
|
||||
return [{ value: current, label: current }, ...CATEGORY_ICON_OPTIONS]
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { HtmlEditor, ImageUpload, Item, MediaResizing, Toolbar } from 'devextrem
|
|||
import { useStoreState } from '@/store/store'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { Button, FormContainer, FormItem } from '@/components/ui'
|
||||
import { stripHtml } from './utils'
|
||||
import {
|
||||
fontFamilyOptions,
|
||||
fontSizeOptions,
|
||||
|
|
@ -14,45 +15,65 @@ import {
|
|||
sizeValues,
|
||||
} from '@/proxy/reports/data'
|
||||
|
||||
interface CreatePostModalProps {
|
||||
onClose: () => void
|
||||
onSubmit: (data: { content: string; parentPostId?: string; tenantId?: string }) => void
|
||||
parentPostId: string
|
||||
interface PostFormValues {
|
||||
content: string
|
||||
}
|
||||
|
||||
const postInitialValues = {
|
||||
interface CreatePostModalProps {
|
||||
onClose: () => void
|
||||
onSubmit: (data: { content: string; parentPostId?: string; tenantId?: string }) => Promise<unknown> | void
|
||||
/** Dolu ise yanıt, boş ise topic'e yeni gönderi. */
|
||||
parentPostId?: string
|
||||
}
|
||||
|
||||
const postInitialValues: PostFormValues = {
|
||||
content: '',
|
||||
}
|
||||
|
||||
const postValidationSchema = Yup.object().shape({
|
||||
content: Yup.string().test('is-not-empty', 'App.Forum.ContentRequired', (value) => {
|
||||
const plainText = value?.replace(/<[^>]+>/g, '').trim()
|
||||
return !!plainText
|
||||
}),
|
||||
content: Yup.string().test(
|
||||
'is-not-empty',
|
||||
'App.Forum.ContentRequired',
|
||||
(value) => !!stripHtml(value),
|
||||
),
|
||||
})
|
||||
|
||||
export function CreatePostModal({ onClose, onSubmit, parentPostId }: CreatePostModalProps) {
|
||||
const { translate } = useLocalization()
|
||||
const { tenant } = useStoreState((state) => state.auth)
|
||||
|
||||
const handleSubmit = (
|
||||
values: { content: string },
|
||||
const handleSubmit = async (
|
||||
values: PostFormValues,
|
||||
{ setSubmitting }: { setSubmitting: (isSubmitting: boolean) => void },
|
||||
) => {
|
||||
const plainText = values.content.replace(/<[^>]+>/g, '').trim()
|
||||
if (plainText) {
|
||||
onSubmit({
|
||||
try {
|
||||
if (!stripHtml(values.content)) return
|
||||
await onSubmit({
|
||||
content: values.content,
|
||||
parentPostId,
|
||||
tenantId: tenant.tenantId,
|
||||
tenantId: tenant?.tenantId,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error creating post:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={onClose}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white shadow-xl dark:bg-gray-900"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{parentPostId
|
||||
|
|
@ -63,8 +84,8 @@ export function CreatePostModal({ onClose, onSubmit, parentPostId }: CreatePostM
|
|||
type="button"
|
||||
variant="plain"
|
||||
shape="none"
|
||||
onClick={onClose}
|
||||
className="!inline-flex !h-auto items-center justify-center !bg-transparent !p-1 text-gray-400 transition-colors hover:!bg-transparent hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
onClick={onClose}
|
||||
>
|
||||
<FaTimes className="w-5 h-5" />
|
||||
</Button>
|
||||
|
|
@ -92,13 +113,13 @@ export function CreatePostModal({ onClose, onSubmit, parentPostId }: CreatePostM
|
|||
{({ field }: FieldProps) => (
|
||||
<HtmlEditor
|
||||
value={field.value}
|
||||
onValueChanged={(e) => setFieldValue('content', e.value)}
|
||||
height={300}
|
||||
placeholder={
|
||||
parentPostId
|
||||
? translate('::App.ForumPostManagement.MessageEdit')
|
||||
: translate('::App.ForumPostManagement.MessageNew')
|
||||
}
|
||||
onValueChanged={(e) => setFieldValue('content', e.value)}
|
||||
>
|
||||
<MediaResizing enabled={true} />
|
||||
<ImageUpload fileUploadMode="base64" />
|
||||
|
|
@ -164,11 +185,11 @@ export function CreatePostModal({ onClose, onSubmit, parentPostId }: CreatePostM
|
|||
variant="solid"
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
disabled={
|
||||
!values.content || values.content.replace(/<[^>]+>/g, '').trim() === ''
|
||||
}
|
||||
disabled={!stripHtml(values.content)}
|
||||
>
|
||||
{translate('::App.ForumPostManagement.PostReply')}
|
||||
{parentPostId
|
||||
? translate('::App.ForumPostManagement.PostReply')
|
||||
: translate('::App.ForumPostManagement.AddPost')}
|
||||
</Button>
|
||||
</div>
|
||||
</FormContainer>
|
||||
|
|
|
|||
|
|
@ -5,40 +5,61 @@ import { Field, FieldProps, Form, Formik } from 'formik'
|
|||
import { Button, FormContainer, FormItem, Input } from '@/components/ui'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
interface CreateTopicModalProps {
|
||||
onClose: () => void
|
||||
onSubmit: (data: { title: string; content: string; tenantId?: string }) => void
|
||||
interface TopicFormValues {
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export const topicInitialValues = {
|
||||
interface CreateTopicModalProps {
|
||||
onClose: () => void
|
||||
onSubmit: (data: { title: string; content: string; tenantId?: string }) => Promise<unknown> | void
|
||||
}
|
||||
|
||||
const topicInitialValues: TopicFormValues = {
|
||||
title: '',
|
||||
content: '',
|
||||
}
|
||||
|
||||
export const topicValidationSchema = Yup.object().shape({
|
||||
title: Yup.string().required(),
|
||||
content: Yup.string().required(),
|
||||
const topicValidationSchema = Yup.object().shape({
|
||||
title: Yup.string().trim().required(),
|
||||
content: Yup.string().trim().required(),
|
||||
})
|
||||
|
||||
export function CreateTopicModal({ onClose, onSubmit }: CreateTopicModalProps) {
|
||||
const { translate } = useLocalization()
|
||||
const { tenant } = useStoreState((state) => state.auth)
|
||||
|
||||
const handleSubmit = (
|
||||
values: { title: string; content: string },
|
||||
const handleSubmit = async (
|
||||
values: TopicFormValues,
|
||||
{ setSubmitting }: { setSubmitting: (isSubmitting: boolean) => void },
|
||||
) => {
|
||||
onSubmit({
|
||||
title: values.title.trim(),
|
||||
content: values.content.trim(),
|
||||
tenantId: tenant.tenantId,
|
||||
})
|
||||
setSubmitting(false)
|
||||
try {
|
||||
await onSubmit({
|
||||
title: values.title.trim(),
|
||||
content: values.content.trim(),
|
||||
tenantId: tenant?.tenantId,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error creating topic:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto p-6">
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={onClose}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl bg-white p-6 shadow-2xl dark:bg-gray-900"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{translate('::App.ForumTopicManagement.NewTopic')}
|
||||
|
|
@ -47,8 +68,8 @@ export function CreateTopicModal({ onClose, onSubmit }: CreateTopicModalProps) {
|
|||
type="button"
|
||||
variant="plain"
|
||||
shape="none"
|
||||
onClick={onClose}
|
||||
className="!inline-flex !h-auto items-center justify-center !bg-transparent !p-1 text-gray-400 transition-colors hover:!bg-transparent hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
onClick={onClose}
|
||||
>
|
||||
<FaTimes className="w-5 h-5" />
|
||||
</Button>
|
||||
|
|
@ -58,32 +79,31 @@ export function CreateTopicModal({ onClose, onSubmit }: CreateTopicModalProps) {
|
|||
initialValues={topicInitialValues}
|
||||
validationSchema={topicValidationSchema}
|
||||
onSubmit={handleSubmit}
|
||||
enableReinitialize={true}
|
||||
>
|
||||
{({ values, touched, errors, isSubmitting }) => (
|
||||
<Form>
|
||||
<FormContainer className="space-y-4">
|
||||
<FormItem
|
||||
label={translate('::App.ForumTopicManagement.TopicManagementTitle')}
|
||||
invalid={errors.title && touched.title}
|
||||
errorMessage={errors.title}
|
||||
asterisk
|
||||
label={translate('::App.ForumTopicManagement.TopicManagementTitle')}
|
||||
invalid={!!errors.title && !!touched.title}
|
||||
errorMessage={errors.title}
|
||||
>
|
||||
<Field
|
||||
autoFocus
|
||||
type="text"
|
||||
name="title"
|
||||
placeholder={translate('::App.Forum.TitlePlaceholder')}
|
||||
autoFocus
|
||||
className="w-full text-sm border border-gray-300 dark:border-gray-700 rounded-md px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
component={Input}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
label={translate('::App.Listform.ListformField.Content')}
|
||||
invalid={errors.content && touched.content}
|
||||
errorMessage={errors.content}
|
||||
asterisk
|
||||
label={translate('::App.Listform.ListformField.Content')}
|
||||
invalid={!!errors.content && !!touched.content}
|
||||
errorMessage={errors.content}
|
||||
>
|
||||
<Field name="content">
|
||||
{({ field }: FieldProps) => (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { FaComment, FaLock, FaArrowUp } from 'react-icons/fa'
|
||||
import { ForumCategory } from '@/proxy/forum/forum'
|
||||
import { formatForumRelativeDate } from './utils'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { formatForumDate, formatForumRelativeDate } from './utils'
|
||||
|
||||
interface CategoryCardProps {
|
||||
category: ForumCategory
|
||||
|
|
@ -8,38 +9,60 @@ interface CategoryCardProps {
|
|||
}
|
||||
|
||||
export function ForumCategoryCard({ category, onClick }: CategoryCardProps) {
|
||||
const { translate, cultureName } = useLocalization()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="group cursor-pointer rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-200 hover:border-blue-200 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-blue-500"
|
||||
onClick={onClick}
|
||||
className="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 hover:shadow-md hover:border-blue-200 dark:hover:border-blue-500 transition-all duration-200 cursor-pointer group"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onClick()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4 flex-1">
|
||||
<div className="text-3xl">{category.icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
|
||||
<div className="flex flex-1 items-start space-x-4">
|
||||
<div className="text-3xl" aria-hidden="true">
|
||||
{category.icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center space-x-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 transition-colors group-hover:text-blue-600 dark:text-white dark:group-hover:text-blue-400">
|
||||
{category.name}
|
||||
</h3>
|
||||
{category.isLocked && <FaLock className="w-4 h-4 text-gray-400 dark:text-gray-500" />}
|
||||
{category.isLocked && (
|
||||
<FaLock className="h-4 w-4 text-gray-400 dark:text-gray-500" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mb-3 line-clamp-2">{category.description}</p>
|
||||
<p className="mb-3 line-clamp-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{category.description}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center space-x-1">
|
||||
<FaComment className="w-4 h-4" />
|
||||
<span>{category.topicCount} topics</span>
|
||||
<FaComment className="h-4 w-4" aria-hidden="true" />
|
||||
<span>
|
||||
{category.topicCount} {translate('::App.ForumDashboard.Topics')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<FaArrowUp className="w-4 h-4" />
|
||||
<span>{category.postCount} posts</span>
|
||||
<FaArrowUp className="h-4 w-4" aria-hidden="true" />
|
||||
<span>
|
||||
{category.postCount} {translate('::App.ForumDashboard.Posts')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-gray-500 dark:text-gray-400 ml-4">
|
||||
<div>Last post</div>
|
||||
<div
|
||||
className="ml-4 shrink-0 text-right text-sm text-gray-500 dark:text-gray-400"
|
||||
title={formatForumDate(category.lastPostDate)}
|
||||
>
|
||||
<div className="font-medium text-gray-700 dark:text-gray-200">
|
||||
{formatForumRelativeDate(category.lastPostDate)}
|
||||
{formatForumRelativeDate(category.lastPostDate, cultureName)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import { FaHeart, FaCheckCircle, FaReply } from 'react-icons/fa'
|
||||
import { ForumPost } from '@/proxy/forum/forum'
|
||||
|
|
@ -6,7 +6,7 @@ import { AVATAR_URL } from '@/constants/app.constant'
|
|||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { Avatar, Button } from '@/components/ui'
|
||||
import UserProfileCard from '@/views/intranet/SocialWall/UserProfileCard'
|
||||
import { formatForumDate } from './utils'
|
||||
import { formatForumDate, sanitizeForumHtml } from './utils'
|
||||
|
||||
interface PostCardProps {
|
||||
post: ForumPost
|
||||
|
|
@ -25,6 +25,7 @@ export function ForumPostCard({
|
|||
}: PostCardProps) {
|
||||
const { translate } = useLocalization()
|
||||
const [showUserCard, setShowUserCard] = useState(false)
|
||||
const sanitizedContent = useMemo(() => sanitizeForumHtml(post.content), [post.content])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -73,12 +74,11 @@ export function ForumPostCard({
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-sm max-w-none mb-4">
|
||||
<p
|
||||
className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap"
|
||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
||||
/>
|
||||
</div>
|
||||
{/* İçerik kullanıcı girdisi olduğu için sanitize edilmiş HTML basılır. */}
|
||||
<div
|
||||
className="prose prose-sm dark:prose-invert mb-4 max-w-none break-words text-gray-700 dark:text-gray-300"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
|
||||
/>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
|
|
@ -86,6 +86,7 @@ export function ForumPostCard({
|
|||
variant="plain"
|
||||
shape="none"
|
||||
className="!inline-flex !h-auto items-center gap-1 !rounded-none !border-0 !bg-transparent !px-0 py-1 text-sm text-gray-600 transition-colors hover:!bg-transparent active:!bg-transparent focus:!bg-transparent dark:text-gray-300"
|
||||
aria-pressed={isLiked}
|
||||
onClick={() => onLike(post.id, isFirst)}
|
||||
>
|
||||
<FaHeart
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { AVATAR_URL } from '@/constants/app.constant'
|
|||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { Avatar } from '@/components/ui'
|
||||
import UserProfileCard from '@/views/intranet/SocialWall/UserProfileCard'
|
||||
import { formatForumRelativeDate } from './utils'
|
||||
import { formatForumDate, formatForumRelativeDate, stripHtml } from './utils'
|
||||
|
||||
interface TopicCardProps {
|
||||
topic: ForumTopic
|
||||
|
|
@ -14,51 +14,63 @@ interface TopicCardProps {
|
|||
}
|
||||
|
||||
export function ForumTopicCard({ topic, onClick }: TopicCardProps) {
|
||||
const { translate } = useLocalization()
|
||||
const { translate, cultureName } = useLocalization()
|
||||
const [showUserCard, setShowUserCard] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 hover:shadow-md hover:border-blue-200 dark:hover:border-blue-500 transition-all duration-200 cursor-pointer group"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="group cursor-pointer rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-200 hover:border-blue-200 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-blue-500"
|
||||
onClick={onClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onClick()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
{/* Sol taraf: Başlık, içerik, istatistik */}
|
||||
<div className="flex-1 min-w-0 pr-4">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
{topic.isPinned && <FaThumbtack className="w-4 h-4 text-orange-500" />}
|
||||
{topic.isLocked && <FaLock className="w-4 h-4 text-gray-400" />}
|
||||
{topic.isSolved && <FaCheckCircle className="w-4 h-4 text-emerald-500" />}
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors line-clamp-1">
|
||||
<div className="min-w-0 flex-1 pr-4">
|
||||
<div className="mb-2 flex items-center space-x-2">
|
||||
{topic.isPinned && <FaThumbtack className="h-4 w-4 text-orange-500" />}
|
||||
{topic.isLocked && <FaLock className="h-4 w-4 text-gray-400" />}
|
||||
{topic.isSolved && <FaCheckCircle className="h-4 w-4 text-emerald-500" />}
|
||||
<h3 className="line-clamp-1 text-lg font-semibold text-gray-900 transition-colors group-hover:text-blue-600 dark:text-white dark:group-hover:text-blue-400">
|
||||
{topic.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mb-4 line-clamp-2">
|
||||
{topic.content}
|
||||
<p className="mb-4 line-clamp-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{stripHtml(topic.content)}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center space-x-1" title={translate('::App.Platform.Views')}>
|
||||
<FaEye className="w-4 h-4" />
|
||||
<FaEye className="h-4 w-4" aria-hidden="true" />
|
||||
<span>{topic.viewCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1" title="Replies">
|
||||
<FaComment className="w-4 h-4" />
|
||||
<div
|
||||
className="flex items-center space-x-1"
|
||||
title={translate('::App.ForumDashboard.Posts')}
|
||||
>
|
||||
<FaComment className="h-4 w-4" aria-hidden="true" />
|
||||
<span>{topic.replyCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1" title="Likes">
|
||||
<FaHeart className="w-4 h-4" />
|
||||
<div className="flex items-center space-x-1">
|
||||
<FaHeart className="h-4 w-4" aria-hidden="true" />
|
||||
<span>{topic.likeCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sağ taraf: Avatar + Yazar bilgisi */}
|
||||
<div className="flex flex-col items-center justify-start w-24 text-center space-y-1">
|
||||
<div className="flex w-24 flex-col items-center justify-start space-y-1 text-center">
|
||||
<div
|
||||
className="relative flex-shrink-0"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
onMouseEnter={() => setShowUserCard(true)}
|
||||
onMouseLeave={() => setShowUserCard(false)}
|
||||
>
|
||||
|
|
@ -84,21 +96,26 @@ export function ForumTopicCard({ topic, onClick }: TopicCardProps) {
|
|||
<div className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
{topic.authorName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatForumRelativeDate(topic.creationTime)}
|
||||
<div
|
||||
className="text-xs text-gray-500 dark:text-gray-400"
|
||||
title={formatForumDate(topic.creationTime)}
|
||||
>
|
||||
{formatForumRelativeDate(topic.creationTime, cultureName)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{topic.lastPostDate && topic.lastPostUserName && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="mt-4 border-t border-gray-100 pt-4 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>
|
||||
{translate('::App.ForumTopicManagement.Lastreplyby')}{' '}
|
||||
<span className="font-medium text-gray-700 dark:text-gray-200">
|
||||
{topic.lastPostUserName}
|
||||
</span>{' '}
|
||||
<span>{formatForumRelativeDate(topic.lastPostDate)}</span>
|
||||
<span title={formatForumDate(topic.lastPostDate)}>
|
||||
{formatForumRelativeDate(topic.lastPostDate, cultureName)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { FaArrowLeft, FaPlus, FaSpinner, FaSearch } from 'react-icons/fa'
|
||||
import { CreateTopicModal } from './CreateTopicModal'
|
||||
import { CreatePostModal } from './CreatePostModal'
|
||||
|
|
@ -10,38 +10,31 @@ import { ForumCategoryCard } from './ForumCategoryCard'
|
|||
import { ForumTopicCard } from './ForumTopicCard'
|
||||
import { useStoreState } from '@/store/store'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { forumService } from '@/services/forum.service'
|
||||
import { CreatePostRequest, CreateTopicRequest, forumService } from '@/services/forum.service'
|
||||
import { Button } from '@/components/ui'
|
||||
|
||||
export type ForumViewState = 'categories' | 'topics' | 'posts'
|
||||
|
||||
interface ForumViewProps {
|
||||
categories: ForumCategory[]
|
||||
topics: ForumTopic[]
|
||||
posts: ForumPost[]
|
||||
loading: boolean
|
||||
onCreateTopic: (topic: {
|
||||
title: string
|
||||
content: string
|
||||
categoryId: string
|
||||
isPinned?: boolean
|
||||
isLocked?: boolean
|
||||
tenantId?: string
|
||||
}) => Promise<void>
|
||||
onCreatePost: (post: {
|
||||
topicId: string
|
||||
content: string
|
||||
parentPostId?: string
|
||||
tenantId?: string
|
||||
}) => Promise<void>
|
||||
onLikePost: (id: string) => Promise<void>
|
||||
onUnlikePost: (id: string) => Promise<void>
|
||||
selectedCategory?: ForumCategory | null
|
||||
selectedTopic?: ForumTopic | null
|
||||
viewState?: 'categories' | 'topics' | 'posts'
|
||||
onCategorySelect?: (category: ForumCategory | null) => void
|
||||
onTopicSelect?: (topic: ForumTopic | null) => void
|
||||
onViewStateChange?: (state: 'categories' | 'topics' | 'posts') => void
|
||||
onCreateTopic: (topic: CreateTopicRequest) => Promise<unknown>
|
||||
onCreatePost: (post: CreatePostRequest) => Promise<unknown>
|
||||
onLikePost: (id: string) => Promise<unknown>
|
||||
onUnlikePost: (id: string) => Promise<unknown>
|
||||
selectedCategory: ForumCategory | null
|
||||
selectedTopic: ForumTopic | null
|
||||
viewState: ForumViewState
|
||||
onCategorySelect: (category: ForumCategory | null) => void
|
||||
onTopicSelect: (topic: ForumTopic | null) => void
|
||||
onViewStateChange: (state: ForumViewState) => void
|
||||
}
|
||||
|
||||
/** Yanıt ağacının sınırsız içeri kaymasını engellemek için girinti seviyesi üst sınırı. */
|
||||
const MAX_THREAD_DEPTH = 6
|
||||
|
||||
export function ForumView({
|
||||
categories,
|
||||
topics,
|
||||
|
|
@ -51,236 +44,226 @@ export function ForumView({
|
|||
onCreatePost,
|
||||
onLikePost,
|
||||
onUnlikePost,
|
||||
selectedCategory: propSelectedCategory,
|
||||
selectedTopic: propSelectedTopic,
|
||||
viewState: propViewState,
|
||||
selectedCategory,
|
||||
selectedTopic,
|
||||
viewState,
|
||||
onCategorySelect,
|
||||
onTopicSelect,
|
||||
onViewStateChange,
|
||||
}: ForumViewProps) {
|
||||
const { translate } = useLocalization()
|
||||
const [localViewState, setLocalViewState] = useState<'categories' | 'topics' | 'posts'>(
|
||||
'categories',
|
||||
)
|
||||
const [localSelectedCategory, setLocalSelectedCategory] = useState<ForumCategory | null>(null)
|
||||
const [localSelectedTopic, setLocalSelectedTopic] = useState<ForumTopic | null>(null)
|
||||
|
||||
const viewState = propViewState || localViewState
|
||||
const selectedCategory = propSelectedCategory || localSelectedCategory
|
||||
const selectedTopic = propSelectedTopic || localSelectedTopic
|
||||
const { tenant } = useStoreState((state) => state.auth)
|
||||
|
||||
const [showCreateTopic, setShowCreateTopic] = useState(false)
|
||||
const [showCreatePost, setShowCreatePost] = useState(false)
|
||||
const [likedPosts, setLikedPosts] = useState<Set<string>>(new Set())
|
||||
const [replyToPostId, setReplyToPostId] = useState<string | undefined>()
|
||||
|
||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false)
|
||||
const [postLikeCounts, setPostLikeCounts] = useState<Record<string, number>>({})
|
||||
const { tenant } = useStoreState((state) => state.auth)
|
||||
|
||||
const handleSearchCategorySelect = (category: ForumCategory) => {
|
||||
if (onCategorySelect) onCategorySelect(category)
|
||||
else setLocalSelectedCategory(category)
|
||||
const findCategoryOfTopic = useCallback(
|
||||
(topic?: ForumTopic | null) => categories.find((c) => c.id === topic?.categoryId) ?? null,
|
||||
[categories],
|
||||
)
|
||||
|
||||
if (onViewStateChange) onViewStateChange('topics')
|
||||
else setLocalViewState('topics')
|
||||
const goToCategories = useCallback(() => {
|
||||
onViewStateChange('categories')
|
||||
onCategorySelect(null)
|
||||
onTopicSelect(null)
|
||||
}, [onCategorySelect, onTopicSelect, onViewStateChange])
|
||||
|
||||
setIsSearchModalOpen(false)
|
||||
}
|
||||
|
||||
const handleSearchTopicSelect = (topic: ForumTopic) => {
|
||||
if (onTopicSelect) onTopicSelect(topic)
|
||||
else setLocalSelectedTopic(topic)
|
||||
|
||||
if (onCategorySelect) {
|
||||
const category = categories.find((cat) => cat.id === topic.categoryId)
|
||||
onCategorySelect(category || null)
|
||||
} else {
|
||||
const category = categories.find((cat) => cat.id === topic.categoryId)
|
||||
setLocalSelectedCategory(category || null)
|
||||
}
|
||||
|
||||
if (onViewStateChange) onViewStateChange('posts')
|
||||
else setLocalViewState('posts')
|
||||
|
||||
setIsSearchModalOpen(false)
|
||||
}
|
||||
|
||||
const handleSearchPostSelect = (post: ForumPost) => {
|
||||
const topic = topics.find((t) => t.id === post.topicId)
|
||||
const category = categories.find((c) => c.id === topic?.categoryId)
|
||||
|
||||
if (onCategorySelect) onCategorySelect(category || null)
|
||||
else setLocalSelectedCategory(category || null)
|
||||
|
||||
if (onTopicSelect) onTopicSelect(topic || null)
|
||||
else setLocalSelectedTopic(topic || null)
|
||||
|
||||
if (onViewStateChange) onViewStateChange('posts')
|
||||
else setLocalViewState('posts')
|
||||
|
||||
setIsSearchModalOpen(false)
|
||||
}
|
||||
|
||||
const handleCategoryClick = (category: ForumCategory) => {
|
||||
if (onCategorySelect) {
|
||||
const goToTopics = useCallback(
|
||||
(category: ForumCategory | null) => {
|
||||
onCategorySelect(category)
|
||||
} else {
|
||||
setLocalSelectedCategory(category)
|
||||
}
|
||||
onTopicSelect(null)
|
||||
onViewStateChange(category ? 'topics' : 'categories')
|
||||
},
|
||||
[onCategorySelect, onTopicSelect, onViewStateChange],
|
||||
)
|
||||
|
||||
if (onViewStateChange) {
|
||||
onViewStateChange('topics')
|
||||
} else {
|
||||
setLocalViewState('topics')
|
||||
}
|
||||
}
|
||||
const goToPosts = useCallback(
|
||||
(topic: ForumTopic, category: ForumCategory | null) => {
|
||||
onCategorySelect(category)
|
||||
onTopicSelect(topic)
|
||||
onViewStateChange('posts')
|
||||
},
|
||||
[onCategorySelect, onTopicSelect, onViewStateChange],
|
||||
)
|
||||
|
||||
const handleTopicClick = async (topic: ForumTopic) => {
|
||||
try {
|
||||
const updatedTopic = await forumService.getTopicById(topic.id)
|
||||
if (onTopicSelect) {
|
||||
onTopicSelect(updatedTopic)
|
||||
} else {
|
||||
setLocalSelectedTopic(updatedTopic)
|
||||
const handleBack = useCallback(() => {
|
||||
if (viewState === 'posts') {
|
||||
goToTopics(selectedCategory)
|
||||
return
|
||||
}
|
||||
goToCategories()
|
||||
}, [goToCategories, goToTopics, selectedCategory, viewState])
|
||||
|
||||
const handleCategoryClick = useCallback(
|
||||
(category: ForumCategory) => goToTopics(category),
|
||||
[goToTopics],
|
||||
)
|
||||
|
||||
const handleTopicClick = useCallback(
|
||||
async (topic: ForumTopic) => {
|
||||
// getTopicById görüntülenme sayacını da işler; başarısız olursa navigasyonu engellemeyiz.
|
||||
let target = topic
|
||||
try {
|
||||
target = await forumService.getTopicById(topic.id)
|
||||
} catch (err) {
|
||||
console.error('Failed to load topic:', err)
|
||||
}
|
||||
goToPosts(target, findCategoryOfTopic(target))
|
||||
},
|
||||
[findCategoryOfTopic, goToPosts],
|
||||
)
|
||||
|
||||
if (onViewStateChange) {
|
||||
onViewStateChange('posts')
|
||||
} else {
|
||||
setLocalViewState('posts')
|
||||
}
|
||||
const handleSearchCategorySelect = useCallback(
|
||||
(category: ForumCategory) => {
|
||||
goToTopics(category)
|
||||
setIsSearchModalOpen(false)
|
||||
},
|
||||
[goToTopics],
|
||||
)
|
||||
|
||||
const category = categories.find((c) => c.id === updatedTopic.categoryId)
|
||||
if (onCategorySelect) onCategorySelect(category || null)
|
||||
else setLocalSelectedCategory(category || null)
|
||||
} catch (err) {
|
||||
console.error('Failed to load topic:', err)
|
||||
}
|
||||
}
|
||||
const handleSearchTopicSelect = useCallback(
|
||||
(topic: ForumTopic) => {
|
||||
goToPosts(topic, findCategoryOfTopic(topic))
|
||||
setIsSearchModalOpen(false)
|
||||
},
|
||||
[findCategoryOfTopic, goToPosts],
|
||||
)
|
||||
|
||||
const handleBreadcrumbClick = (target: 'forum' | 'category') => {
|
||||
if (target === 'forum') {
|
||||
onViewStateChange?.('categories')
|
||||
onCategorySelect?.(null)
|
||||
onTopicSelect?.(null)
|
||||
} else if (target === 'category' && selectedCategory) {
|
||||
onViewStateChange?.('topics')
|
||||
onTopicSelect?.(null)
|
||||
}
|
||||
}
|
||||
const handleSearchPostSelect = useCallback(
|
||||
(post: ForumPost) => {
|
||||
const topic = topics.find((t) => t.id === post.topicId)
|
||||
if (!topic) return
|
||||
goToPosts(topic, findCategoryOfTopic(topic))
|
||||
setIsSearchModalOpen(false)
|
||||
},
|
||||
[findCategoryOfTopic, goToPosts, topics],
|
||||
)
|
||||
|
||||
const filteredTopics = selectedCategory
|
||||
? topics.filter((topic) => topic.categoryId === selectedCategory.id)
|
||||
: []
|
||||
const visibleCategories = useMemo(
|
||||
() =>
|
||||
categories
|
||||
.filter((cat) => cat.isActive)
|
||||
.sort((a, b) => a.displayOrder - b.displayOrder),
|
||||
[categories],
|
||||
)
|
||||
|
||||
const filteredPosts = selectedTopic
|
||||
? posts.filter((post) => post.topicId === selectedTopic.id)
|
||||
: []
|
||||
const filteredTopics = useMemo(() => {
|
||||
if (!selectedCategory) return []
|
||||
return topics
|
||||
.filter((topic) => topic.categoryId === selectedCategory.id)
|
||||
.sort((a, b) => {
|
||||
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1
|
||||
return new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime()
|
||||
})
|
||||
}, [selectedCategory, topics])
|
||||
|
||||
const handleCreateTopic = async (topicData: { title: string; content: string }) => {
|
||||
if (!selectedCategory) return
|
||||
const threadedPosts = useMemo(
|
||||
() => (selectedTopic ? buildPostTree(posts.filter((p) => p.topicId === selectedTopic.id)) : []),
|
||||
[posts, selectedTopic],
|
||||
)
|
||||
|
||||
try {
|
||||
const handleCreateTopic = useCallback(
|
||||
async (topicData: { title: string; content: string }) => {
|
||||
if (!selectedCategory) return
|
||||
await onCreateTopic({
|
||||
title: topicData.title,
|
||||
content: topicData.content,
|
||||
categoryId: selectedCategory.id,
|
||||
isPinned: false,
|
||||
isLocked: false,
|
||||
tenantId: tenant.tenantId,
|
||||
tenantId: tenant?.tenantId,
|
||||
})
|
||||
setShowCreateTopic(false)
|
||||
} catch (error) {
|
||||
console.error('Error creating topic:', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
[onCreateTopic, selectedCategory, tenant?.tenantId],
|
||||
)
|
||||
|
||||
const handleCreatePost = async (postData: { content: string }) => {
|
||||
if (!selectedTopic) return
|
||||
|
||||
try {
|
||||
const handleCreatePost = useCallback(
|
||||
async (postData: { content: string }) => {
|
||||
if (!selectedTopic) return
|
||||
await onCreatePost({
|
||||
topicId: selectedTopic.id,
|
||||
content: postData.content,
|
||||
parentPostId: replyToPostId,
|
||||
tenantId: tenant.tenantId,
|
||||
tenantId: tenant?.tenantId,
|
||||
})
|
||||
setShowCreatePost(false)
|
||||
setReplyToPostId(undefined)
|
||||
} catch (error) {
|
||||
console.error('Error creating post:', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
[onCreatePost, replyToPostId, selectedTopic, tenant?.tenantId],
|
||||
)
|
||||
|
||||
const threadedPosts = buildPostTree(filteredPosts)
|
||||
const handleLike = useCallback(
|
||||
async (postId: string, isFirst = false) => {
|
||||
const isLiked = likedPosts.has(postId)
|
||||
try {
|
||||
if (isFirst) {
|
||||
// İlk kart topic'in kendisidir; topic uçları post uçlarından ayrıdır.
|
||||
const updatedTopic = isLiked
|
||||
? await forumService.unlikeTopic(postId)
|
||||
: await forumService.likeTopic(postId)
|
||||
onTopicSelect(updatedTopic)
|
||||
} else if (isLiked) {
|
||||
await onUnlikePost(postId)
|
||||
} else {
|
||||
await onLikePost(postId)
|
||||
}
|
||||
|
||||
function renderPosts(posts: ForumPost[]) {
|
||||
return posts.map((post) => (
|
||||
setLikedPosts((prev) => {
|
||||
const updated = new Set(prev)
|
||||
if (isLiked) updated.delete(postId)
|
||||
else updated.add(postId)
|
||||
return updated
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error liking/unliking post or topic:', error)
|
||||
}
|
||||
},
|
||||
[likedPosts, onLikePost, onTopicSelect, onUnlikePost],
|
||||
)
|
||||
|
||||
const handleReply = useCallback((postId: string) => {
|
||||
setReplyToPostId(postId)
|
||||
setShowCreatePost(true)
|
||||
}, [])
|
||||
|
||||
const renderPosts = (items: ForumPost[], depth = 0) =>
|
||||
items.map((post) => (
|
||||
<div key={post.id}>
|
||||
<ForumPostCard
|
||||
post={post}
|
||||
isLiked={likedPosts.has(post.id)}
|
||||
onLike={handleLike}
|
||||
onReply={handleReply}
|
||||
isLiked={likedPosts.has(post.id)}
|
||||
/>
|
||||
{post.children.length > 0 && (
|
||||
<div className="pl-6 border-gray-200 dark:border-gray-700 mt-4">
|
||||
{renderPosts(post.children)}
|
||||
<div
|
||||
className={`mt-4 space-y-4 border-l border-gray-200 dark:border-gray-700 ${
|
||||
depth < MAX_THREAD_DEPTH ? 'pl-6' : 'pl-2'
|
||||
}`}
|
||||
>
|
||||
{renderPosts(post.children, depth + 1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
const handleLike = async (postId: string, isFirst: boolean = false) => {
|
||||
try {
|
||||
const isLiked = likedPosts.has(postId)
|
||||
|
||||
if (isLiked) {
|
||||
if (isFirst) {
|
||||
await forumService.unlikeTopic(postId)
|
||||
} else {
|
||||
await onUnlikePost(postId)
|
||||
}
|
||||
} else {
|
||||
if (isFirst) {
|
||||
await forumService.likeTopic(postId)
|
||||
} else {
|
||||
await onLikePost(postId)
|
||||
}
|
||||
}
|
||||
|
||||
setLikedPosts((prev) => {
|
||||
const updated = new Set(prev)
|
||||
if (isLiked) updated.delete(postId)
|
||||
else updated.add(postId)
|
||||
return updated
|
||||
})
|
||||
|
||||
if (selectedTopic?.id === postId) {
|
||||
setPostLikeCounts((prev) => ({
|
||||
...prev,
|
||||
[postId]: (prev[postId] ?? selectedTopic.likeCount) + (isLiked ? -1 : 1),
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error liking/unliking post or topic:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReply = (postId: string) => {
|
||||
setReplyToPostId(postId)
|
||||
setShowCreatePost(true)
|
||||
}
|
||||
const emptyState = (
|
||||
<div className="rounded-xl border border-dashed border-gray-300 p-8 text-center text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
||||
{translate('::App.Platform.NoDataFound')}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<FaSpinner className="w-8 h-8 animate-spin text-blue-600" />
|
||||
<span className="ml-2 text-gray-600 dark:text-gray-300">Loading forum data...</span>
|
||||
</div>
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<FaSpinner className="h-8 w-8 animate-spin text-blue-600" />
|
||||
<span className="ml-2 text-gray-600 dark:text-gray-300">
|
||||
{translate('::App.Platform.LoadingWithThreeDot')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -288,48 +271,67 @@ export function ForumView({
|
|||
return (
|
||||
<>
|
||||
{/* Breadcrumb + Actions + Search Row */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{/* Left Side: Breadcrumb */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
{/* Left Side: Back + Breadcrumb */}
|
||||
<div className="flex min-w-0 items-center space-x-2">
|
||||
{viewState !== 'categories' && (
|
||||
<FaArrowLeft className="w-4 h-4 text-gray-700 dark:text-gray-200" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="plain"
|
||||
shape="circle"
|
||||
size="xs"
|
||||
icon={<FaArrowLeft />}
|
||||
aria-label={translate('::App.Platform.Back')}
|
||||
title={translate('::App.Platform.Back')}
|
||||
className="!bg-transparent text-gray-700 dark:text-gray-200"
|
||||
onClick={handleBack}
|
||||
/>
|
||||
)}
|
||||
<nav className="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<nav
|
||||
aria-label="breadcrumb"
|
||||
className="flex min-w-0 items-center space-x-2 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{viewState === 'categories' ? (
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{translate('::App.Forum')}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="plain"
|
||||
shape="none"
|
||||
className="!inline-flex !h-auto items-center !bg-transparent !p-0 font-medium text-gray-900 transition-colors hover:text-blue-600 dark:text-gray-100 dark:hover:text-blue-400"
|
||||
onClick={goToCategories}
|
||||
>
|
||||
{translate('::App.Forum')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{selectedCategory && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="plain"
|
||||
shape="none"
|
||||
onClick={() => handleBreadcrumbClick('forum')}
|
||||
className={`!inline-flex !h-auto items-center !bg-transparent !p-0 transition-colors ${
|
||||
viewState === 'categories'
|
||||
? 'text-gray-900 dark:text-gray-100 font-medium cursor-default'
|
||||
: 'hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Forum</div>
|
||||
</Button>
|
||||
<span>/</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="plain"
|
||||
shape="none"
|
||||
onClick={() => handleBreadcrumbClick('category')}
|
||||
className={`!inline-flex !h-auto items-center !bg-transparent !p-0 transition-colors ${
|
||||
viewState === 'topics'
|
||||
? 'text-gray-900 dark:text-gray-100 font-medium cursor-default'
|
||||
: 'hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
{selectedCategory.name}
|
||||
</Button>
|
||||
<span aria-hidden="true">/</span>
|
||||
{viewState === 'topics' ? (
|
||||
<span className="truncate font-medium text-gray-900 dark:text-gray-100">
|
||||
{selectedCategory.name}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="plain"
|
||||
shape="none"
|
||||
className="!inline-flex !h-auto items-center !bg-transparent !p-0 transition-colors hover:text-blue-600 dark:hover:text-blue-400"
|
||||
onClick={() => goToTopics(selectedCategory)}
|
||||
>
|
||||
{selectedCategory.name}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{selectedTopic && (
|
||||
|
||||
{viewState === 'posts' && selectedTopic && (
|
||||
<>
|
||||
<span>/</span>
|
||||
<span className="text-gray-900 dark:text-gray-100 font-medium">
|
||||
<span aria-hidden="true">/</span>
|
||||
<span className="truncate font-medium text-gray-900 dark:text-gray-100">
|
||||
{selectedTopic.title}
|
||||
</span>
|
||||
</>
|
||||
|
|
@ -338,14 +340,14 @@ export function ForumView({
|
|||
</div>
|
||||
|
||||
{/* Right Side: Actions + Search */}
|
||||
<div className="flex items-center space-x-2 ml-auto">
|
||||
<div className="ml-auto flex shrink-0 items-center space-x-2">
|
||||
{viewState === 'topics' && selectedCategory && !selectedCategory.isLocked && (
|
||||
<Button
|
||||
size="sm"
|
||||
icon={<FaPlus className="w-4 h-4" />}
|
||||
icon={<FaPlus className="h-4 w-4" />}
|
||||
variant="solid"
|
||||
onClick={() => setShowCreateTopic(true)}
|
||||
className="!inline-flex items-center gap-2 rounded-lg !bg-blue-600 !px-4 py-2 text-white transition-colors hover:!bg-blue-700 dark:!bg-blue-700 dark:hover:!bg-blue-800"
|
||||
onClick={() => setShowCreateTopic(true)}
|
||||
>
|
||||
<span>{translate('::App.ForumTopicManagement.NewTopic')}</span>
|
||||
</Button>
|
||||
|
|
@ -353,22 +355,25 @@ export function ForumView({
|
|||
{viewState === 'posts' && selectedTopic && !selectedTopic.isLocked && (
|
||||
<Button
|
||||
size="sm"
|
||||
icon={<FaPlus className="w-4 h-4" />}
|
||||
icon={<FaPlus className="h-4 w-4" />}
|
||||
variant="solid"
|
||||
onClick={() => setShowCreatePost(true)}
|
||||
className="!inline-flex items-center gap-2 rounded-lg !bg-emerald-600 !px-4 py-2 text-white transition-colors hover:!bg-emerald-700 dark:!bg-emerald-700 dark:hover:!bg-emerald-800"
|
||||
onClick={() => {
|
||||
setReplyToPostId(undefined)
|
||||
setShowCreatePost(true)
|
||||
}}
|
||||
>
|
||||
<span>{translate('::App.ForumPostManagement.NewPost')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
{/* Search — geniş ekranda etiketli, dar ekranda ikon */}
|
||||
<Button
|
||||
size="sm"
|
||||
icon={<FaSearch className="w-4 h-4" />}
|
||||
onClick={() => setIsSearchModalOpen(true)}
|
||||
icon={<FaSearch className="h-4 w-4" />}
|
||||
variant="default"
|
||||
className="hidden items-center gap-2 rounded-lg border border-gray-300 !px-4 py-2 transition-colors hover:!bg-gray-50 dark:border-gray-700 dark:hover:!bg-gray-800 md:!inline-flex"
|
||||
onClick={() => setIsSearchModalOpen(true)}
|
||||
>
|
||||
<span className="text-gray-500 dark:text-gray-300">
|
||||
{translate('::App.ForumTopicManagement.Searchtopics')}
|
||||
|
|
@ -377,115 +382,109 @@ export function ForumView({
|
|||
|
||||
<Button
|
||||
size="sm"
|
||||
icon={<FaSearch className="w-5 h-5" />}
|
||||
onClick={() => setIsSearchModalOpen(true)}
|
||||
icon={<FaSearch className="h-5 w-5" />}
|
||||
variant="default"
|
||||
aria-label={translate('::App.Platform.Search')}
|
||||
title={translate('::App.Platform.Search')}
|
||||
className="!inline-flex !h-auto items-center justify-center !p-2 text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-300 dark:hover:text-gray-100 md:!hidden"
|
||||
></Button>
|
||||
onClick={() => setIsSearchModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories View */}
|
||||
{viewState === 'categories' && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
{[...categories]
|
||||
.filter((cat) => cat.isActive)
|
||||
.sort((a, b) => a.displayOrder - b.displayOrder)
|
||||
.map((category) => (
|
||||
<ForumCategoryCard
|
||||
key={category.id}
|
||||
category={category}
|
||||
onClick={() => handleCategoryClick(category)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{visibleCategories.length === 0
|
||||
? emptyState
|
||||
: visibleCategories.map((category) => (
|
||||
<ForumCategoryCard
|
||||
key={category.id}
|
||||
category={category}
|
||||
onClick={() => handleCategoryClick(category)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Topics View */}
|
||||
{viewState === 'topics' && selectedCategory && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">{selectedCategory.name}</h2>
|
||||
<div className="space-y-4">
|
||||
{[...filteredTopics]
|
||||
.sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1
|
||||
if (!a.isPinned && b.isPinned) return 1
|
||||
return new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime()
|
||||
})
|
||||
.map((topic) => (
|
||||
<div>
|
||||
<h2 className="mb-6 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{selectedCategory.name}
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{filteredTopics.length === 0
|
||||
? emptyState
|
||||
: filteredTopics.map((topic) => (
|
||||
<ForumTopicCard
|
||||
key={topic.id}
|
||||
topic={topic}
|
||||
onClick={() => handleTopicClick(topic)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Posts View */}
|
||||
{viewState === 'posts' && selectedTopic && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">{selectedTopic.title}</h2>
|
||||
<div>
|
||||
<h2 className="mb-6 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{selectedTopic.title}
|
||||
</h2>
|
||||
|
||||
{/* Topic Ana İçeriği */}
|
||||
<ForumPostCard
|
||||
post={{
|
||||
id: selectedTopic.id,
|
||||
topicId: selectedTopic.id,
|
||||
content: selectedTopic.content,
|
||||
authorId: selectedTopic.authorId,
|
||||
authorName: selectedTopic.authorName,
|
||||
authorTitle: selectedTopic.authorTitle,
|
||||
authorEmail: selectedTopic.authorEmail,
|
||||
authorPhoneNumber: selectedTopic.authorPhoneNumber,
|
||||
authorDepartment: selectedTopic.authorDepartment,
|
||||
likeCount: postLikeCounts[selectedTopic.id] ?? selectedTopic.likeCount,
|
||||
isAcceptedAnswer: false,
|
||||
parentPostId: undefined,
|
||||
creationTime: selectedTopic.creationTime,
|
||||
tenantId: selectedTopic.tenantId,
|
||||
children: [],
|
||||
}}
|
||||
onLike={handleLike}
|
||||
onReply={handleReply}
|
||||
isFirst={true}
|
||||
isLiked={likedPosts.has(selectedTopic.id)}
|
||||
/>
|
||||
{/* Topic ana içeriği */}
|
||||
<ForumPostCard
|
||||
isFirst
|
||||
post={{
|
||||
id: selectedTopic.id,
|
||||
topicId: selectedTopic.id,
|
||||
content: selectedTopic.content,
|
||||
authorId: selectedTopic.authorId,
|
||||
authorName: selectedTopic.authorName,
|
||||
authorTitle: selectedTopic.authorTitle,
|
||||
authorEmail: selectedTopic.authorEmail,
|
||||
authorPhoneNumber: selectedTopic.authorPhoneNumber,
|
||||
authorDepartment: selectedTopic.authorDepartment,
|
||||
likeCount: selectedTopic.likeCount,
|
||||
isAcceptedAnswer: false,
|
||||
parentPostId: undefined,
|
||||
creationTime: selectedTopic.creationTime,
|
||||
tenantId: selectedTopic.tenantId,
|
||||
children: [],
|
||||
}}
|
||||
isLiked={likedPosts.has(selectedTopic.id)}
|
||||
onLike={handleLike}
|
||||
onReply={handleReply}
|
||||
/>
|
||||
|
||||
{/* Hiyerarşik Postlar */}
|
||||
<div className="mt-4 space-y-4">{renderPosts(threadedPosts)}</div>
|
||||
</div>
|
||||
{/* Hiyerarşik postlar */}
|
||||
<div className="mt-4 space-y-4">{renderPosts(threadedPosts)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Topic Modal */}
|
||||
{showCreateTopic && (
|
||||
<CreateTopicModal onClose={() => setShowCreateTopic(false)} onSubmit={handleCreateTopic} />
|
||||
)}
|
||||
|
||||
{/* Create Post Modal */}
|
||||
{showCreatePost && (
|
||||
<CreatePostModal
|
||||
onClose={() => setShowCreatePost(false)}
|
||||
parentPostId={replyToPostId}
|
||||
onClose={() => {
|
||||
setShowCreatePost(false)
|
||||
setReplyToPostId(undefined)
|
||||
}}
|
||||
onSubmit={handleCreatePost}
|
||||
parentPostId={replyToPostId!}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SearchModal
|
||||
isOpen={isSearchModalOpen}
|
||||
onClose={() => setIsSearchModalOpen(false)}
|
||||
categories={categories}
|
||||
topics={topics}
|
||||
posts={posts}
|
||||
onClose={() => setIsSearchModalOpen(false)}
|
||||
onCategorySelect={handleSearchCategorySelect}
|
||||
onTopicSelect={handleSearchTopicSelect}
|
||||
onPostSelect={handleSearchPostSelect}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useState, useEffect, type KeyboardEvent } from 'react'
|
||||
import { useEffect, useMemo, useState, type KeyboardEvent } from 'react'
|
||||
import { FaTimes, FaSearch, FaFolder, FaRegComment, FaFileAlt } from 'react-icons/fa'
|
||||
import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum'
|
||||
import { useForumSearch } from '@/utils/hooks/useForumSearch'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { formatForumDate } from './utils'
|
||||
import { formatForumDate, sanitizeForumHtml } from './utils'
|
||||
|
||||
interface SearchModalProps {
|
||||
isOpen: boolean
|
||||
|
|
@ -16,6 +17,11 @@ interface SearchModalProps {
|
|||
onPostSelect: (post: ForumPost) => void
|
||||
}
|
||||
|
||||
const RESULT_ROW_CLASS =
|
||||
'w-full !h-auto !justify-start !rounded-none !px-4 !py-3 transition-colors hover:!bg-gray-50 dark:hover:!bg-gray-800'
|
||||
const RESULT_ROW_ACTIVE_CLASS =
|
||||
'!bg-blue-50 dark:!bg-blue-900 border-r-2 border-blue-500 dark:border-blue-400'
|
||||
|
||||
export function SearchModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
|
|
@ -26,7 +32,8 @@ export function SearchModal({
|
|||
onTopicSelect,
|
||||
onPostSelect,
|
||||
}: SearchModalProps) {
|
||||
const { searchQuery, setSearchQuery, searchResults, hasResults } = useForumSearch({
|
||||
const { translate } = useLocalization()
|
||||
const { searchQuery, setSearchQuery, searchResults, hasResults, clearSearch } = useForumSearch({
|
||||
categories,
|
||||
topics,
|
||||
posts,
|
||||
|
|
@ -34,142 +41,153 @@ export function SearchModal({
|
|||
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
// Modal her açıldığında temiz başlar; sonuçlar değişince seçim başa döner.
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectedIndex(0)
|
||||
} else {
|
||||
clearSearch()
|
||||
}
|
||||
}, [isOpen, searchResults])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [searchResults])
|
||||
|
||||
const totalResults = searchResults.totalCount
|
||||
|
||||
const topicTitleById = useMemo(
|
||||
() => new Map(topics.map((topic) => [topic.id, topic.title])),
|
||||
[topics],
|
||||
)
|
||||
|
||||
const sanitizedPostPreviews = useMemo(
|
||||
() => new Map(searchResults.posts.map((post) => [post.id, sanitizeForumHtml(post.content)])),
|
||||
[searchResults.posts],
|
||||
)
|
||||
|
||||
const selectResultAt = (index: number) => {
|
||||
const { categories: cats, topics: tops, posts: pos } = searchResults
|
||||
|
||||
if (index < cats.length) {
|
||||
onCategorySelect(cats[index])
|
||||
} else if (index < cats.length + tops.length) {
|
||||
onTopicSelect(tops[index - cats.length])
|
||||
} else if (index < cats.length + tops.length + pos.length) {
|
||||
onPostSelect(pos[index - cats.length - tops.length])
|
||||
} else {
|
||||
return
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const totalResults =
|
||||
searchResults.categories.length + searchResults.topics.length + searchResults.posts.length
|
||||
|
||||
if (totalResults === 0 && ['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (!['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)) return
|
||||
|
||||
e.preventDefault()
|
||||
if (totalResults === 0) return
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev + 1) % totalResults)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev - 1 + totalResults) % totalResults)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSelectResult(selectedIndex)
|
||||
} else if (e.key === 'Escape') {
|
||||
onClose()
|
||||
} else {
|
||||
selectResultAt(selectedIndex)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectResult = (index: number) => {
|
||||
let currentIndex = 0
|
||||
|
||||
// Check categories
|
||||
if (index < searchResults.categories.length) {
|
||||
onCategorySelect(searchResults.categories[index])
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
currentIndex += searchResults.categories.length
|
||||
|
||||
// Check topics
|
||||
if (index < currentIndex + searchResults.topics.length) {
|
||||
onTopicSelect(searchResults.topics[index - currentIndex])
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
currentIndex += searchResults.topics.length
|
||||
|
||||
// Check posts
|
||||
if (index < currentIndex + searchResults.posts.length) {
|
||||
onPostSelect(searchResults.posts[index - currentIndex])
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const getTopicTitle = (topicId: string) => {
|
||||
const topic = topics.find((t) => t.id === topicId)
|
||||
return topic ? topic.title : 'Unknown Topic'
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-start justify-center pt-20 p-4 z-50">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-xl max-w-2xl w-full max-h-[70vh] overflow-hidden">
|
||||
<div className="flex items-center p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<FaSearch className="w-5 h-5 text-gray-400 dark:text-gray-500 mr-3" />
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-black bg-opacity-50 p-4 pt-20"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="max-h-[70vh] w-full max-w-2xl overflow-hidden rounded-xl bg-white shadow-xl dark:bg-gray-900"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center border-b border-gray-200 p-4 dark:border-gray-700">
|
||||
<FaSearch className="mr-3 h-5 w-5 text-gray-400 dark:text-gray-500" aria-hidden="true" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
placeholder={translate('::App.ForumTopicManagement.Searchtopics')}
|
||||
aria-label={translate('::App.Platform.Search')}
|
||||
className="flex-1 bg-transparent text-lg text-gray-900 outline-none placeholder-gray-400 dark:text-white dark:placeholder-gray-500"
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search categories, topics, and posts..."
|
||||
className="flex-1 outline-none text-lg bg-transparent text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="plain"
|
||||
shape="circle"
|
||||
icon={<FaTimes className="h-5 w-5" />}
|
||||
aria-label={translate('::App.Platform.Close')}
|
||||
title={translate('::App.Platform.Close')}
|
||||
className="ml-3 !h-8 !w-8 !px-0 text-gray-400 transition-colors hover:!bg-transparent hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-96">
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{!searchQuery.trim() ? (
|
||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<FaSearch className="w-12 h-12 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
|
||||
<p>Start typing to search categories, topics, and posts...</p>
|
||||
<FaSearch
|
||||
className="mx-auto mb-4 h-12 w-12 text-gray-300 dark:text-gray-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p>{translate('::App.Platform.Search')}</p>
|
||||
</div>
|
||||
) : !hasResults ? (
|
||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<p>No results found for {searchQuery}</p>
|
||||
<p>{translate('::App.Platform.NoResults')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2">
|
||||
{/* Categories */}
|
||||
{searchResults.categories.length > 0 && (
|
||||
<div>
|
||||
<div className="px-4 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide bg-gray-50 dark:bg-gray-800">
|
||||
Categories ({searchResults.categories.length})
|
||||
<div className="bg-gray-50 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:bg-gray-800 dark:text-gray-400">
|
||||
{translate('::App.Platform.Category')} ({searchResults.categories.length})
|
||||
</div>
|
||||
{searchResults.categories.map((category, index) => (
|
||||
<Button
|
||||
key={`category-${category.id}`}
|
||||
variant="plain"
|
||||
shape="none"
|
||||
className={`${RESULT_ROW_CLASS} ${
|
||||
selectedIndex === index ? RESULT_ROW_ACTIVE_CLASS : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
onCategorySelect(category)
|
||||
onClose()
|
||||
}}
|
||||
variant="plain"
|
||||
shape="none"
|
||||
className={`w-full !h-auto !justify-start !rounded-none !px-4 !py-3 transition-colors hover:!bg-gray-50 dark:hover:!bg-gray-800 ${
|
||||
selectedIndex === index
|
||||
? '!bg-blue-50 dark:!bg-blue-900 border-r-2 border-blue-500 dark:border-blue-400'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
|
||||
<FaFolder className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex flex-1 items-center space-x-3">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900">
|
||||
<FaFolder className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
{category.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 line-clamp-1">
|
||||
<div className="line-clamp-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{category.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{category.topicCount} topics
|
||||
{category.topicCount} {translate('::App.ForumDashboard.Topics')}
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
|
|
@ -179,43 +197,39 @@ export function SearchModal({
|
|||
{/* Topics */}
|
||||
{searchResults.topics.length > 0 && (
|
||||
<div>
|
||||
<div className="px-4 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide bg-gray-50 dark:bg-gray-800">
|
||||
Topics ({searchResults.topics.length})
|
||||
<div className="bg-gray-50 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:bg-gray-800 dark:text-gray-400">
|
||||
{translate('::App.ForumDashboard.Topics')} ({searchResults.topics.length})
|
||||
</div>
|
||||
{searchResults.topics.map((topic, index) => {
|
||||
const globalIndex = searchResults.categories.length + index
|
||||
return (
|
||||
<Button
|
||||
key={`topic-${topic.id}`}
|
||||
variant="plain"
|
||||
shape="none"
|
||||
className={`${RESULT_ROW_CLASS} ${
|
||||
selectedIndex === globalIndex ? RESULT_ROW_ACTIVE_CLASS : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
onTopicSelect(topic)
|
||||
onClose()
|
||||
}}
|
||||
variant="plain"
|
||||
shape="none"
|
||||
className={`w-full !h-auto !justify-start !rounded-none !px-4 !py-3 transition-colors hover:!bg-gray-50 dark:hover:!bg-gray-800 ${
|
||||
selectedIndex === globalIndex
|
||||
? '!bg-blue-50 dark:!bg-blue-900 border-r-2 border-blue-500 dark:border-blue-400'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-emerald-100 dark:bg-emerald-900 rounded-lg flex items-center justify-center">
|
||||
<FaRegComment className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div className="flex flex-1 items-center space-x-3">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900">
|
||||
<FaRegComment className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-gray-900 dark:text-white line-clamp-1">
|
||||
<div className="line-clamp-1 font-medium text-gray-900 dark:text-white">
|
||||
{topic.title}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
by {topic.authorName} • {formatForumDate(topic.creationTime)}
|
||||
{topic.authorName} • {formatForumDate(topic.creationTime)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{topic.replyCount} replies
|
||||
{topic.replyCount} {translate('::App.ForumDashboard.Posts')}
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
|
|
@ -226,8 +240,8 @@ export function SearchModal({
|
|||
{/* Posts */}
|
||||
{searchResults.posts.length > 0 && (
|
||||
<div>
|
||||
<div className="px-4 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide bg-gray-50 dark:bg-gray-800">
|
||||
Posts ({searchResults.posts.length})
|
||||
<div className="bg-gray-50 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:bg-gray-800 dark:text-gray-400">
|
||||
{translate('::App.ForumDashboard.Posts')} ({searchResults.posts.length})
|
||||
</div>
|
||||
{searchResults.posts.map((post, index) => {
|
||||
const globalIndex =
|
||||
|
|
@ -235,38 +249,39 @@ export function SearchModal({
|
|||
return (
|
||||
<Button
|
||||
key={`post-${post.id}`}
|
||||
variant="plain"
|
||||
shape="none"
|
||||
className={`${RESULT_ROW_CLASS} ${
|
||||
selectedIndex === globalIndex ? RESULT_ROW_ACTIVE_CLASS : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
onPostSelect(post)
|
||||
onClose()
|
||||
}}
|
||||
variant="plain"
|
||||
shape="none"
|
||||
className={`w-full !h-auto !justify-start !rounded-none !px-4 !py-3 transition-colors hover:!bg-gray-50 ${
|
||||
selectedIndex === globalIndex
|
||||
? '!bg-blue-50 border-r-2 border-blue-500'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<FaFileAlt className="w-4 h-4 text-orange-600" />
|
||||
</div>
|
||||
<div className="flex flex-1 items-center space-x-3">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-orange-100 dark:bg-orange-900">
|
||||
<FaFileAlt className="h-4 w-4 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-gray-900 text-sm line-clamp-1">
|
||||
{getTopicTitle(post.topicId)}
|
||||
<div className="min-w-0 text-left">
|
||||
<div className="line-clamp-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{topicTitleById.get(post.topicId) ??
|
||||
translate('::App.Platform.Unknown')}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm text-gray-600 line-clamp-2 mt-1"
|
||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
||||
></div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
by {post.authorName} • {formatForumDate(post.creationTime)}
|
||||
className="mt-1 line-clamp-2 text-sm text-gray-600 dark:text-gray-400"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizedPostPreviews.get(post.id) ?? '',
|
||||
}}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{post.authorName} • {formatForumDate(post.creationTime)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">{post.likeCount} likes</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{post.likeCount}
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
|
|
@ -277,8 +292,8 @@ export function SearchModal({
|
|||
</div>
|
||||
|
||||
{hasResults && (
|
||||
<div className="px-4 py-2 border-t border-gray-200 text-xs text-gray-500 bg-gray-50">
|
||||
Use ↑↓ to navigate, Enter to select, Esc to close
|
||||
<div className="border-t border-gray-200 bg-gray-50 px-4 py-2 text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
|
||||
↑↓ / Enter / Esc
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
81
ui/src/views/forum/forum/utils.ts
Normal file
81
ui/src/views/forum/forum/utils.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import DOMPurify from 'dompurify'
|
||||
import { ForumPost } from '@/proxy/forum/forum'
|
||||
import { showDbDateAsIs } from '@/utils/dateUtils'
|
||||
|
||||
/**
|
||||
* Postları parentPostId üzerinden ağaç yapısına çevirir.
|
||||
* Parenti listede bulunmayan postlar kök olarak ele alınır (sayfalama sonucu kopan dallar kaybolmasın).
|
||||
*/
|
||||
export function buildPostTree(posts: ForumPost[]): ForumPost[] {
|
||||
const postMap = new Map<string, ForumPost>()
|
||||
|
||||
posts.forEach((post) => {
|
||||
postMap.set(post.id, { ...post, children: [] })
|
||||
})
|
||||
|
||||
const roots: ForumPost[] = []
|
||||
|
||||
postMap.forEach((post) => {
|
||||
const parent = post.parentPostId ? postMap.get(post.parentPostId) : undefined
|
||||
if (parent && parent.id !== post.id) {
|
||||
parent.children.push(post)
|
||||
} else {
|
||||
roots.push(post)
|
||||
}
|
||||
})
|
||||
|
||||
const byCreationTime = (a: ForumPost, b: ForumPost) =>
|
||||
new Date(a.creationTime).getTime() - new Date(b.creationTime).getTime()
|
||||
|
||||
const sortTree = (items: ForumPost[]) => {
|
||||
items.sort(byCreationTime)
|
||||
items.forEach((item) => sortTree(item.children))
|
||||
}
|
||||
sortTree(roots)
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
export function formatForumDate(value?: string | Date) {
|
||||
if (!value) return ''
|
||||
return showDbDateAsIs(typeof value === 'string' ? value : value.toISOString())
|
||||
}
|
||||
|
||||
const RELATIVE_UNITS: Array<{ unit: Intl.RelativeTimeFormatUnit; ms: number }> = [
|
||||
{ unit: 'year', ms: 365 * 24 * 60 * 60 * 1000 },
|
||||
{ unit: 'month', ms: 30 * 24 * 60 * 60 * 1000 },
|
||||
{ unit: 'day', ms: 24 * 60 * 60 * 1000 },
|
||||
{ unit: 'hour', ms: 60 * 60 * 1000 },
|
||||
{ unit: 'minute', ms: 60 * 1000 },
|
||||
]
|
||||
|
||||
/**
|
||||
* "3 saat önce" tarzı göreli tarih. Gün bazlı yuvarlama yerine en uygun birimi seçer.
|
||||
*/
|
||||
export function formatForumRelativeDate(dateString?: string, locale?: string) {
|
||||
if (!dateString) return '—'
|
||||
|
||||
const time = new Date(dateString).getTime()
|
||||
if (Number.isNaN(time)) return '—'
|
||||
|
||||
const diff = time - Date.now()
|
||||
const formatter = new Intl.RelativeTimeFormat(locale || undefined, { numeric: 'auto' })
|
||||
|
||||
for (const { unit, ms } of RELATIVE_UNITS) {
|
||||
if (Math.abs(diff) >= ms) {
|
||||
return formatter.format(Math.round(diff / ms), unit)
|
||||
}
|
||||
}
|
||||
|
||||
return formatter.format(Math.round(diff / 1000), 'second')
|
||||
}
|
||||
|
||||
/** HtmlEditor içeriği kullanıcı girdisidir; DOM'a basmadan önce mutlaka temizlenir. */
|
||||
export function sanitizeForumHtml(html?: string) {
|
||||
return DOMPurify.sanitize(html || '')
|
||||
}
|
||||
|
||||
/** Önizleme/doğrulama için HTML etiketlerini ayıklar. */
|
||||
export function stripHtml(html?: string) {
|
||||
return (html || '').replace(/<[^>]+>/g, '').trim()
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { ForumPost } from '@/proxy/forum/forum'
|
||||
import { showDbDateAsIs } from '@/utils/dateUtils'
|
||||
|
||||
export function buildPostTree(posts: ForumPost[]): ForumPost[] {
|
||||
const postMap = new Map<string, ForumPost>()
|
||||
|
||||
posts.forEach((post) => {
|
||||
postMap.set(post.id, { ...post, children: [] })
|
||||
})
|
||||
|
||||
const roots: ForumPost[] = []
|
||||
|
||||
postMap.forEach((post) => {
|
||||
if (post.parentPostId && postMap.has(post.parentPostId)) {
|
||||
postMap.get(post.parentPostId)!.children.push(post)
|
||||
} else {
|
||||
roots.push(post)
|
||||
}
|
||||
})
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
export function formatForumDate(value: string | Date) {
|
||||
return showDbDateAsIs(typeof value === 'string' ? value : value.toISOString())
|
||||
}
|
||||
|
||||
export function formatForumRelativeDate(dateString?: string) {
|
||||
if (!dateString) return 'Never'
|
||||
|
||||
const diffTime = new Date(dateString).getTime() - Date.now()
|
||||
const diffDays = Math.round(diffTime / (1000 * 60 * 60 * 24))
|
||||
|
||||
return new Intl.RelativeTimeFormat('en', { numeric: 'auto' }).format(diffDays, 'day')
|
||||
}
|
||||
|
|
@ -1,400 +1,390 @@
|
|||
import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum'
|
||||
import { CreateCategoryRequest, CreatePostRequest, CreateTopicRequest, forumService } from '@/services/forum.service'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
CreateCategoryRequest,
|
||||
CreatePostRequest,
|
||||
CreateTopicRequest,
|
||||
forumService,
|
||||
} from '@/services/forum.service'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
type TotalCounts = { categories: number; topics: number; posts: number }
|
||||
|
||||
export function useForumData() {
|
||||
const [categories, setCategories] = useState<ForumCategory[]>([])
|
||||
const [topics, setTopics] = useState<ForumTopic[]>([])
|
||||
const [posts, setPosts] = useState<ForumPost[]>([])
|
||||
const [totalCounts, setTotalCounts] = useState({ categories: 0, topics: 0, posts: 0 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [totalCounts, setTotalCounts] = useState<TotalCounts>({
|
||||
categories: 0,
|
||||
topics: 0,
|
||||
posts: 0,
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Load initial data
|
||||
// Eşzamanlı yüklemelerde ilk biten isteğin loading'i kapatmasını engeller.
|
||||
const pendingLoads = useRef(0)
|
||||
const mounted = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories()
|
||||
loadTopics()
|
||||
loadPosts()
|
||||
mounted.current = true
|
||||
return () => {
|
||||
mounted.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await forumService.getCategories()
|
||||
setCategories(response.items)
|
||||
setTotalCounts((prev) => ({ ...prev, categories: response.totalCount }))
|
||||
} catch (err) {
|
||||
setError('Failed to load categories')
|
||||
console.error('Error loading categories:', err)
|
||||
} finally {
|
||||
const beginLoad = useCallback(() => {
|
||||
pendingLoads.current += 1
|
||||
setLoading(true)
|
||||
}, [])
|
||||
|
||||
const endLoad = useCallback(() => {
|
||||
pendingLoads.current = Math.max(0, pendingLoads.current - 1)
|
||||
if (pendingLoads.current === 0 && mounted.current) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadTopics = async (categoryId?: string) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await forumService.getTopics({ categoryId })
|
||||
setTopics(response.items)
|
||||
if (!categoryId) setTotalCounts((prev) => ({ ...prev, topics: response.totalCount }))
|
||||
} catch (err) {
|
||||
setError('Failed to load topics')
|
||||
console.error('Error loading topics:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
const fail = useCallback((message: string, err: unknown) => {
|
||||
console.error(message, err)
|
||||
if (mounted.current) setError(message)
|
||||
}, [])
|
||||
|
||||
const loadPosts = async (topicId?: string) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await forumService.getPosts({ topicId })
|
||||
setPosts(response.items)
|
||||
if (!topicId) setTotalCounts((prev) => ({ ...prev, posts: response.totalCount }))
|
||||
} catch (err) {
|
||||
setError('Failed to load posts')
|
||||
console.error('Error loading posts:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
const loadCategories = useCallback(
|
||||
async (params?: { isActive?: boolean }) => {
|
||||
beginLoad()
|
||||
try {
|
||||
const response = await forumService.getCategories(params)
|
||||
if (!mounted.current) return
|
||||
setCategories(response.items)
|
||||
setTotalCounts((prev) => ({ ...prev, categories: response.totalCount }))
|
||||
} catch (err) {
|
||||
fail('Failed to load categories', err)
|
||||
} finally {
|
||||
endLoad()
|
||||
}
|
||||
},
|
||||
[beginLoad, endLoad, fail],
|
||||
)
|
||||
|
||||
// Category operations
|
||||
const createCategory = async (categoryData: CreateCategoryRequest) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const newCategory = await forumService.createCategory(categoryData)
|
||||
setCategories((prev) => [...prev, newCategory])
|
||||
setTotalCounts((prev) => ({ ...prev, categories: prev.categories + 1 }))
|
||||
return newCategory
|
||||
} catch (err) {
|
||||
setError('Failed to create category')
|
||||
console.error('Error creating category:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
const loadTopics = useCallback(
|
||||
async (categoryId?: string) => {
|
||||
beginLoad()
|
||||
try {
|
||||
const response = await forumService.getTopics({ categoryId })
|
||||
if (!mounted.current) return
|
||||
setTopics(response.items)
|
||||
if (!categoryId) setTotalCounts((prev) => ({ ...prev, topics: response.totalCount }))
|
||||
} catch (err) {
|
||||
fail('Failed to load topics', err)
|
||||
} finally {
|
||||
endLoad()
|
||||
}
|
||||
},
|
||||
[beginLoad, endLoad, fail],
|
||||
)
|
||||
|
||||
const updateCategory = async (id: string, updates: Partial<ForumCategory>) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const updatedCategory = await forumService.updateCategory(id, updates)
|
||||
setCategories((prev) => prev.map((cat) => (cat.id === id ? updatedCategory : cat)))
|
||||
return updatedCategory
|
||||
} catch (err) {
|
||||
setError('Failed to update category')
|
||||
console.error('Error updating category:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
const loadPosts = useCallback(
|
||||
async (topicId?: string) => {
|
||||
beginLoad()
|
||||
try {
|
||||
const response = await forumService.getPosts({ topicId })
|
||||
if (!mounted.current) return
|
||||
setPosts(response.items)
|
||||
if (!topicId) setTotalCounts((prev) => ({ ...prev, posts: response.totalCount }))
|
||||
} catch (err) {
|
||||
fail('Failed to load posts', err)
|
||||
} finally {
|
||||
endLoad()
|
||||
}
|
||||
},
|
||||
[beginLoad, endLoad, fail],
|
||||
)
|
||||
|
||||
const updateCategoryLockState = async (id: string) => {
|
||||
await forumService.updateCategoryLockState(id)
|
||||
await loadCategories() // refresh after update
|
||||
}
|
||||
const reloadAll = useCallback(
|
||||
() => Promise.all([loadCategories(), loadTopics(), loadPosts()]),
|
||||
[loadCategories, loadTopics, loadPosts],
|
||||
)
|
||||
|
||||
const updateCategoryActiveState = async (id: string) => {
|
||||
await forumService.updateCategoryActiveState(id)
|
||||
await loadCategories() // refresh after update
|
||||
}
|
||||
// İlk yükleme
|
||||
useEffect(() => {
|
||||
reloadAll()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const deleteCategory = async (id: string) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
await forumService.deleteCategory(id)
|
||||
// Also remove related topics and posts
|
||||
const topicsToDelete = topics.filter((topic) => topic.categoryId === id)
|
||||
const topicIds = topicsToDelete.map((t) => t.id)
|
||||
setCategories((prev) => prev.filter((cat) => cat.id !== id))
|
||||
setTopics((prev) => prev.filter((topic) => topic.categoryId !== id))
|
||||
setPosts((prev) => prev.filter((post) => !topicIds.includes(post.topicId)))
|
||||
await Promise.all([loadCategories(), loadTopics(), loadPosts()])
|
||||
} catch (err) {
|
||||
setError('Failed to delete category')
|
||||
console.error('Error deleting category:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
// ---- Category operations -------------------------------------------------
|
||||
|
||||
// Topic operations
|
||||
const createTopic = async (topicData: CreateTopicRequest) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const newTopic = await forumService.createTopic(topicData)
|
||||
setTopics((prev) => [...prev, newTopic])
|
||||
setTotalCounts((prev) => ({ ...prev, topics: prev.topics + 1 }))
|
||||
// Update category topic count
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) =>
|
||||
cat.id === topicData.categoryId ? { ...cat, topicCount: cat.topicCount + 1 } : cat,
|
||||
),
|
||||
)
|
||||
return newTopic
|
||||
} catch (err) {
|
||||
setError('Failed to create topic')
|
||||
console.error('Error creating topic:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
const createCategory = useCallback(
|
||||
async (categoryData: CreateCategoryRequest) => {
|
||||
try {
|
||||
const newCategory = await forumService.createCategory(categoryData)
|
||||
setCategories((prev) => [...prev, newCategory])
|
||||
setTotalCounts((prev) => ({ ...prev, categories: prev.categories + 1 }))
|
||||
return newCategory
|
||||
} catch (err) {
|
||||
fail('Failed to create category', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[fail],
|
||||
)
|
||||
|
||||
const updateTopic = async (id: string, updates: Partial<ForumTopic>) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const updatedTopic = await forumService.updateTopic(id, updates)
|
||||
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
|
||||
return updatedTopic
|
||||
} catch (err) {
|
||||
setError('Failed to update topic')
|
||||
console.error('Error updating topic:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
const updateCategory = useCallback(
|
||||
async (id: string, updates: Partial<ForumCategory>) => {
|
||||
try {
|
||||
const updatedCategory = await forumService.updateCategory(id, updates)
|
||||
setCategories((prev) => prev.map((cat) => (cat.id === id ? updatedCategory : cat)))
|
||||
return updatedCategory
|
||||
} catch (err) {
|
||||
fail('Failed to update category', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[fail],
|
||||
)
|
||||
|
||||
const deleteTopic = async (id: string) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const topic = topics.find((t) => t.id === id)
|
||||
await forumService.deleteTopic(id)
|
||||
setTopics((prev) => prev.filter((t) => t.id !== id))
|
||||
setPosts((prev) => prev.filter((post) => post.topicId !== id))
|
||||
const updateCategoryLockState = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
const updated = await forumService.updateCategoryLockState(id)
|
||||
setCategories((prev) => prev.map((cat) => (cat.id === id ? updated : cat)))
|
||||
return updated
|
||||
} catch (err) {
|
||||
fail('Failed to update category lock state', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[fail],
|
||||
)
|
||||
|
||||
// Update category counts
|
||||
if (topic) {
|
||||
const updateCategoryActiveState = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
const updated = await forumService.updateCategoryActiveState(id)
|
||||
setCategories((prev) => prev.map((cat) => (cat.id === id ? updated : cat)))
|
||||
return updated
|
||||
} catch (err) {
|
||||
fail('Failed to update category active state', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[fail],
|
||||
)
|
||||
|
||||
const deleteCategory = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await forumService.deleteCategory(id)
|
||||
// Kategori silindiğinde bağlı topic/post sayıları da değişir; tek kaynak sunucu.
|
||||
await reloadAll()
|
||||
} catch (err) {
|
||||
fail('Failed to delete category', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[fail, reloadAll],
|
||||
)
|
||||
|
||||
// ---- Topic operations ----------------------------------------------------
|
||||
|
||||
const createTopic = useCallback(
|
||||
async (topicData: CreateTopicRequest) => {
|
||||
try {
|
||||
const newTopic = await forumService.createTopic(topicData)
|
||||
setTopics((prev) => [...prev, newTopic])
|
||||
setTotalCounts((prev) => ({ ...prev, topics: prev.topics + 1 }))
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) =>
|
||||
cat.id === topic.categoryId
|
||||
? {
|
||||
...cat,
|
||||
topicCount: Math.max(0, cat.topicCount - 1),
|
||||
postCount: Math.max(
|
||||
0,
|
||||
cat.postCount - posts.filter((p) => p.topicId === id).length,
|
||||
),
|
||||
}
|
||||
: cat,
|
||||
cat.id === topicData.categoryId ? { ...cat, topicCount: cat.topicCount + 1 } : cat,
|
||||
),
|
||||
)
|
||||
return newTopic
|
||||
} catch (err) {
|
||||
fail('Failed to create topic', err)
|
||||
throw err
|
||||
}
|
||||
await Promise.all([loadCategories(), loadTopics(), loadPosts()])
|
||||
} catch (err) {
|
||||
setError('Failed to delete topic')
|
||||
console.error('Error deleting topic:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[fail],
|
||||
)
|
||||
|
||||
const pinTopic = async (id: string) => {
|
||||
try {
|
||||
const updatedTopic = await forumService.pinTopic(id)
|
||||
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
|
||||
return updatedTopic
|
||||
} catch (err) {
|
||||
setError('Failed to pin topic')
|
||||
console.error('Error pinning topic:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
const updateTopic = useCallback(
|
||||
async (id: string, updates: Partial<ForumTopic>) => {
|
||||
try {
|
||||
const updatedTopic = await forumService.updateTopic(id, updates)
|
||||
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
|
||||
return updatedTopic
|
||||
} catch (err) {
|
||||
fail('Failed to update topic', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[fail],
|
||||
)
|
||||
|
||||
const unpinTopic = async (id: string) => {
|
||||
try {
|
||||
const updatedTopic = await forumService.unpinTopic(id)
|
||||
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
|
||||
return updatedTopic
|
||||
} catch (err) {
|
||||
setError('Failed to unpin topic')
|
||||
console.error('Error unpinning topic:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
const deleteTopic = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await forumService.deleteTopic(id)
|
||||
await reloadAll()
|
||||
} catch (err) {
|
||||
fail('Failed to delete topic', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[fail, reloadAll],
|
||||
)
|
||||
|
||||
const lockTopic = async (id: string) => {
|
||||
try {
|
||||
const updatedTopic = await forumService.lockTopic(id)
|
||||
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
|
||||
return updatedTopic
|
||||
} catch (err) {
|
||||
setError('Failed to lock topic')
|
||||
console.error('Error locking topic:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
const applyTopicUpdate = useCallback((updated: ForumTopic) => {
|
||||
setTopics((prev) => prev.map((topic) => (topic.id === updated.id ? updated : topic)))
|
||||
return updated
|
||||
}, [])
|
||||
|
||||
const unlockTopic = async (id: string) => {
|
||||
try {
|
||||
const updatedTopic = await forumService.unlockTopic(id)
|
||||
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
|
||||
return updatedTopic
|
||||
} catch (err) {
|
||||
setError('Failed to unlock topic')
|
||||
console.error('Error unlocking topic:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
const topicAction = useCallback(
|
||||
async (
|
||||
id: string,
|
||||
action: (id: string) => Promise<ForumTopic>,
|
||||
errorMessage: string,
|
||||
): Promise<ForumTopic> => {
|
||||
try {
|
||||
return applyTopicUpdate(await action(id))
|
||||
} catch (err) {
|
||||
fail(errorMessage, err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[applyTopicUpdate, fail],
|
||||
)
|
||||
|
||||
const solvedTopic = async (id: string) => {
|
||||
try {
|
||||
const updatedTopic = await forumService.solvedTopic(id)
|
||||
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
|
||||
return updatedTopic
|
||||
} catch (err) {
|
||||
setError('Failed to mark topic as solved')
|
||||
console.error('Error marking topic as solved:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
const pinTopic = useCallback(
|
||||
(id: string) => topicAction(id, (x) => forumService.pinTopic(x), 'Failed to pin topic'),
|
||||
[topicAction],
|
||||
)
|
||||
const unpinTopic = useCallback(
|
||||
(id: string) => topicAction(id, (x) => forumService.unpinTopic(x), 'Failed to unpin topic'),
|
||||
[topicAction],
|
||||
)
|
||||
const lockTopic = useCallback(
|
||||
(id: string) => topicAction(id, (x) => forumService.lockTopic(x), 'Failed to lock topic'),
|
||||
[topicAction],
|
||||
)
|
||||
const unlockTopic = useCallback(
|
||||
(id: string) => topicAction(id, (x) => forumService.unlockTopic(x), 'Failed to unlock topic'),
|
||||
[topicAction],
|
||||
)
|
||||
const solvedTopic = useCallback(
|
||||
(id: string) =>
|
||||
topicAction(id, (x) => forumService.solvedTopic(x), 'Failed to mark topic as solved'),
|
||||
[topicAction],
|
||||
)
|
||||
const unsolvedTopic = useCallback(
|
||||
(id: string) =>
|
||||
topicAction(id, (x) => forumService.unsolvedTopic(x), 'Failed to mark topic as unsolved'),
|
||||
[topicAction],
|
||||
)
|
||||
|
||||
const unsolvedTopic = async (id: string) => {
|
||||
try {
|
||||
const updatedTopic = await forumService.unsolvedTopic(id)
|
||||
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
|
||||
return updatedTopic
|
||||
} catch (err) {
|
||||
setError('Failed to mark topic as unsolved')
|
||||
console.error('Error marking topic as unsolved:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
// ---- Post operations -----------------------------------------------------
|
||||
|
||||
// Post operations
|
||||
const createPost = async (postData: CreatePostRequest) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const newPost = await forumService.createPost(postData)
|
||||
setPosts((prev) => [...prev, newPost])
|
||||
setTotalCounts((prev) => ({ ...prev, posts: prev.posts + 1 }))
|
||||
const createPost = useCallback(
|
||||
async (postData: CreatePostRequest) => {
|
||||
try {
|
||||
const newPost = await forumService.createPost(postData)
|
||||
setPosts((prev) => [...prev, newPost])
|
||||
setTotalCounts((prev) => ({ ...prev, posts: prev.posts + 1 }))
|
||||
|
||||
// Update topic and category post counts
|
||||
const topic = topics.find((t) => t.id === postData.topicId)
|
||||
if (topic) {
|
||||
setTopics((prev) =>
|
||||
prev.map((t) => (t.id === postData.topicId ? { ...t, replyCount: t.replyCount + 1 } : t)),
|
||||
)
|
||||
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) =>
|
||||
cat.id === topic.categoryId ? { ...cat, postCount: cat.postCount + 1 } : cat,
|
||||
),
|
||||
)
|
||||
}
|
||||
return newPost
|
||||
} catch (err) {
|
||||
setError('Failed to create post')
|
||||
console.error('Error creating post:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updatePost = async (id: string, updates: Partial<ForumPost>) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const updatedPost = await forumService.updatePost(id, updates)
|
||||
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post)))
|
||||
return updatedPost
|
||||
} catch (err) {
|
||||
setError('Failed to update post')
|
||||
console.error('Error updating post:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deletePost = async (id: string) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const post = posts.find((p) => p.id === id)
|
||||
await forumService.deletePost(id)
|
||||
setPosts((prev) => prev.filter((p) => p.id !== id))
|
||||
setTotalCounts((prev) => ({ ...prev, posts: Math.max(0, prev.posts - 1) }))
|
||||
|
||||
// Update topic and category counts
|
||||
if (post) {
|
||||
const topic = topics.find((t) => t.id === post.topicId)
|
||||
if (topic) {
|
||||
setTopics((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === post.topicId ? { ...t, replyCount: Math.max(0, t.replyCount - 1) } : t,
|
||||
),
|
||||
)
|
||||
|
||||
const categoryId = topics.find((t) => t.id === postData.topicId)?.categoryId
|
||||
if (categoryId) {
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) =>
|
||||
cat.id === topic.categoryId
|
||||
? { ...cat, postCount: Math.max(0, cat.postCount - 1) }
|
||||
: cat,
|
||||
cat.id === categoryId ? { ...cat, postCount: cat.postCount + 1 } : cat,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return newPost
|
||||
} catch (err) {
|
||||
fail('Failed to create post', err)
|
||||
throw err
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to delete post')
|
||||
console.error('Error deleting post:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[fail, topics],
|
||||
)
|
||||
|
||||
const likePost = async (id: string) => {
|
||||
try {
|
||||
const updatedPost = await forumService.likePost(id)
|
||||
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post)))
|
||||
return updatedPost
|
||||
} catch (err) {
|
||||
setError('Failed to like post')
|
||||
console.error('Error liking post:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
const updatePost = useCallback(
|
||||
async (id: string, updates: Partial<ForumPost>) => {
|
||||
try {
|
||||
const updatedPost = await forumService.updatePost(id, updates)
|
||||
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post)))
|
||||
return updatedPost
|
||||
} catch (err) {
|
||||
fail('Failed to update post', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[fail],
|
||||
)
|
||||
|
||||
const unlikePost = async (id: string) => {
|
||||
try {
|
||||
const updatedPost = await forumService.unlikePost(id)
|
||||
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post)))
|
||||
return updatedPost
|
||||
} catch (err) {
|
||||
setError('Failed to unlike post')
|
||||
console.error('Error unliking post:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
const deletePost = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await forumService.deletePost(id)
|
||||
await reloadAll()
|
||||
} catch (err) {
|
||||
fail('Failed to delete post', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[fail, reloadAll],
|
||||
)
|
||||
|
||||
const markPostAsAcceptedAnswer = async (id: string) => {
|
||||
try {
|
||||
const updatedPost = await forumService.markPostAsAcceptedAnswer(id)
|
||||
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post)))
|
||||
return updatedPost
|
||||
} catch (err) {
|
||||
setError('Failed to mark post as accepted answer')
|
||||
console.error('Error marking post as accepted answer:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
const postAction = useCallback(
|
||||
async (
|
||||
id: string,
|
||||
action: (id: string) => Promise<ForumPost>,
|
||||
errorMessage: string,
|
||||
): Promise<ForumPost> => {
|
||||
try {
|
||||
const updatedPost = await action(id)
|
||||
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post)))
|
||||
return updatedPost
|
||||
} catch (err) {
|
||||
fail(errorMessage, err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[fail],
|
||||
)
|
||||
|
||||
const unmarkPostAsAcceptedAnswer = async (id: string) => {
|
||||
try {
|
||||
const updatedPost = await forumService.unmarkPostAsAcceptedAnswer(id)
|
||||
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post)))
|
||||
return updatedPost
|
||||
} catch (err) {
|
||||
setError('Failed to unmark post as accepted answer')
|
||||
console.error('Error unmarking post as accepted answer:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
const likePost = useCallback(
|
||||
(id: string) => postAction(id, (x) => forumService.likePost(x), 'Failed to like post'),
|
||||
[postAction],
|
||||
)
|
||||
const unlikePost = useCallback(
|
||||
(id: string) => postAction(id, (x) => forumService.unlikePost(x), 'Failed to unlike post'),
|
||||
[postAction],
|
||||
)
|
||||
const markPostAsAcceptedAnswer = useCallback(
|
||||
(id: string) =>
|
||||
postAction(
|
||||
id,
|
||||
(x) => forumService.markPostAsAcceptedAnswer(x),
|
||||
'Failed to mark post as accepted answer',
|
||||
),
|
||||
[postAction],
|
||||
)
|
||||
const unmarkPostAsAcceptedAnswer = useCallback(
|
||||
(id: string) =>
|
||||
postAction(
|
||||
id,
|
||||
(x) => forumService.unmarkPostAsAcceptedAnswer(x),
|
||||
'Failed to unmark post as accepted answer',
|
||||
),
|
||||
[postAction],
|
||||
)
|
||||
|
||||
const clearError = useCallback(() => setError(null), [])
|
||||
|
||||
return {
|
||||
// Data
|
||||
|
|
@ -409,6 +399,7 @@ export function useForumData() {
|
|||
loadCategories,
|
||||
loadTopics,
|
||||
loadPosts,
|
||||
reloadAll,
|
||||
|
||||
// Category operations
|
||||
createCategory,
|
||||
|
|
@ -438,6 +429,6 @@ export function useForumData() {
|
|||
unmarkPostAsAcceptedAnswer,
|
||||
|
||||
// Utility
|
||||
clearError: () => setError(null),
|
||||
clearError,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import {
|
|||
subscribeAppUpdate,
|
||||
type AppUpdateState,
|
||||
} from './swRegistration'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { getLocalization } from '@/services/localization.service'
|
||||
import { store } from '@/store'
|
||||
|
||||
/**
|
||||
* Sürüm indirme penceresi.
|
||||
|
|
@ -20,6 +21,28 @@ import { useLocalization } from '@/utils/hooks/useLocalization'
|
|||
* bileşen güncelleme anında lazy yüklenir ve tek başına ~2 KB kalmalı.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Metin çözümü.
|
||||
*
|
||||
* Pencere uygulama ağacının DIŞINDA, kendi React kökünde mount edilir
|
||||
* (`mountAppUpdateDialog`); orada `StoreProvider` yoktur. Bu yüzden burada
|
||||
* `useLocalization` (dolayısıyla `useStoreState`) KULLANILAMAZ — hook Provider'ı
|
||||
* bulamayıp hata fırlatıyor, kök çöküyor ve indirme ilerlemesi hiç görünmüyordu.
|
||||
*
|
||||
* Metinler store'dan doğrudan (`getState`) okunur; bileşende statik metin
|
||||
* tutulmaz. Karşılık yoksa `useLocalization` ile aynı davranış: anahtar adı
|
||||
* döner.
|
||||
*/
|
||||
const t = (key: string, params?: Record<string, string | number>) => {
|
||||
try {
|
||||
const { texts, config } = store.getState().abpConfig
|
||||
if (!texts) return key
|
||||
return getLocalization(texts, config?.localization?.defaultResourceName, key, params)
|
||||
} catch {
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (!bytes) return '0 KB'
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
|
||||
|
|
@ -27,14 +50,13 @@ const formatBytes = (bytes: number) => {
|
|||
}
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
if (seconds < 60) return `${Math.max(1, seconds)} sn`
|
||||
const minutes = Math.round(seconds / 60)
|
||||
return `${minutes} dk`
|
||||
if (seconds < 60) {
|
||||
return t('::App.Update.DurationSeconds', { value: Math.max(1, seconds) })
|
||||
}
|
||||
return t('::App.Update.DurationMinutes', { value: Math.round(seconds / 60) })
|
||||
}
|
||||
|
||||
type TranslateFn = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
const titleFor = (state: AppUpdateState, t: TranslateFn) => {
|
||||
const titleFor = (state: AppUpdateState) => {
|
||||
switch (state.status) {
|
||||
case 'downloading':
|
||||
return t(
|
||||
|
|
@ -56,9 +78,11 @@ const titleFor = (state: AppUpdateState, t: TranslateFn) => {
|
|||
}
|
||||
|
||||
const countdownText = (state: AppUpdateState) =>
|
||||
state.secondsLeft && state.secondsLeft > 0 ? ` (${state.secondsLeft} sn)` : ''
|
||||
state.secondsLeft && state.secondsLeft > 0
|
||||
? ` ${t('::App.Update.Countdown', { seconds: state.secondsLeft })}`
|
||||
: ''
|
||||
|
||||
const descriptionFor = (state: AppUpdateState, t: TranslateFn) => {
|
||||
const descriptionFor = (state: AppUpdateState) => {
|
||||
switch (state.status) {
|
||||
case 'downloading':
|
||||
return t(
|
||||
|
|
@ -73,7 +97,8 @@ const descriptionFor = (state: AppUpdateState, t: TranslateFn) => {
|
|||
case 'activating':
|
||||
return t('::App.UpdateDescription.Activating')
|
||||
case 'error':
|
||||
return `${state.message ? t('::' + state.message) : t('::App.UpdateDescription.Error')}${countdownText(state)}`
|
||||
// `swRegistration` yalnızca anahtar taşır (ör. App.UpdateMessage.*).
|
||||
return `${t(state.message ? `::${state.message}` : '::App.UpdateDescription.Error')}${countdownText(state)}`
|
||||
default:
|
||||
return t('::App.UpdateDescription.Checking')
|
||||
}
|
||||
|
|
@ -132,7 +157,6 @@ const byActivity = (a: AppUpdateState['files'][number], b: AppUpdateState['files
|
|||
a.index - b.index
|
||||
|
||||
const AppUpdateDialog = () => {
|
||||
const { translate } = useLocalization()
|
||||
const state = useSyncExternalStore(subscribeAppUpdate, getAppUpdateState, getAppUpdateState)
|
||||
|
||||
if (!state.visible) return null
|
||||
|
|
@ -153,18 +177,14 @@ const AppUpdateDialog = () => {
|
|||
<span className="mt-1 h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-500 dark:border-gray-600 dark:border-t-indigo-400" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
{titleFor(state, translate)}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{descriptionFor(state, translate)}
|
||||
</p>
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">{titleFor(state)}</h2>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{descriptionFor(state)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 px-6 pt-4 text-xs">
|
||||
<span className="rounded-full bg-gray-100 px-2.5 py-1 font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
{translate('::App.Update.Version')}: v{state.currentVersion}
|
||||
{t('::App.Update.Version')}: v{state.currentVersion}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -172,17 +192,17 @@ const AppUpdateDialog = () => {
|
|||
<div className="px-6 pt-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>
|
||||
{translate('::App.Update.AssetProgress', {
|
||||
{t('::App.Update.AssetProgress', {
|
||||
done: doneCount,
|
||||
total: state.files.length,
|
||||
})}
|
||||
{state.reusedCount > 0 &&
|
||||
` · ${translate('::App.Update.ReusedFromCache', { count: state.reusedCount })}`}
|
||||
` · ${t('::App.Update.ReusedFromCache', { count: state.reusedCount })}`}
|
||||
{state.status === 'downloading' && state.secondsRemaining !== undefined && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
{translate('::App.Update.Remaining', {
|
||||
{t('::App.Update.Remaining', {
|
||||
duration: formatDuration(state.secondsRemaining),
|
||||
})}
|
||||
</>
|
||||
|
|
@ -206,7 +226,7 @@ const AppUpdateDialog = () => {
|
|||
{state.files.length > 0 && (
|
||||
<>
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-gray-400">
|
||||
{translate('::App.Update.DownloadedAssets')}
|
||||
{t('::App.Update.DownloadedAssets')}
|
||||
</p>
|
||||
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{ordered.map((file) => (
|
||||
|
|
@ -219,7 +239,7 @@ const AppUpdateDialog = () => {
|
|||
{state.changeLog.length > 0 && (
|
||||
<div className="mt-4 border-t border-gray-100 pt-4 dark:border-gray-700">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-gray-400">
|
||||
v{state.currentVersion} ile gelen yenilikler
|
||||
{t('::App.Update.ChangeLogTitle', { version: state.currentVersion })}
|
||||
</p>
|
||||
<ul className="mt-2 flex flex-col gap-1.5 text-sm text-gray-600 dark:text-gray-300">
|
||||
{state.changeLog.map((line, index) => (
|
||||
|
|
@ -235,10 +255,8 @@ const AppUpdateDialog = () => {
|
|||
|
||||
<div className="flex items-center justify-between gap-3 px-6 py-4">
|
||||
<span className="text-xs text-gray-400">
|
||||
{translate(
|
||||
state.dismissible
|
||||
? '::App.UpdateFooter.Dismissible'
|
||||
: '::App.UpdateFooter.Blocking',
|
||||
{t(
|
||||
state.dismissible ? '::App.UpdateFooter.Dismissible' : '::App.UpdateFooter.Blocking',
|
||||
)}
|
||||
</span>
|
||||
{state.dismissible ? (
|
||||
|
|
@ -247,8 +265,10 @@ const AppUpdateDialog = () => {
|
|||
className="shrink-0 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
|
||||
onClick={dismissAppUpdateDialog}
|
||||
>
|
||||
{translate(
|
||||
state.status === 'completed' ? '::App.Platform.OK' : '::App.Update.ContinueInBackground',
|
||||
{t(
|
||||
state.status === 'completed'
|
||||
? '::App.Platform.OK'
|
||||
: '::App.Update.ContinueInBackground',
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
|
|
@ -258,7 +278,7 @@ const AppUpdateDialog = () => {
|
|||
className="shrink-0 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
|
||||
onClick={applyAppUpdate}
|
||||
>
|
||||
{translate('::App.Update.RetryNow')}
|
||||
{t('::App.Update.RetryNow')}
|
||||
{countdownText(state)}
|
||||
</button>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue