Add user moderation, matches, and photo review pages
- 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.
This commit is contained in:
@@ -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<AdminMatchView[] | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex flex-col gap-6 max-w-3xl">
|
||||
<div>
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
|
||||
رفقا (مچها) 💞
|
||||
</h1>
|
||||
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||
همهی جفتهایی که با هم مچ شدن، جدیدترین اول.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
{!items ? (
|
||||
<FullScreenLoader />
|
||||
) : items.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={<HeartHandshake size={32} />}
|
||||
title="هنوز هیچ مچی اتفاق نیفتاده"
|
||||
description="وقتی دو نفر همدیگه رو لایک کنن، اینجا نشون داده میشن."
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card padded={false} className="overflow-hidden">
|
||||
{items.map((m, i) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`flex items-center gap-4 px-4 py-3.5 ${
|
||||
i !== items.length - 1 ? "border-b border-(--color-border-subtle)" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center -space-x-3 space-x-reverse shrink-0">
|
||||
<Avatar name={m.user_one_name || m.user_one_phone} />
|
||||
<Avatar name={m.user_two_name || m.user_two_phone} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 grid grid-cols-2 gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-[13.5px] text-(--color-content-primary) truncate">
|
||||
{m.user_one_name || "بدون نام"}
|
||||
</p>
|
||||
<p dir="ltr" className="text-[11.5px] text-(--color-content-tertiary) text-right">
|
||||
{m.user_one_phone}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-[13.5px] text-(--color-content-primary) truncate">
|
||||
{m.user_two_name || "بدون نام"}
|
||||
</p>
|
||||
<p dir="ltr" className="text-[11.5px] text-(--color-content-tertiary) text-right">
|
||||
{m.user_two_phone}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11.5px] text-(--color-content-tertiary) shrink-0">
|
||||
{new Date(m.created_at).toLocaleDateString("fa-IR")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[12px] text-(--color-content-tertiary)">
|
||||
صفحه {page.toLocaleString("fa-IR")} از {totalPages.toLocaleString("fa-IR")} · {total.toLocaleString("fa-IR")} مچ
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon={<ChevronRight size={14} />}
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
قبلی
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon={<ChevronLeft size={14} />}
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
بعدی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<AdminPhotoView[] | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<AdminPhotoView | null>(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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
|
||||
نظارت بر عکسها 🖼️
|
||||
</h1>
|
||||
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||
آخرین عکسهای آپلودشدهی همهی کاربران، جدیدترین اول.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
{!items ? (
|
||||
<FullScreenLoader />
|
||||
) : items.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={<ImageOff size={32} />}
|
||||
title="هنوز عکسی آپلود نشده"
|
||||
description="عکسهای آپلودشدهی کاربران اینجا برای نظارت نشون داده میشن."
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{items.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="group relative aspect-square rounded-(--radius-md) overflow-hidden bg-(--color-bg-tertiary) border border-(--color-border-subtle)"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={photoUrl(p.url)}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 pt-6">
|
||||
<p className="text-[11px] font-bold text-white truncate">
|
||||
{p.owner_name || "بدون نام"}
|
||||
</p>
|
||||
<p dir="ltr" className="text-[10px] text-white/80 truncate">
|
||||
{p.owner_phone}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPendingDelete(p)}
|
||||
className="absolute top-1.5 left-1.5 w-7 h-7 rounded-full bg-black/50 hover:bg-(--color-status-error) flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label="حذف عکس"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[12px] text-(--color-content-tertiary)">
|
||||
صفحه {page.toLocaleString("fa-IR")} از {totalPages.toLocaleString("fa-IR")} · {total.toLocaleString("fa-IR")} عکس
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon={<ChevronRight size={14} />}
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
قبلی
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon={<ChevronLeft size={14} />}
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
بعدی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!pendingDelete}
|
||||
title="حذف عکس"
|
||||
description="این عکس برای همیشه حذف میشه و از پروفایل کاربر هم پاک میشه. مطمئنی؟"
|
||||
loading={deleting}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ApprovalStatus, { label: string; tone: "warning" | "success" | "error" }> = {
|
||||
@@ -71,6 +78,8 @@ export default function UserReviewPage() {
|
||||
const id = Number(params.id);
|
||||
|
||||
const [profile, setProfile] = useState<FullProfile | null>(null);
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [activity, setActivity] = useState<RequestLogEntry[]>([]);
|
||||
const [error, setError] = useState<string | null>(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 && (
|
||||
<Badge tone="neutral">{user.gender === "male" ? "مرد" : "زن"}</Badge>
|
||||
)}
|
||||
{user.state === "blocked" && <Badge tone="error">بلاک</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<UserModerationActions userId={id} state={user.state} onChanged={load} />
|
||||
<Button
|
||||
variant="tertiary"
|
||||
icon={<XCircle size={16} />}
|
||||
@@ -171,6 +184,10 @@ export default function UserReviewPage() {
|
||||
<ErrorBanner message={`دلیل رد قبلی: ${user.rejection_reason}`} />
|
||||
)}
|
||||
|
||||
{user.state === "blocked" && user.blocked_reason && (
|
||||
<ErrorBanner message={`دلیل بلاک: ${user.blocked_reason}`} />
|
||||
)}
|
||||
|
||||
<SectionCard icon={<Ruler size={15} />} title="قد و وزن">
|
||||
<div className="flex gap-4 text-[14px]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
@@ -239,6 +256,42 @@ export default function UserReviewPage() {
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{reports.length > 0 && (
|
||||
<SectionCard icon={<Flag size={15} />} title={`سابقهی گزارشها (${reports.length})`}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{reports.map((r) => (
|
||||
<div key={r.id} className="flex flex-col gap-0.5">
|
||||
<p className="text-[14px] text-(--color-content-primary)">{r.reason}</p>
|
||||
<p className="text-[12px] text-(--color-content-tertiary)">
|
||||
{new Date(r.created_at).toLocaleString("fa-IR")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard icon={<Activity size={15} />} title="فعالیت اخیر">
|
||||
{activity.length === 0 ? (
|
||||
<p className="text-[13px] text-(--color-content-tertiary)">
|
||||
درخواستی از این کاربر در حافظهی اخیر سرور ثبت نشده.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{activity.map((a, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-[12.5px]">
|
||||
<span dir="ltr" className="font-mono text-(--color-content-secondary) truncate">
|
||||
{a.method} {a.path}
|
||||
</span>
|
||||
<span className="text-(--color-content-tertiary) shrink-0 pr-2">
|
||||
{new Date(a.time).toLocaleTimeString("fa-IR")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<RejectModal
|
||||
open={rejectOpen}
|
||||
onClose={() => setRejectOpen(false)}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronLeft, ChevronRight, Search, Users as UsersIcon } from "lucide-react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
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 { UserModerationActions } from "@/components/UserModerationActions";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { calcAge } from "@/lib/utils";
|
||||
import { AdminUserListItem, listAllUsers } from "@/lib/api";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const approvalToneMap = {
|
||||
pending: "warning",
|
||||
approved: "success",
|
||||
rejected: "error",
|
||||
} as const;
|
||||
const approvalLabelMap = {
|
||||
pending: "در انتظار",
|
||||
approved: "تاییدشده",
|
||||
rejected: "ردشده",
|
||||
};
|
||||
|
||||
export default function AllUsersPage() {
|
||||
const [items, setItems] = useState<AdminUserListItem[] | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (p: number, s: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await listAllUsers(s, 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, search);
|
||||
// search دونهشده جدا (effect پایین) هندل میشه؛ اینجا فقط تغییر page مهمه
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, load]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setPage(1);
|
||||
load(1, search);
|
||||
}, 350);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-4xl">
|
||||
<div>
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
|
||||
همهی کاربران 👥
|
||||
</h1>
|
||||
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||
جستوجو بر اساس شماره یا اسم؛ بلاک/آنبلاک و گزارش کاربران از همینجا.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-sm">
|
||||
<Search size={16} className="absolute right-3.5 top-1/2 -translate-y-1/2 text-(--color-content-tertiary)" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="جستوجو با شماره یا اسم..."
|
||||
className="w-full h-11 rounded-(--radius-md) border-2 border-(--color-border-default) bg-(--color-surface-default) pr-10 pl-4 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
{!items ? (
|
||||
<FullScreenLoader />
|
||||
) : items.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState icon={<UsersIcon size={32} />} title="کاربری پیدا نشد" description="جستوجوت رو تغییر بده." />
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card padded={false} className="overflow-hidden">
|
||||
{items.map((u, i) => (
|
||||
<div
|
||||
key={u.id}
|
||||
className={`flex items-center gap-3 px-4 py-3.5 ${
|
||||
i !== items.length - 1 ? "border-b border-(--color-border-subtle)" : ""
|
||||
}`}
|
||||
>
|
||||
<Link href={`/users/${u.id}`} className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Avatar name={u.first_name || u.phone} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-[14px] text-(--color-content-primary) truncate">
|
||||
{u.first_name || "بدون نام"}
|
||||
{calcAge(u.birth_date) ? `، ${calcAge(u.birth_date)} سال` : ""}
|
||||
</p>
|
||||
<p dir="ltr" className="text-[12px] text-(--color-content-tertiary) text-right">
|
||||
{u.phone}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{u.is_onboarded && (
|
||||
<Badge tone={approvalToneMap[u.approval_status]}>
|
||||
{approvalLabelMap[u.approval_status]}
|
||||
</Badge>
|
||||
)}
|
||||
{u.state === "blocked" && <Badge tone="error">بلاک</Badge>}
|
||||
{u.report_count > 0 && (
|
||||
<Badge tone="warning">{u.report_count} گزارش</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UserModerationActions
|
||||
userId={u.id}
|
||||
state={u.state}
|
||||
onChanged={() => load(page, search)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[12px] text-(--color-content-tertiary)">
|
||||
صفحه {page.toLocaleString("fa-IR")} از {totalPages.toLocaleString("fa-IR")} · {total.toLocaleString("fa-IR")} کاربر
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon={<ChevronRight size={14} />}
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
قبلی
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon={<ChevronLeft size={14} />}
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
بعدی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{state === "blocked" ? (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon={<ShieldCheck size={14} />}
|
||||
isLoading={loading}
|
||||
onClick={handleUnblock}
|
||||
>
|
||||
آنبلاک
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon={<ShieldAlert size={14} />}
|
||||
className="!text-(--color-status-error) !border-red-200"
|
||||
onClick={() => setBlockOpen(true)}
|
||||
>
|
||||
بلاک
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" icon={<Flag size={14} />} onClick={() => setReportOpen(true)}>
|
||||
گزارش
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal open={blockOpen} onClose={() => setBlockOpen(false)} title="بلاک کردن کاربر">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-[13px] text-(--color-content-secondary)">
|
||||
کاربر بلاکشده دیگه نمیتونه وارد اپ بشه (حتی با توکن معتبر فعلیش).
|
||||
</p>
|
||||
<textarea
|
||||
value={blockReason}
|
||||
onChange={(e) => setBlockReason(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="دلیل بلاک کردن..."
|
||||
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setBlockOpen(false)}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={loading}
|
||||
disabled={blockReason.trim().length < 3}
|
||||
className="!bg-(--color-status-error) !shadow-none hover:!bg-red-700"
|
||||
onClick={handleBlock}
|
||||
>
|
||||
بلاک کن
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal open={reportOpen} onClose={() => setReportOpen(false)} title="ثبت گزارش روی کاربر">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-[13px] text-(--color-content-secondary)">
|
||||
یک یادداشت نظارتی برای این کاربر ثبت میشه (مستقل از بلاک کردن)؛ برای پیگیری
|
||||
سابقهی شکایتها.
|
||||
</p>
|
||||
<textarea
|
||||
value={reportReason}
|
||||
onChange={(e) => setReportReason(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="دلیل گزارش..."
|
||||
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setReportOpen(false)}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={loading}
|
||||
disabled={reportReason.trim().length < 3}
|
||||
onClick={handleReport}
|
||||
>
|
||||
ثبت گزارش
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+107
-2
@@ -3,6 +3,15 @@ import { tokenStore } from "./token-store";
|
||||
export const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080/api/v1";
|
||||
|
||||
// عکسها از مسیر نسبی (/uploads/...) برمیگردن، نه URL کامل؛ باید به origin
|
||||
// سرور (بدون /api/v1) وصل بشن.
|
||||
export const API_ORIGIN = API_BASE.replace(/\/api\/v1\/?$/, "");
|
||||
export function photoUrl(path: string) {
|
||||
if (!path) return "";
|
||||
if (path.startsWith("http")) return path;
|
||||
return `${API_ORIGIN}${path}`;
|
||||
}
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
@@ -24,6 +33,8 @@ export class ApiError extends Error {
|
||||
|
||||
export type ApprovalStatus = "pending" | "approved" | "rejected";
|
||||
|
||||
export type UserState = "inactive" | "active" | "blocked";
|
||||
|
||||
export interface UserProfile {
|
||||
id: number;
|
||||
phone: string;
|
||||
@@ -37,6 +48,9 @@ export interface UserProfile {
|
||||
approval_status: ApprovalStatus;
|
||||
rejection_reason?: string;
|
||||
is_admin: boolean;
|
||||
state: UserState;
|
||||
blocked_reason?: string;
|
||||
blocked_at?: string;
|
||||
}
|
||||
|
||||
export interface StepView {
|
||||
@@ -334,8 +348,99 @@ export function getMonitoringSummary() {
|
||||
return authRequest<MonitoringSummary>("/admin/monitoring/summary");
|
||||
}
|
||||
|
||||
export function getRecentRequests(limit = 100) {
|
||||
return authRequest<RequestLogEntry[]>(`/admin/monitoring/requests?limit=${limit}`);
|
||||
export function getRecentRequests(limit = 100, userId?: number) {
|
||||
const userParam = userId ? `&user_id=${userId}` : "";
|
||||
return authRequest<RequestLogEntry[]>(`/admin/monitoring/requests?limit=${limit}${userParam}`);
|
||||
}
|
||||
|
||||
// ---------------- ادمین: همهی کاربران، بلاک/آنبلاک، گزارش ----------------
|
||||
|
||||
export interface AdminUserListItem extends UserProfile {
|
||||
report_count: number;
|
||||
}
|
||||
|
||||
interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export function listAllUsers(search: string, page = 1, pageSize = 25) {
|
||||
const searchParam = search ? `&search=${encodeURIComponent(search)}` : "";
|
||||
return authRequest<PaginatedResult<AdminUserListItem>>(
|
||||
`/admin/users?page=${page}&page_size=${pageSize}${searchParam}`
|
||||
);
|
||||
}
|
||||
|
||||
export function blockUser(id: number, reason: string) {
|
||||
return authRequest<UserProfile>(`/admin/users/${id}/block`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
}
|
||||
|
||||
export function unblockUser(id: number) {
|
||||
return authRequest<UserProfile>(`/admin/users/${id}/unblock`, { method: "POST" });
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
id: number;
|
||||
user_id: number;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function reportUser(id: number, reason: string) {
|
||||
return authRequest<Report>(`/admin/users/${id}/report`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
}
|
||||
|
||||
export function listUserReports(id: number) {
|
||||
return authRequest<Report[]>(`/admin/users/${id}/reports`);
|
||||
}
|
||||
|
||||
// ---------------- ادمین: مچها (رفقا) ----------------
|
||||
|
||||
export interface AdminMatchView {
|
||||
id: number;
|
||||
created_at: string;
|
||||
user_one_id: number;
|
||||
user_one_name: string;
|
||||
user_one_phone: string;
|
||||
user_two_id: number;
|
||||
user_two_name: string;
|
||||
user_two_phone: string;
|
||||
}
|
||||
|
||||
export function listAllMatches(page = 1, pageSize = 25) {
|
||||
return authRequest<PaginatedResult<AdminMatchView>>(
|
||||
`/admin/matches?page=${page}&page_size=${pageSize}`
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- ادمین: نظارت بر عکسها ----------------
|
||||
|
||||
export interface AdminPhotoView {
|
||||
id: number;
|
||||
user_id: number;
|
||||
url: string;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
owner_name: string;
|
||||
owner_phone: string;
|
||||
}
|
||||
|
||||
export function listAllPhotos(page = 1, pageSize = 50) {
|
||||
return authRequest<PaginatedResult<AdminPhotoView>>(
|
||||
`/admin/photos?page=${page}&page_size=${pageSize}`
|
||||
);
|
||||
}
|
||||
|
||||
export function deletePhotoAdmin(id: number) {
|
||||
return authRequest<null>(`/admin/photos/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// ---------------- ادمین: بکآپ دیتابیس ----------------
|
||||
|
||||
Reference in New Issue
Block a user