From 032b08bfb5c2555afbac806ad5311b81851688b0 Mon Sep 17 00:00:00 2001 From: Thibault Pouch Date: Wed, 18 Mar 2026 10:59:07 +0100 Subject: [PATCH] feat: implement forum moderation features with category management and thread operations --- .../src/pages/intranet/IntranetModeration.tsx | 443 +++++++++++++++++- nest-intra/src/utils/api.ts | 79 ++++ 2 files changed, 509 insertions(+), 13 deletions(-) diff --git a/nest-intra/src/pages/intranet/IntranetModeration.tsx b/nest-intra/src/pages/intranet/IntranetModeration.tsx index 02c4fc0..4f9a40d 100644 --- a/nest-intra/src/pages/intranet/IntranetModeration.tsx +++ b/nest-intra/src/pages/intranet/IntranetModeration.tsx @@ -1,21 +1,80 @@ import { useState, useMemo, useCallback, useEffect } from 'react'; import { formatDateTime } from '../../utils/format'; -import { settingsApi } from '../../utils/api'; -import type { ForumThread, ForumReply } from '../../types'; +import { forumApi, settingsApi } from '../../utils/api'; +import type { ForumCategory, ForumReply, ForumThread } from '../../types'; export default function IntranetModeration() { + const [categories, setCategories] = useState([]); const [threads, setThreads] = useState([]); const [replies, setReplies] = useState([]); const [selectedThreadId, setSelectedThreadId] = useState(null); + const [createTitle, setCreateTitle] = useState(''); + const [createContent, setCreateContent] = useState(''); + const [createCategoryId, setCreateCategoryId] = useState(''); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false); + const [editingCategoryId, setEditingCategoryId] = useState(null); + const [categoryName, setCategoryName] = useState(''); + const [categoryDescription, setCategoryDescription] = useState(''); + const [categoryIcon, setCategoryIcon] = useState('📁'); const [search, setSearch] = useState(''); - const [activeTab, setActiveTab] = useState<'threads' | 'replies'>('threads'); + const [activeTab, setActiveTab] = useState<'threads' | 'replies' | 'categories'>('threads'); const [isEnabled, setIsEnabled] = useState(true); const [toggling, setToggling] = useState(false); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [savingCategory, setSavingCategory] = useState(false); + const [error, setError] = useState(''); + + const loadModerationData = useCallback(async () => { + setLoading(true); + setError(''); + + try { + const [cats, threadRes] = await Promise.all([ + forumApi.getCategories(), + forumApi.getThreads({ limit: 200 }), + ]); + + const loadedThreads = threadRes.data; + setCategories(cats); + setThreads(loadedThreads); + + const detailed = await Promise.all( + loadedThreads.map((thread) => forumApi.getThread(thread.id).catch(() => null)) + ); + + const allReplies = detailed + .filter((thread): thread is ForumThread & { replies: ForumReply[] } => Boolean(thread)) + .flatMap((thread) => thread.replies); + setReplies(allReplies); + } catch { + setError('Failed to load moderation data.'); + } finally { + setLoading(false); + } + }, []); useEffect(() => { settingsApi.get().then((s) => setIsEnabled(s.forumEnabled)).catch(() => {}); }, []); + useEffect(() => { + void loadModerationData(); + }, [loadModerationData]); + + useEffect(() => { + if (categories.length === 0) { + setCreateCategoryId(''); + return; + } + + const exists = categories.some((category) => category.id === createCategoryId); + if (!exists) { + setCreateCategoryId(categories[0].id); + } + }, [categories, createCategoryId]); + const handleToggle = useCallback((enabled: boolean) => { setToggling(true); settingsApi.update({ forumEnabled: enabled }) @@ -36,23 +95,151 @@ export default function IntranetModeration() { }, [replies, selectedThreadId]); const deleteThread = useCallback((id: string) => { - setThreads((prev) => prev.filter((t) => t.id !== id)); - setReplies((prev) => prev.filter((r) => r.threadId !== id)); - if (selectedThreadId === id) setSelectedThreadId(null); + forumApi.deleteThread(id) + .then(() => { + setThreads((prev) => prev.filter((t) => t.id !== id)); + setReplies((prev) => prev.filter((r) => r.threadId !== id)); + if (selectedThreadId === id) setSelectedThreadId(null); + }) + .catch(() => { + setError('Failed to delete thread.'); + }); }, [selectedThreadId]); const togglePin = useCallback((id: string) => { - setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isPinned: !t.isPinned } : t)); - }, []); + const thread = threads.find((t) => t.id === id); + if (!thread) return; + + forumApi.updateThread(id, { isPinned: !thread.isPinned }) + .then((updated) => { + setThreads((prev) => prev.map((t) => (t.id === id ? updated : t))); + }) + .catch(() => { + setError('Failed to update pin state.'); + }); + }, [threads]); const toggleLock = useCallback((id: string) => { - setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isLocked: !t.isLocked } : t)); - }, []); + const thread = threads.find((t) => t.id === id); + if (!thread) return; + + forumApi.updateThread(id, { isLocked: !thread.isLocked }) + .then((updated) => { + setThreads((prev) => prev.map((t) => (t.id === id ? updated : t))); + }) + .catch(() => { + setError('Failed to update lock state.'); + }); + }, [threads]); const deleteReply = useCallback((id: string) => { - setReplies((prev) => prev.filter((r) => r.id !== id)); + const removedReply = replies.find((r) => r.id === id); + + forumApi.deleteReply(id) + .then(() => { + setReplies((prev) => prev.filter((r) => r.id !== id)); + setThreads((prev) => prev.map((t) => { + if (!removedReply || removedReply.threadId !== t.id) return t; + return { ...t, replyCount: Math.max(0, t.replyCount - 1) }; + })); + }) + .catch(() => { + setError('Failed to delete reply.'); + }); + }, [replies]); + + const createThread = useCallback(() => { + const title = createTitle.trim(); + const content = createContent.trim(); + + if (!title || !content || !createCategoryId) { + setError('Title, category and content are required.'); + return; + } + + setCreating(true); + setError(''); + + forumApi.createThread({ + title, + content, + categoryId: createCategoryId, + }) + .then((thread) => { + setThreads((prev) => [thread, ...prev]); + setCategories((prev) => prev.map((cat) => ( + cat.id === createCategoryId + ? { ...cat, threadCount: cat.threadCount + 1 } + : cat + ))); + setCreateTitle(''); + setCreateContent(''); + setIsCreateModalOpen(false); + }) + .catch(() => { + setError('Failed to create thread.'); + }) + .finally(() => { + setCreating(false); + }); + }, [createCategoryId, createContent, createTitle]); + + const openCreateCategoryModal = useCallback(() => { + setEditingCategoryId(null); + setCategoryName(''); + setCategoryDescription(''); + setCategoryIcon('📁'); + setIsCategoryModalOpen(true); }, []); + const openEditCategoryModal = useCallback((category: ForumCategory) => { + setEditingCategoryId(category.id); + setCategoryName(category.name); + setCategoryDescription(category.description); + setCategoryIcon(category.icon || '📁'); + setIsCategoryModalOpen(true); + }, []); + + const saveCategory = useCallback(() => { + const name = categoryName.trim(); + const description = categoryDescription.trim(); + const icon = categoryIcon.trim() || '📁'; + + if (!name || !description) { + setError('Category name and description are required.'); + return; + } + + setSavingCategory(true); + setError(''); + + const action = editingCategoryId + ? forumApi.updateCategory(editingCategoryId, { name, description, icon }) + : forumApi.createCategory({ name, description, icon }); + + action + .then(() => loadModerationData()) + .then(() => setIsCategoryModalOpen(false)) + .catch(() => { + setError(editingCategoryId ? 'Failed to update category.' : 'Failed to create category.'); + }) + .finally(() => { + setSavingCategory(false); + }); + }, [categoryDescription, categoryIcon, categoryName, editingCategoryId, loadModerationData]); + + const removeCategory = useCallback((id: string) => { + const confirmed = window.confirm('Delete this category? This can fail if it still has threads.'); + if (!confirmed) return; + + setError(''); + forumApi.deleteCategory(id) + .then(() => loadModerationData()) + .catch(() => { + setError('Failed to delete category. Remove or move threads first.'); + }); + }, [loadModerationData]); + const recentReplies = useMemo(() => { return [...replies].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 20); }, [replies]); @@ -87,6 +274,16 @@ export default function IntranetModeration() { ) : (
+ {loading && ( +
+ Loading moderation data... +
+ )} + {error && ( +
+ {error} +
+ )}
@@ -123,7 +320,7 @@ export default function IntranetModeration() { {/* Tabs */}
- {(['threads', 'replies'] as const).map((tab) => ( + {(['threads', 'replies', 'categories'] as const).map((tab) => ( ))}
@@ -149,6 +350,22 @@ export default function IntranetModeration() {
{/* Thread list */}
+
+ + {categories.length === 0 && ( +
+ No categories available. +
+ )} +
+ togglePin(thread.id)} + disabled={loading} > {thread.isPinned ? 'Unpin' : 'Pin'} @@ -199,6 +417,7 @@ export default function IntranetModeration() { className="btn-terminal btn-amber" style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }} onClick={() => toggleLock(thread.id)} + disabled={loading} > {thread.isLocked ? 'Unlock' : 'Lock'} @@ -206,6 +425,7 @@ export default function IntranetModeration() { className="btn-terminal btn-danger" style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }} onClick={() => deleteThread(thread.id)} + disabled={loading} > Delete @@ -292,6 +512,203 @@ export default function IntranetModeration() { )}
)} + + {activeTab === 'categories' && ( +
+
+ +
+ +
+ {categories.map((category) => ( +
+
+
+ {category.icon} {category.name} +
+ + {category.threadCount} threads + +
+
+ {category.description} +
+
+ + +
+
+ ))} + + {categories.length === 0 && ( +
+ No categories found. +
+ )} +
+
+ )} + + {isCreateModalOpen && ( +
+
+
+
+ CREATE THREAD +
+ +
+
+ setCreateTitle(e.target.value)} + /> + +