Fix posts query (like/comment counts), add multi-select with bulk actions to all list pages
This commit is contained in:
parent
766392e5b0
commit
ec5a0aad8b
|
|
@ -1,6 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import AdminShell from '@/components/AdminShell';
|
||||
import SelectionBar from '@/components/SelectionBar';
|
||||
import { api } from '@/lib/api';
|
||||
import { statusColor, formatDateTime } from '@/lib/utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
|
@ -27,6 +28,8 @@ export default function ModerationPage() {
|
|||
const [statusFilter, setStatusFilter] = useState('pending');
|
||||
const [reviewingId, setReviewingId] = useState<string | null>(null);
|
||||
const [reason, setReason] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [bulkLoading, setBulkLoading] = useState(false);
|
||||
|
||||
const fetchQueue = () => {
|
||||
setLoading(true);
|
||||
|
|
@ -47,6 +50,20 @@ export default function ModerationPage() {
|
|||
} catch {}
|
||||
};
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelected((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
|
||||
};
|
||||
|
||||
const handleBulkAction = async (action: string) => {
|
||||
setBulkLoading(true);
|
||||
try {
|
||||
await api.bulkReviewModeration(Array.from(selected), action, 'Bulk admin review');
|
||||
setSelected(new Set());
|
||||
fetchQueue();
|
||||
} catch {}
|
||||
setBulkLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
|
|
@ -61,6 +78,22 @@ export default function ModerationPage() {
|
|||
</select>
|
||||
</div>
|
||||
|
||||
{statusFilter === 'pending' && (
|
||||
<SelectionBar
|
||||
count={selected.size}
|
||||
total={items.length}
|
||||
onSelectAll={() => setSelected(new Set(items.map((i) => i.id)))}
|
||||
onClearSelection={() => setSelected(new Set())}
|
||||
loading={bulkLoading}
|
||||
actions={[
|
||||
{ label: 'Approve All', action: 'approve', color: 'bg-green-50 text-green-700 hover:bg-green-100', icon: <CheckCircle className="w-3.5 h-3.5" /> },
|
||||
{ label: 'Dismiss All', action: 'dismiss', color: 'bg-gray-100 text-gray-700 hover:bg-gray-200', icon: <XCircle className="w-3.5 h-3.5" /> },
|
||||
{ label: 'Remove Content', action: 'remove_content', confirm: true, color: 'bg-red-50 text-red-700 hover:bg-red-100', icon: <Trash2 className="w-3.5 h-3.5" /> },
|
||||
]}
|
||||
onAction={handleBulkAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => <div key={i} className="card p-6 animate-pulse"><div className="h-20 bg-warm-300 rounded" /></div>)}
|
||||
|
|
@ -74,8 +107,12 @@ export default function ModerationPage() {
|
|||
) : (
|
||||
<div className="space-y-4">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="card p-5">
|
||||
<div key={item.id} className={`card p-5 ${selected.has(item.id) ? 'ring-2 ring-brand-300' : ''}`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
{statusFilter === 'pending' && (
|
||||
<input type="checkbox" className="rounded border-gray-300 mt-1" checked={selected.has(item.id)} onChange={() => toggleSelect(item.id)} />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
|
|
@ -120,6 +157,7 @@ export default function ModerationPage() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{item.status === 'pending' && (
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import AdminShell from '@/components/AdminShell';
|
||||
import SelectionBar from '@/components/SelectionBar';
|
||||
import { api } from '@/lib/api';
|
||||
import { statusColor, formatDate, truncate } from '@/lib/utils';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { Search, ChevronLeft, ChevronRight, Image, Video, MapPin } from 'lucide-react';
|
||||
import { Search, ChevronLeft, ChevronRight, Image, Video, MapPin, Trash2, XCircle, CheckCircle } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
|
|
@ -24,6 +25,8 @@ function PostsPageInner() {
|
|||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [bulkLoading, setBulkLoading] = useState(false);
|
||||
const limit = 25;
|
||||
const authorId = searchParams.get('author_id') || undefined;
|
||||
|
||||
|
|
@ -50,6 +53,24 @@ function PostsPageInner() {
|
|||
} catch {}
|
||||
};
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelected((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
|
||||
};
|
||||
const toggleAll = () => {
|
||||
if (selected.size === posts.length) setSelected(new Set());
|
||||
else setSelected(new Set(posts.map((p) => p.id)));
|
||||
};
|
||||
|
||||
const handleBulkAction = async (action: string) => {
|
||||
setBulkLoading(true);
|
||||
try {
|
||||
await api.bulkUpdatePosts(Array.from(selected), action, 'Bulk admin action');
|
||||
setSelected(new Set());
|
||||
fetchPosts();
|
||||
} catch {}
|
||||
setBulkLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div className="mb-6">
|
||||
|
|
@ -71,12 +92,29 @@ function PostsPageInner() {
|
|||
</select>
|
||||
</div>
|
||||
|
||||
<SelectionBar
|
||||
count={selected.size}
|
||||
total={posts.length}
|
||||
onSelectAll={() => setSelected(new Set(posts.map((p) => p.id)))}
|
||||
onClearSelection={() => setSelected(new Set())}
|
||||
loading={bulkLoading}
|
||||
actions={[
|
||||
{ label: 'Remove', action: 'remove', confirm: true, color: 'bg-red-50 text-red-700 hover:bg-red-100', icon: <XCircle className="w-3.5 h-3.5" /> },
|
||||
{ label: 'Activate', action: 'activate', color: 'bg-green-50 text-green-700 hover:bg-green-100', icon: <CheckCircle className="w-3.5 h-3.5" /> },
|
||||
{ label: 'Delete', action: 'delete', confirm: true, color: 'bg-red-100 text-red-800 hover:bg-red-200', icon: <Trash2 className="w-3.5 h-3.5" /> },
|
||||
]}
|
||||
onAction={handleBulkAction}
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-warm-200">
|
||||
<tr>
|
||||
<th className="table-header w-10">
|
||||
<input type="checkbox" className="rounded border-gray-300" checked={posts.length > 0 && selected.size === posts.length} onChange={toggleAll} />
|
||||
</th>
|
||||
<th className="table-header">Content</th>
|
||||
<th className="table-header">Author</th>
|
||||
<th className="table-header">Media</th>
|
||||
|
|
@ -89,13 +127,16 @@ function PostsPageInner() {
|
|||
<tbody className="divide-y divide-warm-300">
|
||||
{loading ? (
|
||||
[...Array(5)].map((_, i) => (
|
||||
<tr key={i}>{[...Array(7)].map((_, j) => <td key={j} className="table-cell"><div className="h-4 bg-warm-300 rounded animate-pulse w-20" /></td>)}</tr>
|
||||
<tr key={i}>{[...Array(8)].map((_, j) => <td key={j} className="table-cell"><div className="h-4 bg-warm-300 rounded animate-pulse w-20" /></td>)}</tr>
|
||||
))
|
||||
) : posts.length === 0 ? (
|
||||
<tr><td colSpan={7} className="table-cell text-center text-gray-400 py-8">No posts found</td></tr>
|
||||
<tr><td colSpan={8} className="table-cell text-center text-gray-400 py-8">No posts found</td></tr>
|
||||
) : (
|
||||
posts.map((post) => (
|
||||
<tr key={post.id} className="hover:bg-warm-50 transition-colors">
|
||||
<tr key={post.id} className={`hover:bg-warm-50 transition-colors ${selected.has(post.id) ? 'bg-brand-50' : ''}`}>
|
||||
<td className="table-cell">
|
||||
<input type="checkbox" className="rounded border-gray-300" checked={selected.has(post.id)} onChange={() => toggleSelect(post.id)} />
|
||||
</td>
|
||||
<td className="table-cell max-w-xs">
|
||||
<p className="text-sm text-gray-900 line-clamp-2">{truncate(post.body || '', 80)}</p>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import AdminShell from '@/components/AdminShell';
|
||||
import SelectionBar from '@/components/SelectionBar';
|
||||
import { api } from '@/lib/api';
|
||||
import { statusColor, formatDateTime } from '@/lib/utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
|
@ -12,6 +13,8 @@ export default function ReportsPage() {
|
|||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState('pending');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [bulkLoading, setBulkLoading] = useState(false);
|
||||
|
||||
const fetchReports = () => {
|
||||
setLoading(true);
|
||||
|
|
@ -30,6 +33,24 @@ export default function ReportsPage() {
|
|||
} catch {}
|
||||
};
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelected((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
|
||||
};
|
||||
const toggleAll = () => {
|
||||
if (selected.size === reports.length) setSelected(new Set());
|
||||
else setSelected(new Set(reports.map((r) => r.id)));
|
||||
};
|
||||
|
||||
const handleBulkAction = async (action: string) => {
|
||||
setBulkLoading(true);
|
||||
try {
|
||||
await api.bulkUpdateReports(Array.from(selected), action);
|
||||
setSelected(new Set());
|
||||
fetchReports();
|
||||
} catch {}
|
||||
setBulkLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
|
|
@ -45,6 +66,21 @@ export default function ReportsPage() {
|
|||
</select>
|
||||
</div>
|
||||
|
||||
{statusFilter === 'pending' && (
|
||||
<SelectionBar
|
||||
count={selected.size}
|
||||
total={reports.length}
|
||||
onSelectAll={() => setSelected(new Set(reports.map((r) => r.id)))}
|
||||
onClearSelection={() => setSelected(new Set())}
|
||||
loading={bulkLoading}
|
||||
actions={[
|
||||
{ label: 'Action All', action: 'actioned', color: 'bg-green-50 text-green-700 hover:bg-green-100', icon: <CheckCircle className="w-3.5 h-3.5" /> },
|
||||
{ label: 'Dismiss All', action: 'dismissed', color: 'bg-gray-100 text-gray-700 hover:bg-gray-200', icon: <XCircle className="w-3.5 h-3.5" /> },
|
||||
]}
|
||||
onAction={handleBulkAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => <div key={i} className="card p-6 animate-pulse"><div className="h-16 bg-warm-300 rounded" /></div>)}
|
||||
|
|
@ -59,6 +95,11 @@ export default function ReportsPage() {
|
|||
<table className="w-full">
|
||||
<thead className="bg-warm-200">
|
||||
<tr>
|
||||
{statusFilter === 'pending' && (
|
||||
<th className="table-header w-10">
|
||||
<input type="checkbox" className="rounded border-gray-300" checked={reports.length > 0 && selected.size === reports.length} onChange={toggleAll} />
|
||||
</th>
|
||||
)}
|
||||
<th className="table-header">Reporter</th>
|
||||
<th className="table-header">Target</th>
|
||||
<th className="table-header">Type</th>
|
||||
|
|
@ -71,7 +112,12 @@ export default function ReportsPage() {
|
|||
</thead>
|
||||
<tbody className="divide-y divide-warm-300">
|
||||
{reports.map((report) => (
|
||||
<tr key={report.id} className="hover:bg-warm-50 transition-colors">
|
||||
<tr key={report.id} className={`hover:bg-warm-50 transition-colors ${selected.has(report.id) ? 'bg-brand-50' : ''}`}>
|
||||
{statusFilter === 'pending' && (
|
||||
<td className="table-cell">
|
||||
<input type="checkbox" className="rounded border-gray-300" checked={selected.has(report.id)} onChange={() => toggleSelect(report.id)} />
|
||||
</td>
|
||||
)}
|
||||
<td className="table-cell">
|
||||
<Link href={`/users/${report.reporter_id}`} className="text-brand-500 hover:text-brand-700 text-sm">
|
||||
@{report.reporter_handle || '—'}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import AdminShell from '@/components/AdminShell';
|
||||
import SelectionBar from '@/components/SelectionBar';
|
||||
import { api } from '@/lib/api';
|
||||
import { statusColor, formatDate, truncate } from '@/lib/utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Search, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Search, ChevronLeft, ChevronRight, Ban, CheckCircle, PauseCircle } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function UsersPage() {
|
||||
|
|
@ -14,6 +15,8 @@ export default function UsersPage() {
|
|||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [bulkLoading, setBulkLoading] = useState(false);
|
||||
const limit = 25;
|
||||
|
||||
const fetchUsers = () => {
|
||||
|
|
@ -32,6 +35,24 @@ export default function UsersPage() {
|
|||
fetchUsers();
|
||||
};
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelected((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
|
||||
};
|
||||
const toggleAll = () => {
|
||||
if (selected.size === users.length) setSelected(new Set());
|
||||
else setSelected(new Set(users.map((u) => u.id)));
|
||||
};
|
||||
|
||||
const handleBulkAction = async (action: string) => {
|
||||
setBulkLoading(true);
|
||||
try {
|
||||
await api.bulkUpdateUsers(Array.from(selected), action, 'Bulk admin action');
|
||||
setSelected(new Set());
|
||||
fetchUsers();
|
||||
} catch {}
|
||||
setBulkLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
|
|
@ -62,12 +83,29 @@ export default function UsersPage() {
|
|||
</select>
|
||||
</div>
|
||||
|
||||
<SelectionBar
|
||||
count={selected.size}
|
||||
total={users.length}
|
||||
onSelectAll={() => setSelected(new Set(users.map((u) => u.id)))}
|
||||
onClearSelection={() => setSelected(new Set())}
|
||||
loading={bulkLoading}
|
||||
actions={[
|
||||
{ label: 'Activate', action: 'activate', color: 'bg-green-50 text-green-700 hover:bg-green-100', icon: <CheckCircle className="w-3.5 h-3.5" /> },
|
||||
{ label: 'Suspend', action: 'suspend', confirm: true, color: 'bg-yellow-50 text-yellow-700 hover:bg-yellow-100', icon: <PauseCircle className="w-3.5 h-3.5" /> },
|
||||
{ label: 'Ban', action: 'ban', confirm: true, color: 'bg-red-100 text-red-800 hover:bg-red-200', icon: <Ban className="w-3.5 h-3.5" /> },
|
||||
]}
|
||||
onAction={handleBulkAction}
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-warm-200">
|
||||
<tr>
|
||||
<th className="table-header w-10">
|
||||
<input type="checkbox" className="rounded border-gray-300" checked={users.length > 0 && selected.size === users.length} onChange={toggleAll} />
|
||||
</th>
|
||||
<th className="table-header">User</th>
|
||||
<th className="table-header">Email</th>
|
||||
<th className="table-header">Role</th>
|
||||
|
|
@ -81,16 +119,19 @@ export default function UsersPage() {
|
|||
{loading ? (
|
||||
[...Array(5)].map((_, i) => (
|
||||
<tr key={i}>
|
||||
{[...Array(7)].map((_, j) => (
|
||||
{[...Array(8)].map((_, j) => (
|
||||
<td key={j} className="table-cell"><div className="h-4 bg-warm-300 rounded animate-pulse w-20" /></td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : users.length === 0 ? (
|
||||
<tr><td colSpan={7} className="table-cell text-center text-gray-400 py-8">No users found</td></tr>
|
||||
<tr><td colSpan={8} className="table-cell text-center text-gray-400 py-8">No users found</td></tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-warm-50 transition-colors">
|
||||
<tr key={user.id} className={`hover:bg-warm-50 transition-colors ${selected.has(user.id) ? 'bg-brand-50' : ''}`}>
|
||||
<td className="table-cell">
|
||||
<input type="checkbox" className="rounded border-gray-300" checked={selected.has(user.id)} onChange={() => toggleSelect(user.id)} />
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-brand-100 rounded-full flex items-center justify-center text-brand-600 text-xs font-bold">
|
||||
|
|
|
|||
60
admin/src/components/SelectionBar.tsx
Normal file
60
admin/src/components/SelectionBar.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
'use client';
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface BulkAction {
|
||||
label: string;
|
||||
action: string;
|
||||
color?: string;
|
||||
icon?: React.ReactNode;
|
||||
confirm?: boolean;
|
||||
}
|
||||
|
||||
interface SelectionBarProps {
|
||||
count: number;
|
||||
total: number;
|
||||
onSelectAll: () => void;
|
||||
onClearSelection: () => void;
|
||||
actions: BulkAction[];
|
||||
onAction: (action: string) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function SelectionBar({ count, total, onSelectAll, onClearSelection, actions, onAction, loading }: SelectionBarProps) {
|
||||
if (count === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-brand-50 border border-brand-200 rounded-lg px-4 py-3 mb-4 flex items-center gap-3 animate-in slide-in-from-top-2">
|
||||
<span className="text-sm font-medium text-brand-700">
|
||||
{count} selected
|
||||
</span>
|
||||
{count < total && (
|
||||
<button onClick={onSelectAll} className="text-xs text-brand-600 hover:text-brand-800 underline">
|
||||
Select all {total}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
{actions.map((a) => (
|
||||
<button
|
||||
key={a.action}
|
||||
onClick={() => {
|
||||
if (a.confirm && !confirm(`Are you sure you want to ${a.label.toLowerCase()} ${count} items?`)) return;
|
||||
onAction(a.action);
|
||||
}}
|
||||
disabled={loading}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
a.color || 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{a.icon}
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
<button onClick={onClearSelection} className="p-1.5 rounded hover:bg-brand-100 text-brand-500" title="Clear selection">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -138,6 +138,20 @@ class ApiClient {
|
|||
return this.request<any>(`/api/v1/admin/posts/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async bulkUpdatePosts(ids: string[], action: string, reason?: string) {
|
||||
return this.request<any>('/api/v1/admin/posts/bulk', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids, action, reason }),
|
||||
});
|
||||
}
|
||||
|
||||
async bulkUpdateUsers(ids: string[], action: string, reason?: string) {
|
||||
return this.request<any>('/api/v1/admin/users/bulk', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids, action, reason }),
|
||||
});
|
||||
}
|
||||
|
||||
// Moderation
|
||||
async getModerationQueue(params: { limit?: number; offset?: number; status?: string } = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
|
|
@ -154,6 +168,13 @@ class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
async bulkReviewModeration(ids: string[], action: string, reason?: string) {
|
||||
return this.request<any>('/api/v1/admin/moderation/bulk', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids, action, reason }),
|
||||
});
|
||||
}
|
||||
|
||||
// Appeals
|
||||
async listAppeals(params: { limit?: number; offset?: number; status?: string } = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
|
|
@ -186,6 +207,13 @@ class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
async bulkUpdateReports(ids: string[], action: string) {
|
||||
return this.request<any>('/api/v1/admin/reports/bulk', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids, action }),
|
||||
});
|
||||
}
|
||||
|
||||
// Algorithm
|
||||
async getAlgorithmConfig() {
|
||||
return this.request<any>('/api/v1/admin/algorithm');
|
||||
|
|
|
|||
|
|
@ -367,10 +367,15 @@ func main() {
|
|||
admin.GET("/posts/:id", adminHandler.GetPost)
|
||||
admin.PATCH("/posts/:id/status", adminHandler.UpdatePostStatus)
|
||||
admin.DELETE("/posts/:id", adminHandler.DeletePost)
|
||||
admin.POST("/posts/bulk", adminHandler.BulkUpdatePosts)
|
||||
|
||||
// User Bulk
|
||||
admin.POST("/users/bulk", adminHandler.BulkUpdateUsers)
|
||||
|
||||
// Moderation Queue
|
||||
admin.GET("/moderation", adminHandler.GetModerationQueue)
|
||||
admin.PATCH("/moderation/:id/review", adminHandler.ReviewModerationFlag)
|
||||
admin.POST("/moderation/bulk", adminHandler.BulkReviewModeration)
|
||||
|
||||
// Appeals
|
||||
admin.GET("/appeals", adminHandler.ListAppeals)
|
||||
|
|
@ -379,6 +384,7 @@ func main() {
|
|||
// Reports
|
||||
admin.GET("/reports", adminHandler.ListReports)
|
||||
admin.PATCH("/reports/:id", adminHandler.UpdateReportStatus)
|
||||
admin.POST("/reports/bulk", adminHandler.BulkUpdateReports)
|
||||
|
||||
// Algorithm / Feed Config
|
||||
admin.GET("/algorithm", adminHandler.GetAlgorithmConfig)
|
||||
|
|
|
|||
|
|
@ -551,7 +551,9 @@ func (h *AdminHandler) ListPosts(c *gin.Context) {
|
|||
|
||||
query := `
|
||||
SELECT p.id, p.author_id, p.body, p.status, p.image_url, p.video_url,
|
||||
p.like_count, p.comment_count, p.is_beacon, p.visibility, p.created_at,
|
||||
COALESCE((SELECT COUNT(*) FROM post_likes pl WHERE pl.post_id = p.id), 0) AS like_count,
|
||||
COALESCE((SELECT COUNT(*) FROM comments c WHERE c.post_id = p.id AND c.deleted_at IS NULL), 0) AS comment_count,
|
||||
p.is_beacon, p.visibility, p.created_at,
|
||||
pr.handle, pr.display_name, pr.avatar_url
|
||||
FROM posts p
|
||||
LEFT JOIN profiles pr ON p.author_id = pr.id
|
||||
|
|
@ -649,7 +651,9 @@ func (h *AdminHandler) GetPost(c *gin.Context) {
|
|||
SELECT p.id, p.author_id, p.body, p.status, p.body_format, p.image_url, p.video_url,
|
||||
p.thumbnail_url, p.tone_label, p.cis_score, COALESCE(p.duration_ms, 0),
|
||||
p.background_id, p.is_beacon, p.beacon_type, p.allow_chain, p.visibility,
|
||||
p.like_count, p.comment_count, p.created_at, p.edited_at,
|
||||
COALESCE((SELECT COUNT(*) FROM post_likes pl WHERE pl.post_id = p.id), 0),
|
||||
COALESCE((SELECT COUNT(*) FROM comments c WHERE c.post_id = p.id AND c.deleted_at IS NULL), 0),
|
||||
p.created_at, p.edited_at,
|
||||
pr.handle, pr.display_name, pr.avatar_url
|
||||
FROM posts p
|
||||
LEFT JOIN profiles pr ON p.author_id = pr.id
|
||||
|
|
@ -757,6 +761,184 @@ func (h *AdminHandler) DeletePost(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, gin.H{"message": "Post deleted"})
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Bulk Actions
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
func (h *AdminHandler) BulkUpdatePosts(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
adminID, _ := c.Get("user_id")
|
||||
|
||||
var req struct {
|
||||
IDs []string `json:"ids" binding:"required"`
|
||||
Action string `json:"action" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var affected int64
|
||||
for _, id := range req.IDs {
|
||||
var err error
|
||||
switch req.Action {
|
||||
case "remove":
|
||||
tag, e := h.pool.Exec(ctx, `UPDATE posts SET status = 'removed' WHERE id = $1::uuid AND deleted_at IS NULL`, id)
|
||||
err = e
|
||||
affected += tag.RowsAffected()
|
||||
case "activate":
|
||||
tag, e := h.pool.Exec(ctx, `UPDATE posts SET status = 'active' WHERE id = $1::uuid AND deleted_at IS NULL`, id)
|
||||
err = e
|
||||
affected += tag.RowsAffected()
|
||||
case "delete":
|
||||
tag, e := h.pool.Exec(ctx, `UPDATE posts SET deleted_at = NOW(), status = 'removed' WHERE id = $1::uuid`, id)
|
||||
err = e
|
||||
affected += tag.RowsAffected()
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("post_id", id).Msg("Bulk post action failed")
|
||||
}
|
||||
}
|
||||
|
||||
adminUUID, _ := uuid.Parse(adminID.(string))
|
||||
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, details) VALUES ($1, $2, 'post', $3)`,
|
||||
adminUUID, "bulk_"+req.Action+"_posts", fmt.Sprintf(`{"count":%d,"reason":"%s"}`, affected, req.Reason))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d posts updated", affected), "affected": affected})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) BulkUpdateUsers(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
adminID, _ := c.Get("user_id")
|
||||
|
||||
var req struct {
|
||||
IDs []string `json:"ids" binding:"required"`
|
||||
Action string `json:"action" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var affected int64
|
||||
for _, id := range req.IDs {
|
||||
var err error
|
||||
switch req.Action {
|
||||
case "ban":
|
||||
tag, e := h.pool.Exec(ctx, `UPDATE users SET status = 'banned' WHERE id = $1::uuid`, id)
|
||||
err = e
|
||||
affected += tag.RowsAffected()
|
||||
case "suspend":
|
||||
tag, e := h.pool.Exec(ctx, `UPDATE users SET status = 'suspended' WHERE id = $1::uuid`, id)
|
||||
err = e
|
||||
affected += tag.RowsAffected()
|
||||
case "activate":
|
||||
tag, e := h.pool.Exec(ctx, `UPDATE users SET status = 'active' WHERE id = $1::uuid`, id)
|
||||
err = e
|
||||
affected += tag.RowsAffected()
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("user_id", id).Msg("Bulk user action failed")
|
||||
}
|
||||
}
|
||||
|
||||
adminUUID, _ := uuid.Parse(adminID.(string))
|
||||
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, details) VALUES ($1, $2, 'user', $3)`,
|
||||
adminUUID, "bulk_"+req.Action+"_users", fmt.Sprintf(`{"count":%d,"reason":"%s"}`, affected, req.Reason))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d users updated", affected), "affected": affected})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) BulkReviewModeration(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
adminID, _ := c.Get("user_id")
|
||||
|
||||
var req struct {
|
||||
IDs []string `json:"ids" binding:"required"`
|
||||
Action string `json:"action" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
adminUUID, _ := uuid.Parse(adminID.(string))
|
||||
var affected int64
|
||||
for _, id := range req.IDs {
|
||||
var newStatus string
|
||||
switch req.Action {
|
||||
case "approve":
|
||||
newStatus = "approved"
|
||||
case "dismiss":
|
||||
newStatus = "dismissed"
|
||||
case "remove_content":
|
||||
newStatus = "actioned"
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action"})
|
||||
return
|
||||
}
|
||||
tag, err := h.pool.Exec(ctx,
|
||||
`UPDATE moderation_flags SET status = $1, reviewed_by = $2, reviewed_at = NOW() WHERE id = $3::uuid AND status = 'pending'`,
|
||||
newStatus, adminUUID, id)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("flag_id", id).Msg("Bulk moderation action failed")
|
||||
}
|
||||
affected += tag.RowsAffected()
|
||||
|
||||
if req.Action == "remove_content" {
|
||||
h.pool.Exec(ctx, `UPDATE posts SET status = 'removed' WHERE id = (SELECT post_id FROM moderation_flags WHERE id = $1::uuid)`, id)
|
||||
}
|
||||
}
|
||||
|
||||
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, details) VALUES ($1, $2, 'moderation', $3)`,
|
||||
adminUUID, "bulk_"+req.Action+"_moderation", fmt.Sprintf(`{"count":%d}`, affected))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d flags updated", affected), "affected": affected})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) BulkUpdateReports(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
adminID, _ := c.Get("user_id")
|
||||
|
||||
var req struct {
|
||||
IDs []string `json:"ids" binding:"required"`
|
||||
Action string `json:"action" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Action != "actioned" && req.Action != "dismissed" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action, must be actioned or dismissed"})
|
||||
return
|
||||
}
|
||||
|
||||
var affected int64
|
||||
for _, id := range req.IDs {
|
||||
tag, err := h.pool.Exec(ctx, `UPDATE reports SET status = $1 WHERE id = $2::uuid AND status = 'pending'`, req.Action, id)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("report_id", id).Msg("Bulk report action failed")
|
||||
}
|
||||
affected += tag.RowsAffected()
|
||||
}
|
||||
|
||||
adminUUID, _ := uuid.Parse(adminID.(string))
|
||||
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, details) VALUES ($1, $2, 'report', $3)`,
|
||||
adminUUID, "bulk_"+req.Action+"_reports", fmt.Sprintf(`{"count":%d}`, affected))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d reports updated", affected), "affected": affected})
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Moderation Queue
|
||||
// ──────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in a new issue