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,
|
Sparkles,
|
||||||
Search,
|
Search,
|
||||||
Check,
|
Check,
|
||||||
|
Flag,
|
||||||
|
Activity,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
import { Avatar } from "@/components/ui/Avatar";
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
@@ -24,14 +26,19 @@ import { Modal } from "@/components/ui/Modal";
|
|||||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
|
import { UserModerationActions } from "@/components/UserModerationActions";
|
||||||
import { formatApiError } from "@/lib/hooks";
|
import { formatApiError } from "@/lib/hooks";
|
||||||
import { calcAge } from "@/lib/utils";
|
import { calcAge } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
FullProfile,
|
FullProfile,
|
||||||
ApprovalStatus,
|
ApprovalStatus,
|
||||||
|
Report,
|
||||||
|
RequestLogEntry,
|
||||||
getUserDetail,
|
getUserDetail,
|
||||||
approveUser,
|
approveUser,
|
||||||
rejectUser,
|
rejectUser,
|
||||||
|
listUserReports,
|
||||||
|
getRecentRequests,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
|
||||||
const statusMeta: Record<ApprovalStatus, { label: string; tone: "warning" | "success" | "error" }> = {
|
const statusMeta: Record<ApprovalStatus, { label: string; tone: "warning" | "success" | "error" }> = {
|
||||||
@@ -71,6 +78,8 @@ export default function UserReviewPage() {
|
|||||||
const id = Number(params.id);
|
const id = Number(params.id);
|
||||||
|
|
||||||
const [profile, setProfile] = useState<FullProfile | null>(null);
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [acting, setActing] = useState(false);
|
const [acting, setActing] = useState(false);
|
||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
@@ -82,6 +91,8 @@ export default function UserReviewPage() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(formatApiError(err));
|
setError(formatApiError(err));
|
||||||
}
|
}
|
||||||
|
listUserReports(id).then(setReports).catch(() => setReports([]));
|
||||||
|
getRecentRequests(15, id).then(setActivity).catch(() => setActivity([]));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -147,11 +158,13 @@ export default function UserReviewPage() {
|
|||||||
{user.gender && (
|
{user.gender && (
|
||||||
<Badge tone="neutral">{user.gender === "male" ? "مرد" : "زن"}</Badge>
|
<Badge tone="neutral">{user.gender === "male" ? "مرد" : "زن"}</Badge>
|
||||||
)}
|
)}
|
||||||
|
{user.state === "blocked" && <Badge tone="error">بلاک</Badge>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<Button
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
icon={<XCircle size={16} />}
|
icon={<XCircle size={16} />}
|
||||||
@@ -171,6 +184,10 @@ export default function UserReviewPage() {
|
|||||||
<ErrorBanner message={`دلیل رد قبلی: ${user.rejection_reason}`} />
|
<ErrorBanner message={`دلیل رد قبلی: ${user.rejection_reason}`} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{user.state === "blocked" && user.blocked_reason && (
|
||||||
|
<ErrorBanner message={`دلیل بلاک: ${user.blocked_reason}`} />
|
||||||
|
)}
|
||||||
|
|
||||||
<SectionCard icon={<Ruler size={15} />} title="قد و وزن">
|
<SectionCard icon={<Ruler size={15} />} title="قد و وزن">
|
||||||
<div className="flex gap-4 text-[14px]">
|
<div className="flex gap-4 text-[14px]">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
@@ -239,6 +256,42 @@ export default function UserReviewPage() {
|
|||||||
</SectionCard>
|
</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
|
<RejectModal
|
||||||
open={rejectOpen}
|
open={rejectOpen}
|
||||||
onClose={() => setRejectOpen(false)}
|
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,
|
LogOut,
|
||||||
Heart,
|
Heart,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
|
Users,
|
||||||
|
HeartHandshake,
|
||||||
|
Images,
|
||||||
Activity,
|
Activity,
|
||||||
Database,
|
Database,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -18,6 +21,9 @@ import { useAuth } from "@/lib/auth-context";
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/", label: "داشبورد", icon: LayoutGrid },
|
{ href: "/", label: "داشبورد", icon: LayoutGrid },
|
||||||
{ href: "/users", label: "کاربران در انتظار تایید", icon: UserCheck },
|
{ 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: "/categories", label: "دستهبندیها و گزینهها", icon: ListTree },
|
||||||
{ href: "/questions", label: "سوالات پروفایل", icon: MessageCircleQuestion },
|
{ href: "/questions", label: "سوالات پروفایل", icon: MessageCircleQuestion },
|
||||||
{ href: "/monitoring", label: "مانیتورینگ سرور", icon: Activity },
|
{ 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 =
|
export const API_BASE =
|
||||||
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080/api/v1";
|
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> {
|
interface ApiEnvelope<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -24,6 +33,8 @@ export class ApiError extends Error {
|
|||||||
|
|
||||||
export type ApprovalStatus = "pending" | "approved" | "rejected";
|
export type ApprovalStatus = "pending" | "approved" | "rejected";
|
||||||
|
|
||||||
|
export type UserState = "inactive" | "active" | "blocked";
|
||||||
|
|
||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
id: number;
|
id: number;
|
||||||
phone: string;
|
phone: string;
|
||||||
@@ -37,6 +48,9 @@ export interface UserProfile {
|
|||||||
approval_status: ApprovalStatus;
|
approval_status: ApprovalStatus;
|
||||||
rejection_reason?: string;
|
rejection_reason?: string;
|
||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
|
state: UserState;
|
||||||
|
blocked_reason?: string;
|
||||||
|
blocked_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StepView {
|
export interface StepView {
|
||||||
@@ -334,8 +348,99 @@ export function getMonitoringSummary() {
|
|||||||
return authRequest<MonitoringSummary>("/admin/monitoring/summary");
|
return authRequest<MonitoringSummary>("/admin/monitoring/summary");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRecentRequests(limit = 100) {
|
export function getRecentRequests(limit = 100, userId?: number) {
|
||||||
return authRequest<RequestLogEntry[]>(`/admin/monitoring/requests?limit=${limit}`);
|
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