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",
"tr": "{done}/{total} bileşen"
},
{
"resourceName": "Platform",
"key": "App.Update.ChangeLogTitle",
"en": "What's new in v{version}",
"tr": "v{version} ile gelen yenilikler"
},
{
"resourceName": "Platform",
"key": "App.Update.Countdown",
"en": "({seconds} s)",
"tr": "({seconds} sn)"
},
{
"resourceName": "Platform",
"key": "App.Update.DurationMinutes",
"en": "{value} min",
"tr": "{value} dk"
},
{
"resourceName": "Platform",
"key": "App.Update.DurationSeconds",
"en": "{value} s",
"tr": "{value} sn"
},
{
"resourceName": "Platform",
"key": "App.UpdateDescription.Activating",
@ -25649,6 +25673,150 @@
"key": "App.ListFormEditWorkflow.CriteriaStart",
"en": "Start",
"tr": "Başlat"
},
{
"resourceName": "Platform",
"key": "App.Platform.Locked",
"en": "Locked",
"tr": "Kilitli"
},
{
"resourceName": "Platform",
"key": "App.Platform.Replies",
"en": "Replies",
"tr": "Yanıtlar"
},
{
"resourceName": "Platform",
"key": "App.ForumCategoryManagement.ShowCategory",
"en": "Show category",
"tr": "Kategoriyi göster"
},
{
"resourceName": "Platform",
"key": "App.ForumCategoryManagement.HideCategory",
"en": "Hide category",
"tr": "Kategoriyi gizle"
},
{
"resourceName": "Platform",
"key": "App.ForumCategoryManagement.LockCategory",
"en": "Lock category",
"tr": "Kategoriyi kilitle"
},
{
"resourceName": "Platform",
"key": "App.ForumCategoryManagement.UnlockCategory",
"en": "Unlock category",
"tr": "Kategori kilidini aç"
},
{
"resourceName": "Platform",
"key": "App.ForumTopicManagement.PinTopic",
"en": "Pin topic",
"tr": "Konuyu sabitle"
},
{
"resourceName": "Platform",
"key": "App.ForumTopicManagement.UnpinTopic",
"en": "Unpin topic",
"tr": "Konu sabitlemesini kaldır"
},
{
"resourceName": "Platform",
"key": "App.ForumTopicManagement.LockTopic",
"en": "Lock topic",
"tr": "Konuyu kilitle"
},
{
"resourceName": "Platform",
"key": "App.ForumTopicManagement.UnlockTopic",
"en": "Unlock topic",
"tr": "Konu kilidini aç"
},
{
"resourceName": "Platform",
"key": "App.ForumTopicManagement.MarkAsSolved",
"en": "Mark as solved",
"tr": "Çözüldü olarak işaretle"
},
{
"resourceName": "Platform",
"key": "App.ForumTopicManagement.MarkAsUnsolved",
"en": "Mark as unsolved",
"tr": "Çözülmedi olarak işaretle"
},
{
"resourceName": "Platform",
"key": "App.ForumTopicManagement.SelectCategory",
"en": "Select a category",
"tr": "Kategori seçin"
},
{
"resourceName": "Platform",
"key": "App.ForumTopicManagement.Pinned",
"en": "Pinned",
"tr": "Sabitlenmiş"
},
{
"resourceName": "Platform",
"key": "App.ForumTopicManagement.Solved",
"en": "Solved",
"tr": "Çözüldü"
},
{
"resourceName": "Platform",
"key": "App.ForumPostManagement.MarkAsAcceptedAnswer",
"en": "Mark as accepted answer",
"tr": "Kabul edilen cevap olarak işaretle"
},
{
"resourceName": "Platform",
"key": "App.ForumPostManagement.RemoveAcceptedAnswer",
"en": "Remove accepted answer",
"tr": "Kabul edilen cevabı kaldır"
},
{
"resourceName": "Platform",
"key": "App.ForumPostManagement.SelectTopic",
"en": "Select a topic",
"tr": "Konu seçin"
},
{
"resourceName": "Platform",
"key": "App.ForumPostManagement.ReplyTo",
"en": "Reply to",
"tr": "Yanıtlanan"
},
{
"resourceName": "Platform",
"key": "App.ForumDashboard.ActiveCategories",
"en": "{0} active",
"tr": "{0} aktif"
},
{
"resourceName": "Platform",
"key": "App.ForumDashboard.SolvedTopics",
"en": "{0} solved",
"tr": "{0} çözüldü"
},
{
"resourceName": "Platform",
"key": "App.ForumDashboard.AcceptedAnswers",
"en": "{0} accepted answers",
"tr": "{0} kabul edilen cevap"
},
{
"resourceName": "Platform",
"key": "App.ForumDashboard.PostsPerTopic",
"en": "posts per topic",
"tr": "konu başına gönderi"
},
{
"resourceName": "Platform",
"key": "App.ForumDashboard.NewCategoryCreated",
"en": "New category created",
"tr": "Yeni kategori oluşturuldu"
}
]
}

View file

@ -1,11 +1,11 @@
import { ForumCategory, ForumTopic } from '@/proxy/forum/forum'
import { useState, useEffect } from 'react'
import { useForumData } from './useForumData'
import { ForumView } from './forum/ForumView'
import { ForumView, ForumViewState } from './forum/ForumView'
import { ForumErrorBanner } from './ForumErrorBanner'
import { Container } from '@/components/shared'
import PageTitle from '@/components/shared/PageTitle'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { Button } from '@/components/ui'
export function Forum() {
const { translate } = useLocalization()
@ -25,57 +25,32 @@ export function Forum() {
const [selectedCategory, setSelectedCategory] = useState<ForumCategory | null>(null)
const [selectedTopic, setSelectedTopic] = useState<ForumTopic | null>(null)
const [forumViewState, setForumViewState] = useState<'categories' | 'topics' | 'posts'>(
'categories',
)
const [forumViewState, setForumViewState] = useState<ForumViewState>('categories')
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
useEffect(() => {
if (error) {
const timer = setTimeout(() => {
clearError()
}, 5000)
return () => clearTimeout(timer)
}
if (!error) return
const timer = setTimeout(clearError, 5000)
return () => clearTimeout(timer)
}, [error, clearError])
return (
<Container>
<PageTitle title={translate('::' + 'App.Forum')} />
<PageTitle title={translate('::App.Forum')} />
{error && (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-200 px-4 py-3 rounded relative">
<strong className="font-bold">Error: </strong>
<span className="block sm:inline">{error}</span>
<Button onClick={clearError} className="absolute top-0 bottom-0 right-0 px-4 py-3 text-red-700 dark:text-red-200">
<span className="sr-only">Dismiss</span>×
</Button>
</div>
</div>
)}
<ForumErrorBanner error={error} onDismiss={clearError} />
<ForumView
categories={categories}
topics={topics}
posts={posts}
loading={loading}
onCreateTopic={(topicData) => createTopic(topicData).then(() => {})}
onCreatePost={(postData) => createPost(postData).then(() => {})}
onLikePost={(id) => likePost(id).then(() => {})}
onUnlikePost={(id) => unlikePost(id).then(() => {})}
selectedCategory={selectedCategory}
selectedTopic={selectedTopic}
viewState={forumViewState}
onCreateTopic={createTopic}
onCreatePost={createPost}
onLikePost={likePost}
onUnlikePost={unlikePost}
onCategorySelect={setSelectedCategory}
onTopicSelect={setSelectedTopic}
onViewStateChange={setForumViewState}

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 { useForumData } from './useForumData'
import { AdminView } from './admin/AdminView'
import { ForumErrorBanner } from './ForumErrorBanner'
import { Container } from '@/components/shared'
import PageTitle from '@/components/shared/PageTitle'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { Button } from '@/components/ui'
export function Management() {
const {
@ -38,40 +38,16 @@ export function Management() {
const { translate } = useLocalization()
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
// Search modal will be opened by Header component
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
useEffect(() => {
if (error) {
const timer = setTimeout(() => {
clearError()
}, 5000)
return () => clearTimeout(timer)
}
if (!error) return
const timer = setTimeout(clearError, 5000)
return () => clearTimeout(timer)
}, [error, clearError])
return (
<Container>
<PageTitle title={translate('::' + 'App.ForumManagement')} />
<PageTitle title={translate('::App.ForumManagement')} />
{error && (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-200 px-4 py-3 rounded relative">
<strong className="font-bold">Error: </strong>
<span className="block sm:inline">{error}</span>
<Button onClick={clearError} className="absolute top-0 bottom-0 right-0 px-4 py-3 text-red-700 dark:text-red-200">
<span className="sr-only">Dismiss</span>×
</Button>
</div>
</div>
)}
<ForumErrorBanner error={error} onDismiss={clearError} />
<AdminView
categories={categories}
@ -79,25 +55,25 @@ export function Management() {
posts={posts}
totalCounts={totalCounts}
loading={loading}
onCreateCategory={(data) => createCategory(data).then(() => {})}
onUpdateCategory={(id, data) => updateCategory(id, data).then(() => {})}
onUpdateCategoryLockState={(id) => updateCategoryLockState(id).then(() => {})}
onUpdateCategoryActiveState={(id) => updateCategoryActiveState(id).then(() => {})}
onDeleteCategory={(id) => deleteCategory(id).then(() => {})}
onCreateTopic={(data) => createTopic(data).then(() => {})}
onUpdateTopic={(id, data) => updateTopic(id, data).then(() => {})}
onDeleteTopic={(id) => deleteTopic(id).then(() => {})}
onPinTopic={(id) => pinTopic(id).then(() => {})}
onUnpinTopic={(id) => unpinTopic(id).then(() => {})}
onLockTopic={(id) => lockTopic(id).then(() => {})}
onUnlockTopic={(id) => unlockTopic(id).then(() => {})}
onMarkTopicAsSolved={(id) => solvedTopic(id).then(() => {})}
onMarkTopicAsUnsolved={(id) => unsolvedTopic(id).then(() => {})}
onCreatePost={(data) => createPost(data).then(() => {})}
onUpdatePost={(id, data) => updatePost(id, data).then(() => {})}
onDeletePost={(id) => deletePost(id).then(() => {})}
onMarkPostAsAcceptedAnswer={(id) => markPostAsAcceptedAnswer(id).then(() => {})}
onUnmarkPostAsAcceptedAnswer={(id) => unmarkPostAsAcceptedAnswer(id).then(() => {})}
onCreateCategory={createCategory}
onUpdateCategory={updateCategory}
onUpdateCategoryLockState={updateCategoryLockState}
onUpdateCategoryActiveState={updateCategoryActiveState}
onDeleteCategory={deleteCategory}
onCreateTopic={createTopic}
onUpdateTopic={updateTopic}
onDeleteTopic={deleteTopic}
onPinTopic={pinTopic}
onUnpinTopic={unpinTopic}
onLockTopic={lockTopic}
onUnlockTopic={unlockTopic}
onMarkTopicAsSolved={solvedTopic}
onMarkTopicAsUnsolved={unsolvedTopic}
onCreatePost={createPost}
onUpdatePost={updatePost}
onDeletePost={deletePost}
onMarkPostAsAcceptedAnswer={markPostAsAcceptedAnswer}
onUnmarkPostAsAcceptedAnswer={unmarkPostAsAcceptedAnswer}
/>
</Container>
)

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -5,40 +5,61 @@ import { Field, FieldProps, Form, Formik } from 'formik'
import { Button, FormContainer, FormItem, Input } from '@/components/ui'
import * as Yup from 'yup'
interface CreateTopicModalProps {
onClose: () => void
onSubmit: (data: { title: string; content: string; tenantId?: string }) => void
interface TopicFormValues {
title: string
content: string
}
export const topicInitialValues = {
interface CreateTopicModalProps {
onClose: () => void
onSubmit: (data: { title: string; content: string; tenantId?: string }) => Promise<unknown> | void
}
const topicInitialValues: TopicFormValues = {
title: '',
content: '',
}
export const topicValidationSchema = Yup.object().shape({
title: Yup.string().required(),
content: Yup.string().required(),
const topicValidationSchema = Yup.object().shape({
title: Yup.string().trim().required(),
content: Yup.string().trim().required(),
})
export function CreateTopicModal({ onClose, onSubmit }: CreateTopicModalProps) {
const { translate } = useLocalization()
const { tenant } = useStoreState((state) => state.auth)
const handleSubmit = (
values: { title: string; content: string },
const handleSubmit = async (
values: TopicFormValues,
{ setSubmitting }: { setSubmitting: (isSubmitting: boolean) => void },
) => {
onSubmit({
title: values.title.trim(),
content: values.content.trim(),
tenantId: tenant.tenantId,
})
setSubmitting(false)
try {
await onSubmit({
title: values.title.trim(),
content: values.content.trim(),
tenantId: tenant?.tenantId,
})
} catch (error) {
console.error('Error creating topic:', error)
} finally {
setSubmitting(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto p-6">
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"
role="dialog"
aria-modal="true"
onClick={onClose}
onKeyDown={(event) => {
if (event.key === 'Escape') onClose()
}}
>
<div
className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl bg-white p-6 shadow-2xl dark:bg-gray-900"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{translate('::App.ForumTopicManagement.NewTopic')}
@ -47,8 +68,8 @@ export function CreateTopicModal({ onClose, onSubmit }: CreateTopicModalProps) {
type="button"
variant="plain"
shape="none"
onClick={onClose}
className="!inline-flex !h-auto items-center justify-center !bg-transparent !p-1 text-gray-400 transition-colors hover:!bg-transparent hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
onClick={onClose}
>
<FaTimes className="w-5 h-5" />
</Button>
@ -58,32 +79,31 @@ export function CreateTopicModal({ onClose, onSubmit }: CreateTopicModalProps) {
initialValues={topicInitialValues}
validationSchema={topicValidationSchema}
onSubmit={handleSubmit}
enableReinitialize={true}
>
{({ values, touched, errors, isSubmitting }) => (
<Form>
<FormContainer className="space-y-4">
<FormItem
label={translate('::App.ForumTopicManagement.TopicManagementTitle')}
invalid={errors.title && touched.title}
errorMessage={errors.title}
asterisk
label={translate('::App.ForumTopicManagement.TopicManagementTitle')}
invalid={!!errors.title && !!touched.title}
errorMessage={errors.title}
>
<Field
autoFocus
type="text"
name="title"
placeholder={translate('::App.Forum.TitlePlaceholder')}
autoFocus
className="w-full text-sm border border-gray-300 dark:border-gray-700 rounded-md px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
component={Input}
/>
</FormItem>
<FormItem
label={translate('::App.Listform.ListformField.Content')}
invalid={errors.content && touched.content}
errorMessage={errors.content}
asterisk
label={translate('::App.Listform.ListformField.Content')}
invalid={!!errors.content && !!touched.content}
errorMessage={errors.content}
>
<Field name="content">
{({ field }: FieldProps) => (

View file

@ -1,6 +1,7 @@
import { FaComment, FaLock, FaArrowUp } from 'react-icons/fa'
import { ForumCategory } from '@/proxy/forum/forum'
import { formatForumRelativeDate } from './utils'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { formatForumDate, formatForumRelativeDate } from './utils'
interface CategoryCardProps {
category: ForumCategory
@ -8,38 +9,60 @@ interface CategoryCardProps {
}
export function ForumCategoryCard({ category, onClick }: CategoryCardProps) {
const { translate, cultureName } = useLocalization()
return (
<div
role="button"
tabIndex={0}
className="group cursor-pointer rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-200 hover:border-blue-200 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-blue-500"
onClick={onClick}
className="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 hover:shadow-md hover:border-blue-200 dark:hover:border-blue-500 transition-all duration-200 cursor-pointer group"
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onClick()
}
}}
>
<div className="flex items-start justify-between">
<div className="flex items-start space-x-4 flex-1">
<div className="text-3xl">{category.icon}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
<div className="flex flex-1 items-start space-x-4">
<div className="text-3xl" aria-hidden="true">
{category.icon}
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center space-x-2">
<h3 className="text-lg font-semibold text-gray-900 transition-colors group-hover:text-blue-600 dark:text-white dark:group-hover:text-blue-400">
{category.name}
</h3>
{category.isLocked && <FaLock className="w-4 h-4 text-gray-400 dark:text-gray-500" />}
{category.isLocked && (
<FaLock className="h-4 w-4 text-gray-400 dark:text-gray-500" aria-hidden="true" />
)}
</div>
<p className="text-gray-600 dark:text-gray-400 text-sm mb-3 line-clamp-2">{category.description}</p>
<p className="mb-3 line-clamp-2 text-sm text-gray-600 dark:text-gray-400">
{category.description}
</p>
<div className="flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400">
<div className="flex items-center space-x-1">
<FaComment className="w-4 h-4" />
<span>{category.topicCount} topics</span>
<FaComment className="h-4 w-4" aria-hidden="true" />
<span>
{category.topicCount} {translate('::App.ForumDashboard.Topics')}
</span>
</div>
<div className="flex items-center space-x-1">
<FaArrowUp className="w-4 h-4" />
<span>{category.postCount} posts</span>
<FaArrowUp className="h-4 w-4" aria-hidden="true" />
<span>
{category.postCount} {translate('::App.ForumDashboard.Posts')}
</span>
</div>
</div>
</div>
</div>
<div className="text-right text-sm text-gray-500 dark:text-gray-400 ml-4">
<div>Last post</div>
<div
className="ml-4 shrink-0 text-right text-sm text-gray-500 dark:text-gray-400"
title={formatForumDate(category.lastPostDate)}
>
<div className="font-medium text-gray-700 dark:text-gray-200">
{formatForumRelativeDate(category.lastPostDate)}
{formatForumRelativeDate(category.lastPostDate, cultureName)}
</div>
</div>
</div>

View file

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

View file

@ -6,7 +6,7 @@ import { AVATAR_URL } from '@/constants/app.constant'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { Avatar } from '@/components/ui'
import UserProfileCard from '@/views/intranet/SocialWall/UserProfileCard'
import { formatForumRelativeDate } from './utils'
import { formatForumDate, formatForumRelativeDate, stripHtml } from './utils'
interface TopicCardProps {
topic: ForumTopic
@ -14,51 +14,63 @@ interface TopicCardProps {
}
export function ForumTopicCard({ topic, onClick }: TopicCardProps) {
const { translate } = useLocalization()
const { translate, cultureName } = useLocalization()
const [showUserCard, setShowUserCard] = useState(false)
return (
<div
className="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 hover:shadow-md hover:border-blue-200 dark:hover:border-blue-500 transition-all duration-200 cursor-pointer group"
role="button"
tabIndex={0}
className="group cursor-pointer rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-200 hover:border-blue-200 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-blue-500"
onClick={onClick}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onClick()
}
}}
>
<div className="flex items-start justify-between">
{/* Sol taraf: Başlık, içerik, istatistik */}
<div className="flex-1 min-w-0 pr-4">
<div className="flex items-center space-x-2 mb-2">
{topic.isPinned && <FaThumbtack className="w-4 h-4 text-orange-500" />}
{topic.isLocked && <FaLock className="w-4 h-4 text-gray-400" />}
{topic.isSolved && <FaCheckCircle className="w-4 h-4 text-emerald-500" />}
<h3 className="text-lg font-semibold text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors line-clamp-1">
<div className="min-w-0 flex-1 pr-4">
<div className="mb-2 flex items-center space-x-2">
{topic.isPinned && <FaThumbtack className="h-4 w-4 text-orange-500" />}
{topic.isLocked && <FaLock className="h-4 w-4 text-gray-400" />}
{topic.isSolved && <FaCheckCircle className="h-4 w-4 text-emerald-500" />}
<h3 className="line-clamp-1 text-lg font-semibold text-gray-900 transition-colors group-hover:text-blue-600 dark:text-white dark:group-hover:text-blue-400">
{topic.title}
</h3>
</div>
<p className="text-gray-600 dark:text-gray-400 text-sm mb-4 line-clamp-2">
{topic.content}
<p className="mb-4 line-clamp-2 text-sm text-gray-600 dark:text-gray-400">
{stripHtml(topic.content)}
</p>
<div className="flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400">
<div className="flex items-center space-x-1" title={translate('::App.Platform.Views')}>
<FaEye className="w-4 h-4" />
<FaEye className="h-4 w-4" aria-hidden="true" />
<span>{topic.viewCount}</span>
</div>
<div className="flex items-center space-x-1" title="Replies">
<FaComment className="w-4 h-4" />
<div
className="flex items-center space-x-1"
title={translate('::App.ForumDashboard.Posts')}
>
<FaComment className="h-4 w-4" aria-hidden="true" />
<span>{topic.replyCount}</span>
</div>
<div className="flex items-center space-x-1" title="Likes">
<FaHeart className="w-4 h-4" />
<div className="flex items-center space-x-1">
<FaHeart className="h-4 w-4" aria-hidden="true" />
<span>{topic.likeCount}</span>
</div>
</div>
</div>
{/* Sağ taraf: Avatar + Yazar bilgisi */}
<div className="flex flex-col items-center justify-start w-24 text-center space-y-1">
<div className="flex w-24 flex-col items-center justify-start space-y-1 text-center">
<div
className="relative flex-shrink-0"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
onMouseEnter={() => setShowUserCard(true)}
onMouseLeave={() => setShowUserCard(false)}
>
@ -84,21 +96,26 @@ export function ForumTopicCard({ topic, onClick }: TopicCardProps) {
<div className="text-sm font-medium text-gray-700 dark:text-gray-200">
{topic.authorName}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{formatForumRelativeDate(topic.creationTime)}
<div
className="text-xs text-gray-500 dark:text-gray-400"
title={formatForumDate(topic.creationTime)}
>
{formatForumRelativeDate(topic.creationTime, cultureName)}
</div>
</div>
</div>
{topic.lastPostDate && topic.lastPostUserName && (
<div className="mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
<div className="mt-4 border-t border-gray-100 pt-4 dark:border-gray-700">
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
<span>
{translate('::App.ForumTopicManagement.Lastreplyby')}{' '}
<span className="font-medium text-gray-700 dark:text-gray-200">
{topic.lastPostUserName}
</span>{' '}
<span>{formatForumRelativeDate(topic.lastPostDate)}</span>
<span title={formatForumDate(topic.lastPostDate)}>
{formatForumRelativeDate(topic.lastPostDate, cultureName)}
</span>
</span>
</div>
</div>

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

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 { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum'
import { useForumSearch } from '@/utils/hooks/useForumSearch'
import { useLocalization } from '@/utils/hooks/useLocalization'
import Button from '@/components/ui/Button'
import { formatForumDate } from './utils'
import { formatForumDate, sanitizeForumHtml } from './utils'
interface SearchModalProps {
isOpen: boolean
@ -16,6 +17,11 @@ interface SearchModalProps {
onPostSelect: (post: ForumPost) => void
}
const RESULT_ROW_CLASS =
'w-full !h-auto !justify-start !rounded-none !px-4 !py-3 transition-colors hover:!bg-gray-50 dark:hover:!bg-gray-800'
const RESULT_ROW_ACTIVE_CLASS =
'!bg-blue-50 dark:!bg-blue-900 border-r-2 border-blue-500 dark:border-blue-400'
export function SearchModal({
isOpen,
onClose,
@ -26,7 +32,8 @@ export function SearchModal({
onTopicSelect,
onPostSelect,
}: SearchModalProps) {
const { searchQuery, setSearchQuery, searchResults, hasResults } = useForumSearch({
const { translate } = useLocalization()
const { searchQuery, setSearchQuery, searchResults, hasResults, clearSearch } = useForumSearch({
categories,
topics,
posts,
@ -34,142 +41,153 @@ export function SearchModal({
const [selectedIndex, setSelectedIndex] = useState(0)
// Modal her açıldığında temiz başlar; sonuçlar değişince seçim başa döner.
useEffect(() => {
if (isOpen) {
setSelectedIndex(0)
} else {
clearSearch()
}
}, [isOpen, searchResults])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen])
useEffect(() => {
setSelectedIndex(0)
}, [searchResults])
const totalResults = searchResults.totalCount
const topicTitleById = useMemo(
() => new Map(topics.map((topic) => [topic.id, topic.title])),
[topics],
)
const sanitizedPostPreviews = useMemo(
() => new Map(searchResults.posts.map((post) => [post.id, sanitizeForumHtml(post.content)])),
[searchResults.posts],
)
const selectResultAt = (index: number) => {
const { categories: cats, topics: tops, posts: pos } = searchResults
if (index < cats.length) {
onCategorySelect(cats[index])
} else if (index < cats.length + tops.length) {
onTopicSelect(tops[index - cats.length])
} else if (index < cats.length + tops.length + pos.length) {
onPostSelect(pos[index - cats.length - tops.length])
} else {
return
}
onClose()
}
const handleKeyDown = (e: KeyboardEvent) => {
const totalResults =
searchResults.categories.length + searchResults.topics.length + searchResults.posts.length
if (totalResults === 0 && ['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)) {
if (e.key === 'Escape') {
e.preventDefault()
onClose()
return
}
if (!['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)) return
e.preventDefault()
if (totalResults === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex((prev) => (prev + 1) % totalResults)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex((prev) => (prev - 1 + totalResults) % totalResults)
} else if (e.key === 'Enter') {
e.preventDefault()
handleSelectResult(selectedIndex)
} else if (e.key === 'Escape') {
onClose()
} else {
selectResultAt(selectedIndex)
}
}
const handleSelectResult = (index: number) => {
let currentIndex = 0
// Check categories
if (index < searchResults.categories.length) {
onCategorySelect(searchResults.categories[index])
onClose()
return
}
currentIndex += searchResults.categories.length
// Check topics
if (index < currentIndex + searchResults.topics.length) {
onTopicSelect(searchResults.topics[index - currentIndex])
onClose()
return
}
currentIndex += searchResults.topics.length
// Check posts
if (index < currentIndex + searchResults.posts.length) {
onPostSelect(searchResults.posts[index - currentIndex])
onClose()
return
}
}
const getTopicTitle = (topicId: string) => {
const topic = topics.find((t) => t.id === topicId)
return topic ? topic.title : 'Unknown Topic'
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-start justify-center pt-20 p-4 z-50">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-xl max-w-2xl w-full max-h-[70vh] overflow-hidden">
<div className="flex items-center p-4 border-b border-gray-200 dark:border-gray-700">
<FaSearch className="w-5 h-5 text-gray-400 dark:text-gray-500 mr-3" />
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black bg-opacity-50 p-4 pt-20"
role="dialog"
aria-modal="true"
onClick={onClose}
>
<div
className="max-h-[70vh] w-full max-w-2xl overflow-hidden rounded-xl bg-white shadow-xl dark:bg-gray-900"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center border-b border-gray-200 p-4 dark:border-gray-700">
<FaSearch className="mr-3 h-5 w-5 text-gray-400 dark:text-gray-500" aria-hidden="true" />
<input
autoFocus
type="text"
value={searchQuery}
placeholder={translate('::App.ForumTopicManagement.Searchtopics')}
aria-label={translate('::App.Platform.Search')}
className="flex-1 bg-transparent text-lg text-gray-900 outline-none placeholder-gray-400 dark:text-white dark:placeholder-gray-500"
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search categories, topics, and posts..."
className="flex-1 outline-none text-lg bg-transparent text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500"
autoFocus
/>
<Button
onClick={onClose}
variant="plain"
shape="circle"
icon={<FaTimes className="h-5 w-5" />}
aria-label={translate('::App.Platform.Close')}
title={translate('::App.Platform.Close')}
className="ml-3 !h-8 !w-8 !px-0 text-gray-400 transition-colors hover:!bg-transparent hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
onClick={onClose}
/>
</div>
<div className="overflow-y-auto max-h-96">
<div className="max-h-96 overflow-y-auto">
{!searchQuery.trim() ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
<FaSearch className="w-12 h-12 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
<p>Start typing to search categories, topics, and posts...</p>
<FaSearch
className="mx-auto mb-4 h-12 w-12 text-gray-300 dark:text-gray-600"
aria-hidden="true"
/>
<p>{translate('::App.Platform.Search')}</p>
</div>
) : !hasResults ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
<p>No results found for {searchQuery}</p>
<p>{translate('::App.Platform.NoResults')}</p>
</div>
) : (
<div className="py-2">
{/* Categories */}
{searchResults.categories.length > 0 && (
<div>
<div className="px-4 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide bg-gray-50 dark:bg-gray-800">
Categories ({searchResults.categories.length})
<div className="bg-gray-50 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:bg-gray-800 dark:text-gray-400">
{translate('::App.Platform.Category')} ({searchResults.categories.length})
</div>
{searchResults.categories.map((category, index) => (
<Button
key={`category-${category.id}`}
variant="plain"
shape="none"
className={`${RESULT_ROW_CLASS} ${
selectedIndex === index ? RESULT_ROW_ACTIVE_CLASS : ''
}`}
onClick={() => {
onCategorySelect(category)
onClose()
}}
variant="plain"
shape="none"
className={`w-full !h-auto !justify-start !rounded-none !px-4 !py-3 transition-colors hover:!bg-gray-50 dark:hover:!bg-gray-800 ${
selectedIndex === index
? '!bg-blue-50 dark:!bg-blue-900 border-r-2 border-blue-500 dark:border-blue-400'
: ''
}`}
>
<div className="flex items-center space-x-3 flex-1">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<FaFolder className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex flex-1 items-center space-x-3">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900">
<FaFolder className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="text-left">
<div className="font-medium text-gray-900 dark:text-white">
{category.name}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 line-clamp-1">
<div className="line-clamp-1 text-sm text-gray-500 dark:text-gray-400">
{category.description}
</div>
</div>
</div>
<div className="text-xs text-gray-400 dark:text-gray-500">
{category.topicCount} topics
{category.topicCount} {translate('::App.ForumDashboard.Topics')}
</div>
</Button>
))}
@ -179,43 +197,39 @@ export function SearchModal({
{/* Topics */}
{searchResults.topics.length > 0 && (
<div>
<div className="px-4 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide bg-gray-50 dark:bg-gray-800">
Topics ({searchResults.topics.length})
<div className="bg-gray-50 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:bg-gray-800 dark:text-gray-400">
{translate('::App.ForumDashboard.Topics')} ({searchResults.topics.length})
</div>
{searchResults.topics.map((topic, index) => {
const globalIndex = searchResults.categories.length + index
return (
<Button
key={`topic-${topic.id}`}
variant="plain"
shape="none"
className={`${RESULT_ROW_CLASS} ${
selectedIndex === globalIndex ? RESULT_ROW_ACTIVE_CLASS : ''
}`}
onClick={() => {
onTopicSelect(topic)
onClose()
}}
variant="plain"
shape="none"
className={`w-full !h-auto !justify-start !rounded-none !px-4 !py-3 transition-colors hover:!bg-gray-50 dark:hover:!bg-gray-800 ${
selectedIndex === globalIndex
? '!bg-blue-50 dark:!bg-blue-900 border-r-2 border-blue-500 dark:border-blue-400'
: ''
}`}
>
<div className="flex items-center space-x-3 flex-1">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-emerald-100 dark:bg-emerald-900 rounded-lg flex items-center justify-center">
<FaRegComment className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
</div>
<div className="flex flex-1 items-center space-x-3">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900">
<FaRegComment className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
<div className="text-left">
<div className="font-medium text-gray-900 dark:text-white line-clamp-1">
<div className="line-clamp-1 font-medium text-gray-900 dark:text-white">
{topic.title}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
by {topic.authorName} {formatForumDate(topic.creationTime)}
{topic.authorName} {formatForumDate(topic.creationTime)}
</div>
</div>
</div>
<div className="text-xs text-gray-400 dark:text-gray-500">
{topic.replyCount} replies
{topic.replyCount} {translate('::App.ForumDashboard.Posts')}
</div>
</Button>
)
@ -226,8 +240,8 @@ export function SearchModal({
{/* Posts */}
{searchResults.posts.length > 0 && (
<div>
<div className="px-4 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide bg-gray-50 dark:bg-gray-800">
Posts ({searchResults.posts.length})
<div className="bg-gray-50 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:bg-gray-800 dark:text-gray-400">
{translate('::App.ForumDashboard.Posts')} ({searchResults.posts.length})
</div>
{searchResults.posts.map((post, index) => {
const globalIndex =
@ -235,38 +249,39 @@ export function SearchModal({
return (
<Button
key={`post-${post.id}`}
variant="plain"
shape="none"
className={`${RESULT_ROW_CLASS} ${
selectedIndex === globalIndex ? RESULT_ROW_ACTIVE_CLASS : ''
}`}
onClick={() => {
onPostSelect(post)
onClose()
}}
variant="plain"
shape="none"
className={`w-full !h-auto !justify-start !rounded-none !px-4 !py-3 transition-colors hover:!bg-gray-50 ${
selectedIndex === globalIndex
? '!bg-blue-50 border-r-2 border-blue-500'
: ''
}`}
>
<div className="flex items-center space-x-3 flex-1">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center">
<FaFileAlt className="w-4 h-4 text-orange-600" />
</div>
<div className="flex flex-1 items-center space-x-3">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-orange-100 dark:bg-orange-900">
<FaFileAlt className="h-4 w-4 text-orange-600 dark:text-orange-400" />
</div>
<div className="text-left">
<div className="font-medium text-gray-900 text-sm line-clamp-1">
{getTopicTitle(post.topicId)}
<div className="min-w-0 text-left">
<div className="line-clamp-1 text-sm font-medium text-gray-900 dark:text-white">
{topicTitleById.get(post.topicId) ??
translate('::App.Platform.Unknown')}
</div>
<div
className="text-sm text-gray-600 line-clamp-2 mt-1"
dangerouslySetInnerHTML={{ __html: post.content }}
></div>
<div className="text-xs text-gray-500 mt-1">
by {post.authorName} {formatForumDate(post.creationTime)}
className="mt-1 line-clamp-2 text-sm text-gray-600 dark:text-gray-400"
dangerouslySetInnerHTML={{
__html: sanitizedPostPreviews.get(post.id) ?? '',
}}
/>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{post.authorName} {formatForumDate(post.creationTime)}
</div>
</div>
</div>
<div className="text-xs text-gray-400">{post.likeCount} likes</div>
<div className="text-xs text-gray-400 dark:text-gray-500">
{post.likeCount}
</div>
</Button>
)
})}
@ -277,8 +292,8 @@ export function SearchModal({
</div>
{hasResults && (
<div className="px-4 py-2 border-t border-gray-200 text-xs text-gray-500 bg-gray-50">
Use to navigate, Enter to select, Esc to close
<div className="border-t border-gray-200 bg-gray-50 px-4 py-2 text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
/ Enter / Esc
</div>
)}
</div>

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,400 +1,390 @@
import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum'
import { CreateCategoryRequest, CreatePostRequest, CreateTopicRequest, forumService } from '@/services/forum.service'
import { useState, useEffect } from 'react'
import {
CreateCategoryRequest,
CreatePostRequest,
CreateTopicRequest,
forumService,
} from '@/services/forum.service'
import { useCallback, useEffect, useRef, useState } from 'react'
type TotalCounts = { categories: number; topics: number; posts: number }
export function useForumData() {
const [categories, setCategories] = useState<ForumCategory[]>([])
const [topics, setTopics] = useState<ForumTopic[]>([])
const [posts, setPosts] = useState<ForumPost[]>([])
const [totalCounts, setTotalCounts] = useState({ categories: 0, topics: 0, posts: 0 })
const [loading, setLoading] = useState(false)
const [totalCounts, setTotalCounts] = useState<TotalCounts>({
categories: 0,
topics: 0,
posts: 0,
})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Load initial data
// Eşzamanlı yüklemelerde ilk biten isteğin loading'i kapatmasını engeller.
const pendingLoads = useRef(0)
const mounted = useRef(true)
useEffect(() => {
loadCategories()
loadTopics()
loadPosts()
mounted.current = true
return () => {
mounted.current = false
}
}, [])
const loadCategories = async () => {
try {
setLoading(true)
const response = await forumService.getCategories()
setCategories(response.items)
setTotalCounts((prev) => ({ ...prev, categories: response.totalCount }))
} catch (err) {
setError('Failed to load categories')
console.error('Error loading categories:', err)
} finally {
const beginLoad = useCallback(() => {
pendingLoads.current += 1
setLoading(true)
}, [])
const endLoad = useCallback(() => {
pendingLoads.current = Math.max(0, pendingLoads.current - 1)
if (pendingLoads.current === 0 && mounted.current) {
setLoading(false)
}
}
}, [])
const loadTopics = async (categoryId?: string) => {
try {
setLoading(true)
const response = await forumService.getTopics({ categoryId })
setTopics(response.items)
if (!categoryId) setTotalCounts((prev) => ({ ...prev, topics: response.totalCount }))
} catch (err) {
setError('Failed to load topics')
console.error('Error loading topics:', err)
} finally {
setLoading(false)
}
}
const fail = useCallback((message: string, err: unknown) => {
console.error(message, err)
if (mounted.current) setError(message)
}, [])
const loadPosts = async (topicId?: string) => {
try {
setLoading(true)
const response = await forumService.getPosts({ topicId })
setPosts(response.items)
if (!topicId) setTotalCounts((prev) => ({ ...prev, posts: response.totalCount }))
} catch (err) {
setError('Failed to load posts')
console.error('Error loading posts:', err)
} finally {
setLoading(false)
}
}
const loadCategories = useCallback(
async (params?: { isActive?: boolean }) => {
beginLoad()
try {
const response = await forumService.getCategories(params)
if (!mounted.current) return
setCategories(response.items)
setTotalCounts((prev) => ({ ...prev, categories: response.totalCount }))
} catch (err) {
fail('Failed to load categories', err)
} finally {
endLoad()
}
},
[beginLoad, endLoad, fail],
)
// Category operations
const createCategory = async (categoryData: CreateCategoryRequest) => {
try {
setLoading(true)
const newCategory = await forumService.createCategory(categoryData)
setCategories((prev) => [...prev, newCategory])
setTotalCounts((prev) => ({ ...prev, categories: prev.categories + 1 }))
return newCategory
} catch (err) {
setError('Failed to create category')
console.error('Error creating category:', err)
throw err
} finally {
setLoading(false)
}
}
const loadTopics = useCallback(
async (categoryId?: string) => {
beginLoad()
try {
const response = await forumService.getTopics({ categoryId })
if (!mounted.current) return
setTopics(response.items)
if (!categoryId) setTotalCounts((prev) => ({ ...prev, topics: response.totalCount }))
} catch (err) {
fail('Failed to load topics', err)
} finally {
endLoad()
}
},
[beginLoad, endLoad, fail],
)
const updateCategory = async (id: string, updates: Partial<ForumCategory>) => {
try {
setLoading(true)
const updatedCategory = await forumService.updateCategory(id, updates)
setCategories((prev) => prev.map((cat) => (cat.id === id ? updatedCategory : cat)))
return updatedCategory
} catch (err) {
setError('Failed to update category')
console.error('Error updating category:', err)
throw err
} finally {
setLoading(false)
}
}
const loadPosts = useCallback(
async (topicId?: string) => {
beginLoad()
try {
const response = await forumService.getPosts({ topicId })
if (!mounted.current) return
setPosts(response.items)
if (!topicId) setTotalCounts((prev) => ({ ...prev, posts: response.totalCount }))
} catch (err) {
fail('Failed to load posts', err)
} finally {
endLoad()
}
},
[beginLoad, endLoad, fail],
)
const updateCategoryLockState = async (id: string) => {
await forumService.updateCategoryLockState(id)
await loadCategories() // refresh after update
}
const reloadAll = useCallback(
() => Promise.all([loadCategories(), loadTopics(), loadPosts()]),
[loadCategories, loadTopics, loadPosts],
)
const updateCategoryActiveState = async (id: string) => {
await forumService.updateCategoryActiveState(id)
await loadCategories() // refresh after update
}
// İlk yükleme
useEffect(() => {
reloadAll()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const deleteCategory = async (id: string) => {
try {
setLoading(true)
await forumService.deleteCategory(id)
// Also remove related topics and posts
const topicsToDelete = topics.filter((topic) => topic.categoryId === id)
const topicIds = topicsToDelete.map((t) => t.id)
setCategories((prev) => prev.filter((cat) => cat.id !== id))
setTopics((prev) => prev.filter((topic) => topic.categoryId !== id))
setPosts((prev) => prev.filter((post) => !topicIds.includes(post.topicId)))
await Promise.all([loadCategories(), loadTopics(), loadPosts()])
} catch (err) {
setError('Failed to delete category')
console.error('Error deleting category:', err)
throw err
} finally {
setLoading(false)
}
}
// ---- Category operations -------------------------------------------------
// Topic operations
const createTopic = async (topicData: CreateTopicRequest) => {
try {
setLoading(true)
const newTopic = await forumService.createTopic(topicData)
setTopics((prev) => [...prev, newTopic])
setTotalCounts((prev) => ({ ...prev, topics: prev.topics + 1 }))
// Update category topic count
setCategories((prev) =>
prev.map((cat) =>
cat.id === topicData.categoryId ? { ...cat, topicCount: cat.topicCount + 1 } : cat,
),
)
return newTopic
} catch (err) {
setError('Failed to create topic')
console.error('Error creating topic:', err)
throw err
} finally {
setLoading(false)
}
}
const createCategory = useCallback(
async (categoryData: CreateCategoryRequest) => {
try {
const newCategory = await forumService.createCategory(categoryData)
setCategories((prev) => [...prev, newCategory])
setTotalCounts((prev) => ({ ...prev, categories: prev.categories + 1 }))
return newCategory
} catch (err) {
fail('Failed to create category', err)
throw err
}
},
[fail],
)
const updateTopic = async (id: string, updates: Partial<ForumTopic>) => {
try {
setLoading(true)
const updatedTopic = await forumService.updateTopic(id, updates)
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
return updatedTopic
} catch (err) {
setError('Failed to update topic')
console.error('Error updating topic:', err)
throw err
} finally {
setLoading(false)
}
}
const updateCategory = useCallback(
async (id: string, updates: Partial<ForumCategory>) => {
try {
const updatedCategory = await forumService.updateCategory(id, updates)
setCategories((prev) => prev.map((cat) => (cat.id === id ? updatedCategory : cat)))
return updatedCategory
} catch (err) {
fail('Failed to update category', err)
throw err
}
},
[fail],
)
const deleteTopic = async (id: string) => {
try {
setLoading(true)
const topic = topics.find((t) => t.id === id)
await forumService.deleteTopic(id)
setTopics((prev) => prev.filter((t) => t.id !== id))
setPosts((prev) => prev.filter((post) => post.topicId !== id))
const updateCategoryLockState = useCallback(
async (id: string) => {
try {
const updated = await forumService.updateCategoryLockState(id)
setCategories((prev) => prev.map((cat) => (cat.id === id ? updated : cat)))
return updated
} catch (err) {
fail('Failed to update category lock state', err)
throw err
}
},
[fail],
)
// Update category counts
if (topic) {
const updateCategoryActiveState = useCallback(
async (id: string) => {
try {
const updated = await forumService.updateCategoryActiveState(id)
setCategories((prev) => prev.map((cat) => (cat.id === id ? updated : cat)))
return updated
} catch (err) {
fail('Failed to update category active state', err)
throw err
}
},
[fail],
)
const deleteCategory = useCallback(
async (id: string) => {
try {
await forumService.deleteCategory(id)
// Kategori silindiğinde bağlı topic/post sayıları da değişir; tek kaynak sunucu.
await reloadAll()
} catch (err) {
fail('Failed to delete category', err)
throw err
}
},
[fail, reloadAll],
)
// ---- Topic operations ----------------------------------------------------
const createTopic = useCallback(
async (topicData: CreateTopicRequest) => {
try {
const newTopic = await forumService.createTopic(topicData)
setTopics((prev) => [...prev, newTopic])
setTotalCounts((prev) => ({ ...prev, topics: prev.topics + 1 }))
setCategories((prev) =>
prev.map((cat) =>
cat.id === topic.categoryId
? {
...cat,
topicCount: Math.max(0, cat.topicCount - 1),
postCount: Math.max(
0,
cat.postCount - posts.filter((p) => p.topicId === id).length,
),
}
: cat,
cat.id === topicData.categoryId ? { ...cat, topicCount: cat.topicCount + 1 } : cat,
),
)
return newTopic
} catch (err) {
fail('Failed to create topic', err)
throw err
}
await Promise.all([loadCategories(), loadTopics(), loadPosts()])
} catch (err) {
setError('Failed to delete topic')
console.error('Error deleting topic:', err)
throw err
} finally {
setLoading(false)
}
}
},
[fail],
)
const pinTopic = async (id: string) => {
try {
const updatedTopic = await forumService.pinTopic(id)
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
return updatedTopic
} catch (err) {
setError('Failed to pin topic')
console.error('Error pinning topic:', err)
throw err
}
}
const updateTopic = useCallback(
async (id: string, updates: Partial<ForumTopic>) => {
try {
const updatedTopic = await forumService.updateTopic(id, updates)
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
return updatedTopic
} catch (err) {
fail('Failed to update topic', err)
throw err
}
},
[fail],
)
const unpinTopic = async (id: string) => {
try {
const updatedTopic = await forumService.unpinTopic(id)
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
return updatedTopic
} catch (err) {
setError('Failed to unpin topic')
console.error('Error unpinning topic:', err)
throw err
}
}
const deleteTopic = useCallback(
async (id: string) => {
try {
await forumService.deleteTopic(id)
await reloadAll()
} catch (err) {
fail('Failed to delete topic', err)
throw err
}
},
[fail, reloadAll],
)
const lockTopic = async (id: string) => {
try {
const updatedTopic = await forumService.lockTopic(id)
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
return updatedTopic
} catch (err) {
setError('Failed to lock topic')
console.error('Error locking topic:', err)
throw err
}
}
const applyTopicUpdate = useCallback((updated: ForumTopic) => {
setTopics((prev) => prev.map((topic) => (topic.id === updated.id ? updated : topic)))
return updated
}, [])
const unlockTopic = async (id: string) => {
try {
const updatedTopic = await forumService.unlockTopic(id)
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
return updatedTopic
} catch (err) {
setError('Failed to unlock topic')
console.error('Error unlocking topic:', err)
throw err
}
}
const topicAction = useCallback(
async (
id: string,
action: (id: string) => Promise<ForumTopic>,
errorMessage: string,
): Promise<ForumTopic> => {
try {
return applyTopicUpdate(await action(id))
} catch (err) {
fail(errorMessage, err)
throw err
}
},
[applyTopicUpdate, fail],
)
const solvedTopic = async (id: string) => {
try {
const updatedTopic = await forumService.solvedTopic(id)
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
return updatedTopic
} catch (err) {
setError('Failed to mark topic as solved')
console.error('Error marking topic as solved:', err)
throw err
}
}
const pinTopic = useCallback(
(id: string) => topicAction(id, (x) => forumService.pinTopic(x), 'Failed to pin topic'),
[topicAction],
)
const unpinTopic = useCallback(
(id: string) => topicAction(id, (x) => forumService.unpinTopic(x), 'Failed to unpin topic'),
[topicAction],
)
const lockTopic = useCallback(
(id: string) => topicAction(id, (x) => forumService.lockTopic(x), 'Failed to lock topic'),
[topicAction],
)
const unlockTopic = useCallback(
(id: string) => topicAction(id, (x) => forumService.unlockTopic(x), 'Failed to unlock topic'),
[topicAction],
)
const solvedTopic = useCallback(
(id: string) =>
topicAction(id, (x) => forumService.solvedTopic(x), 'Failed to mark topic as solved'),
[topicAction],
)
const unsolvedTopic = useCallback(
(id: string) =>
topicAction(id, (x) => forumService.unsolvedTopic(x), 'Failed to mark topic as unsolved'),
[topicAction],
)
const unsolvedTopic = async (id: string) => {
try {
const updatedTopic = await forumService.unsolvedTopic(id)
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
return updatedTopic
} catch (err) {
setError('Failed to mark topic as unsolved')
console.error('Error marking topic as unsolved:', err)
throw err
}
}
// ---- Post operations -----------------------------------------------------
// Post operations
const createPost = async (postData: CreatePostRequest) => {
try {
setLoading(true)
const newPost = await forumService.createPost(postData)
setPosts((prev) => [...prev, newPost])
setTotalCounts((prev) => ({ ...prev, posts: prev.posts + 1 }))
const createPost = useCallback(
async (postData: CreatePostRequest) => {
try {
const newPost = await forumService.createPost(postData)
setPosts((prev) => [...prev, newPost])
setTotalCounts((prev) => ({ ...prev, posts: prev.posts + 1 }))
// Update topic and category post counts
const topic = topics.find((t) => t.id === postData.topicId)
if (topic) {
setTopics((prev) =>
prev.map((t) => (t.id === postData.topicId ? { ...t, replyCount: t.replyCount + 1 } : t)),
)
setCategories((prev) =>
prev.map((cat) =>
cat.id === topic.categoryId ? { ...cat, postCount: cat.postCount + 1 } : cat,
),
)
}
return newPost
} catch (err) {
setError('Failed to create post')
console.error('Error creating post:', err)
throw err
} finally {
setLoading(false)
}
}
const updatePost = async (id: string, updates: Partial<ForumPost>) => {
try {
setLoading(true)
const updatedPost = await forumService.updatePost(id, updates)
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post)))
return updatedPost
} catch (err) {
setError('Failed to update post')
console.error('Error updating post:', err)
throw err
} finally {
setLoading(false)
}
}
const deletePost = async (id: string) => {
try {
setLoading(true)
const post = posts.find((p) => p.id === id)
await forumService.deletePost(id)
setPosts((prev) => prev.filter((p) => p.id !== id))
setTotalCounts((prev) => ({ ...prev, posts: Math.max(0, prev.posts - 1) }))
// Update topic and category counts
if (post) {
const topic = topics.find((t) => t.id === post.topicId)
if (topic) {
setTopics((prev) =>
prev.map((t) =>
t.id === post.topicId ? { ...t, replyCount: Math.max(0, t.replyCount - 1) } : t,
),
)
const categoryId = topics.find((t) => t.id === postData.topicId)?.categoryId
if (categoryId) {
setCategories((prev) =>
prev.map((cat) =>
cat.id === topic.categoryId
? { ...cat, postCount: Math.max(0, cat.postCount - 1) }
: cat,
cat.id === categoryId ? { ...cat, postCount: cat.postCount + 1 } : cat,
),
)
}
return newPost
} catch (err) {
fail('Failed to create post', err)
throw err
}
} catch (err) {
setError('Failed to delete post')
console.error('Error deleting post:', err)
throw err
} finally {
setLoading(false)
}
}
},
[fail, topics],
)
const likePost = async (id: string) => {
try {
const updatedPost = await forumService.likePost(id)
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post)))
return updatedPost
} catch (err) {
setError('Failed to like post')
console.error('Error liking post:', err)
throw err
}
}
const updatePost = useCallback(
async (id: string, updates: Partial<ForumPost>) => {
try {
const updatedPost = await forumService.updatePost(id, updates)
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post)))
return updatedPost
} catch (err) {
fail('Failed to update post', err)
throw err
}
},
[fail],
)
const unlikePost = async (id: string) => {
try {
const updatedPost = await forumService.unlikePost(id)
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post)))
return updatedPost
} catch (err) {
setError('Failed to unlike post')
console.error('Error unliking post:', err)
throw err
}
}
const deletePost = useCallback(
async (id: string) => {
try {
await forumService.deletePost(id)
await reloadAll()
} catch (err) {
fail('Failed to delete post', err)
throw err
}
},
[fail, reloadAll],
)
const markPostAsAcceptedAnswer = async (id: string) => {
try {
const updatedPost = await forumService.markPostAsAcceptedAnswer(id)
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post)))
return updatedPost
} catch (err) {
setError('Failed to mark post as accepted answer')
console.error('Error marking post as accepted answer:', err)
throw err
}
}
const postAction = useCallback(
async (
id: string,
action: (id: string) => Promise<ForumPost>,
errorMessage: string,
): Promise<ForumPost> => {
try {
const updatedPost = await action(id)
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post)))
return updatedPost
} catch (err) {
fail(errorMessage, err)
throw err
}
},
[fail],
)
const unmarkPostAsAcceptedAnswer = async (id: string) => {
try {
const updatedPost = await forumService.unmarkPostAsAcceptedAnswer(id)
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post)))
return updatedPost
} catch (err) {
setError('Failed to unmark post as accepted answer')
console.error('Error unmarking post as accepted answer:', err)
throw err
}
}
const likePost = useCallback(
(id: string) => postAction(id, (x) => forumService.likePost(x), 'Failed to like post'),
[postAction],
)
const unlikePost = useCallback(
(id: string) => postAction(id, (x) => forumService.unlikePost(x), 'Failed to unlike post'),
[postAction],
)
const markPostAsAcceptedAnswer = useCallback(
(id: string) =>
postAction(
id,
(x) => forumService.markPostAsAcceptedAnswer(x),
'Failed to mark post as accepted answer',
),
[postAction],
)
const unmarkPostAsAcceptedAnswer = useCallback(
(id: string) =>
postAction(
id,
(x) => forumService.unmarkPostAsAcceptedAnswer(x),
'Failed to unmark post as accepted answer',
),
[postAction],
)
const clearError = useCallback(() => setError(null), [])
return {
// Data
@ -409,6 +399,7 @@ export function useForumData() {
loadCategories,
loadTopics,
loadPosts,
reloadAll,
// Category operations
createCategory,
@ -438,6 +429,6 @@ export function useForumData() {
unmarkPostAsAcceptedAnswer,
// Utility
clearError: () => setError(null),
clearError,
}
}

View file

@ -6,7 +6,8 @@ import {
subscribeAppUpdate,
type AppUpdateState,
} from './swRegistration'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { getLocalization } from '@/services/localization.service'
import { store } from '@/store'
/**
* Sürüm indirme penceresi.
@ -20,6 +21,28 @@ import { useLocalization } from '@/utils/hooks/useLocalization'
* bileşen güncelleme anında lazy yüklenir ve tek başına ~2 KB kalmalı.
*/
/**
* Metin çözümü.
*
* Pencere uygulama ağacının DIŞINDA, kendi React kökünde mount edilir
* (`mountAppUpdateDialog`); orada `StoreProvider` yoktur. Bu yüzden burada
* `useLocalization` (dolayısıyla `useStoreState`) KULLANILAMAZ hook Provider'ı
* bulamayıp hata fırlatıyor, kök çöküyor ve indirme ilerlemesi hiç görünmüyordu.
*
* Metinler store'dan doğrudan (`getState`) okunur; bileşende statik metin
* tutulmaz. Karşılık yoksa `useLocalization` ile aynı davranış: anahtar adı
* döner.
*/
const t = (key: string, params?: Record<string, string | number>) => {
try {
const { texts, config } = store.getState().abpConfig
if (!texts) return key
return getLocalization(texts, config?.localization?.defaultResourceName, key, params)
} catch {
return key
}
}
const formatBytes = (bytes: number) => {
if (!bytes) return '0 KB'
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
@ -27,14 +50,13 @@ const formatBytes = (bytes: number) => {
}
const formatDuration = (seconds: number) => {
if (seconds < 60) return `${Math.max(1, seconds)} sn`
const minutes = Math.round(seconds / 60)
return `${minutes} dk`
if (seconds < 60) {
return t('::App.Update.DurationSeconds', { value: Math.max(1, seconds) })
}
return t('::App.Update.DurationMinutes', { value: Math.round(seconds / 60) })
}
type TranslateFn = (key: string, params?: Record<string, string | number>) => string
const titleFor = (state: AppUpdateState, t: TranslateFn) => {
const titleFor = (state: AppUpdateState) => {
switch (state.status) {
case 'downloading':
return t(
@ -56,9 +78,11 @@ const titleFor = (state: AppUpdateState, t: TranslateFn) => {
}
const countdownText = (state: AppUpdateState) =>
state.secondsLeft && state.secondsLeft > 0 ? ` (${state.secondsLeft} sn)` : ''
state.secondsLeft && state.secondsLeft > 0
? ` ${t('::App.Update.Countdown', { seconds: state.secondsLeft })}`
: ''
const descriptionFor = (state: AppUpdateState, t: TranslateFn) => {
const descriptionFor = (state: AppUpdateState) => {
switch (state.status) {
case 'downloading':
return t(
@ -73,7 +97,8 @@ const descriptionFor = (state: AppUpdateState, t: TranslateFn) => {
case 'activating':
return t('::App.UpdateDescription.Activating')
case 'error':
return `${state.message ? t('::' + state.message) : t('::App.UpdateDescription.Error')}${countdownText(state)}`
// `swRegistration` yalnızca anahtar taşır (ör. App.UpdateMessage.*).
return `${t(state.message ? `::${state.message}` : '::App.UpdateDescription.Error')}${countdownText(state)}`
default:
return t('::App.UpdateDescription.Checking')
}
@ -132,7 +157,6 @@ const byActivity = (a: AppUpdateState['files'][number], b: AppUpdateState['files
a.index - b.index
const AppUpdateDialog = () => {
const { translate } = useLocalization()
const state = useSyncExternalStore(subscribeAppUpdate, getAppUpdateState, getAppUpdateState)
if (!state.visible) return null
@ -153,18 +177,14 @@ const AppUpdateDialog = () => {
<span className="mt-1 h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-500 dark:border-gray-600 dark:border-t-indigo-400" />
)}
<div className="min-w-0">
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">
{titleFor(state, translate)}
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{descriptionFor(state, translate)}
</p>
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">{titleFor(state)}</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{descriptionFor(state)}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 px-6 pt-4 text-xs">
<span className="rounded-full bg-gray-100 px-2.5 py-1 font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{translate('::App.Update.Version')}: v{state.currentVersion}
{t('::App.Update.Version')}: v{state.currentVersion}
</span>
</div>
@ -172,17 +192,17 @@ const AppUpdateDialog = () => {
<div className="px-6 pt-4">
<div className="flex flex-wrap items-center justify-between gap-x-3 text-xs text-gray-500 dark:text-gray-400">
<span>
{translate('::App.Update.AssetProgress', {
{t('::App.Update.AssetProgress', {
done: doneCount,
total: state.files.length,
})}
{state.reusedCount > 0 &&
` · ${translate('::App.Update.ReusedFromCache', { count: state.reusedCount })}`}
` · ${t('::App.Update.ReusedFromCache', { count: state.reusedCount })}`}
{state.status === 'downloading' && state.secondsRemaining !== undefined && (
<>
{' '}
·{' '}
{translate('::App.Update.Remaining', {
{t('::App.Update.Remaining', {
duration: formatDuration(state.secondsRemaining),
})}
</>
@ -206,7 +226,7 @@ const AppUpdateDialog = () => {
{state.files.length > 0 && (
<>
<p className="text-xs font-bold uppercase tracking-wide text-gray-400">
{translate('::App.Update.DownloadedAssets')}
{t('::App.Update.DownloadedAssets')}
</p>
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
{ordered.map((file) => (
@ -219,7 +239,7 @@ const AppUpdateDialog = () => {
{state.changeLog.length > 0 && (
<div className="mt-4 border-t border-gray-100 pt-4 dark:border-gray-700">
<p className="text-xs font-bold uppercase tracking-wide text-gray-400">
v{state.currentVersion} ile gelen yenilikler
{t('::App.Update.ChangeLogTitle', { version: state.currentVersion })}
</p>
<ul className="mt-2 flex flex-col gap-1.5 text-sm text-gray-600 dark:text-gray-300">
{state.changeLog.map((line, index) => (
@ -235,10 +255,8 @@ const AppUpdateDialog = () => {
<div className="flex items-center justify-between gap-3 px-6 py-4">
<span className="text-xs text-gray-400">
{translate(
state.dismissible
? '::App.UpdateFooter.Dismissible'
: '::App.UpdateFooter.Blocking',
{t(
state.dismissible ? '::App.UpdateFooter.Dismissible' : '::App.UpdateFooter.Blocking',
)}
</span>
{state.dismissible ? (
@ -247,8 +265,10 @@ const AppUpdateDialog = () => {
className="shrink-0 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
onClick={dismissAppUpdateDialog}
>
{translate(
state.status === 'completed' ? '::App.Platform.OK' : '::App.Update.ContinueInBackground',
{t(
state.status === 'completed'
? '::App.Platform.OK'
: '::App.Update.ContinueInBackground',
)}
</button>
) : (
@ -258,7 +278,7 @@ const AppUpdateDialog = () => {
className="shrink-0 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
onClick={applyAppUpdate}
>
{translate('::App.Update.RetryNow')}
{t('::App.Update.RetryNow')}
{countdownText(state)}
</button>
)