From 35830189eaa24914c98716a1993efde6d299952e Mon Sep 17 00:00:00 2001 From: mobiniqow Date: Sun, 20 Sep 2026 05:30:33 +0330 Subject: [PATCH] Add user moderation, matches, and photo review pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New "همه کاربران" page: searchable/paginated full user list with block/unblock/report actions and report-count badges. - User detail page gained block/unblock/report buttons, a report history section, and a recent-activity panel (via the backend's per-user monitoring filter). - New "رفقا" page listing every match across the app. - New "نظارت بر عکس‌ها" page: grid of every uploaded photo with uploader info and a delete action, for content review without going profile-by-profile. --- src/app/(dashboard)/matches/page.tsx | 131 ++++++++++++++++++ src/app/(dashboard)/photos/page.tsx | 154 +++++++++++++++++++++ src/app/(dashboard)/users/[id]/page.tsx | 55 +++++++- src/app/(dashboard)/users/all/page.tsx | 169 +++++++++++++++++++++++ src/components/Sidebar.tsx | 6 + src/components/UserModerationActions.tsx | 162 ++++++++++++++++++++++ src/lib/api.ts | 109 ++++++++++++++- 7 files changed, 783 insertions(+), 3 deletions(-) create mode 100644 src/app/(dashboard)/matches/page.tsx create mode 100644 src/app/(dashboard)/photos/page.tsx create mode 100644 src/app/(dashboard)/users/all/page.tsx create mode 100644 src/components/UserModerationActions.tsx diff --git a/src/app/(dashboard)/matches/page.tsx b/src/app/(dashboard)/matches/page.tsx new file mode 100644 index 0000000..2c66dac --- /dev/null +++ b/src/app/(dashboard)/matches/page.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { ChevronLeft, ChevronRight, HeartHandshake } from "lucide-react"; +import { Card } from "@/components/ui/Card"; +import { Avatar } from "@/components/ui/Avatar"; +import { Button } from "@/components/ui/Button"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { FullScreenLoader } from "@/components/ui/FullScreenLoader"; +import { ErrorBanner } from "@/components/ui/ErrorBanner"; +import { formatApiError } from "@/lib/hooks"; +import { AdminMatchView, listAllMatches } from "@/lib/api"; + +const PAGE_SIZE = 20; + +export default function MatchesPage() { + const [items, setItems] = useState(null); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [error, setError] = useState(null); + + const load = useCallback(async (p: number) => { + setError(null); + try { + const res = await listAllMatches(p, PAGE_SIZE); + setItems(res.items); + setTotal(res.total); + } catch (err) { + setError(formatApiError(err)); + } + }, []); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + load(page); + }, [page, load]); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + return ( +
+
+

+ رفقا (مچ‌ها) 💞 +

+

+ همه‌ی جفت‌هایی که با هم مچ شدن، جدیدترین اول. +

+
+ + {error && } + + {!items ? ( + + ) : items.length === 0 ? ( + + } + title="هنوز هیچ مچی اتفاق نیفتاده" + description="وقتی دو نفر همدیگه رو لایک کنن، اینجا نشون داده می‌شن." + /> + + ) : ( + <> + + {items.map((m, i) => ( +
+
+ + +
+
+
+

+ {m.user_one_name || "بدون نام"} +

+

+ {m.user_one_phone} +

+
+
+

+ {m.user_two_name || "بدون نام"} +

+

+ {m.user_two_phone} +

+
+
+

+ {new Date(m.created_at).toLocaleDateString("fa-IR")} +

+
+ ))} +
+ +
+

+ صفحه {page.toLocaleString("fa-IR")} از {totalPages.toLocaleString("fa-IR")} · {total.toLocaleString("fa-IR")} مچ +

+
+ + +
+
+ + )} +
+ ); +} diff --git a/src/app/(dashboard)/photos/page.tsx b/src/app/(dashboard)/photos/page.tsx new file mode 100644 index 0000000..6d36fc5 --- /dev/null +++ b/src/app/(dashboard)/photos/page.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { ChevronLeft, ChevronRight, ImageOff, Trash2 } from "lucide-react"; +import { Card } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { FullScreenLoader } from "@/components/ui/FullScreenLoader"; +import { ErrorBanner } from "@/components/ui/ErrorBanner"; +import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; +import { useToast } from "@/components/ui/Toast"; +import { formatApiError } from "@/lib/hooks"; +import { AdminPhotoView, listAllPhotos, deletePhotoAdmin, photoUrl } from "@/lib/api"; + +const PAGE_SIZE = 24; + +export default function PhotosModerationPage() { + const toast = useToast(); + const [items, setItems] = useState(null); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [error, setError] = useState(null); + const [pendingDelete, setPendingDelete] = useState(null); + const [deleting, setDeleting] = useState(false); + + const load = useCallback(async (p: number) => { + setError(null); + try { + const res = await listAllPhotos(p, PAGE_SIZE); + setItems(res.items); + setTotal(res.total); + } catch (err) { + setError(formatApiError(err)); + } + }, []); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + load(page); + }, [page, load]); + + async function handleDelete() { + if (!pendingDelete) return; + setDeleting(true); + try { + await deletePhotoAdmin(pendingDelete.id); + toast.showSuccess("عکس حذف شد"); + setPendingDelete(null); + load(page); + } catch (err) { + toast.showError(formatApiError(err)); + } finally { + setDeleting(false); + } + } + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + return ( +
+
+

+ نظارت بر عکس‌ها 🖼️ +

+

+ آخرین عکس‌های آپلودشده‌ی همه‌ی کاربران، جدیدترین اول. +

+
+ + {error && } + + {!items ? ( + + ) : items.length === 0 ? ( + + } + title="هنوز عکسی آپلود نشده" + description="عکس‌های آپلودشده‌ی کاربران اینجا برای نظارت نشون داده می‌شن." + /> + + ) : ( + <> +
+ {items.map((p) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+

+ {p.owner_name || "بدون نام"} +

+

+ {p.owner_phone} +

+
+ +
+ ))} +
+ +
+

+ صفحه {page.toLocaleString("fa-IR")} از {totalPages.toLocaleString("fa-IR")} · {total.toLocaleString("fa-IR")} عکس +

+
+ + +
+
+ + )} + + setPendingDelete(null)} + /> +
+ ); +} diff --git a/src/app/(dashboard)/users/[id]/page.tsx b/src/app/(dashboard)/users/[id]/page.tsx index 1ecd925..f2b9515 100644 --- a/src/app/(dashboard)/users/[id]/page.tsx +++ b/src/app/(dashboard)/users/[id]/page.tsx @@ -14,6 +14,8 @@ import { Sparkles, Search, Check, + Flag, + Activity, } from "lucide-react"; import { Card } from "@/components/ui/Card"; import { Avatar } from "@/components/ui/Avatar"; @@ -24,14 +26,19 @@ import { Modal } from "@/components/ui/Modal"; import { FullScreenLoader } from "@/components/ui/FullScreenLoader"; import { ErrorBanner } from "@/components/ui/ErrorBanner"; import { useToast } from "@/components/ui/Toast"; +import { UserModerationActions } from "@/components/UserModerationActions"; import { formatApiError } from "@/lib/hooks"; import { calcAge } from "@/lib/utils"; import { FullProfile, ApprovalStatus, + Report, + RequestLogEntry, getUserDetail, approveUser, rejectUser, + listUserReports, + getRecentRequests, } from "@/lib/api"; const statusMeta: Record = { @@ -71,6 +78,8 @@ export default function UserReviewPage() { const id = Number(params.id); const [profile, setProfile] = useState(null); + const [reports, setReports] = useState([]); + const [activity, setActivity] = useState([]); const [error, setError] = useState(null); const [acting, setActing] = useState(false); const [rejectOpen, setRejectOpen] = useState(false); @@ -82,6 +91,8 @@ export default function UserReviewPage() { } catch (err) { setError(formatApiError(err)); } + listUserReports(id).then(setReports).catch(() => setReports([])); + getRecentRequests(15, id).then(setActivity).catch(() => setActivity([])); }, [id]); useEffect(() => { @@ -147,11 +158,13 @@ export default function UserReviewPage() { {user.gender && ( {user.gender === "male" ? "مرد" : "زن"} )} + {user.state === "blocked" && بلاک} -
+
+ + +
+
+ + )} + + ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index be95a30..e6d5a3c 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -9,6 +9,9 @@ import { LogOut, Heart, UserCheck, + Users, + HeartHandshake, + Images, Activity, Database, } from "lucide-react"; @@ -18,6 +21,9 @@ import { useAuth } from "@/lib/auth-context"; const navItems = [ { href: "/", label: "داشبورد", icon: LayoutGrid }, { href: "/users", label: "کاربران در انتظار تایید", icon: UserCheck }, + { href: "/users/all", label: "همه کاربران", icon: Users }, + { href: "/matches", label: "رفقا (مچ‌ها)", icon: HeartHandshake }, + { href: "/photos", label: "نظارت بر عکس‌ها", icon: Images }, { href: "/categories", label: "دسته‌بندی‌ها و گزینه‌ها", icon: ListTree }, { href: "/questions", label: "سوالات پروفایل", icon: MessageCircleQuestion }, { href: "/monitoring", label: "مانیتورینگ سرور", icon: Activity }, diff --git a/src/components/UserModerationActions.tsx b/src/components/UserModerationActions.tsx new file mode 100644 index 0000000..bb963cc --- /dev/null +++ b/src/components/UserModerationActions.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useState } from "react"; +import { ShieldAlert, ShieldCheck, Flag } from "lucide-react"; +import { Button } from "@/components/ui/Button"; +import { Modal } from "@/components/ui/Modal"; +import { useToast } from "@/components/ui/Toast"; +import { formatApiError } from "@/lib/hooks"; +import { blockUser, unblockUser, reportUser, UserState } from "@/lib/api"; + +// دکمه‌های بلاک/آنبلاک/گزارش یک کاربر؛ هم توی لیست همه‌ی کاربران و هم توی +// صفحه‌ی جزئیات یک کاربر استفاده می‌شه. +export function UserModerationActions({ + userId, + state, + onChanged, +}: { + userId: number; + state: UserState; + onChanged: () => void; +}) { + const toast = useToast(); + const [blockOpen, setBlockOpen] = useState(false); + const [reportOpen, setReportOpen] = useState(false); + const [blockReason, setBlockReason] = useState(""); + const [reportReason, setReportReason] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleBlock() { + if (blockReason.trim().length < 3) return; + setLoading(true); + try { + await blockUser(userId, blockReason.trim()); + toast.showSuccess("کاربر بلاک شد"); + setBlockOpen(false); + setBlockReason(""); + onChanged(); + } catch (err) { + toast.showError(formatApiError(err)); + } finally { + setLoading(false); + } + } + + async function handleUnblock() { + setLoading(true); + try { + await unblockUser(userId); + toast.showSuccess("کاربر آنبلاک شد"); + onChanged(); + } catch (err) { + toast.showError(formatApiError(err)); + } finally { + setLoading(false); + } + } + + async function handleReport() { + if (reportReason.trim().length < 3) return; + setLoading(true); + try { + await reportUser(userId, reportReason.trim()); + toast.showSuccess("گزارش ثبت شد"); + setReportOpen(false); + setReportReason(""); + onChanged(); + } catch (err) { + toast.showError(formatApiError(err)); + } finally { + setLoading(false); + } + } + + return ( + <> +
+ {state === "blocked" ? ( + + ) : ( + + )} + +
+ + setBlockOpen(false)} title="بلاک کردن کاربر"> +
+

+ کاربر بلاک‌شده دیگه نمی‌تونه وارد اپ بشه (حتی با توکن معتبر فعلیش). +

+