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:
2026-09-20 05:30:33 +03:30
parent 3480eac2b0
commit 35830189ea
7 changed files with 783 additions and 3 deletions
+131
View File
@@ -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>
);
}
+154
View File
@@ -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>
);
}
+54 -1
View File
@@ -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)}
+169
View File
@@ -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>
);
}
+6
View File
@@ -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 },
+162
View File
@@ -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
View File
@@ -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" });
}
// ---------------- ادمین: بک‌آپ دیتابیس ----------------