From 02e798751210dc52fc08f64fc14b350deecf7c3f Mon Sep 17 00:00:00 2001 From: Christomatt Date: Mon, 26 Jan 2026 18:30:33 +0100 Subject: [PATCH] refactor(admin): Extract moderation features into dedicated page - Move reports, posts, and users management to new moderation page - Simplify admin page to focus on node settings configuration only - Remove unused types (AdminUser, AdminPost, Report) from admin page - Remove report resolution, post actions, and user moderation handlers - Remove tab navigation and related state management from admin page - Update sidebar navigation to include moderation page link - Consolidate moderation dashboard functionality into separate component - Improve code organization by separating concerns between admin and moderation --- src/app/admin/page.tsx | 405 ++--------------------------- src/app/moderation/page.tsx | 489 ++++++++++++++++++++++++++++++++++++ src/components/Sidebar.tsx | 10 +- 3 files changed, 523 insertions(+), 381 deletions(-) create mode 100644 src/app/moderation/page.tsx diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 393dcfc..67b4270 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,67 +1,16 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import Link from 'next/link'; import AutoTextarea from '@/components/AutoTextarea'; -import { Bot } from 'lucide-react'; import { useToast } from '@/lib/contexts/ToastContext'; import { useAccentColor } from '@/lib/contexts/AccentColorContext'; -type AdminUser = { - id: string; - handle: string; - displayName?: string | null; - email?: string | null; - isSuspended: boolean; - isSilenced: boolean; - suspensionReason?: string | null; - silenceReason?: string | null; - createdAt: string; - isBot?: boolean; -}; - -type AdminPost = { - id: string; - content: string; - createdAt: string; - isRemoved: boolean; - removedReason?: string | null; - author: { - id: string; - handle: string; - displayName?: string | null; - }; -}; - -type Report = { - id: string; - targetType: 'post' | 'user'; - targetId: string; - reason: string; - status: 'open' | 'resolved'; - createdAt: string; - reporter?: { - id: string; - handle: string; - } | null; - target?: AdminPost | AdminUser | null; -}; - -const formatDate = (value: string) => { - const date = new Date(value); - return date.toLocaleString(); -}; - export default function AdminPage() { const { showToast } = useToast(); const { refreshAccentColor } = useAccentColor(); const [isAdmin, setIsAdmin] = useState(null); - const [tab, setTab] = useState<'reports' | 'posts' | 'users' | 'settings'>('reports'); - const [reports, setReports] = useState([]); - const [posts, setPosts] = useState([]); - const [users, setUsers] = useState([]); const [loading, setLoading] = useState(false); - const [reportStatus, setReportStatus] = useState<'open' | 'resolved' | 'all'>('open'); const [nodeSettings, setNodeSettings] = useState({ name: '', description: '', @@ -90,45 +39,6 @@ export default function AdminPage() { .catch(() => setIsAdmin(false)); }, []); - const loadReports = async () => { - setLoading(true); - try { - const res = await fetch(`/api/admin/reports?status=${reportStatus}`); - const data = await res.json(); - setReports(data.reports || []); - } catch { - setReports([]); - } finally { - setLoading(false); - } - }; - - const loadPosts = async () => { - setLoading(true); - try { - const res = await fetch('/api/admin/posts?status=all'); - const data = await res.json(); - setPosts(data.posts || []); - } catch { - setPosts([]); - } finally { - setLoading(false); - } - }; - - const loadUsers = async () => { - setLoading(true); - try { - const res = await fetch('/api/admin/users'); - const data = await res.json(); - setUsers(data.users || []); - } catch { - setUsers([]); - } finally { - setLoading(false); - } - }; - const loadNodeSettings = async () => { setLoading(true); try { @@ -155,36 +65,10 @@ export default function AdminPage() { }; useEffect(() => { - if (!isAdmin) return; - if (tab === 'reports') loadReports(); - if (tab === 'posts') loadPosts(); - if (tab === 'users') loadUsers(); - if (tab === 'settings') loadNodeSettings(); - }, [tab, isAdmin, reportStatus]); - - const handleReportResolve = async (id: string, status: 'open' | 'resolved') => { - const note = status === 'resolved' ? window.prompt('Resolution note (optional):') || '' : ''; - await fetch(`/api/admin/reports/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status, note }), - }); - loadReports(); - }; - - const handlePostAction = async (id: string, action: 'remove' | 'restore') => { - const reason = action === 'remove' ? window.prompt('Reason (optional):') || '' : ''; - await fetch(`/api/admin/posts/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action, reason }), - }); - if (tab === 'reports') { - loadReports(); - } else { - loadPosts(); + if (isAdmin) { + loadNodeSettings(); } - }; + }, [isAdmin]); const handleSaveSettings = async (override?: typeof nodeSettings) => { const payload = override ?? nodeSettings; @@ -313,37 +197,19 @@ export default function AdminPage() { } }; - const handleUserAction = async (id: string, action: 'suspend' | 'unsuspend' | 'silence' | 'unsilence') => { - const needsReason = action === 'suspend' || action === 'silence'; - const reason = needsReason ? window.prompt('Reason (optional):') || '' : ''; - await fetch(`/api/admin/users/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action, reason }), - }); - loadUsers(); - }; - - const reportCounts = useMemo(() => { - return { - open: reports.filter((r) => r.status === 'open').length, - resolved: reports.filter((r) => r.status === 'resolved').length, - }; - }, [reports]); - if (isAdmin === null) { return ( -
-
Checking permissions...
+
+
Checking permissions...
); } if (!isAdmin) { return ( -
-
-

Moderation

+
+
+

Admin Settings

You do not have access to this page.

Back to home @@ -354,242 +220,23 @@ export default function AdminPage() { } return ( -
-
-
-

Moderation Dashboard

-

Manage reports, posts, and user actions.

-
- - Return to feed - -
- -
- - - - -
- - {tab === 'reports' && ( -
-
-
- {(['open', 'resolved', 'all'] as const).map((status) => ( - - ))} -
-
- Open: {reportCounts.open} - Resolved: {reportCounts.resolved} -
-
+ <> +
+

Admin Settings

+
+
+
{loading ? ( -
Loading reports...
- ) : reports.length === 0 ? ( -
No reports found.
- ) : ( -
- {reports.map((report) => ( -
-
-
- - {report.status} - - - {report.targetType.toUpperCase()} report - -
-
- {report.reason} -
-
- Reported by {report.reporter?.handle || 'anonymous'} • {formatDate(report.createdAt)} -
- {report.targetType === 'post' && report.target && 'content' in report.target && ( -
- @{report.target.author.handle}: {report.target.content || '[repost]'} -
- )} - {report.targetType === 'user' && report.target && 'handle' in report.target && ( -
- User: @{report.target.handle} -
- )} -
-
- {report.targetType === 'post' && report.target && 'content' in report.target && ( - - )} - {report.status === 'open' ? ( - - ) : ( - - )} -
-
- ))} -
- )} -
- )} - - {tab === 'posts' && ( -
- {loading ? ( -
Loading posts...
- ) : posts.length === 0 ? ( -
No posts found.
- ) : ( -
- {posts.map((post) => ( -
-
-
- - {post.isRemoved ? 'removed' : 'active'} - - - @{post.author.handle} • {formatDate(post.createdAt)} - -
-
{post.content || '[repost]'}
- {post.removedReason && ( -
Reason: {post.removedReason}
- )} -
-
- {post.isRemoved ? ( - - ) : ( - - )} -
-
- ))} -
- )} -
- )} - - {tab === 'users' && ( -
- {loading ? ( -
Loading users...
- ) : users.length === 0 ? ( -
No users found.
- ) : ( -
- {users.map((user) => ( -
-
-
- - {user.isSuspended ? 'suspended' : 'active'} - - - {user.isSilenced ? 'silenced' : 'visible'} - - - @{user.handle} • {formatDate(user.createdAt)} - -
-
- {user.displayName || user.handle} - {user.isBot && ( - - - AI Account - - )} -
- {user.suspensionReason && ( -
Suspension: {user.suspensionReason}
- )} - {user.silenceReason && ( -
Silence: {user.silenceReason}
- )} -
-
- - View - - {user.isSuspended ? ( - - ) : ( - - )} - {user.isSilenced ? ( - - ) : ( - - )} -
-
- ))} -
- )} -
- )} - {tab === 'settings' && ( -
-
Node Settings
- - {loading ? ( -
Loading settings...
+
Loading settings...
) : (
@@ -886,7 +533,7 @@ export default function AdminPage() {
)}
- )} -
+
+ ); } diff --git a/src/app/moderation/page.tsx b/src/app/moderation/page.tsx new file mode 100644 index 0000000..d83fecf --- /dev/null +++ b/src/app/moderation/page.tsx @@ -0,0 +1,489 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { Bot } from 'lucide-react'; + +type AdminUser = { + id: string; + handle: string; + displayName?: string | null; + email?: string | null; + isSuspended: boolean; + isSilenced: boolean; + suspensionReason?: string | null; + silenceReason?: string | null; + createdAt: string; + isBot?: boolean; +}; + +type AdminPost = { + id: string; + content: string; + createdAt: string; + isRemoved: boolean; + removedReason?: string | null; + author: { + id: string; + handle: string; + displayName?: string | null; + }; +}; + +type Report = { + id: string; + targetType: 'post' | 'user'; + targetId: string; + reason: string; + status: 'open' | 'resolved'; + createdAt: string; + reporter?: { + id: string; + handle: string; + } | null; + target?: AdminPost | AdminUser | null; +}; + +const formatDate = (value: string) => { + const date = new Date(value); + return date.toLocaleString(); +}; + +export default function ModerationPage() { + const [isAdmin, setIsAdmin] = useState(null); + const [tab, setTab] = useState<'reports' | 'posts' | 'users'>('reports'); + const [reports, setReports] = useState([]); + const [posts, setPosts] = useState([]); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(false); + const [reportStatus, setReportStatus] = useState<'open' | 'resolved' | 'all'>('open'); + + useEffect(() => { + fetch('/api/admin/me') + .then((res) => res.json()) + .then((data) => setIsAdmin(!!data.isAdmin)) + .catch(() => setIsAdmin(false)); + }, []); + + const loadReports = async () => { + setLoading(true); + try { + const res = await fetch(`/api/admin/reports?status=${reportStatus}`); + const data = await res.json(); + setReports(data.reports || []); + } catch { + setReports([]); + } finally { + setLoading(false); + } + }; + + const loadPosts = async () => { + setLoading(true); + try { + const res = await fetch('/api/admin/posts?status=all'); + const data = await res.json(); + setPosts(data.posts || []); + } catch { + setPosts([]); + } finally { + setLoading(false); + } + }; + + const loadUsers = async () => { + setLoading(true); + try { + const res = await fetch('/api/admin/users'); + const data = await res.json(); + setUsers(data.users || []); + } catch { + setUsers([]); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (!isAdmin) return; + if (tab === 'reports') loadReports(); + if (tab === 'posts') loadPosts(); + if (tab === 'users') loadUsers(); + }, [tab, isAdmin, reportStatus]); + + const handleReportResolve = async (id: string, status: 'open' | 'resolved') => { + const note = status === 'resolved' ? window.prompt('Resolution note (optional):') || '' : ''; + await fetch(`/api/admin/reports/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status, note }), + }); + loadReports(); + }; + + const handlePostAction = async (id: string, action: 'remove' | 'restore') => { + const reason = action === 'remove' ? window.prompt('Reason (optional):') || '' : ''; + await fetch(`/api/admin/posts/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, reason }), + }); + if (tab === 'reports') { + loadReports(); + } else { + loadPosts(); + } + }; + + const handleUserAction = async (id: string, action: 'suspend' | 'unsuspend' | 'silence' | 'unsilence') => { + const needsReason = action === 'suspend' || action === 'silence'; + const reason = needsReason ? window.prompt('Reason (optional):') || '' : ''; + await fetch(`/api/admin/users/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, reason }), + }); + loadUsers(); + }; + + const reportCounts = useMemo(() => { + return { + open: reports.filter((r) => r.status === 'open').length, + resolved: reports.filter((r) => r.status === 'resolved').length, + }; + }, [reports]); + + if (isAdmin === null) { + return ( +
+
Checking permissions...
+
+ ); + } + + if (!isAdmin) { + return ( +
+
+

Moderation

+

You do not have access to this page.

+ + Back to home + +
+
+ ); + } + + return ( + <> +
+

Moderation

+
+ +
+
+ + + +
+ + {tab === 'reports' && ( +
+
+
+ {(['open', 'resolved', 'all'] as const).map((status) => ( + + ))} +
+
+ Open: {reportCounts.open} + + Resolved: {reportCounts.resolved} +
+
+ + {loading ? ( +
Loading reports...
+ ) : reports.length === 0 ? ( +
No reports found.
+ ) : ( +
+ {reports.map((report) => ( +
+
+
+
+ + {report.status} + + + {report.targetType.toUpperCase()} report + +
+
{report.reason}
+
+ Reported by {report.reporter?.handle || 'anonymous'} • {formatDate(report.createdAt)} +
+ {report.targetType === 'post' && report.target && 'content' in report.target && ( +
+ @{report.target.author.handle}: {report.target.content || '[repost]'} +
+ )} + {report.targetType === 'user' && report.target && 'handle' in report.target && ( +
+ User: @{report.target.handle} +
+ )} +
+
+ {report.targetType === 'post' && report.target && 'content' in report.target && ( + + )} + {report.status === 'open' ? ( + + ) : ( + + )} +
+
+
+ ))} +
+ )} +
+ )} + + {tab === 'posts' && ( +
+ {loading ? ( +
Loading posts...
+ ) : posts.length === 0 ? ( +
No posts found.
+ ) : ( +
+ {posts.map((post) => ( +
+
+
+
+ + {post.isRemoved ? 'removed' : 'active'} + + + @{post.author.handle} • {formatDate(post.createdAt)} + +
+
{post.content || '[repost]'}
+ {post.removedReason && ( +
+ Reason: {post.removedReason} +
+ )} +
+
+ {post.isRemoved ? ( + + ) : ( + + )} +
+
+
+ ))} +
+ )} +
+ )} + + {tab === 'users' && ( +
+ {loading ? ( +
Loading users...
+ ) : users.length === 0 ? ( +
No users found.
+ ) : ( +
+ {users.map((user) => ( +
+
+
+
+ + {user.isSuspended ? 'suspended' : 'active'} + + {user.isSilenced && ( + + silenced + + )} + + @{user.handle} • {formatDate(user.createdAt)} + +
+
+ {user.displayName || user.handle} + {user.isBot && ( + + + AI Account + + )} +
+ {user.suspensionReason && ( +
+ Suspension: {user.suspensionReason} +
+ )} + {user.silenceReason && ( +
+ Silence: {user.silenceReason} +
+ )} +
+
+ + View + + {user.isSuspended ? ( + + ) : ( + + )} + {user.isSilenced ? ( + + ) : ( + + )} +
+
+
+ ))} +
+ )} +
+ )} +
+ + ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 5634bef..0dbdc2c 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -7,7 +7,7 @@ import { usePathname, useRouter } from 'next/navigation'; import { useAuth } from '@/lib/contexts/AuthContext'; import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons'; import { formatFullHandle } from '@/lib/utils/handle'; -import { LogOut } from 'lucide-react'; +import { LogOut, Settings2 } from 'lucide-react'; export function Sidebar() { const { user, isAdmin } = useAuth(); @@ -118,8 +118,14 @@ export function Sidebar() { )} {isAdmin && ( - + + Moderation + + )} + {isAdmin && ( + + Admin )}