From cedf7887874ebb95a312bf346de52c2d4480db0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96ZT=C3=9CRK?= <76204082+iamsedatozturk@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:57:47 +0300 Subject: [PATCH] =?UTF-8?q?Claude=20g=C3=BCncellemesi=20Forum=20g=C3=BCnce?= =?UTF-8?q?llemesi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Migrations/LanguagesData.json | 168 +++++ ui/src/views/forum/Forum.tsx | 49 +- ui/src/views/forum/ForumErrorBanner.tsx | 38 + ui/src/views/forum/Management.tsx | 74 +- ui/src/views/forum/admin/AdminView.tsx | 64 +- .../views/forum/admin/CategoryManagement.tsx | 203 +++--- ui/src/views/forum/admin/Dashboard.tsx | 131 ++-- ui/src/views/forum/admin/PostManagement.tsx | 122 ++-- ui/src/views/forum/admin/TopicManagement.tsx | 177 +++-- ui/src/views/forum/admin/categoryIcons.ts | 52 ++ ui/src/views/forum/forum/CreatePostModal.tsx | 69 +- ui/src/views/forum/forum/CreateTopicModal.tsx | 72 +- .../views/forum/forum/ForumCategoryCard.tsx | 55 +- ui/src/views/forum/forum/ForumPostCard.tsx | 17 +- ui/src/views/forum/forum/ForumTopicCard.tsx | 59 +- ui/src/views/forum/forum/ForumView.tsx | 607 ++++++++-------- ui/src/views/forum/forum/SearchModal.tsx | 249 ++++--- ui/src/views/forum/forum/utils.ts | 81 +++ ui/src/views/forum/forum/utils.tsx | 35 - ui/src/views/forum/useForumData.ts | 685 +++++++++--------- ui/src/views/version/AppUpdateDialog.tsx | 80 +- 21 files changed, 1764 insertions(+), 1323 deletions(-) create mode 100644 ui/src/views/forum/ForumErrorBanner.tsx create mode 100644 ui/src/views/forum/admin/categoryIcons.ts create mode 100644 ui/src/views/forum/forum/utils.ts delete mode 100644 ui/src/views/forum/forum/utils.tsx diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json index 34cd578a..d6ff7c96 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json @@ -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" } ] } diff --git a/ui/src/views/forum/Forum.tsx b/ui/src/views/forum/Forum.tsx index 330f8cd1..7c2ce73e 100644 --- a/ui/src/views/forum/Forum.tsx +++ b/ui/src/views/forum/Forum.tsx @@ -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(null) const [selectedTopic, setSelectedTopic] = useState(null) - const [forumViewState, setForumViewState] = useState<'categories' | 'topics' | 'posts'>( - 'categories', - ) + const [forumViewState, setForumViewState] = useState('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 ( - + - {error && ( -
-
- Error: - {error} - -
-
- )} + 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} diff --git a/ui/src/views/forum/ForumErrorBanner.tsx b/ui/src/views/forum/ForumErrorBanner.tsx new file mode 100644 index 00000000..97b659c7 --- /dev/null +++ b/ui/src/views/forum/ForumErrorBanner.tsx @@ -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 ( +
+
+
+ {translate('::App.Platform.Error')}: + {error} +
+
+
+ ) +} + +export default ForumErrorBanner diff --git a/ui/src/views/forum/Management.tsx b/ui/src/views/forum/Management.tsx index 68fe43e6..dcb49af4 100644 --- a/ui/src/views/forum/Management.tsx +++ b/ui/src/views/forum/Management.tsx @@ -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 ( - + - {error && ( -
-
- Error: - {error} - -
-
- )} + 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} />
) diff --git a/ui/src/views/forum/admin/AdminView.tsx b/ui/src/views/forum/admin/AdminView.tsx index 36576376..5c96d1f7 100644 --- a/ui/src/views/forum/admin/AdminView.tsx +++ b/ui/src/views/forum/admin/AdminView.tsx @@ -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 - onUpdateCategory: (id: string, category: Partial) => Promise - onUpdateCategoryLockState: (id: string) => Promise - onUpdateCategoryActiveState: (id: string) => Promise - onDeleteCategory: (id: string) => Promise - onCreateTopic: (topic: { - title: string - content: string - categoryId: string - isPinned?: boolean - isLocked?: boolean - }) => Promise - onUpdateTopic: (id: string, topic: Partial) => Promise - onDeleteTopic: (id: string) => Promise - onPinTopic: (id: string) => Promise - onUnpinTopic: (id: string) => Promise - onLockTopic: (id: string) => Promise - onUnlockTopic: (id: string) => Promise - onMarkTopicAsSolved: (id: string) => Promise - onMarkTopicAsUnsolved: (id: string) => Promise - onCreatePost: (post: { topicId: string; content: string; parentPostId?: string }) => Promise - onUpdatePost: (id: string, post: Partial) => Promise - onDeletePost: (id: string) => Promise - onMarkPostAsAcceptedAnswer: (id: string) => Promise - onUnmarkPostAsAcceptedAnswer: (id: string) => Promise + onCreateCategory: (category: CreateCategoryRequest) => Promise + onUpdateCategory: (id: string, category: Partial) => Promise + onUpdateCategoryLockState: (id: string) => Promise + onUpdateCategoryActiveState: (id: string) => Promise + onDeleteCategory: (id: string) => Promise + onCreateTopic: (topic: CreateTopicRequest) => Promise + onUpdateTopic: (id: string, topic: Partial) => Promise + onDeleteTopic: (id: string) => Promise + onPinTopic: (id: string) => Promise + onUnpinTopic: (id: string) => Promise + onLockTopic: (id: string) => Promise + onUnlockTopic: (id: string) => Promise + onMarkTopicAsSolved: (id: string) => Promise + onMarkTopicAsUnsolved: (id: string) => Promise + onCreatePost: (post: CreatePostRequest) => Promise + onUpdatePost: (id: string, post: Partial) => Promise + onDeletePost: (id: string) => Promise + onMarkPostAsAcceptedAnswer: (id: string) => Promise + onUnmarkPostAsAcceptedAnswer: (id: string) => Promise } type AdminSection = 'stats' | 'categories' | 'topics' | 'posts' @@ -114,12 +105,13 @@ export function AdminView({ return ( ) diff --git a/ui/src/views/forum/admin/CategoryManagement.tsx b/ui/src/views/forum/admin/CategoryManagement.tsx index 0fb74701..128edc90 100644 --- a/ui/src/views/forum/admin/CategoryManagement.tsx +++ b/ui/src/views/forum/admin/CategoryManagement.tsx @@ -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 - onUpdateCategory: (id: string, category: Partial) => Promise - onDeleteCategory: (id: string) => Promise - onUpdateCategoryLockState: (id: string) => Promise - onUpdateCategoryActiveState: (id: string) => Promise + onCreateCategory: (category: CreateCategoryRequest) => Promise + onUpdateCategory: (id: string, category: Partial) => Promise + onDeleteCategory: (id: string) => Promise + onUpdateCategoryLockState: (id: string) => Promise + onUpdateCategoryActiveState: (id: string) => Promise } export function CategoryManagement({ @@ -53,17 +59,6 @@ export function CategoryManagement({ const [showConfirm, setShowConfirm] = useState(false) const [categoryToDelete, setCategoryToDelete] = useState(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 && (
-

+

{editingCategory ? translate('::App.ForumCategoryManagement.EditCategory') : translate('::App.ForumCategoryManagement.AddCategory')} @@ -131,103 +126,116 @@ export function CategoryManagement({ } }} > - {({ errors, touched, isSubmitting }) => ( + {({ values, errors, touched, isSubmitting }) => (
- + - +
- + + {({ field, form }: FieldProps) => { + const iconOptions = resolveCategoryIconOptions(values.icon) + return ( + + isClearable + field={field} + form={form} + options={iconOptions} + placeholder="💬" + value={iconOptions.filter((option) => option.value === values.icon)} + formatOptionLabel={(option) => ( + + {option.value} + {option.label} + + )} + onChange={(option) => + form.setFieldValue('icon', option?.value ?? '') + } + /> + ) + }} +
- + - +
-
-
- - -
+
+ +
@@ -245,9 +253,9 @@ export function CategoryManagement({
) : (
+ {categories.length === 0 && ( +

+ {translate('::App.Platform.NoDataFound')} +

+ )} {[...categories] .sort((a, b) => a.displayOrder - b.displayOrder) .map((category) => ( @@ -280,12 +293,12 @@ export function CategoryManagement({

{!category.isActive && ( - Inactive + {translate('::App.Platform.Passive')} )} {category.isLocked && ( - Locked + {translate('::App.Platform.Locked')} )}
@@ -293,9 +306,15 @@ export function CategoryManagement({ {category.description}

- {category.topicCount} topics - {category.postCount} posts - Order: {category.displayOrder} + + {category.topicCount} {translate('::App.ForumDashboard.Topics')} + + + {category.postCount} {translate('::App.ForumDashboard.Posts')} + + + {translate('::App.MenuField.Order')}: {category.displayOrder} +
@@ -303,13 +322,17 @@ export function CategoryManagement({
diff --git a/ui/src/views/forum/admin/Dashboard.tsx b/ui/src/views/forum/admin/Dashboard.tsx index bc1a1a67..dee38e3a 100644 --- a/ui/src/views/forum/admin/Dashboard.tsx +++ b/ui/src/views/forum/admin/Dashboard.tsx @@ -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(() => { + 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 (
-
- {stats.map((stat, index) => ( +
+ {stats.map((stat) => ( {/* Recent Activity */} -
-

+
+

{translate('::App.ForumDashboard.RecentActivity')}

- {latestActivities.map((activity, index) => ( -
-
-
-

{activity.message}

-

{dayjs(activity.date).fromNow()}

+ {latestActivities.length === 0 ? ( +

+ {translate('::App.Platform.NoDataFound')} +

+ ) : ( + latestActivities.map((activity) => ( +
+
+
+

{activity.message}

+

+ {dayjs(activity.date) + .locale(cultureName?.startsWith('tr') ? 'tr' : 'en') + .fromNow()} +

+
-
- ))} + )) + )}
diff --git a/ui/src/views/forum/admin/PostManagement.tsx b/ui/src/views/forum/admin/PostManagement.tsx index d2c169f0..6907e80d 100644 --- a/ui/src/views/forum/admin/PostManagement.tsx +++ b/ui/src/views/forum/admin/PostManagement.tsx @@ -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 - onUpdatePost: (id: string, post: Partial) => Promise - onDeletePost: (id: string) => Promise - onMarkPostAsAcceptedAnswer: (id: string) => Promise - onUnmarkPostAsAcceptedAnswer: (id: string) => Promise + onCreatePost: (post: CreatePostRequest) => Promise + onUpdatePost: (id: string, post: Partial) => Promise + onDeletePost: (id: string) => Promise + onMarkPostAsAcceptedAnswer: (id: string) => Promise + onUnmarkPostAsAcceptedAnswer: (id: string) => Promise } 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 (
{/* Create/Edit Form */} {showCreateForm && (
-

+

{editingPost ? translate('::App.ForumPostManagement.EditPost') : translate('::App.ForumPostManagement.AddPost')}

{ 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({
@@ -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" > - + {topics.map((topic) => ( @@ -167,9 +162,9 @@ export function PostManagement({ {({ field }: FieldProps) => ( setFieldValue('content', e.value)} height={400} - placeholder="Write your message..." + placeholder={translate('::App.ForumPostManagement.MessageNew')} + onValueChanged={(e) => setFieldValue('content', e.value)} > @@ -231,10 +226,10 @@ export function PostManagement({ - + @@ -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')}
@@ -272,13 +265,15 @@ export function PostManagement({ {/* Posts List */}
-

Posts ({totalCount})

+

+ {translate('::App.ForumDashboard.Posts')} ({totalCount}) +

) : (
+ {posts.length === 0 && ( +

+ {translate('::App.Platform.NoDataFound')} +

+ )} {[...posts] .sort( (a, b) => new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime(), @@ -305,20 +305,20 @@ export function PostManagement({ {post.isAcceptedAnswer && (
- Accepted Answer + {translate('::App.ForumPostManagement.AcceptedAnswer')}
)}

- Reply to:{' '} + {translate('::App.ForumPostManagement.ReplyTo')}:{' '} {getTopicTitle(post.topicId)}

-

+
@@ -326,7 +326,9 @@ export function PostManagement({ {formatForumDate(post.creationTime)}
- {post.likeCount} likes + + {post.likeCount} {translate('::App.Listform.ListformField.Likes')} +
@@ -335,7 +337,6 @@ export function PostManagement({
diff --git a/ui/src/views/forum/admin/TopicManagement.tsx b/ui/src/views/forum/admin/TopicManagement.tsx index 2436a4c4..9a0aa32f 100644 --- a/ui/src/views/forum/admin/TopicManagement.tsx +++ b/ui/src/views/forum/admin/TopicManagement.tsx @@ -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 - onUpdateTopic: (id: string, topic: Partial) => Promise - onDeleteTopic: (id: string) => Promise - onPinTopic: (id: string) => Promise - onUnpinTopic: (id: string) => Promise - onLockTopic: (id: string) => Promise - onUnlockTopic: (id: string) => Promise - onMarkTopicAsSolved: (id: string) => Promise - onMarkTopicAsUnsolved: (id: string) => Promise + onCreateTopic: (topic: CreateTopicRequest) => Promise + onUpdateTopic: (id: string, topic: Partial) => Promise + onDeleteTopic: (id: string) => Promise + onPinTopic: (id: string) => Promise + onUnpinTopic: (id: string) => Promise + onLockTopic: (id: string) => Promise + onUnlockTopic: (id: string) => Promise + onMarkTopicAsSolved: (id: string) => Promise + onMarkTopicAsUnsolved: (id: string) => Promise } 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 (
{/* Create/Edit Form */} {showCreateForm && (
-

+

{editingTopic ? translate('::App.ForumTopicManagement.EditTopic') : translate('::App.ForumTopicManagement.AddTopic')}

{ 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 }) => ( - - - {categories.map((cat) => ( - - ))} + + {({ field, form }: FieldProps) => ( + + field={field} + form={form} + options={categoryOptions} + placeholder={translate('::App.ForumTopicManagement.SelectCategory')} + value={categoryOptions.filter( + (option) => option.value === values.categoryId, + )} + formatOptionLabel={(option) => ( + + {option.icon} + {option.label} + + )} + onChange={(option) => + form.setFieldValue('categoryId', option?.value ?? '') + } + /> + )} @@ -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} /> - +
@@ -272,9 +290,9 @@ export function TopicManagement({
) : (
+ {topics.length === 0 && ( +

+ {translate('::App.Platform.NoDataFound')} +

+ )} {[...topics] .sort( (a, b) => new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime(), @@ -323,7 +346,7 @@ export function TopicManagement({
{getCategoryName(topic.categoryId)} - by {topic.authorName} + {topic.authorName} {formatForumDate(topic.creationTime)}
@@ -331,8 +354,12 @@ export function TopicManagement({ {topic.viewCount}
- {topic.replyCount} replies - {topic.likeCount} likes + + {topic.replyCount} {translate('::App.Platform.Replies')} + + + {topic.likeCount} {translate('::App.Listform.ListformField.Likes')} +
@@ -340,30 +367,34 @@ export function TopicManagement({
diff --git a/ui/src/views/forum/admin/categoryIcons.ts b/ui/src/views/forum/admin/categoryIcons.ts new file mode 100644 index 00000000..47e6d923 --- /dev/null +++ b/ui/src/views/forum/admin/categoryIcons.ts @@ -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] +} diff --git a/ui/src/views/forum/forum/CreatePostModal.tsx b/ui/src/views/forum/forum/CreatePostModal.tsx index 88aada5b..3ce1da50 100644 --- a/ui/src/views/forum/forum/CreatePostModal.tsx +++ b/ui/src/views/forum/forum/CreatePostModal.tsx @@ -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 | 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 ( -
-
+
{ + if (event.key === 'Escape') onClose() + }} + > +
event.stopPropagation()} + >

{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} > @@ -92,13 +113,13 @@ export function CreatePostModal({ onClose, onSubmit, parentPostId }: CreatePostM {({ field }: FieldProps) => ( setFieldValue('content', e.value)} height={300} placeholder={ parentPostId ? translate('::App.ForumPostManagement.MessageEdit') : translate('::App.ForumPostManagement.MessageNew') } + onValueChanged={(e) => setFieldValue('content', e.value)} > @@ -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')}

diff --git a/ui/src/views/forum/forum/CreateTopicModal.tsx b/ui/src/views/forum/forum/CreateTopicModal.tsx index fc9bbeac..65c0a71a 100644 --- a/ui/src/views/forum/forum/CreateTopicModal.tsx +++ b/ui/src/views/forum/forum/CreateTopicModal.tsx @@ -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 | 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 ( -
-
+
{ + if (event.key === 'Escape') onClose() + }} + > +
event.stopPropagation()} + >

{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} > @@ -58,32 +79,31 @@ export function CreateTopicModal({ onClose, onSubmit }: CreateTopicModalProps) { initialValues={topicInitialValues} validationSchema={topicValidationSchema} onSubmit={handleSubmit} - enableReinitialize={true} > {({ values, touched, errors, isSubmitting }) => ( {({ field }: FieldProps) => ( diff --git a/ui/src/views/forum/forum/ForumCategoryCard.tsx b/ui/src/views/forum/forum/ForumCategoryCard.tsx index 174f8cd5..7bfb7027 100644 --- a/ui/src/views/forum/forum/ForumCategoryCard.tsx +++ b/ui/src/views/forum/forum/ForumCategoryCard.tsx @@ -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 (
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onClick() + } + }} >
-
-
{category.icon}
-
-
-

+
+ +
+
+

{category.name}

- {category.isLocked && } + {category.isLocked && ( +
-

{category.description}

+

+ {category.description} +

- - {category.topicCount} topics +
- - {category.postCount} posts +
-
-
Last post
+
- {formatForumRelativeDate(category.lastPostDate)} + {formatForumRelativeDate(category.lastPostDate, cultureName)}
diff --git a/ui/src/views/forum/forum/ForumPostCard.tsx b/ui/src/views/forum/forum/ForumPostCard.tsx index dcb43f6b..5fb639e7 100644 --- a/ui/src/views/forum/forum/ForumPostCard.tsx +++ b/ui/src/views/forum/forum/ForumPostCard.tsx @@ -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 (
-
-

-

+ {/* İçerik kullanıcı girdisi olduğu için sanitize edilmiş HTML basılır. */} +
+ )} + {selectedCategory && ( <> - - / - + + {viewState === 'topics' ? ( + + {selectedCategory.name} + + ) : ( + + )} )} - {selectedTopic && ( + + {viewState === 'posts' && selectedTopic && ( <> - / - + + {selectedTopic.title} @@ -338,14 +340,14 @@ export function ForumView({
{/* Right Side: Actions + Search */} -
+
{viewState === 'topics' && selectedCategory && !selectedCategory.isLocked && ( @@ -353,22 +355,25 @@ export function ForumView({ {viewState === 'posts' && selectedTopic && !selectedTopic.isLocked && ( )} - {/* Search */} + {/* Search — geniş ekranda etiketli, dar ekranda ikon */} + onClick={() => setIsSearchModalOpen(true)} + />
{/* Categories View */} {viewState === 'categories' && ( -
-
-
- {[...categories] - .filter((cat) => cat.isActive) - .sort((a, b) => a.displayOrder - b.displayOrder) - .map((category) => ( - handleCategoryClick(category)} - /> - ))} -
-
+
+ {visibleCategories.length === 0 + ? emptyState + : visibleCategories.map((category) => ( + handleCategoryClick(category)} + /> + ))}
)} {/* Topics View */} {viewState === 'topics' && selectedCategory && ( -
-
-

{selectedCategory.name}

-
- {[...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) => ( +
+

+ {selectedCategory.name} +

+
+ {filteredTopics.length === 0 + ? emptyState + : filteredTopics.map((topic) => ( handleTopicClick(topic)} /> ))} -
)} {/* Posts View */} {viewState === 'posts' && selectedTopic && ( -
-
-

{selectedTopic.title}

+
+

+ {selectedTopic.title} +

- {/* Topic Ana İçeriği */} - + {/* Topic ana içeriği */} + - {/* Hiyerarşik Postlar */} -
{renderPosts(threadedPosts)}
-
+ {/* Hiyerarşik postlar */} +
{renderPosts(threadedPosts)}
)} - {/* Create Topic Modal */} {showCreateTopic && ( setShowCreateTopic(false)} onSubmit={handleCreateTopic} /> )} - {/* Create Post Modal */} {showCreatePost && ( setShowCreatePost(false)} + parentPostId={replyToPostId} + onClose={() => { + setShowCreatePost(false) + setReplyToPostId(undefined) + }} onSubmit={handleCreatePost} - parentPostId={replyToPostId!} /> )} setIsSearchModalOpen(false)} categories={categories} topics={topics} posts={posts} + onClose={() => setIsSearchModalOpen(false)} onCategorySelect={handleSearchCategorySelect} onTopicSelect={handleSearchTopicSelect} onPostSelect={handleSearchPostSelect} diff --git a/ui/src/views/forum/forum/SearchModal.tsx b/ui/src/views/forum/forum/SearchModal.tsx index d3e657c9..e796060d 100644 --- a/ui/src/views/forum/forum/SearchModal.tsx +++ b/ui/src/views/forum/forum/SearchModal.tsx @@ -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 ( -
-
-
- +
+
event.stopPropagation()} + > +
+
-
+
{!searchQuery.trim() ? (
- -

Start typing to search categories, topics, and posts...

+
) : !hasResults ? (
-

No results found for {searchQuery}

+

{translate('::App.Platform.NoResults')}

) : (
{/* Categories */} {searchResults.categories.length > 0 && (
-
- Categories ({searchResults.categories.length}) +
+ {translate('::App.Platform.Category')} ({searchResults.categories.length})
{searchResults.categories.map((category, index) => ( ))} @@ -179,43 +197,39 @@ export function SearchModal({ {/* Topics */} {searchResults.topics.length > 0 && (
-
- Topics ({searchResults.topics.length}) +
+ {translate('::App.ForumDashboard.Topics')} ({searchResults.topics.length})
{searchResults.topics.map((topic, index) => { const globalIndex = searchResults.categories.length + index return ( ) @@ -226,8 +240,8 @@ export function SearchModal({ {/* Posts */} {searchResults.posts.length > 0 && (
-
- Posts ({searchResults.posts.length}) +
+ {translate('::App.ForumDashboard.Posts')} ({searchResults.posts.length})
{searchResults.posts.map((post, index) => { const globalIndex = @@ -235,38 +249,39 @@ export function SearchModal({ return ( ) })} @@ -277,8 +292,8 @@ export function SearchModal({
{hasResults && ( -
- Use ↑↓ to navigate, Enter to select, Esc to close +
+ ↑↓ / Enter / Esc
)}
diff --git a/ui/src/views/forum/forum/utils.ts b/ui/src/views/forum/forum/utils.ts new file mode 100644 index 00000000..cc84726a --- /dev/null +++ b/ui/src/views/forum/forum/utils.ts @@ -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() + + 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() +} diff --git a/ui/src/views/forum/forum/utils.tsx b/ui/src/views/forum/forum/utils.tsx deleted file mode 100644 index 54603332..00000000 --- a/ui/src/views/forum/forum/utils.tsx +++ /dev/null @@ -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() - - 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') -} diff --git a/ui/src/views/forum/useForumData.ts b/ui/src/views/forum/useForumData.ts index 61d7cf11..7997a6dd 100644 --- a/ui/src/views/forum/useForumData.ts +++ b/ui/src/views/forum/useForumData.ts @@ -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([]) const [topics, setTopics] = useState([]) const [posts, setPosts] = useState([]) - const [totalCounts, setTotalCounts] = useState({ categories: 0, topics: 0, posts: 0 }) - const [loading, setLoading] = useState(false) + const [totalCounts, setTotalCounts] = useState({ + categories: 0, + topics: 0, + posts: 0, + }) + const [loading, setLoading] = useState(true) const [error, setError] = useState(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) => { - 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) => { - 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) => { + 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) => { + 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, + errorMessage: string, + ): Promise => { + 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) => { - 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) => { + 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, + errorMessage: string, + ): Promise => { + 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, } } diff --git a/ui/src/views/version/AppUpdateDialog.tsx b/ui/src/views/version/AppUpdateDialog.tsx index 9a944e62..3236f31e 100644 --- a/ui/src/views/version/AppUpdateDialog.tsx +++ b/ui/src/views/version/AppUpdateDialog.tsx @@ -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) => { + 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 - -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 = () => { )}
-

- {titleFor(state, translate)} -

-

- {descriptionFor(state, translate)} -

+

{titleFor(state)}

+

{descriptionFor(state)}

- {translate('::App.Update.Version')}: v{state.currentVersion} + {t('::App.Update.Version')}: v{state.currentVersion}
@@ -172,17 +192,17 @@ const AppUpdateDialog = () => {
- {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 && ( <>

- {translate('::App.Update.DownloadedAssets')} + {t('::App.Update.DownloadedAssets')}

    {ordered.map((file) => ( @@ -219,7 +239,7 @@ const AppUpdateDialog = () => { {state.changeLog.length > 0 && (

    - v{state.currentVersion} ile gelen yenilikler + {t('::App.Update.ChangeLogTitle', { version: state.currentVersion })}

      {state.changeLog.map((line, index) => ( @@ -235,10 +255,8 @@ const AppUpdateDialog = () => {
      - {translate( - state.dismissible - ? '::App.UpdateFooter.Dismissible' - : '::App.UpdateFooter.Blocking', + {t( + state.dismissible ? '::App.UpdateFooter.Dismissible' : '::App.UpdateFooter.Blocking', )} {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', )} ) : ( @@ -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)} )