Claude güncellemesi Forum güncellemesi

This commit is contained in:
Sedat ÖZTÜRK 2026-08-15 08:57:47 +03:00
parent c1a74bc245
commit cedf788787
21 changed files with 1764 additions and 1323 deletions

View file

@ -20424,6 +20424,30 @@
"en": "{done}/{total} assets", "en": "{done}/{total} assets",
"tr": "{done}/{total} bileşen" "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", "resourceName": "Platform",
"key": "App.UpdateDescription.Activating", "key": "App.UpdateDescription.Activating",
@ -25649,6 +25673,150 @@
"key": "App.ListFormEditWorkflow.CriteriaStart", "key": "App.ListFormEditWorkflow.CriteriaStart",
"en": "Start", "en": "Start",
"tr": "Başlat" "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"
} }
] ]
} }

View file

@ -1,11 +1,11 @@
import { ForumCategory, ForumTopic } from '@/proxy/forum/forum' import { ForumCategory, ForumTopic } from '@/proxy/forum/forum'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useForumData } from './useForumData' 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 { Container } from '@/components/shared'
import PageTitle from '@/components/shared/PageTitle' import PageTitle from '@/components/shared/PageTitle'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { Button } from '@/components/ui'
export function Forum() { export function Forum() {
const { translate } = useLocalization() const { translate } = useLocalization()
@ -25,57 +25,32 @@ export function Forum() {
const [selectedCategory, setSelectedCategory] = useState<ForumCategory | null>(null) const [selectedCategory, setSelectedCategory] = useState<ForumCategory | null>(null)
const [selectedTopic, setSelectedTopic] = useState<ForumTopic | null>(null) const [selectedTopic, setSelectedTopic] = useState<ForumTopic | null>(null)
const [forumViewState, setForumViewState] = useState<'categories' | 'topics' | 'posts'>( const [forumViewState, setForumViewState] = useState<ForumViewState>('categories')
'categories',
)
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { if (!error) return
if ((e.metaKey || e.ctrlKey) && e.key === 'k') { const timer = setTimeout(clearError, 5000)
e.preventDefault()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
useEffect(() => {
if (error) {
const timer = setTimeout(() => {
clearError()
}, 5000)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}
}, [error, clearError]) }, [error, clearError])
return ( return (
<Container> <Container>
<PageTitle title={translate('::' + 'App.Forum')} /> <PageTitle title={translate('::App.Forum')} />
{error && ( <ForumErrorBanner error={error} onDismiss={clearError} />
<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>
)}
<ForumView <ForumView
categories={categories} categories={categories}
topics={topics} topics={topics}
posts={posts} posts={posts}
loading={loading} loading={loading}
onCreateTopic={(topicData) => createTopic(topicData).then(() => {})}
onCreatePost={(postData) => createPost(postData).then(() => {})}
onLikePost={(id) => likePost(id).then(() => {})}
onUnlikePost={(id) => unlikePost(id).then(() => {})}
selectedCategory={selectedCategory} selectedCategory={selectedCategory}
selectedTopic={selectedTopic} selectedTopic={selectedTopic}
viewState={forumViewState} viewState={forumViewState}
onCreateTopic={createTopic}
onCreatePost={createPost}
onLikePost={likePost}
onUnlikePost={unlikePost}
onCategorySelect={setSelectedCategory} onCategorySelect={setSelectedCategory}
onTopicSelect={setSelectedTopic} onTopicSelect={setSelectedTopic}
onViewStateChange={setForumViewState} onViewStateChange={setForumViewState}

View 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

View file

@ -1,10 +1,10 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useForumData } from './useForumData' import { useForumData } from './useForumData'
import { AdminView } from './admin/AdminView' import { AdminView } from './admin/AdminView'
import { ForumErrorBanner } from './ForumErrorBanner'
import { Container } from '@/components/shared' import { Container } from '@/components/shared'
import PageTitle from '@/components/shared/PageTitle' import PageTitle from '@/components/shared/PageTitle'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { Button } from '@/components/ui'
export function Management() { export function Management() {
const { const {
@ -38,40 +38,16 @@ export function Management() {
const { translate } = useLocalization() const { translate } = useLocalization()
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { if (!error) return
if ((e.metaKey || e.ctrlKey) && e.key === 'k') { const timer = setTimeout(clearError, 5000)
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) return () => clearTimeout(timer)
}
}, [error, clearError]) }, [error, clearError])
return ( return (
<Container> <Container>
<PageTitle title={translate('::' + 'App.ForumManagement')} /> <PageTitle title={translate('::App.ForumManagement')} />
{error && ( <ForumErrorBanner error={error} onDismiss={clearError} />
<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>
)}
<AdminView <AdminView
categories={categories} categories={categories}
@ -79,25 +55,25 @@ export function Management() {
posts={posts} posts={posts}
totalCounts={totalCounts} totalCounts={totalCounts}
loading={loading} loading={loading}
onCreateCategory={(data) => createCategory(data).then(() => {})} onCreateCategory={createCategory}
onUpdateCategory={(id, data) => updateCategory(id, data).then(() => {})} onUpdateCategory={updateCategory}
onUpdateCategoryLockState={(id) => updateCategoryLockState(id).then(() => {})} onUpdateCategoryLockState={updateCategoryLockState}
onUpdateCategoryActiveState={(id) => updateCategoryActiveState(id).then(() => {})} onUpdateCategoryActiveState={updateCategoryActiveState}
onDeleteCategory={(id) => deleteCategory(id).then(() => {})} onDeleteCategory={deleteCategory}
onCreateTopic={(data) => createTopic(data).then(() => {})} onCreateTopic={createTopic}
onUpdateTopic={(id, data) => updateTopic(id, data).then(() => {})} onUpdateTopic={updateTopic}
onDeleteTopic={(id) => deleteTopic(id).then(() => {})} onDeleteTopic={deleteTopic}
onPinTopic={(id) => pinTopic(id).then(() => {})} onPinTopic={pinTopic}
onUnpinTopic={(id) => unpinTopic(id).then(() => {})} onUnpinTopic={unpinTopic}
onLockTopic={(id) => lockTopic(id).then(() => {})} onLockTopic={lockTopic}
onUnlockTopic={(id) => unlockTopic(id).then(() => {})} onUnlockTopic={unlockTopic}
onMarkTopicAsSolved={(id) => solvedTopic(id).then(() => {})} onMarkTopicAsSolved={solvedTopic}
onMarkTopicAsUnsolved={(id) => unsolvedTopic(id).then(() => {})} onMarkTopicAsUnsolved={unsolvedTopic}
onCreatePost={(data) => createPost(data).then(() => {})} onCreatePost={createPost}
onUpdatePost={(id, data) => updatePost(id, data).then(() => {})} onUpdatePost={updatePost}
onDeletePost={(id) => deletePost(id).then(() => {})} onDeletePost={deletePost}
onMarkPostAsAcceptedAnswer={(id) => markPostAsAcceptedAnswer(id).then(() => {})} onMarkPostAsAcceptedAnswer={markPostAsAcceptedAnswer}
onUnmarkPostAsAcceptedAnswer={(id) => unmarkPostAsAcceptedAnswer(id).then(() => {})} onUnmarkPostAsAcceptedAnswer={unmarkPostAsAcceptedAnswer}
/> />
</Container> </Container>
) )

View file

@ -4,6 +4,11 @@ import { CategoryManagement } from './CategoryManagement'
import { TopicManagement } from './TopicManagement' import { TopicManagement } from './TopicManagement'
import { PostManagement } from './PostManagement' import { PostManagement } from './PostManagement'
import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum' import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum'
import {
CreateCategoryRequest,
CreatePostRequest,
CreateTopicRequest,
} from '@/services/forum.service'
import { AdminStats } from './Dashboard' import { AdminStats } from './Dashboard'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { Button } from '@/components/ui' import { Button } from '@/components/ui'
@ -14,39 +19,25 @@ interface AdminViewProps {
posts: ForumPost[] posts: ForumPost[]
totalCounts: { categories: number; topics: number; posts: number } totalCounts: { categories: number; topics: number; posts: number }
loading: boolean loading: boolean
onCreateCategory: (category: { onCreateCategory: (category: CreateCategoryRequest) => Promise<unknown>
name: string onUpdateCategory: (id: string, category: Partial<ForumCategory>) => Promise<unknown>
slug: string onUpdateCategoryLockState: (id: string) => Promise<unknown>
description: string onUpdateCategoryActiveState: (id: string) => Promise<unknown>
icon: string onDeleteCategory: (id: string) => Promise<unknown>
displayOrder: number onCreateTopic: (topic: CreateTopicRequest) => Promise<unknown>
isActive: boolean onUpdateTopic: (id: string, topic: Partial<ForumTopic>) => Promise<unknown>
isLocked: boolean onDeleteTopic: (id: string) => Promise<unknown>
}) => Promise<void> onPinTopic: (id: string) => Promise<unknown>
onUpdateCategory: (id: string, category: Partial<ForumCategory>) => Promise<void> onUnpinTopic: (id: string) => Promise<unknown>
onUpdateCategoryLockState: (id: string) => Promise<void> onLockTopic: (id: string) => Promise<unknown>
onUpdateCategoryActiveState: (id: string) => Promise<void> onUnlockTopic: (id: string) => Promise<unknown>
onDeleteCategory: (id: string) => Promise<void> onMarkTopicAsSolved: (id: string) => Promise<unknown>
onCreateTopic: (topic: { onMarkTopicAsUnsolved: (id: string) => Promise<unknown>
title: string onCreatePost: (post: CreatePostRequest) => Promise<unknown>
content: string onUpdatePost: (id: string, post: Partial<ForumPost>) => Promise<unknown>
categoryId: string onDeletePost: (id: string) => Promise<unknown>
isPinned?: boolean onMarkPostAsAcceptedAnswer: (id: string) => Promise<unknown>
isLocked?: boolean onUnmarkPostAsAcceptedAnswer: (id: string) => Promise<unknown>
}) => 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>
} }
type AdminSection = 'stats' | 'categories' | 'topics' | 'posts' type AdminSection = 'stats' | 'categories' | 'topics' | 'posts'
@ -114,12 +105,13 @@ export function AdminView({
return ( return (
<Button <Button
key={item.id} key={item.id}
variant={isActive ? "solid" : "plain"} variant={isActive ? 'solid' : 'plain'}
active={isActive} 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)} 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> <span className="font-medium">{item.label}</span>
</Button> </Button>
) )

View file

@ -10,31 +10,37 @@ import {
FaSpinner, FaSpinner,
} from 'react-icons/fa' } from 'react-icons/fa'
import { ForumCategory } from '@/proxy/forum/forum' import { ForumCategory } from '@/proxy/forum/forum'
import { CreateCategoryRequest } from '@/services/forum.service'
import { useStoreState } from '@/store/store' import { useStoreState } from '@/store/store'
import { useLocalization } from '@/utils/hooks/useLocalization' 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 * as Yup from 'yup'
import { FormContainer, FormItem } from '@/components/ui/Form' 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 { 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 { interface CategoryManagementProps {
categories: ForumCategory[] categories: ForumCategory[]
loading: boolean loading: boolean
onCreateCategory: (category: { onCreateCategory: (category: CreateCategoryRequest) => Promise<unknown>
name: string onUpdateCategory: (id: string, category: Partial<ForumCategory>) => Promise<unknown>
slug: string onDeleteCategory: (id: string) => Promise<unknown>
description: string onUpdateCategoryLockState: (id: string) => Promise<unknown>
icon: string onUpdateCategoryActiveState: (id: string) => Promise<unknown>
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>
} }
export function CategoryManagement({ export function CategoryManagement({
@ -53,17 +59,6 @@ export function CategoryManagement({
const [showConfirm, setShowConfirm] = useState(false) const [showConfirm, setShowConfirm] = useState(false)
const [categoryToDelete, setCategoryToDelete] = useState<ForumCategory | null>(null) 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) => { const confirmDeleteCategory = (category: ForumCategory) => {
setCategoryToDelete(category) setCategoryToDelete(category)
setShowConfirm(true) setShowConfirm(true)
@ -95,7 +90,7 @@ export function CategoryManagement({
{/* Create/Edit Form */} {/* Create/Edit Form */}
{showCreateForm && ( {showCreateForm && (
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6"> <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 {editingCategory
? translate('::App.ForumCategoryManagement.EditCategory') ? translate('::App.ForumCategoryManagement.EditCategory')
: translate('::App.ForumCategoryManagement.AddCategory')} : translate('::App.ForumCategoryManagement.AddCategory')}
@ -131,89 +126,101 @@ export function CategoryManagement({
} }
}} }}
> >
{({ errors, touched, isSubmitting }) => ( {({ values, errors, touched, isSubmitting }) => (
<Form> <Form>
<FormContainer className="space-y-4"> <FormContainer className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormItem <FormItem
label="Name"
asterisk asterisk
invalid={errors.name && touched.name} label={translate('::App.Listform.ListformField.Name')}
invalid={!!errors.name && !!touched.name}
errorMessage={errors.name} errorMessage={errors.name}
> >
<Field <Field name="name" className={FIELD_CLASS} component={Input} />
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"
/>
</FormItem> </FormItem>
<FormItem <FormItem
label="Slug"
asterisk asterisk
invalid={errors.slug && touched.slug} label={translate('::App.Listform.ListformField.Slug')}
invalid={!!errors.slug && !!touched.slug}
errorMessage={errors.slug} errorMessage={errors.slug}
> >
<Field <Field name="slug" className={FIELD_CLASS} component={Input} />
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"
/>
</FormItem> </FormItem>
</div> </div>
<FormItem <FormItem
label="Description"
asterisk asterisk
invalid={errors.slug && touched.slug} label={translate('::App.Listform.ListformField.Description')}
errorMessage={errors.slug} invalid={!!errors.description && !!touched.description}
errorMessage={errors.description}
> >
<Field <Field
textArea
name="description" 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" className={FIELD_CLASS}
textArea="true"
component={Input} component={Input}
/> />
</FormItem> </FormItem>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormItem <FormItem
label="Icon (Emoji)" label={translate('::App.MenuField.Icon')}
asterisk invalid={!!errors.icon && !!touched.icon}
invalid={errors.icon && touched.icon}
errorMessage={errors.icon} errorMessage={errors.icon}
> >
<Field <Field name="icon">
name="icon" {({ field, form }: FieldProps<string>) => {
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" const iconOptions = resolveCategoryIconOptions(values.icon)
return (
<Select<CategoryIconOption>
isClearable
field={field}
form={form}
options={iconOptions}
placeholder="💬" 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>
<FormItem <FormItem
label="Display Order"
asterisk asterisk
invalid={errors.displayOrder && touched.displayOrder} label={translate('::App.Listform.ListformField.DisplayOrder')}
invalid={!!errors.displayOrder && !!touched.displayOrder}
errorMessage={errors.displayOrder} errorMessage={errors.displayOrder}
> >
<Field <Field
name="displayOrder" 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" className={FIELD_CLASS}
placeholder="💬" placeholder="0"
type="number" type="number"
component={Input}
/> />
</FormItem> </FormItem>
</div> </div>
<div className="flex items-center space-x-4 pt-6"> <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" /> <Field as={Checkbox} name="isActive" />
</FormItem> </FormItem>
<FormItem label="Locked"> <FormItem label={translate('::App.Platform.Locked')}>
<Field as={Checkbox} name="isLocked" /> <Field as={Checkbox} name="isLocked" />
</FormItem> </FormItem>
</div> </div>
<div className="flex justify-end space-x-3 mt-4"> <div className="mt-4 flex justify-end space-x-3">
<div className="flex justify-end space-x-3 pt-2">
<Button <Button
variant="plain" variant="plain"
type="button" type="button"
@ -225,10 +232,11 @@ export function CategoryManagement({
{translate('::App.Platform.Cancel')} {translate('::App.Platform.Cancel')}
</Button> </Button>
<Button variant="solid" type="submit" loading={isSubmitting}> <Button variant="solid" type="submit" loading={isSubmitting}>
{editingCategory ? translate('::App.Platform.Update') : translate('::App.Platform.Create')} {editingCategory
? translate('::App.Platform.Update')
: translate('::App.Platform.Create')}
</Button> </Button>
</div> </div>
</div>
</FormContainer> </FormContainer>
</Form> </Form>
)} )}
@ -245,9 +253,9 @@ export function CategoryManagement({
<Button <Button
size="sm" size="sm"
variant="solid" variant="solid"
onClick={() => setShowCreateForm(true)}
disabled={loading} disabled={loading}
className="flex items-center space-x-2" className="flex items-center space-x-2"
onClick={() => setShowCreateForm(true)}
> >
<FaPlus className="w-4 h-4" /> <FaPlus className="w-4 h-4" />
<span>{translate('::App.ForumCategoryManagement.AddCategory')}</span> <span>{translate('::App.ForumCategoryManagement.AddCategory')}</span>
@ -263,6 +271,11 @@ export function CategoryManagement({
</div> </div>
) : ( ) : (
<div className="divide-y divide-gray-200 dark:divide-gray-800"> <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] {[...categories]
.sort((a, b) => a.displayOrder - b.displayOrder) .sort((a, b) => a.displayOrder - b.displayOrder)
.map((category) => ( .map((category) => (
@ -280,12 +293,12 @@ export function CategoryManagement({
</h4> </h4>
{!category.isActive && ( {!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"> <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> </span>
)} )}
{category.isLocked && ( {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"> <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> </span>
)} )}
</div> </div>
@ -293,9 +306,15 @@ export function CategoryManagement({
{category.description} {category.description}
</p> </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-4 text-sm text-gray-500 dark:text-gray-400">
<span>{category.topicCount} topics</span> <span>
<span>{category.postCount} posts</span> {category.topicCount} {translate('::App.ForumDashboard.Topics')}
<span>Order: {category.displayOrder}</span> </span>
<span>
{category.postCount} {translate('::App.ForumDashboard.Posts')}
</span>
<span>
{translate('::App.MenuField.Order')}: {category.displayOrder}
</span>
</div> </div>
</div> </div>
</div> </div>
@ -303,13 +322,17 @@ export function CategoryManagement({
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button <Button
size="xs" size="xs"
onClick={() => handleToggleActive(category)}
className={`p-1 rounded-lg transition-colors ${ className={`p-1 rounded-lg transition-colors ${
category.isActive category.isActive
? 'text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900' ? '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' : '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 ? ( {category.isActive ? (
<FaEye className="w-3 h-3" /> <FaEye className="w-3 h-3" />
@ -320,13 +343,17 @@ export function CategoryManagement({
<Button <Button
size="xs" size="xs"
onClick={() => handleToggleLocked(category)}
className={`p-1 rounded-lg transition-colors ${ className={`p-1 rounded-lg transition-colors ${
category.isLocked category.isLocked
? 'text-yellow-600 dark:text-yellow-300 hover:bg-yellow-100 dark:hover:bg-yellow-900' ? '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' : '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 ? ( {category.isLocked ? (
<FaLock className="w-3 h-3" /> <FaLock className="w-3 h-3" />
@ -337,18 +364,18 @@ export function CategoryManagement({
<Button <Button
size="xs" 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" 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')} title={translate('::App.ForumCategoryManagement.EditCategory')}
onClick={() => handleEdit(category)}
> >
<FaEdit className="w-3 h-3" /> <FaEdit className="w-3 h-3" />
</Button> </Button>
<Button <Button
size="xs" 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" 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')} title={translate('::App.ForumCategoryManagement.DeleteCategory')}
onClick={() => confirmDeleteCategory(category)}
> >
<FaTrash className="w-3 h-3" /> <FaTrash className="w-3 h-3" />
</Button> </Button>

View file

@ -1,3 +1,4 @@
import { useMemo } from 'react'
import Widget from '@/components/ui/Widget/Widget' import Widget from '@/components/ui/Widget/Widget'
import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum' import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
@ -5,6 +6,11 @@ import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/tr' 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 { interface AdminStatsProps {
categories: ForumCategory[] categories: ForumCategory[]
topics: ForumTopic[] topics: ForumTopic[]
@ -13,100 +19,104 @@ interface AdminStatsProps {
} }
interface Activity { interface Activity {
id: string
message: string message: string
color: string color: string
date: Date date: Date
} }
export function AdminStats({ categories, topics, posts, totalCounts }: AdminStatsProps) { export function AdminStats({ categories, topics, posts, totalCounts }: AdminStatsProps) {
const { translate } = useLocalization() const { translate, cultureName } = useLocalization()
const totalCategories = totalCounts.categories
const activeCategories = categories.filter((c) => c.isActive).length const activeCategories = categories.filter((c) => c.isActive).length
const totalTopics = totalCounts.topics
const solvedTopics = topics.filter((t) => t.isSolved).length const solvedTopics = topics.filter((t) => t.isSolved).length
const totalPosts = totalCounts.posts
const acceptedAnswers = posts.filter((p) => p.isAcceptedAnswer).length const acceptedAnswers = posts.filter((p) => p.isAcceptedAnswer).length
const stats = [ const stats = [
{ {
key: 'categories',
title: translate('::App.ForumDashboard.TotalCategories'), title: translate('::App.ForumDashboard.TotalCategories'),
value: totalCategories, value: totalCounts.categories,
subTitle: `${activeCategories} active`, subTitle: translate('::App.ForumDashboard.ActiveCategories', { 0: activeCategories }),
icon: 'FaFolder' as const, icon: 'FaFolder' as const,
color: 'blue' as const, color: 'blue' as const,
}, },
{ {
key: 'topics',
title: translate('::App.ForumDashboard.TotalTopics'), title: translate('::App.ForumDashboard.TotalTopics'),
value: totalTopics, value: totalCounts.topics,
subTitle: `${solvedTopics} solved`, subTitle: translate('::App.ForumDashboard.SolvedTopics', { 0: solvedTopics }),
icon: 'FaCommentDots' as const, icon: 'FaCommentDots' as const,
color: 'green' as const, color: 'green' as const,
}, },
{ {
key: 'posts',
title: translate('::App.ForumDashboard.TotalPosts'), title: translate('::App.ForumDashboard.TotalPosts'),
value: totalPosts, value: totalCounts.posts,
subTitle: `${acceptedAnswers} accepted answers`, subTitle: translate('::App.ForumDashboard.AcceptedAnswers', { 0: acceptedAnswers }),
icon: 'FaFileAlt' as const, icon: 'FaFileAlt' as const,
color: 'orange' as const, color: 'orange' as const,
}, },
{ {
key: 'engagement',
title: translate('::App.ForumDashboard.EngagementRate'), title: translate('::App.ForumDashboard.EngagementRate'),
value: totalTopics > 0 ? Math.round((totalPosts / totalTopics) * 100) / 100 : 0, value:
subTitle: 'posts per topic', totalCounts.topics > 0
? Math.round((totalCounts.posts / totalCounts.topics) * 100) / 100
: 0,
subTitle: translate('::App.ForumDashboard.PostsPerTopic'),
icon: 'FaChartLine' as const, icon: 'FaChartLine' as const,
color: 'purple' as const, color: 'purple' as const,
}, },
] ]
const recentActivities: Activity[] = [] const latestActivities = useMemo<Activity[]>(() => {
const categoryNameById = new Map(categories.map((c) => [c.id, c.name]))
dayjs.extend(relativeTime) const activities: Activity[] = []
dayjs.locale('tr')
topics.forEach((topic) => { topics.forEach((topic) => {
const category = categories.find((c) => c.id === topic.categoryId) if (!topic.creationTime) return
if (topic.creationTime) { activities.push({
recentActivities.push({ id: `topic-${topic.id}`,
message: `New topic created in ${category?.name ?? 'Unknown Category'}`, message: `${translate('::App.ForumTopicManagement.NewTopic')}${
categoryNameById.get(topic.categoryId) ?? translate('::App.Platform.Unknown')
}`,
color: 'bg-blue-500', color: 'bg-blue-500',
date: new Date(topic.creationTime), date: new Date(topic.creationTime),
}) })
}
}) })
// Posts -> "Post marked as accepted answer"
posts.forEach((post) => { posts.forEach((post) => {
if (post.isAcceptedAnswer && post.creationTime) { if (!post.isAcceptedAnswer || !post.creationTime) return
recentActivities.push({ activities.push({
message: 'Post marked as accepted answer', id: `post-${post.id}`,
message: translate('::App.ForumDashboard.Postmarked'),
color: 'bg-emerald-500', color: 'bg-emerald-500',
date: new Date(post.creationTime), date: new Date(post.creationTime),
}) })
}
}) })
// Categories -> "New category created: {name}"
categories.forEach((category) => { categories.forEach((category) => {
if (category.creationTime) { if (!category.creationTime) return
recentActivities.push({ activities.push({
message: `New category created: ${category.name}`, id: `category-${category.id}`,
message: `${translate('::App.ForumDashboard.NewCategoryCreated')}${category.name}`,
color: 'bg-orange-500', color: 'bg-orange-500',
date: new Date(category.creationTime), date: new Date(category.creationTime),
}) })
}
}) })
// Tarihe göre sırala, en güncel ilk 3 aktiviteyi al return activities
const latestActivities = recentActivities
.sort((a, b) => b.date.getTime() - a.date.getTime()) .sort((a, b) => b.date.getTime() - a.date.getTime())
.slice(0, 3) .slice(0, MAX_ACTIVITIES)
}, [categories, posts, topics, translate])
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{stats.map((stat, index) => ( {stats.map((stat) => (
<Widget <Widget
key={index} key={stat.key}
title={stat.title} title={stat.title}
value={stat.value} value={stat.value}
subTitle={stat.subTitle} subTitle={stat.subTitle}
@ -117,20 +127,33 @@ export function AdminStats({ categories, topics, posts, totalCounts }: AdminStat
</div> </div>
{/* Recent Activity */} {/* Recent Activity */}
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6"> <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4"> <h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
{translate('::App.ForumDashboard.RecentActivity')} {translate('::App.ForumDashboard.RecentActivity')}
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
{latestActivities.map((activity, index) => ( {latestActivities.length === 0 ? (
<div key={index} className="flex items-start space-x-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"> <p className="text-sm text-gray-500 dark:text-gray-400">
<div className={`w-2 h-2 ${activity.color} rounded-full mt-2`} /> {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> <div>
<p className="text-sm text-gray-900 dark:text-white">{activity.message}</p> <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> <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>
</div> </div>
</div> </div>

View file

@ -9,6 +9,7 @@ import {
FaSpinner, FaSpinner,
} from 'react-icons/fa' } from 'react-icons/fa'
import { ForumPost, ForumTopic } from '@/proxy/forum/forum' 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 { HtmlEditor, ImageUpload, Item, MediaResizing, Toolbar } from 'devextreme-react/html-editor'
import { useStoreState } from '@/store/store' import { useStoreState } from '@/store/store'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
@ -16,7 +17,7 @@ import { Formik, Form, Field, FieldProps } from 'formik'
import * as Yup from 'yup' import * as Yup from 'yup'
import { FormContainer, FormItem, Button } from '@/components/ui' import { FormContainer, FormItem, Button } from '@/components/ui'
import { ConfirmDialog } from '@/components/shared' import { ConfirmDialog } from '@/components/shared'
import { formatForumDate } from '../forum/utils' import { formatForumDate, sanitizeForumHtml, stripHtml } from '../forum/utils'
import { import {
fontFamilyOptions, fontFamilyOptions,
fontSizeOptions, fontSizeOptions,
@ -26,21 +27,21 @@ import {
sizeValues, sizeValues,
} from '@/proxy/reports/data' } 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 { interface PostManagementProps {
posts: ForumPost[] posts: ForumPost[]
totalCount: number totalCount: number
topics: ForumTopic[] topics: ForumTopic[]
loading: boolean loading: boolean
onCreatePost: (post: { onCreatePost: (post: CreatePostRequest) => Promise<unknown>
topicId: string onUpdatePost: (id: string, post: Partial<ForumPost>) => Promise<unknown>
content: string onDeletePost: (id: string) => Promise<unknown>
parentPostId?: string onMarkPostAsAcceptedAnswer: (id: string) => Promise<unknown>
tenantId?: string onUnmarkPostAsAcceptedAnswer: (id: string) => Promise<unknown>
}) => Promise<void>
onUpdatePost: (id: string, post: Partial<ForumPost>) => Promise<void>
onDeletePost: (id: string) => Promise<void>
onMarkPostAsAcceptedAnswer: (id: string) => Promise<void>
onUnmarkPostAsAcceptedAnswer: (id: string) => Promise<void>
} }
export function PostManagement({ export function PostManagement({
@ -83,52 +84,46 @@ export function PostManagement({
} }
} }
const getTopicTitle = (topicId: string) => { const getTopicTitle = (topicId: string) =>
const topic = topics.find((t) => t.id === topicId) topics.find((t) => t.id === topicId)?.title ?? translate('::App.Platform.Unknown')
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(),
})
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* Create/Edit Form */} {/* Create/Edit Form */}
{showCreateForm && ( {showCreateForm && (
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6"> <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 {editingPost
? translate('::App.ForumPostManagement.EditPost') ? translate('::App.ForumPostManagement.EditPost')
: translate('::App.ForumPostManagement.AddPost')} : translate('::App.ForumPostManagement.AddPost')}
</h3> </h3>
<Formik <Formik
enableReinitialize
initialValues={{ initialValues={{
topicId: editingPost?.topicId || '', topicId: editingPost?.topicId ?? '',
content: editingPost?.content || '', content: editingPost?.content ?? '',
isAcceptedAnswer: editingPost?.isAcceptedAnswer || false, isAcceptedAnswer: editingPost?.isAcceptedAnswer ?? false,
parentPostId: '', tenantId: tenant?.tenantId ?? '',
tenantId: tenant.tenantId || '',
}} }}
validationSchema={postValidationSchema} validationSchema={postValidationSchema}
enableReinitialize
onSubmit={async (values, { setSubmitting, resetForm }) => { onSubmit={async (values, { setSubmitting, resetForm }) => {
try { try {
if (editingPost) { if (editingPost) {
await onUpdatePost(editingPost.id, values) await onUpdatePost(editingPost.id, values)
} else { } 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() resetForm()
setShowCreateForm(false) setShowCreateForm(false)
setEditingPost(null) setEditingPost(null)
} catch (error) {
console.error('Error submitting post:', error)
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@ -138,8 +133,8 @@ export function PostManagement({
<Form> <Form>
<FormContainer className="space-y-4"> <FormContainer className="space-y-4">
<FormItem <FormItem
label="Topic"
asterisk asterisk
label={translate('::App.ForumTopicManagement.Baslik')}
invalid={!!errors.topicId && touched.topicId} invalid={!!errors.topicId && touched.topicId}
errorMessage={errors.topicId} errorMessage={errors.topicId}
> >
@ -148,7 +143,7 @@ export function PostManagement({
name="topicId" 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" 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) => ( {topics.map((topic) => (
<option key={topic.id} value={topic.id}> <option key={topic.id} value={topic.id}>
{topic.title} {topic.title}
@ -158,8 +153,8 @@ export function PostManagement({
</FormItem> </FormItem>
<FormItem <FormItem
label="Content"
asterisk asterisk
label={translate('::App.Listform.ListformField.Content')}
invalid={!!errors.content && touched.content} invalid={!!errors.content && touched.content}
errorMessage={errors.content} errorMessage={errors.content}
> >
@ -167,9 +162,9 @@ export function PostManagement({
{({ field }: FieldProps) => ( {({ field }: FieldProps) => (
<HtmlEditor <HtmlEditor
value={field.value} value={field.value}
onValueChanged={(e) => setFieldValue('content', e.value)}
height={400} height={400}
placeholder="Write your message..." placeholder={translate('::App.ForumPostManagement.MessageNew')}
onValueChanged={(e) => setFieldValue('content', e.value)}
> >
<MediaResizing enabled={true} /> <MediaResizing enabled={true} />
<ImageUpload fileUploadMode="base64" /> <ImageUpload fileUploadMode="base64" />
@ -231,10 +226,10 @@ export function PostManagement({
</Field> </Field>
</FormItem> </FormItem>
<FormItem label="Accepted Answer"> <FormItem label={translate('::App.ForumPostManagement.AcceptedAnswer')}>
<label className="flex items-center"> <label className="flex items-center">
<Field type="checkbox" name="isAcceptedAnswer" className="mr-2" /> <Field type="checkbox" name="isAcceptedAnswer" className="mr-2" />
Mark as Accepted Answer {translate('::App.ForumPostManagement.MarkAsAcceptedAnswer')}
</label> </label>
</FormItem> </FormItem>
@ -253,13 +248,11 @@ export function PostManagement({
variant="solid" variant="solid"
type="submit" type="submit"
loading={isSubmitting} loading={isSubmitting}
disabled={ disabled={!values.topicId || !stripHtml(values.content)}
!values.topicId ||
!values.content ||
values.content.replace(/<[^>]+>/g, '').trim() === ''
}
> >
{editingPost ? 'Update' : 'Create'} {editingPost
? translate('::App.Platform.Update')
: translate('::App.Platform.Create')}
</Button> </Button>
</div> </div>
</FormContainer> </FormContainer>
@ -272,13 +265,15 @@ export function PostManagement({
{/* Posts List */} {/* 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="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"> <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 <Button
size="sm" size="sm"
variant="solid" variant="solid"
onClick={() => setShowCreateForm(true)}
disabled={loading} disabled={loading}
className="flex items-center space-x-2" className="flex items-center space-x-2"
onClick={() => setShowCreateForm(true)}
> >
<FaPlus className="w-4 h-4" /> <FaPlus className="w-4 h-4" />
<span>{translate('::App.ForumPostManagement.AddPost')}</span> <span>{translate('::App.ForumPostManagement.AddPost')}</span>
@ -292,6 +287,11 @@ export function PostManagement({
</div> </div>
) : ( ) : (
<div className="divide-y divide-gray-200 dark:divide-gray-800"> <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] {[...posts]
.sort( .sort(
(a, b) => new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime(), (a, b) => new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime(),
@ -305,20 +305,20 @@ export function PostManagement({
{post.isAcceptedAnswer && ( {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"> <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" /> <FaCheckCircle className="w-3 h-3" />
<span>Accepted Answer</span> <span>{translate('::App.ForumPostManagement.AcceptedAnswer')}</span>
</div> </div>
)} )}
</div> </div>
<div className="mb-3"> <div className="mb-3">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1"> <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> <span className="font-medium">{getTopicTitle(post.topicId)}</span>
</p> </p>
<p <div
className="text-gray-700 dark:text-gray-200 line-clamp-3" className="line-clamp-3 break-words text-gray-700 dark:text-gray-200"
dangerouslySetInnerHTML={{ __html: post.content }} dangerouslySetInnerHTML={{ __html: sanitizeForumHtml(post.content) }}
></p> />
</div> </div>
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400"> <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> <span>{formatForumDate(post.creationTime)}</span>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<FaHeart className="w-4 h-4" /> <FaHeart className="w-4 h-4" />
<span>{post.likeCount} likes</span> <span>
{post.likeCount} {translate('::App.Listform.ListformField.Likes')}
</span>
</div> </div>
</div> </div>
</div> </div>
@ -335,7 +337,6 @@ export function PostManagement({
<div className="flex items-center space-x-2 ml-4"> <div className="flex items-center space-x-2 ml-4">
<Button <Button
size="xs" size="xs"
onClick={() => handleToggleAcceptedAnswer(post)}
className={`p-1 rounded-lg transition-colors ${ className={`p-1 rounded-lg transition-colors ${
post.isAcceptedAnswer post.isAcceptedAnswer
? 'text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900' ? 'text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900'
@ -343,9 +344,10 @@ export function PostManagement({
}`} }`}
title={ title={
post.isAcceptedAnswer post.isAcceptedAnswer
? 'Remove Accepted Answer' ? translate('::App.ForumPostManagement.RemoveAcceptedAnswer')
: 'Mark as Accepted Answer' : translate('::App.ForumPostManagement.MarkAsAcceptedAnswer')
} }
onClick={() => handleToggleAcceptedAnswer(post)}
> >
{post.isAcceptedAnswer ? ( {post.isAcceptedAnswer ? (
<FaCheckCircle className="w-3 h-3" /> <FaCheckCircle className="w-3 h-3" />
@ -356,18 +358,18 @@ export function PostManagement({
<Button <Button
size="xs" 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" 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')} title={translate('::App.ForumPostManagement.EditPost')}
onClick={() => handleEdit(post)}
> >
<FaEdit className="w-3 h-3" /> <FaEdit className="w-3 h-3" />
</Button> </Button>
<Button <Button
size="xs" 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" 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')} title={translate('::App.ForumPostManagement.DeletePost')}
onClick={() => confirmDeletePost(post)}
> >
<FaTrashAlt className="w-3 h-3" /> <FaTrashAlt className="w-3 h-3" />
</Button> </Button>

View file

@ -10,38 +10,46 @@ import {
FaCircle, FaCircle,
FaEye, FaEye,
FaSpinner, FaSpinner,
FaTree,
} from 'react-icons/fa' } from 'react-icons/fa'
import { ForumCategory, ForumTopic } from '@/proxy/forum/forum' import { ForumCategory, ForumTopic } from '@/proxy/forum/forum'
import { CreateTopicRequest } from '@/services/forum.service'
import { useStoreState } from '@/store/store' import { useStoreState } from '@/store/store'
import { useLocalization } from '@/utils/hooks/useLocalization' 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 * 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 { ConfirmDialog } from '@/components/shared'
import { formatForumDate } from '../forum/utils' 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 { interface TopicManagementProps {
topics: ForumTopic[] topics: ForumTopic[]
totalCount: number totalCount: number
categories: ForumCategory[] categories: ForumCategory[]
loading: boolean loading: boolean
onCreateTopic: (topic: { onCreateTopic: (topic: CreateTopicRequest) => Promise<unknown>
title: string onUpdateTopic: (id: string, topic: Partial<ForumTopic>) => Promise<unknown>
content: string onDeleteTopic: (id: string) => Promise<unknown>
categoryId: string onPinTopic: (id: string) => Promise<unknown>
isPinned?: boolean onUnpinTopic: (id: string) => Promise<unknown>
isLocked?: boolean onLockTopic: (id: string) => Promise<unknown>
tenantId?: string onUnlockTopic: (id: string) => Promise<unknown>
}) => Promise<void> onMarkTopicAsSolved: (id: string) => Promise<unknown>
onUpdateTopic: (id: string, topic: Partial<ForumTopic>) => Promise<void> onMarkTopicAsUnsolved: (id: string) => Promise<unknown>
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>
} }
export function TopicManagement({ export function TopicManagement({
@ -112,10 +120,14 @@ export function TopicManagement({
} }
} }
const getCategoryName = (categoryId: string) => { const getCategoryName = (categoryId: string) =>
const category = categories.find((c) => c.id === categoryId) categories.find((c) => c.id === categoryId)?.name ?? translate('::App.Platform.Unknown')
return category ? category.name : 'Unknown Category'
} const categoryOptions: CategoryOption[] = categories.map((category) => ({
value: category.id,
label: category.name,
icon: category.icon,
}))
const topicInitialValues = { const topicInitialValues = {
title: '', title: '',
@ -124,27 +136,22 @@ export function TopicManagement({
isPinned: false, isPinned: false,
isLocked: false, isLocked: false,
isSolved: 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 ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* Create/Edit Form */} {/* Create/Edit Form */}
{showCreateForm && ( {showCreateForm && (
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6"> <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 {editingTopic
? translate('::App.ForumTopicManagement.EditTopic') ? translate('::App.ForumTopicManagement.EditTopic')
: translate('::App.ForumTopicManagement.AddTopic')} : translate('::App.ForumTopicManagement.AddTopic')}
</h3> </h3>
<Formik <Formik
enableReinitialize
initialValues={ initialValues={
editingTopic editingTopic
? { ? {
@ -159,7 +166,6 @@ export function TopicManagement({
: topicInitialValues : topicInitialValues
} }
validationSchema={topicValidationSchema} validationSchema={topicValidationSchema}
enableReinitialize
onSubmit={async (values, { setSubmitting, resetForm }) => { onSubmit={async (values, { setSubmitting, resetForm }) => {
try { try {
if (editingTopic) { if (editingTopic) {
@ -170,49 +176,61 @@ export function TopicManagement({
resetForm() resetForm()
setShowCreateForm(false) setShowCreateForm(false)
setEditingTopic(null) setEditingTopic(null)
} catch (error) {
console.error('Error submitting topic:', error)
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
}} }}
> >
{({ isSubmitting, errors, touched }) => ( {({ values, isSubmitting, errors, touched }) => (
<Form> <Form>
<FormContainer className="space-y-4"> <FormContainer className="space-y-4">
<FormItem <FormItem
label="Title"
asterisk asterisk
label={translate('::App.Listform.ListformField.Title')}
invalid={!!errors.title && touched.title} invalid={!!errors.title && touched.title}
errorMessage={errors.title} errorMessage={errors.title}
> >
<Field <Field
name="title" 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>
<FormItem <FormItem
label="Category"
asterisk asterisk
invalid={!!errors.categoryId && touched.categoryId} label={translate('::App.Platform.Category')}
invalid={!!errors.categoryId && !!touched.categoryId}
errorMessage={errors.categoryId} errorMessage={errors.categoryId}
> >
<Field <Field name="categoryId">
as="select" {({ field, form }: FieldProps<string>) => (
name="categoryId" <Select<CategoryOption>
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" field={field}
> form={form}
<option value="">Select a category</option> options={categoryOptions}
{categories.map((cat) => ( placeholder={translate('::App.ForumTopicManagement.SelectCategory')}
<option key={cat.id} value={cat.id}> value={categoryOptions.filter(
{cat.name} (option) => option.value === values.categoryId,
</option> )}
))} 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> </Field>
</FormItem> </FormItem>
<FormItem <FormItem
label="Content"
asterisk asterisk
label={translate('::App.Listform.ListformField.Content')}
invalid={!!errors.content && touched.content} invalid={!!errors.content && touched.content}
errorMessage={errors.content} errorMessage={errors.content}
> >
@ -220,23 +238,23 @@ export function TopicManagement({
as="textarea" as="textarea"
name="content" name="content"
rows={6} 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>
<FormItem label="Options"> <FormItem label={translate('::App.Listform.ListformField.Options')}>
<div className="flex items-center space-x-6 pt-1"> <div className="flex items-center space-x-6 pt-1">
<label className="flex items-center"> <label className="flex items-center">
<Field type="checkbox" name="isPinned" className="mr-2" /> <Field type="checkbox" name="isPinned" className="mr-2" />
Pinned {translate('::App.ForumTopicManagement.Pinned')}
</label> </label>
<label className="flex items-center"> <label className="flex items-center">
<Field type="checkbox" name="isLocked" className="mr-2" /> <Field type="checkbox" name="isLocked" className="mr-2" />
Locked {translate('::App.Platform.Locked')}
</label> </label>
<label className="flex items-center"> <label className="flex items-center">
<Field type="checkbox" name="isSolved" className="mr-2" /> <Field type="checkbox" name="isSolved" className="mr-2" />
Solved {translate('::App.ForumTopicManagement.Solved')}
</label> </label>
</div> </div>
</FormItem> </FormItem>
@ -272,9 +290,9 @@ export function TopicManagement({
<Button <Button
size="sm" size="sm"
variant="solid" variant="solid"
onClick={() => setShowCreateForm(true)}
disabled={loading} disabled={loading}
className="flex items-center space-x-2" className="flex items-center space-x-2"
onClick={() => setShowCreateForm(true)}
> >
<FaPlus className="w-4 h-4" /> <FaPlus className="w-4 h-4" />
<span>{translate('::App.ForumTopicManagement.AddTopic')}</span> <span>{translate('::App.ForumTopicManagement.AddTopic')}</span>
@ -290,6 +308,11 @@ export function TopicManagement({
</div> </div>
) : ( ) : (
<div className="divide-y divide-gray-200 dark:divide-gray-800"> <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] {[...topics]
.sort( .sort(
(a, b) => new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime(), (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 justify-between text-sm text-gray-500 dark:text-gray-400">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<span className="font-medium">{getCategoryName(topic.categoryId)}</span> <span className="font-medium">{getCategoryName(topic.categoryId)}</span>
<span>by {topic.authorName}</span> <span>{topic.authorName}</span>
<span>{formatForumDate(topic.creationTime)}</span> <span>{formatForumDate(topic.creationTime)}</span>
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
@ -331,8 +354,12 @@ export function TopicManagement({
<FaEye className="w-4 h-4" /> <FaEye className="w-4 h-4" />
<span>{topic.viewCount}</span> <span>{topic.viewCount}</span>
</div> </div>
<span>{topic.replyCount} replies</span> <span>
<span>{topic.likeCount} likes</span> {topic.replyCount} {translate('::App.Platform.Replies')}
</span>
<span>
{topic.likeCount} {translate('::App.Listform.ListformField.Likes')}
</span>
</div> </div>
</div> </div>
</div> </div>
@ -340,30 +367,34 @@ export function TopicManagement({
<div className="flex items-center space-x-2 ml-4"> <div className="flex items-center space-x-2 ml-4">
<Button <Button
size="xs" size="xs"
onClick={() => handlePin(topic)}
className={`p-1 rounded-lg transition-colors ${ className={`p-1 rounded-lg transition-colors ${
topic.isPinned topic.isPinned
? 'text-orange-600 dark:text-orange-400 hover:bg-orange-100 dark:hover:bg-orange-900' ? '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' : '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="h-3 w-3" />
<FaThumbtack className="w-3 h-3" />
) : (
<FaTree className="w-3 h-3" />
)}
</Button> </Button>
<Button <Button
size="xs" size="xs"
onClick={() => handleLock(topic)}
className={`p-1 rounded-lg transition-colors ${ className={`p-1 rounded-lg transition-colors ${
topic.isLocked topic.isLocked
? 'text-yellow-600 dark:text-yellow-300 hover:bg-yellow-100 dark:hover:bg-yellow-900' ? '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' : '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 ? ( {topic.isLocked ? (
<FaLock className="w-3 h-3" /> <FaLock className="w-3 h-3" />
@ -374,13 +405,17 @@ export function TopicManagement({
<Button <Button
size="xs" size="xs"
onClick={() => handleSolved(topic)}
className={`p-1 rounded-lg transition-colors ${ className={`p-1 rounded-lg transition-colors ${
topic.isSolved topic.isSolved
? 'text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900' ? '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' : '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 ? ( {topic.isSolved ? (
<FaCheckCircle className="w-3 h-3" /> <FaCheckCircle className="w-3 h-3" />
@ -391,18 +426,18 @@ export function TopicManagement({
<Button <Button
size="xs" 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" 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')} title={translate('::App.ForumTopicManagement.EditTopic')}
onClick={() => handleEdit(topic)}
> >
<FaEdit className="w-3 h-3" /> <FaEdit className="w-3 h-3" />
</Button> </Button>
<Button <Button
size="xs" 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" 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')} title={translate('::App.ForumTopicManagement.DeleteTopic')}
onClick={() => confirmDeleteTopic(topic)}
> >
<FaTrashAlt className="w-3 h-3" /> <FaTrashAlt className="w-3 h-3" />
</Button> </Button>

View 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]
}

View file

@ -5,6 +5,7 @@ import { HtmlEditor, ImageUpload, Item, MediaResizing, Toolbar } from 'devextrem
import { useStoreState } from '@/store/store' import { useStoreState } from '@/store/store'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { Button, FormContainer, FormItem } from '@/components/ui' import { Button, FormContainer, FormItem } from '@/components/ui'
import { stripHtml } from './utils'
import { import {
fontFamilyOptions, fontFamilyOptions,
fontSizeOptions, fontSizeOptions,
@ -14,45 +15,65 @@ import {
sizeValues, sizeValues,
} from '@/proxy/reports/data' } from '@/proxy/reports/data'
interface CreatePostModalProps { interface PostFormValues {
onClose: () => void content: string
onSubmit: (data: { content: string; parentPostId?: string; tenantId?: string }) => void
parentPostId: 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: '', content: '',
} }
const postValidationSchema = Yup.object().shape({ const postValidationSchema = Yup.object().shape({
content: Yup.string().test('is-not-empty', 'App.Forum.ContentRequired', (value) => { content: Yup.string().test(
const plainText = value?.replace(/<[^>]+>/g, '').trim() 'is-not-empty',
return !!plainText 'App.Forum.ContentRequired',
}), (value) => !!stripHtml(value),
),
}) })
export function CreatePostModal({ onClose, onSubmit, parentPostId }: CreatePostModalProps) { export function CreatePostModal({ onClose, onSubmit, parentPostId }: CreatePostModalProps) {
const { translate } = useLocalization() const { translate } = useLocalization()
const { tenant } = useStoreState((state) => state.auth) const { tenant } = useStoreState((state) => state.auth)
const handleSubmit = ( const handleSubmit = async (
values: { content: string }, values: PostFormValues,
{ setSubmitting }: { setSubmitting: (isSubmitting: boolean) => void }, { setSubmitting }: { setSubmitting: (isSubmitting: boolean) => void },
) => { ) => {
const plainText = values.content.replace(/<[^>]+>/g, '').trim() try {
if (plainText) { if (!stripHtml(values.content)) return
onSubmit({ await onSubmit({
content: values.content, content: values.content,
parentPostId, parentPostId,
tenantId: tenant.tenantId, tenantId: tenant?.tenantId,
}) })
} } catch (error) {
console.error('Error creating post:', error)
} finally {
setSubmitting(false) setSubmitting(false)
} }
}
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <div
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"> 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"> <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"> <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{parentPostId {parentPostId
@ -63,8 +84,8 @@ export function CreatePostModal({ onClose, onSubmit, parentPostId }: CreatePostM
type="button" type="button"
variant="plain" variant="plain"
shape="none" 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" 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" /> <FaTimes className="w-5 h-5" />
</Button> </Button>
@ -92,13 +113,13 @@ export function CreatePostModal({ onClose, onSubmit, parentPostId }: CreatePostM
{({ field }: FieldProps) => ( {({ field }: FieldProps) => (
<HtmlEditor <HtmlEditor
value={field.value} value={field.value}
onValueChanged={(e) => setFieldValue('content', e.value)}
height={300} height={300}
placeholder={ placeholder={
parentPostId parentPostId
? translate('::App.ForumPostManagement.MessageEdit') ? translate('::App.ForumPostManagement.MessageEdit')
: translate('::App.ForumPostManagement.MessageNew') : translate('::App.ForumPostManagement.MessageNew')
} }
onValueChanged={(e) => setFieldValue('content', e.value)}
> >
<MediaResizing enabled={true} /> <MediaResizing enabled={true} />
<ImageUpload fileUploadMode="base64" /> <ImageUpload fileUploadMode="base64" />
@ -164,11 +185,11 @@ export function CreatePostModal({ onClose, onSubmit, parentPostId }: CreatePostM
variant="solid" variant="solid"
type="submit" type="submit"
loading={isSubmitting} loading={isSubmitting}
disabled={ disabled={!stripHtml(values.content)}
!values.content || values.content.replace(/<[^>]+>/g, '').trim() === ''
}
> >
{translate('::App.ForumPostManagement.PostReply')} {parentPostId
? translate('::App.ForumPostManagement.PostReply')
: translate('::App.ForumPostManagement.AddPost')}
</Button> </Button>
</div> </div>
</FormContainer> </FormContainer>

View file

@ -5,40 +5,61 @@ import { Field, FieldProps, Form, Formik } from 'formik'
import { Button, FormContainer, FormItem, Input } from '@/components/ui' import { Button, FormContainer, FormItem, Input } from '@/components/ui'
import * as Yup from 'yup' import * as Yup from 'yup'
interface CreateTopicModalProps { interface TopicFormValues {
onClose: () => void title: string
onSubmit: (data: { title: string; content: string; tenantId?: string }) => void content: string
} }
export const topicInitialValues = { interface CreateTopicModalProps {
onClose: () => void
onSubmit: (data: { title: string; content: string; tenantId?: string }) => Promise<unknown> | void
}
const topicInitialValues: TopicFormValues = {
title: '', title: '',
content: '', content: '',
} }
export const topicValidationSchema = Yup.object().shape({ const topicValidationSchema = Yup.object().shape({
title: Yup.string().required(), title: Yup.string().trim().required(),
content: Yup.string().required(), content: Yup.string().trim().required(),
}) })
export function CreateTopicModal({ onClose, onSubmit }: CreateTopicModalProps) { export function CreateTopicModal({ onClose, onSubmit }: CreateTopicModalProps) {
const { translate } = useLocalization() const { translate } = useLocalization()
const { tenant } = useStoreState((state) => state.auth) const { tenant } = useStoreState((state) => state.auth)
const handleSubmit = ( const handleSubmit = async (
values: { title: string; content: string }, values: TopicFormValues,
{ setSubmitting }: { setSubmitting: (isSubmitting: boolean) => void }, { setSubmitting }: { setSubmitting: (isSubmitting: boolean) => void },
) => { ) => {
onSubmit({ try {
await onSubmit({
title: values.title.trim(), title: values.title.trim(),
content: values.content.trim(), content: values.content.trim(),
tenantId: tenant.tenantId, tenantId: tenant?.tenantId,
}) })
} catch (error) {
console.error('Error creating topic:', error)
} finally {
setSubmitting(false) setSubmitting(false)
} }
}
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <div
<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"> 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"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"> <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{translate('::App.ForumTopicManagement.NewTopic')} {translate('::App.ForumTopicManagement.NewTopic')}
@ -47,8 +68,8 @@ export function CreateTopicModal({ onClose, onSubmit }: CreateTopicModalProps) {
type="button" type="button"
variant="plain" variant="plain"
shape="none" 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" 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" /> <FaTimes className="w-5 h-5" />
</Button> </Button>
@ -58,32 +79,31 @@ export function CreateTopicModal({ onClose, onSubmit }: CreateTopicModalProps) {
initialValues={topicInitialValues} initialValues={topicInitialValues}
validationSchema={topicValidationSchema} validationSchema={topicValidationSchema}
onSubmit={handleSubmit} onSubmit={handleSubmit}
enableReinitialize={true}
> >
{({ values, touched, errors, isSubmitting }) => ( {({ values, touched, errors, isSubmitting }) => (
<Form> <Form>
<FormContainer className="space-y-4"> <FormContainer className="space-y-4">
<FormItem <FormItem
label={translate('::App.ForumTopicManagement.TopicManagementTitle')}
invalid={errors.title && touched.title}
errorMessage={errors.title}
asterisk asterisk
label={translate('::App.ForumTopicManagement.TopicManagementTitle')}
invalid={!!errors.title && !!touched.title}
errorMessage={errors.title}
> >
<Field <Field
autoFocus
type="text" type="text"
name="title" name="title"
placeholder={translate('::App.Forum.TitlePlaceholder')} 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" 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} component={Input}
/> />
</FormItem> </FormItem>
<FormItem <FormItem
label={translate('::App.Listform.ListformField.Content')}
invalid={errors.content && touched.content}
errorMessage={errors.content}
asterisk asterisk
label={translate('::App.Listform.ListformField.Content')}
invalid={!!errors.content && !!touched.content}
errorMessage={errors.content}
> >
<Field name="content"> <Field name="content">
{({ field }: FieldProps) => ( {({ field }: FieldProps) => (

View file

@ -1,6 +1,7 @@
import { FaComment, FaLock, FaArrowUp } from 'react-icons/fa' import { FaComment, FaLock, FaArrowUp } from 'react-icons/fa'
import { ForumCategory } from '@/proxy/forum/forum' import { ForumCategory } from '@/proxy/forum/forum'
import { formatForumRelativeDate } from './utils' import { useLocalization } from '@/utils/hooks/useLocalization'
import { formatForumDate, formatForumRelativeDate } from './utils'
interface CategoryCardProps { interface CategoryCardProps {
category: ForumCategory category: ForumCategory
@ -8,38 +9,60 @@ interface CategoryCardProps {
} }
export function ForumCategoryCard({ category, onClick }: CategoryCardProps) { export function ForumCategoryCard({ category, onClick }: CategoryCardProps) {
const { translate, cultureName } = useLocalization()
return ( return (
<div <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} 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 justify-between">
<div className="flex items-start space-x-4 flex-1"> <div className="flex flex-1 items-start space-x-4">
<div className="text-3xl">{category.icon}</div> <div className="text-3xl" aria-hidden="true">
<div className="flex-1 min-w-0"> {category.icon}
<div className="flex items-center space-x-2 mb-1"> </div>
<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="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} {category.name}
</h3> </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> </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-4 text-sm text-gray-500 dark:text-gray-400">
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<FaComment className="w-4 h-4" /> <FaComment className="h-4 w-4" aria-hidden="true" />
<span>{category.topicCount} topics</span> <span>
{category.topicCount} {translate('::App.ForumDashboard.Topics')}
</span>
</div> </div>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<FaArrowUp className="w-4 h-4" /> <FaArrowUp className="h-4 w-4" aria-hidden="true" />
<span>{category.postCount} posts</span> <span>
{category.postCount} {translate('::App.ForumDashboard.Posts')}
</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="text-right text-sm text-gray-500 dark:text-gray-400 ml-4"> <div
<div>Last post</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"> <div className="font-medium text-gray-700 dark:text-gray-200">
{formatForumRelativeDate(category.lastPostDate)} {formatForumRelativeDate(category.lastPostDate, cultureName)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,4 +1,4 @@
import { useState } from 'react' import { useMemo, useState } from 'react'
import { AnimatePresence } from 'framer-motion' import { AnimatePresence } from 'framer-motion'
import { FaHeart, FaCheckCircle, FaReply } from 'react-icons/fa' import { FaHeart, FaCheckCircle, FaReply } from 'react-icons/fa'
import { ForumPost } from '@/proxy/forum/forum' import { ForumPost } from '@/proxy/forum/forum'
@ -6,7 +6,7 @@ import { AVATAR_URL } from '@/constants/app.constant'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { Avatar, Button } from '@/components/ui' import { Avatar, Button } from '@/components/ui'
import UserProfileCard from '@/views/intranet/SocialWall/UserProfileCard' import UserProfileCard from '@/views/intranet/SocialWall/UserProfileCard'
import { formatForumDate } from './utils' import { formatForumDate, sanitizeForumHtml } from './utils'
interface PostCardProps { interface PostCardProps {
post: ForumPost post: ForumPost
@ -25,6 +25,7 @@ export function ForumPostCard({
}: PostCardProps) { }: PostCardProps) {
const { translate } = useLocalization() const { translate } = useLocalization()
const [showUserCard, setShowUserCard] = useState(false) const [showUserCard, setShowUserCard] = useState(false)
const sanitizedContent = useMemo(() => sanitizeForumHtml(post.content), [post.content])
return ( return (
<div <div
@ -73,12 +74,11 @@ export function ForumPostCard({
</span> </span>
</div> </div>
<div className="prose prose-sm max-w-none mb-4"> {/* İçerik kullanıcı girdisi olduğu için sanitize edilmiş HTML basılır. */}
<p <div
className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap" className="prose prose-sm dark:prose-invert mb-4 max-w-none break-words text-gray-700 dark:text-gray-300"
dangerouslySetInnerHTML={{ __html: post.content }} dangerouslySetInnerHTML={{ __html: sanitizedContent }}
/> />
</div>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<Button <Button
@ -86,6 +86,7 @@ export function ForumPostCard({
variant="plain" variant="plain"
shape="none" 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" 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)} onClick={() => onLike(post.id, isFirst)}
> >
<FaHeart <FaHeart

View file

@ -6,7 +6,7 @@ import { AVATAR_URL } from '@/constants/app.constant'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { Avatar } from '@/components/ui' import { Avatar } from '@/components/ui'
import UserProfileCard from '@/views/intranet/SocialWall/UserProfileCard' import UserProfileCard from '@/views/intranet/SocialWall/UserProfileCard'
import { formatForumRelativeDate } from './utils' import { formatForumDate, formatForumRelativeDate, stripHtml } from './utils'
interface TopicCardProps { interface TopicCardProps {
topic: ForumTopic topic: ForumTopic
@ -14,51 +14,63 @@ interface TopicCardProps {
} }
export function ForumTopicCard({ topic, onClick }: TopicCardProps) { export function ForumTopicCard({ topic, onClick }: TopicCardProps) {
const { translate } = useLocalization() const { translate, cultureName } = useLocalization()
const [showUserCard, setShowUserCard] = useState(false) const [showUserCard, setShowUserCard] = useState(false)
return ( return (
<div <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} onClick={onClick}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onClick()
}
}}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
{/* Sol taraf: Başlık, içerik, istatistik */} {/* Sol taraf: Başlık, içerik, istatistik */}
<div className="flex-1 min-w-0 pr-4"> <div className="min-w-0 flex-1 pr-4">
<div className="flex items-center space-x-2 mb-2"> <div className="mb-2 flex items-center space-x-2">
{topic.isPinned && <FaThumbtack className="w-4 h-4 text-orange-500" />} {topic.isPinned && <FaThumbtack className="h-4 w-4 text-orange-500" />}
{topic.isLocked && <FaLock className="w-4 h-4 text-gray-400" />} {topic.isLocked && <FaLock className="h-4 w-4 text-gray-400" />}
{topic.isSolved && <FaCheckCircle className="w-4 h-4 text-emerald-500" />} {topic.isSolved && <FaCheckCircle className="h-4 w-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"> <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} {topic.title}
</h3> </h3>
</div> </div>
<p className="text-gray-600 dark:text-gray-400 text-sm mb-4 line-clamp-2"> <p className="mb-4 line-clamp-2 text-sm text-gray-600 dark:text-gray-400">
{topic.content} {stripHtml(topic.content)}
</p> </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-4 text-sm text-gray-500 dark:text-gray-400">
<div className="flex items-center space-x-1" title={translate('::App.Platform.Views')}> <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> <span>{topic.viewCount}</span>
</div> </div>
<div className="flex items-center space-x-1" title="Replies"> <div
<FaComment className="w-4 h-4" /> 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> <span>{topic.replyCount}</span>
</div> </div>
<div className="flex items-center space-x-1" title="Likes"> <div className="flex items-center space-x-1">
<FaHeart className="w-4 h-4" /> <FaHeart className="h-4 w-4" aria-hidden="true" />
<span>{topic.likeCount}</span> <span>{topic.likeCount}</span>
</div> </div>
</div> </div>
</div> </div>
{/* Sağ taraf: Avatar + Yazar bilgisi */} {/* 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 <div
className="relative flex-shrink-0" className="relative flex-shrink-0"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
onMouseEnter={() => setShowUserCard(true)} onMouseEnter={() => setShowUserCard(true)}
onMouseLeave={() => setShowUserCard(false)} 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"> <div className="text-sm font-medium text-gray-700 dark:text-gray-200">
{topic.authorName} {topic.authorName}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400"> <div
{formatForumRelativeDate(topic.creationTime)} className="text-xs text-gray-500 dark:text-gray-400"
title={formatForumDate(topic.creationTime)}
>
{formatForumRelativeDate(topic.creationTime, cultureName)}
</div> </div>
</div> </div>
</div> </div>
{topic.lastPostDate && topic.lastPostUserName && ( {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"> <div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
<span> <span>
{translate('::App.ForumTopicManagement.Lastreplyby')}{' '} {translate('::App.ForumTopicManagement.Lastreplyby')}{' '}
<span className="font-medium text-gray-700 dark:text-gray-200"> <span className="font-medium text-gray-700 dark:text-gray-200">
{topic.lastPostUserName} {topic.lastPostUserName}
</span>{' '} </span>{' '}
<span>{formatForumRelativeDate(topic.lastPostDate)}</span> <span title={formatForumDate(topic.lastPostDate)}>
{formatForumRelativeDate(topic.lastPostDate, cultureName)}
</span>
</span> </span>
</div> </div>
</div> </div>

View file

@ -1,4 +1,4 @@
import { useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { FaArrowLeft, FaPlus, FaSpinner, FaSearch } from 'react-icons/fa' import { FaArrowLeft, FaPlus, FaSpinner, FaSearch } from 'react-icons/fa'
import { CreateTopicModal } from './CreateTopicModal' import { CreateTopicModal } from './CreateTopicModal'
import { CreatePostModal } from './CreatePostModal' import { CreatePostModal } from './CreatePostModal'
@ -10,38 +10,31 @@ import { ForumCategoryCard } from './ForumCategoryCard'
import { ForumTopicCard } from './ForumTopicCard' import { ForumTopicCard } from './ForumTopicCard'
import { useStoreState } from '@/store/store' import { useStoreState } from '@/store/store'
import { useLocalization } from '@/utils/hooks/useLocalization' 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' import { Button } from '@/components/ui'
export type ForumViewState = 'categories' | 'topics' | 'posts'
interface ForumViewProps { interface ForumViewProps {
categories: ForumCategory[] categories: ForumCategory[]
topics: ForumTopic[] topics: ForumTopic[]
posts: ForumPost[] posts: ForumPost[]
loading: boolean loading: boolean
onCreateTopic: (topic: { onCreateTopic: (topic: CreateTopicRequest) => Promise<unknown>
title: string onCreatePost: (post: CreatePostRequest) => Promise<unknown>
content: string onLikePost: (id: string) => Promise<unknown>
categoryId: string onUnlikePost: (id: string) => Promise<unknown>
isPinned?: boolean selectedCategory: ForumCategory | null
isLocked?: boolean selectedTopic: ForumTopic | null
tenantId?: string viewState: ForumViewState
}) => Promise<void> onCategorySelect: (category: ForumCategory | null) => void
onCreatePost: (post: { onTopicSelect: (topic: ForumTopic | null) => void
topicId: string onViewStateChange: (state: ForumViewState) => void
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
} }
/** 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({ export function ForumView({
categories, categories,
topics, topics,
@ -51,205 +44,173 @@ export function ForumView({
onCreatePost, onCreatePost,
onLikePost, onLikePost,
onUnlikePost, onUnlikePost,
selectedCategory: propSelectedCategory, selectedCategory,
selectedTopic: propSelectedTopic, selectedTopic,
viewState: propViewState, viewState,
onCategorySelect, onCategorySelect,
onTopicSelect, onTopicSelect,
onViewStateChange, onViewStateChange,
}: ForumViewProps) { }: ForumViewProps) {
const { translate } = useLocalization() const { translate } = useLocalization()
const [localViewState, setLocalViewState] = useState<'categories' | 'topics' | 'posts'>( const { tenant } = useStoreState((state) => state.auth)
'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 [showCreateTopic, setShowCreateTopic] = useState(false) const [showCreateTopic, setShowCreateTopic] = useState(false)
const [showCreatePost, setShowCreatePost] = useState(false) const [showCreatePost, setShowCreatePost] = useState(false)
const [likedPosts, setLikedPosts] = useState<Set<string>>(new Set()) const [likedPosts, setLikedPosts] = useState<Set<string>>(new Set())
const [replyToPostId, setReplyToPostId] = useState<string | undefined>() const [replyToPostId, setReplyToPostId] = useState<string | undefined>()
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false) const [isSearchModalOpen, setIsSearchModalOpen] = useState(false)
const [postLikeCounts, setPostLikeCounts] = useState<Record<string, number>>({})
const { tenant } = useStoreState((state) => state.auth)
const handleSearchCategorySelect = (category: ForumCategory) => { const findCategoryOfTopic = useCallback(
if (onCategorySelect) onCategorySelect(category) (topic?: ForumTopic | null) => categories.find((c) => c.id === topic?.categoryId) ?? null,
else setLocalSelectedCategory(category) [categories],
)
if (onViewStateChange) onViewStateChange('topics') const goToCategories = useCallback(() => {
else setLocalViewState('topics') onViewStateChange('categories')
onCategorySelect(null)
onTopicSelect(null)
}, [onCategorySelect, onTopicSelect, onViewStateChange])
setIsSearchModalOpen(false) const goToTopics = useCallback(
} (category: ForumCategory | null) => {
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) {
onCategorySelect(category) onCategorySelect(category)
} else { onTopicSelect(null)
setLocalSelectedCategory(category) onViewStateChange(category ? 'topics' : 'categories')
} },
[onCategorySelect, onTopicSelect, onViewStateChange],
)
if (onViewStateChange) { const goToPosts = useCallback(
onViewStateChange('topics') (topic: ForumTopic, category: ForumCategory | null) => {
} else { onCategorySelect(category)
setLocalViewState('topics') onTopicSelect(topic)
}
}
const handleTopicClick = async (topic: ForumTopic) => {
try {
const updatedTopic = await forumService.getTopicById(topic.id)
if (onTopicSelect) {
onTopicSelect(updatedTopic)
} else {
setLocalSelectedTopic(updatedTopic)
}
if (onViewStateChange) {
onViewStateChange('posts') onViewStateChange('posts')
} else { },
setLocalViewState('posts') [onCategorySelect, onTopicSelect, onViewStateChange],
} )
const category = categories.find((c) => c.id === updatedTopic.categoryId) const handleBack = useCallback(() => {
if (onCategorySelect) onCategorySelect(category || null) if (viewState === 'posts') {
else setLocalSelectedCategory(category || null) 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) { } catch (err) {
console.error('Failed to load topic:', err) console.error('Failed to load topic:', err)
} }
} goToPosts(target, findCategoryOfTopic(target))
},
[findCategoryOfTopic, goToPosts],
)
const handleBreadcrumbClick = (target: 'forum' | 'category') => { const handleSearchCategorySelect = useCallback(
if (target === 'forum') { (category: ForumCategory) => {
onViewStateChange?.('categories') goToTopics(category)
onCategorySelect?.(null) setIsSearchModalOpen(false)
onTopicSelect?.(null) },
} else if (target === 'category' && selectedCategory) { [goToTopics],
onViewStateChange?.('topics') )
onTopicSelect?.(null)
}
}
const filteredTopics = selectedCategory const handleSearchTopicSelect = useCallback(
? topics.filter((topic) => topic.categoryId === selectedCategory.id) (topic: ForumTopic) => {
: [] goToPosts(topic, findCategoryOfTopic(topic))
setIsSearchModalOpen(false)
},
[findCategoryOfTopic, goToPosts],
)
const filteredPosts = selectedTopic const handleSearchPostSelect = useCallback(
? posts.filter((post) => post.topicId === selectedTopic.id) (post: ForumPost) => {
: [] const topic = topics.find((t) => t.id === post.topicId)
if (!topic) return
goToPosts(topic, findCategoryOfTopic(topic))
setIsSearchModalOpen(false)
},
[findCategoryOfTopic, goToPosts, topics],
)
const handleCreateTopic = async (topicData: { title: string; content: string }) => { const visibleCategories = useMemo(
() =>
categories
.filter((cat) => cat.isActive)
.sort((a, b) => a.displayOrder - b.displayOrder),
[categories],
)
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 threadedPosts = useMemo(
() => (selectedTopic ? buildPostTree(posts.filter((p) => p.topicId === selectedTopic.id)) : []),
[posts, selectedTopic],
)
const handleCreateTopic = useCallback(
async (topicData: { title: string; content: string }) => {
if (!selectedCategory) return if (!selectedCategory) return
try {
await onCreateTopic({ await onCreateTopic({
title: topicData.title, title: topicData.title,
content: topicData.content, content: topicData.content,
categoryId: selectedCategory.id, categoryId: selectedCategory.id,
isPinned: false, isPinned: false,
isLocked: false, isLocked: false,
tenantId: tenant.tenantId, tenantId: tenant?.tenantId,
}) })
setShowCreateTopic(false) setShowCreateTopic(false)
} catch (error) { },
console.error('Error creating topic:', error) [onCreateTopic, selectedCategory, tenant?.tenantId],
} )
}
const handleCreatePost = async (postData: { content: string }) => { const handleCreatePost = useCallback(
async (postData: { content: string }) => {
if (!selectedTopic) return if (!selectedTopic) return
try {
await onCreatePost({ await onCreatePost({
topicId: selectedTopic.id, topicId: selectedTopic.id,
content: postData.content, content: postData.content,
parentPostId: replyToPostId, parentPostId: replyToPostId,
tenantId: tenant.tenantId, tenantId: tenant?.tenantId,
}) })
setShowCreatePost(false) setShowCreatePost(false)
setReplyToPostId(undefined) 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) => {
function renderPosts(posts: ForumPost[]) {
return posts.map((post) => (
<div key={post.id}>
<ForumPostCard
post={post}
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>
)}
</div>
))
}
const handleLike = async (postId: string, isFirst: boolean = false) => {
try {
const isLiked = likedPosts.has(postId) const isLiked = likedPosts.has(postId)
try {
if (isLiked) {
if (isFirst) { if (isFirst) {
await forumService.unlikeTopic(postId) // İlk kart topic'in kendisidir; topic uçları post uçlarından ayrıdır.
} else { const updatedTopic = isLiked
? await forumService.unlikeTopic(postId)
: await forumService.likeTopic(postId)
onTopicSelect(updatedTopic)
} else if (isLiked) {
await onUnlikePost(postId) await onUnlikePost(postId)
}
} else {
if (isFirst) {
await forumService.likeTopic(postId)
} else { } else {
await onLikePost(postId) await onLikePost(postId)
} }
}
setLikedPosts((prev) => { setLikedPosts((prev) => {
const updated = new Set(prev) const updated = new Set(prev)
@ -257,30 +218,52 @@ export function ForumView({
else updated.add(postId) else updated.add(postId)
return updated return updated
}) })
if (selectedTopic?.id === postId) {
setPostLikeCounts((prev) => ({
...prev,
[postId]: (prev[postId] ?? selectedTopic.likeCount) + (isLiked ? -1 : 1),
}))
}
} catch (error) { } catch (error) {
console.error('Error liking/unliking post or topic:', error) console.error('Error liking/unliking post or topic:', error)
} }
} },
[likedPosts, onLikePost, onTopicSelect, onUnlikePost],
)
const handleReply = (postId: string) => { const handleReply = useCallback((postId: string) => {
setReplyToPostId(postId) setReplyToPostId(postId)
setShowCreatePost(true) 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}
/>
{post.children.length > 0 && (
<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 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) { if (loading) {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="flex h-64 items-center justify-center">
<div className="flex items-center justify-center h-64"> <FaSpinner className="h-8 w-8 animate-spin text-blue-600" />
<FaSpinner className="w-8 h-8 animate-spin text-blue-600" /> <span className="ml-2 text-gray-600 dark:text-gray-300">
<span className="ml-2 text-gray-600 dark:text-gray-300">Loading forum data...</span> {translate('::App.Platform.LoadingWithThreeDot')}
</div> </span>
</div> </div>
) )
} }
@ -288,48 +271,67 @@ export function ForumView({
return ( return (
<> <>
{/* Breadcrumb + Actions + Search Row */} {/* Breadcrumb + Actions + Search Row */}
<div className="flex items-center justify-between mb-4"> <div className="mb-4 flex items-center justify-between gap-2">
{/* Left Side: Breadcrumb */} {/* Left Side: Back + Breadcrumb */}
<div className="flex items-center space-x-2"> <div className="flex min-w-0 items-center space-x-2">
{viewState !== 'categories' && ( {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 && ( {selectedCategory && (
<> <>
<span aria-hidden="true">/</span>
{viewState === 'topics' ? (
<span className="truncate font-medium text-gray-900 dark:text-gray-100">
{selectedCategory.name}
</span>
) : (
<Button <Button
type="button" type="button"
variant="plain" variant="plain"
shape="none" shape="none"
onClick={() => handleBreadcrumbClick('forum')} className="!inline-flex !h-auto items-center !bg-transparent !p-0 transition-colors hover:text-blue-600 dark:hover:text-blue-400"
className={`!inline-flex !h-auto items-center !bg-transparent !p-0 transition-colors ${ onClick={() => goToTopics(selectedCategory)}
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} {selectedCategory.name}
</Button> </Button>
)}
</> </>
)} )}
{selectedTopic && (
{viewState === 'posts' && selectedTopic && (
<> <>
<span>/</span> <span aria-hidden="true">/</span>
<span className="text-gray-900 dark:text-gray-100 font-medium"> <span className="truncate font-medium text-gray-900 dark:text-gray-100">
{selectedTopic.title} {selectedTopic.title}
</span> </span>
</> </>
@ -338,14 +340,14 @@ export function ForumView({
</div> </div>
{/* Right Side: Actions + Search */} {/* 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 && ( {viewState === 'topics' && selectedCategory && !selectedCategory.isLocked && (
<Button <Button
size="sm" size="sm"
icon={<FaPlus className="w-4 h-4" />} icon={<FaPlus className="h-4 w-4" />}
variant="solid" 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" 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> <span>{translate('::App.ForumTopicManagement.NewTopic')}</span>
</Button> </Button>
@ -353,22 +355,25 @@ export function ForumView({
{viewState === 'posts' && selectedTopic && !selectedTopic.isLocked && ( {viewState === 'posts' && selectedTopic && !selectedTopic.isLocked && (
<Button <Button
size="sm" size="sm"
icon={<FaPlus className="w-4 h-4" />} icon={<FaPlus className="h-4 w-4" />}
variant="solid" 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" 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> <span>{translate('::App.ForumPostManagement.NewPost')}</span>
</Button> </Button>
)} )}
{/* Search */} {/* Search — geniş ekranda etiketli, dar ekranda ikon */}
<Button <Button
size="sm" size="sm"
icon={<FaSearch className="w-4 h-4" />} icon={<FaSearch className="h-4 w-4" />}
onClick={() => setIsSearchModalOpen(true)}
variant="default" 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" 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"> <span className="text-gray-500 dark:text-gray-300">
{translate('::App.ForumTopicManagement.Searchtopics')} {translate('::App.ForumTopicManagement.Searchtopics')}
@ -377,23 +382,22 @@ export function ForumView({
<Button <Button
size="sm" size="sm"
icon={<FaSearch className="w-5 h-5" />} icon={<FaSearch className="h-5 w-5" />}
onClick={() => setIsSearchModalOpen(true)}
variant="default" 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" 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>
</div> </div>
{/* Categories View */} {/* Categories View */}
{viewState === 'categories' && ( {viewState === 'categories' && (
<div className="space-y-3">
<div>
<div className="space-y-4"> <div className="space-y-4">
{[...categories] {visibleCategories.length === 0
.filter((cat) => cat.isActive) ? emptyState
.sort((a, b) => a.displayOrder - b.displayOrder) : visibleCategories.map((category) => (
.map((category) => (
<ForumCategoryCard <ForumCategoryCard
key={category.id} key={category.id}
category={category} category={category}
@ -401,23 +405,18 @@ export function ForumView({
/> />
))} ))}
</div> </div>
</div>
</div>
)} )}
{/* Topics View */} {/* Topics View */}
{viewState === 'topics' && selectedCategory && ( {viewState === 'topics' && selectedCategory && (
<div className="space-y-3">
<div> <div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">{selectedCategory.name}</h2> <h2 className="mb-6 text-2xl font-bold text-gray-900 dark:text-gray-100">
{selectedCategory.name}
</h2>
<div className="space-y-4"> <div className="space-y-4">
{[...filteredTopics] {filteredTopics.length === 0
.sort((a, b) => { ? emptyState
if (a.isPinned && !b.isPinned) return -1 : filteredTopics.map((topic) => (
if (!a.isPinned && b.isPinned) return 1
return new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime()
})
.map((topic) => (
<ForumTopicCard <ForumTopicCard
key={topic.id} key={topic.id}
topic={topic} topic={topic}
@ -426,17 +425,18 @@ export function ForumView({
))} ))}
</div> </div>
</div> </div>
</div>
)} )}
{/* Posts View */} {/* Posts View */}
{viewState === 'posts' && selectedTopic && ( {viewState === 'posts' && selectedTopic && (
<div className="space-y-3">
<div> <div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">{selectedTopic.title}</h2> <h2 className="mb-6 text-2xl font-bold text-gray-900 dark:text-gray-100">
{selectedTopic.title}
</h2>
{/* Topic Ana İçeriği */} {/* Topic ana içeriği */}
<ForumPostCard <ForumPostCard
isFirst
post={{ post={{
id: selectedTopic.id, id: selectedTopic.id,
topicId: selectedTopic.id, topicId: selectedTopic.id,
@ -447,45 +447,44 @@ export function ForumView({
authorEmail: selectedTopic.authorEmail, authorEmail: selectedTopic.authorEmail,
authorPhoneNumber: selectedTopic.authorPhoneNumber, authorPhoneNumber: selectedTopic.authorPhoneNumber,
authorDepartment: selectedTopic.authorDepartment, authorDepartment: selectedTopic.authorDepartment,
likeCount: postLikeCounts[selectedTopic.id] ?? selectedTopic.likeCount, likeCount: selectedTopic.likeCount,
isAcceptedAnswer: false, isAcceptedAnswer: false,
parentPostId: undefined, parentPostId: undefined,
creationTime: selectedTopic.creationTime, creationTime: selectedTopic.creationTime,
tenantId: selectedTopic.tenantId, tenantId: selectedTopic.tenantId,
children: [], children: [],
}} }}
isLiked={likedPosts.has(selectedTopic.id)}
onLike={handleLike} onLike={handleLike}
onReply={handleReply} onReply={handleReply}
isFirst={true}
isLiked={likedPosts.has(selectedTopic.id)}
/> />
{/* Hiyerarşik Postlar */} {/* Hiyerarşik postlar */}
<div className="mt-4 space-y-4">{renderPosts(threadedPosts)}</div> <div className="mt-4 space-y-4">{renderPosts(threadedPosts)}</div>
</div> </div>
</div>
)} )}
{/* Create Topic Modal */}
{showCreateTopic && ( {showCreateTopic && (
<CreateTopicModal onClose={() => setShowCreateTopic(false)} onSubmit={handleCreateTopic} /> <CreateTopicModal onClose={() => setShowCreateTopic(false)} onSubmit={handleCreateTopic} />
)} )}
{/* Create Post Modal */}
{showCreatePost && ( {showCreatePost && (
<CreatePostModal <CreatePostModal
onClose={() => setShowCreatePost(false)} parentPostId={replyToPostId}
onClose={() => {
setShowCreatePost(false)
setReplyToPostId(undefined)
}}
onSubmit={handleCreatePost} onSubmit={handleCreatePost}
parentPostId={replyToPostId!}
/> />
)} )}
<SearchModal <SearchModal
isOpen={isSearchModalOpen} isOpen={isSearchModalOpen}
onClose={() => setIsSearchModalOpen(false)}
categories={categories} categories={categories}
topics={topics} topics={topics}
posts={posts} posts={posts}
onClose={() => setIsSearchModalOpen(false)}
onCategorySelect={handleSearchCategorySelect} onCategorySelect={handleSearchCategorySelect}
onTopicSelect={handleSearchTopicSelect} onTopicSelect={handleSearchTopicSelect}
onPostSelect={handleSearchPostSelect} onPostSelect={handleSearchPostSelect}

View file

@ -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 { FaTimes, FaSearch, FaFolder, FaRegComment, FaFileAlt } from 'react-icons/fa'
import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum' import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum'
import { useForumSearch } from '@/utils/hooks/useForumSearch' import { useForumSearch } from '@/utils/hooks/useForumSearch'
import { useLocalization } from '@/utils/hooks/useLocalization'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import { formatForumDate } from './utils' import { formatForumDate, sanitizeForumHtml } from './utils'
interface SearchModalProps { interface SearchModalProps {
isOpen: boolean isOpen: boolean
@ -16,6 +17,11 @@ interface SearchModalProps {
onPostSelect: (post: ForumPost) => void 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({ export function SearchModal({
isOpen, isOpen,
onClose, onClose,
@ -26,7 +32,8 @@ export function SearchModal({
onTopicSelect, onTopicSelect,
onPostSelect, onPostSelect,
}: SearchModalProps) { }: SearchModalProps) {
const { searchQuery, setSearchQuery, searchResults, hasResults } = useForumSearch({ const { translate } = useLocalization()
const { searchQuery, setSearchQuery, searchResults, hasResults, clearSearch } = useForumSearch({
categories, categories,
topics, topics,
posts, posts,
@ -34,142 +41,153 @@ export function SearchModal({
const [selectedIndex, setSelectedIndex] = useState(0) 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(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setSelectedIndex(0) setSelectedIndex(0)
} else {
clearSearch()
}
// 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()
} }
}, [isOpen, searchResults])
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
const totalResults = if (e.key === 'Escape') {
searchResults.categories.length + searchResults.topics.length + searchResults.posts.length
if (totalResults === 0 && ['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)) {
e.preventDefault() e.preventDefault()
onClose()
return return
} }
if (!['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)) return
e.preventDefault()
if (totalResults === 0) return
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex((prev) => (prev + 1) % totalResults) setSelectedIndex((prev) => (prev + 1) % totalResults)
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex((prev) => (prev - 1 + totalResults) % totalResults) setSelectedIndex((prev) => (prev - 1 + totalResults) % totalResults)
} else if (e.key === 'Enter') { } else {
e.preventDefault() selectResultAt(selectedIndex)
handleSelectResult(selectedIndex)
} else if (e.key === 'Escape') {
onClose()
} }
} }
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 if (!isOpen) return null
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-start justify-center pt-20 p-4 z-50"> <div
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-xl max-w-2xl w-full max-h-[70vh] overflow-hidden"> className="fixed inset-0 z-50 flex items-start justify-center bg-black bg-opacity-50 p-4 pt-20"
<div className="flex items-center p-4 border-b border-gray-200 dark:border-gray-700"> role="dialog"
<FaSearch className="w-5 h-5 text-gray-400 dark:text-gray-500 mr-3" /> 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 <input
autoFocus
type="text" type="text"
value={searchQuery} 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)} onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown} 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 <Button
onClick={onClose}
variant="plain" variant="plain"
shape="circle" shape="circle"
icon={<FaTimes className="h-5 w-5" />} 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" 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>
<div className="overflow-y-auto max-h-96"> <div className="max-h-96 overflow-y-auto">
{!searchQuery.trim() ? ( {!searchQuery.trim() ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400"> <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" /> <FaSearch
<p>Start typing to search categories, topics, and posts...</p> 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> </div>
) : !hasResults ? ( ) : !hasResults ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400"> <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>
) : ( ) : (
<div className="py-2"> <div className="py-2">
{/* Categories */} {/* Categories */}
{searchResults.categories.length > 0 && ( {searchResults.categories.length > 0 && (
<div> <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"> <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">
Categories ({searchResults.categories.length}) {translate('::App.Platform.Category')} ({searchResults.categories.length})
</div> </div>
{searchResults.categories.map((category, index) => ( {searchResults.categories.map((category, index) => (
<Button <Button
key={`category-${category.id}`} key={`category-${category.id}`}
variant="plain"
shape="none"
className={`${RESULT_ROW_CLASS} ${
selectedIndex === index ? RESULT_ROW_ACTIVE_CLASS : ''
}`}
onClick={() => { onClick={() => {
onCategorySelect(category) onCategorySelect(category)
onClose() 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 flex-1 items-center space-x-3">
<div className="flex-shrink-0"> <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center"> <FaFolder className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<FaFolder className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
</div> </div>
<div className="text-left"> <div className="text-left">
<div className="font-medium text-gray-900 dark:text-white"> <div className="font-medium text-gray-900 dark:text-white">
{category.name} {category.name}
</div> </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} {category.description}
</div> </div>
</div> </div>
</div> </div>
<div className="text-xs text-gray-400 dark:text-gray-500"> <div className="text-xs text-gray-400 dark:text-gray-500">
{category.topicCount} topics {category.topicCount} {translate('::App.ForumDashboard.Topics')}
</div> </div>
</Button> </Button>
))} ))}
@ -179,43 +197,39 @@ export function SearchModal({
{/* Topics */} {/* Topics */}
{searchResults.topics.length > 0 && ( {searchResults.topics.length > 0 && (
<div> <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"> <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">
Topics ({searchResults.topics.length}) {translate('::App.ForumDashboard.Topics')} ({searchResults.topics.length})
</div> </div>
{searchResults.topics.map((topic, index) => { {searchResults.topics.map((topic, index) => {
const globalIndex = searchResults.categories.length + index const globalIndex = searchResults.categories.length + index
return ( return (
<Button <Button
key={`topic-${topic.id}`} key={`topic-${topic.id}`}
variant="plain"
shape="none"
className={`${RESULT_ROW_CLASS} ${
selectedIndex === globalIndex ? RESULT_ROW_ACTIVE_CLASS : ''
}`}
onClick={() => { onClick={() => {
onTopicSelect(topic) onTopicSelect(topic)
onClose() 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 flex-1 items-center space-x-3">
<div className="flex-shrink-0"> <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900">
<div className="w-8 h-8 bg-emerald-100 dark:bg-emerald-900 rounded-lg flex items-center justify-center"> <FaRegComment className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
<FaRegComment className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
</div>
</div> </div>
<div className="text-left"> <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} {topic.title}
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400"> <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>
</div> </div>
<div className="text-xs text-gray-400 dark:text-gray-500"> <div className="text-xs text-gray-400 dark:text-gray-500">
{topic.replyCount} replies {topic.replyCount} {translate('::App.ForumDashboard.Posts')}
</div> </div>
</Button> </Button>
) )
@ -226,8 +240,8 @@ export function SearchModal({
{/* Posts */} {/* Posts */}
{searchResults.posts.length > 0 && ( {searchResults.posts.length > 0 && (
<div> <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"> <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">
Posts ({searchResults.posts.length}) {translate('::App.ForumDashboard.Posts')} ({searchResults.posts.length})
</div> </div>
{searchResults.posts.map((post, index) => { {searchResults.posts.map((post, index) => {
const globalIndex = const globalIndex =
@ -235,38 +249,39 @@ export function SearchModal({
return ( return (
<Button <Button
key={`post-${post.id}`} key={`post-${post.id}`}
variant="plain"
shape="none"
className={`${RESULT_ROW_CLASS} ${
selectedIndex === globalIndex ? RESULT_ROW_ACTIVE_CLASS : ''
}`}
onClick={() => { onClick={() => {
onPostSelect(post) onPostSelect(post)
onClose() 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 flex-1 items-center space-x-3">
<div className="flex-shrink-0"> <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-orange-100 dark:bg-orange-900">
<div className="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center"> <FaFileAlt className="h-4 w-4 text-orange-600 dark:text-orange-400" />
<FaFileAlt className="w-4 h-4 text-orange-600" />
</div> </div>
</div> <div className="min-w-0 text-left">
<div className="text-left"> <div className="line-clamp-1 text-sm font-medium text-gray-900 dark:text-white">
<div className="font-medium text-gray-900 text-sm line-clamp-1"> {topicTitleById.get(post.topicId) ??
{getTopicTitle(post.topicId)} translate('::App.Platform.Unknown')}
</div> </div>
<div <div
className="text-sm text-gray-600 line-clamp-2 mt-1" className="mt-1 line-clamp-2 text-sm text-gray-600 dark:text-gray-400"
dangerouslySetInnerHTML={{ __html: post.content }} dangerouslySetInnerHTML={{
></div> __html: sanitizedPostPreviews.get(post.id) ?? '',
<div className="text-xs text-gray-500 mt-1"> }}
by {post.authorName} {formatForumDate(post.creationTime)} />
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{post.authorName} {formatForumDate(post.creationTime)}
</div> </div>
</div> </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> </Button>
) )
})} })}
@ -277,8 +292,8 @@ export function SearchModal({
</div> </div>
{hasResults && ( {hasResults && (
<div className="px-4 py-2 border-t border-gray-200 text-xs text-gray-500 bg-gray-50"> <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">
Use to navigate, Enter to select, Esc to close / Enter / Esc
</div> </div>
)} )}
</div> </div>

View 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()
}

View file

@ -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')
}

View file

@ -1,134 +1,197 @@
import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum' import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum'
import { CreateCategoryRequest, CreatePostRequest, CreateTopicRequest, forumService } from '@/services/forum.service' import {
import { useState, useEffect } from 'react' 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() { export function useForumData() {
const [categories, setCategories] = useState<ForumCategory[]>([]) const [categories, setCategories] = useState<ForumCategory[]>([])
const [topics, setTopics] = useState<ForumTopic[]>([]) const [topics, setTopics] = useState<ForumTopic[]>([])
const [posts, setPosts] = useState<ForumPost[]>([]) const [posts, setPosts] = useState<ForumPost[]>([])
const [totalCounts, setTotalCounts] = useState({ categories: 0, topics: 0, posts: 0 }) const [totalCounts, setTotalCounts] = useState<TotalCounts>({
const [loading, setLoading] = useState(false) categories: 0,
topics: 0,
posts: 0,
})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) 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(() => { useEffect(() => {
loadCategories() mounted.current = true
loadTopics() return () => {
loadPosts() mounted.current = false
}
}, []) }, [])
const loadCategories = async () => { const beginLoad = useCallback(() => {
try { pendingLoads.current += 1
setLoading(true) setLoading(true)
const response = await forumService.getCategories() }, [])
const endLoad = useCallback(() => {
pendingLoads.current = Math.max(0, pendingLoads.current - 1)
if (pendingLoads.current === 0 && mounted.current) {
setLoading(false)
}
}, [])
const fail = useCallback((message: string, err: unknown) => {
console.error(message, err)
if (mounted.current) setError(message)
}, [])
const loadCategories = useCallback(
async (params?: { isActive?: boolean }) => {
beginLoad()
try {
const response = await forumService.getCategories(params)
if (!mounted.current) return
setCategories(response.items) setCategories(response.items)
setTotalCounts((prev) => ({ ...prev, categories: response.totalCount })) setTotalCounts((prev) => ({ ...prev, categories: response.totalCount }))
} catch (err) { } catch (err) {
setError('Failed to load categories') fail('Failed to load categories', err)
console.error('Error loading categories:', err)
} finally { } finally {
setLoading(false) endLoad()
}
} }
},
[beginLoad, endLoad, fail],
)
const loadTopics = async (categoryId?: string) => { const loadTopics = useCallback(
async (categoryId?: string) => {
beginLoad()
try { try {
setLoading(true)
const response = await forumService.getTopics({ categoryId }) const response = await forumService.getTopics({ categoryId })
if (!mounted.current) return
setTopics(response.items) setTopics(response.items)
if (!categoryId) setTotalCounts((prev) => ({ ...prev, topics: response.totalCount })) if (!categoryId) setTotalCounts((prev) => ({ ...prev, topics: response.totalCount }))
} catch (err) { } catch (err) {
setError('Failed to load topics') fail('Failed to load topics', err)
console.error('Error loading topics:', err)
} finally { } finally {
setLoading(false) endLoad()
}
} }
},
[beginLoad, endLoad, fail],
)
const loadPosts = async (topicId?: string) => { const loadPosts = useCallback(
async (topicId?: string) => {
beginLoad()
try { try {
setLoading(true)
const response = await forumService.getPosts({ topicId }) const response = await forumService.getPosts({ topicId })
if (!mounted.current) return
setPosts(response.items) setPosts(response.items)
if (!topicId) setTotalCounts((prev) => ({ ...prev, posts: response.totalCount })) if (!topicId) setTotalCounts((prev) => ({ ...prev, posts: response.totalCount }))
} catch (err) { } catch (err) {
setError('Failed to load posts') fail('Failed to load posts', err)
console.error('Error loading posts:', err)
} finally { } finally {
setLoading(false) endLoad()
}
} }
},
[beginLoad, endLoad, fail],
)
// Category operations const reloadAll = useCallback(
const createCategory = async (categoryData: CreateCategoryRequest) => { () => Promise.all([loadCategories(), loadTopics(), loadPosts()]),
[loadCategories, loadTopics, loadPosts],
)
// İlk yükleme
useEffect(() => {
reloadAll()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// ---- Category operations -------------------------------------------------
const createCategory = useCallback(
async (categoryData: CreateCategoryRequest) => {
try { try {
setLoading(true)
const newCategory = await forumService.createCategory(categoryData) const newCategory = await forumService.createCategory(categoryData)
setCategories((prev) => [...prev, newCategory]) setCategories((prev) => [...prev, newCategory])
setTotalCounts((prev) => ({ ...prev, categories: prev.categories + 1 })) setTotalCounts((prev) => ({ ...prev, categories: prev.categories + 1 }))
return newCategory return newCategory
} catch (err) { } catch (err) {
setError('Failed to create category') fail('Failed to create category', err)
console.error('Error creating category:', err)
throw err throw err
} finally {
setLoading(false)
}
} }
},
[fail],
)
const updateCategory = async (id: string, updates: Partial<ForumCategory>) => { const updateCategory = useCallback(
async (id: string, updates: Partial<ForumCategory>) => {
try { try {
setLoading(true)
const updatedCategory = await forumService.updateCategory(id, updates) const updatedCategory = await forumService.updateCategory(id, updates)
setCategories((prev) => prev.map((cat) => (cat.id === id ? updatedCategory : cat))) setCategories((prev) => prev.map((cat) => (cat.id === id ? updatedCategory : cat)))
return updatedCategory return updatedCategory
} catch (err) { } catch (err) {
setError('Failed to update category') fail('Failed to update category', err)
console.error('Error updating category:', err)
throw err throw err
} finally {
setLoading(false)
}
} }
},
[fail],
)
const updateCategoryLockState = async (id: string) => { const updateCategoryLockState = useCallback(
await forumService.updateCategoryLockState(id) async (id: string) => {
await loadCategories() // refresh after update
}
const updateCategoryActiveState = async (id: string) => {
await forumService.updateCategoryActiveState(id)
await loadCategories() // refresh after update
}
const deleteCategory = async (id: string) => {
try { try {
setLoading(true) const updated = await forumService.updateCategoryLockState(id)
await forumService.deleteCategory(id) setCategories((prev) => prev.map((cat) => (cat.id === id ? updated : cat)))
// Also remove related topics and posts return updated
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) { } catch (err) {
setError('Failed to delete category') fail('Failed to update category lock state', err)
console.error('Error deleting category:', err)
throw err throw err
} finally {
setLoading(false)
}
} }
},
[fail],
)
// Topic operations const updateCategoryActiveState = useCallback(
const createTopic = async (topicData: CreateTopicRequest) => { 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 { try {
setLoading(true)
const newTopic = await forumService.createTopic(topicData) const newTopic = await forumService.createTopic(topicData)
setTopics((prev) => [...prev, newTopic]) setTopics((prev) => [...prev, newTopic])
setTotalCounts((prev) => ({ ...prev, topics: prev.topics + 1 })) setTotalCounts((prev) => ({ ...prev, topics: prev.topics + 1 }))
// Update category topic count
setCategories((prev) => setCategories((prev) =>
prev.map((cat) => prev.map((cat) =>
cat.id === topicData.categoryId ? { ...cat, topicCount: cat.topicCount + 1 } : cat, cat.id === topicData.categoryId ? { ...cat, topicCount: cat.topicCount + 1 } : cat,
@ -136,265 +199,192 @@ export function useForumData() {
) )
return newTopic return newTopic
} catch (err) { } catch (err) {
setError('Failed to create topic') fail('Failed to create topic', err)
console.error('Error creating topic:', err)
throw err throw err
} finally {
setLoading(false)
}
} }
},
[fail],
)
const updateTopic = async (id: string, updates: Partial<ForumTopic>) => { const updateTopic = useCallback(
async (id: string, updates: Partial<ForumTopic>) => {
try { try {
setLoading(true)
const updatedTopic = await forumService.updateTopic(id, updates) const updatedTopic = await forumService.updateTopic(id, updates)
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic))) setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
return updatedTopic return updatedTopic
} catch (err) { } catch (err) {
setError('Failed to update topic') fail('Failed to update topic', err)
console.error('Error updating topic:', err)
throw err throw err
} finally {
setLoading(false)
} }
} },
[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))
// Update category counts
if (topic) {
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,
),
) )
}
await Promise.all([loadCategories(), loadTopics(), loadPosts()])
} catch (err) {
setError('Failed to delete topic')
console.error('Error deleting topic:', err)
throw err
} finally {
setLoading(false)
}
}
const pinTopic = async (id: string) => { const deleteTopic = useCallback(
async (id: string) => {
try { try {
const updatedTopic = await forumService.pinTopic(id) await forumService.deleteTopic(id)
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic))) await reloadAll()
return updatedTopic
} catch (err) { } catch (err) {
setError('Failed to pin topic') fail('Failed to delete topic', err)
console.error('Error pinning topic:', err)
throw err throw err
} }
} },
[fail, reloadAll],
)
const unpinTopic = async (id: string) => { const applyTopicUpdate = useCallback((updated: ForumTopic) => {
setTopics((prev) => prev.map((topic) => (topic.id === updated.id ? updated : topic)))
return updated
}, [])
const topicAction = useCallback(
async (
id: string,
action: (id: string) => Promise<ForumTopic>,
errorMessage: string,
): Promise<ForumTopic> => {
try { try {
const updatedTopic = await forumService.unpinTopic(id) return applyTopicUpdate(await action(id))
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
return updatedTopic
} catch (err) { } catch (err) {
setError('Failed to unpin topic') fail(errorMessage, err)
console.error('Error unpinning topic:', err)
throw err throw err
} }
} },
[applyTopicUpdate, fail],
)
const lockTopic = async (id: string) => { const pinTopic = useCallback(
try { (id: string) => topicAction(id, (x) => forumService.pinTopic(x), 'Failed to pin topic'),
const updatedTopic = await forumService.lockTopic(id) [topicAction],
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic))) )
return updatedTopic const unpinTopic = useCallback(
} catch (err) { (id: string) => topicAction(id, (x) => forumService.unpinTopic(x), 'Failed to unpin topic'),
setError('Failed to lock topic') [topicAction],
console.error('Error locking topic:', err) )
throw err 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 unlockTopic = async (id: string) => { // ---- Post operations -----------------------------------------------------
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 solvedTopic = async (id: string) => { const createPost = useCallback(
async (postData: CreatePostRequest) => {
try { 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 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
const createPost = async (postData: CreatePostRequest) => {
try {
setLoading(true)
const newPost = await forumService.createPost(postData) const newPost = await forumService.createPost(postData)
setPosts((prev) => [...prev, newPost]) setPosts((prev) => [...prev, newPost])
setTotalCounts((prev) => ({ ...prev, posts: prev.posts + 1 })) 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) => setTopics((prev) =>
prev.map((t) => (t.id === postData.topicId ? { ...t, replyCount: t.replyCount + 1 } : t)), prev.map((t) => (t.id === postData.topicId ? { ...t, replyCount: t.replyCount + 1 } : t)),
) )
const categoryId = topics.find((t) => t.id === postData.topicId)?.categoryId
if (categoryId) {
setCategories((prev) => setCategories((prev) =>
prev.map((cat) => prev.map((cat) =>
cat.id === topic.categoryId ? { ...cat, postCount: cat.postCount + 1 } : cat, cat.id === categoryId ? { ...cat, postCount: cat.postCount + 1 } : cat,
), ),
) )
} }
return newPost return newPost
} catch (err) { } catch (err) {
setError('Failed to create post') fail('Failed to create post', err)
console.error('Error creating post:', err)
throw err throw err
} finally {
setLoading(false)
}
} }
},
[fail, topics],
)
const updatePost = async (id: string, updates: Partial<ForumPost>) => { const updatePost = useCallback(
async (id: string, updates: Partial<ForumPost>) => {
try { try {
setLoading(true)
const updatedPost = await forumService.updatePost(id, updates) const updatedPost = await forumService.updatePost(id, updates)
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post))) setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post)))
return updatedPost return updatedPost
} catch (err) { } catch (err) {
setError('Failed to update post') fail('Failed to update post', err)
console.error('Error updating post:', err)
throw err throw err
} finally {
setLoading(false)
}
} }
},
[fail],
)
const deletePost = async (id: string) => { const deletePost = useCallback(
async (id: string) => {
try { try {
setLoading(true)
const post = posts.find((p) => p.id === id)
await forumService.deletePost(id) await forumService.deletePost(id)
setPosts((prev) => prev.filter((p) => p.id !== id)) await reloadAll()
setTotalCounts((prev) => ({ ...prev, posts: Math.max(0, prev.posts - 1) })) } catch (err) {
fail('Failed to delete post', err)
// Update topic and category counts throw err
if (post) { }
const topic = topics.find((t) => t.id === post.topicId) },
if (topic) { [fail, reloadAll],
setTopics((prev) =>
prev.map((t) =>
t.id === post.topicId ? { ...t, replyCount: Math.max(0, t.replyCount - 1) } : t,
),
) )
setCategories((prev) => const postAction = useCallback(
prev.map((cat) => async (
cat.id === topic.categoryId id: string,
? { ...cat, postCount: Math.max(0, cat.postCount - 1) } action: (id: string) => Promise<ForumPost>,
: cat, 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],
) )
}
}
} catch (err) {
setError('Failed to delete post')
console.error('Error deleting post:', err)
throw err
} finally {
setLoading(false)
}
}
const likePost = async (id: string) => { const likePost = useCallback(
try { (id: string) => postAction(id, (x) => forumService.likePost(x), 'Failed to like post'),
const updatedPost = await forumService.likePost(id) [postAction],
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post))) )
return updatedPost const unlikePost = useCallback(
} catch (err) { (id: string) => postAction(id, (x) => forumService.unlikePost(x), 'Failed to unlike post'),
setError('Failed to like post') [postAction],
console.error('Error liking post:', err) )
throw err 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 unlikePost = async (id: string) => { const clearError = useCallback(() => setError(null), [])
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 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 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
}
}
return { return {
// Data // Data
@ -409,6 +399,7 @@ export function useForumData() {
loadCategories, loadCategories,
loadTopics, loadTopics,
loadPosts, loadPosts,
reloadAll,
// Category operations // Category operations
createCategory, createCategory,
@ -438,6 +429,6 @@ export function useForumData() {
unmarkPostAsAcceptedAnswer, unmarkPostAsAcceptedAnswer,
// Utility // Utility
clearError: () => setError(null), clearError,
} }
} }

View file

@ -6,7 +6,8 @@ import {
subscribeAppUpdate, subscribeAppUpdate,
type AppUpdateState, type AppUpdateState,
} from './swRegistration' } from './swRegistration'
import { useLocalization } from '@/utils/hooks/useLocalization' import { getLocalization } from '@/services/localization.service'
import { store } from '@/store'
/** /**
* Sürüm indirme penceresi. * 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ı. * 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) => { const formatBytes = (bytes: number) => {
if (!bytes) return '0 KB' if (!bytes) return '0 KB'
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB` if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
@ -27,14 +50,13 @@ const formatBytes = (bytes: number) => {
} }
const formatDuration = (seconds: number) => { const formatDuration = (seconds: number) => {
if (seconds < 60) return `${Math.max(1, seconds)} sn` if (seconds < 60) {
const minutes = Math.round(seconds / 60) return t('::App.Update.DurationSeconds', { value: Math.max(1, seconds) })
return `${minutes} dk` }
return t('::App.Update.DurationMinutes', { value: Math.round(seconds / 60) })
} }
type TranslateFn = (key: string, params?: Record<string, string | number>) => string const titleFor = (state: AppUpdateState) => {
const titleFor = (state: AppUpdateState, t: TranslateFn) => {
switch (state.status) { switch (state.status) {
case 'downloading': case 'downloading':
return t( return t(
@ -56,9 +78,11 @@ const titleFor = (state: AppUpdateState, t: TranslateFn) => {
} }
const countdownText = (state: AppUpdateState) => 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) { switch (state.status) {
case 'downloading': case 'downloading':
return t( return t(
@ -73,7 +97,8 @@ const descriptionFor = (state: AppUpdateState, t: TranslateFn) => {
case 'activating': case 'activating':
return t('::App.UpdateDescription.Activating') return t('::App.UpdateDescription.Activating')
case 'error': 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: default:
return t('::App.UpdateDescription.Checking') return t('::App.UpdateDescription.Checking')
} }
@ -132,7 +157,6 @@ const byActivity = (a: AppUpdateState['files'][number], b: AppUpdateState['files
a.index - b.index a.index - b.index
const AppUpdateDialog = () => { const AppUpdateDialog = () => {
const { translate } = useLocalization()
const state = useSyncExternalStore(subscribeAppUpdate, getAppUpdateState, getAppUpdateState) const state = useSyncExternalStore(subscribeAppUpdate, getAppUpdateState, getAppUpdateState)
if (!state.visible) return null 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" /> <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"> <div className="min-w-0">
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100"> <h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">{titleFor(state)}</h2>
{titleFor(state, translate)} <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{descriptionFor(state)}</p>
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{descriptionFor(state, translate)}
</p>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-2 px-6 pt-4 text-xs"> <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"> <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> </span>
</div> </div>
@ -172,17 +192,17 @@ const AppUpdateDialog = () => {
<div className="px-6 pt-4"> <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"> <div className="flex flex-wrap items-center justify-between gap-x-3 text-xs text-gray-500 dark:text-gray-400">
<span> <span>
{translate('::App.Update.AssetProgress', { {t('::App.Update.AssetProgress', {
done: doneCount, done: doneCount,
total: state.files.length, total: state.files.length,
})} })}
{state.reusedCount > 0 && {state.reusedCount > 0 &&
` · ${translate('::App.Update.ReusedFromCache', { count: state.reusedCount })}`} ` · ${t('::App.Update.ReusedFromCache', { count: state.reusedCount })}`}
{state.status === 'downloading' && state.secondsRemaining !== undefined && ( {state.status === 'downloading' && state.secondsRemaining !== undefined && (
<> <>
{' '} {' '}
·{' '} ·{' '}
{translate('::App.Update.Remaining', { {t('::App.Update.Remaining', {
duration: formatDuration(state.secondsRemaining), duration: formatDuration(state.secondsRemaining),
})} })}
</> </>
@ -206,7 +226,7 @@ const AppUpdateDialog = () => {
{state.files.length > 0 && ( {state.files.length > 0 && (
<> <>
<p className="text-xs font-bold uppercase tracking-wide text-gray-400"> <p className="text-xs font-bold uppercase tracking-wide text-gray-400">
{translate('::App.Update.DownloadedAssets')} {t('::App.Update.DownloadedAssets')}
</p> </p>
<ul className="divide-y divide-gray-100 dark:divide-gray-700"> <ul className="divide-y divide-gray-100 dark:divide-gray-700">
{ordered.map((file) => ( {ordered.map((file) => (
@ -219,7 +239,7 @@ const AppUpdateDialog = () => {
{state.changeLog.length > 0 && ( {state.changeLog.length > 0 && (
<div className="mt-4 border-t border-gray-100 pt-4 dark:border-gray-700"> <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"> <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> </p>
<ul className="mt-2 flex flex-col gap-1.5 text-sm text-gray-600 dark:text-gray-300"> <ul className="mt-2 flex flex-col gap-1.5 text-sm text-gray-600 dark:text-gray-300">
{state.changeLog.map((line, index) => ( {state.changeLog.map((line, index) => (
@ -235,10 +255,8 @@ const AppUpdateDialog = () => {
<div className="flex items-center justify-between gap-3 px-6 py-4"> <div className="flex items-center justify-between gap-3 px-6 py-4">
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">
{translate( {t(
state.dismissible state.dismissible ? '::App.UpdateFooter.Dismissible' : '::App.UpdateFooter.Blocking',
? '::App.UpdateFooter.Dismissible'
: '::App.UpdateFooter.Blocking',
)} )}
</span> </span>
{state.dismissible ? ( {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" className="shrink-0 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
onClick={dismissAppUpdateDialog} onClick={dismissAppUpdateDialog}
> >
{translate( {t(
state.status === 'completed' ? '::App.Platform.OK' : '::App.Update.ContinueInBackground', state.status === 'completed'
? '::App.Platform.OK'
: '::App.Update.ContinueInBackground',
)} )}
</button> </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" className="shrink-0 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
onClick={applyAppUpdate} onClick={applyAppUpdate}
> >
{translate('::App.Update.RetryNow')} {t('::App.Update.RetryNow')}
{countdownText(state)} {countdownText(state)}
</button> </button>
) )