Show user photos in admin panel, add per-photo reporting

- Add the missing `photos` field to FullProfile so the user-detail page
  can render a photo gallery (the backend already returned this data,
  the type just never declared it)
- Show applicants' first photo (or a "no photo" badge) in the pending-
  users list instead of a generic avatar
- Add a report action alongside delete on the photo moderation page, so
  a photo can be flagged without being removed

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 18:10:31 +03:30
parent 35830189ea
commit 4375bb12a4
4 changed files with 133 additions and 13 deletions
+69 -9
View File
@@ -1,16 +1,17 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { ChevronLeft, ChevronRight, ImageOff, Trash2 } from "lucide-react";
import { ChevronLeft, ChevronRight, ImageOff, Trash2, Flag } 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 { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { formatApiError } from "@/lib/hooks";
import { AdminPhotoView, listAllPhotos, deletePhotoAdmin, photoUrl } from "@/lib/api";
import { AdminPhotoView, listAllPhotos, deletePhotoAdmin, reportPhoto, photoUrl } from "@/lib/api";
const PAGE_SIZE = 24;
@@ -22,6 +23,9 @@ export default function PhotosModerationPage() {
const [error, setError] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<AdminPhotoView | null>(null);
const [deleting, setDeleting] = useState(false);
const [pendingReport, setPendingReport] = useState<AdminPhotoView | null>(null);
const [reportReason, setReportReason] = useState("");
const [reporting, setReporting] = useState(false);
const load = useCallback(async (p: number) => {
setError(null);
@@ -54,6 +58,21 @@ export default function PhotosModerationPage() {
}
}
async function handleReport() {
if (!pendingReport || reportReason.trim().length < 3) return;
setReporting(true);
try {
await reportPhoto(pendingReport.id, reportReason.trim());
toast.showSuccess("گزارش ثبت شد");
setPendingReport(null);
setReportReason("");
} catch (err) {
toast.showError(formatApiError(err));
} finally {
setReporting(false);
}
}
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
@@ -102,13 +121,22 @@ export default function PhotosModerationPage() {
{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 className="absolute top-1.5 left-1.5 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => setPendingReport(p)}
className="w-7 h-7 rounded-full bg-black/50 hover:bg-amber-600 flex items-center justify-center text-white"
aria-label="گزارش عکس"
>
<Flag size={12} />
</button>
<button
onClick={() => setPendingDelete(p)}
className="w-7 h-7 rounded-full bg-black/50 hover:bg-(--color-status-error) flex items-center justify-center text-white"
aria-label="حذف عکس"
>
<Trash2 size={13} />
</button>
</div>
</div>
))}
</div>
@@ -149,6 +177,38 @@ export default function PhotosModerationPage() {
onConfirm={handleDelete}
onCancel={() => setPendingDelete(null)}
/>
<Modal
open={!!pendingReport}
onClose={() => setPendingReport(null)}
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={() => setPendingReport(null)}>
انصراف
</Button>
<Button
size="sm"
isLoading={reporting}
disabled={reportReason.trim().length < 3}
onClick={handleReport}
>
ثبت گزارش
</Button>
</div>
</div>
</Modal>
</div>
);
}
+27
View File
@@ -16,6 +16,8 @@ import {
Check,
Flag,
Activity,
Images,
User as UserIcon,
} from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Avatar } from "@/components/ui/Avatar";
@@ -39,6 +41,7 @@ import {
rejectUser,
listUserReports,
getRecentRequests,
photoUrl,
} from "@/lib/api";
const statusMeta: Record<ApprovalStatus, { label: string; tone: "warning" | "success" | "error" }> = {
@@ -188,6 +191,30 @@ export default function UserReviewPage() {
<ErrorBanner message={`دلیل بلاک: ${user.blocked_reason}`} />
)}
<SectionCard icon={<Images size={15} />} title={`عکس‌ها (${profile.photos.length})`}>
{profile.photos.length === 0 ? (
<div className="flex items-center gap-2 text-(--color-content-tertiary) text-[13px]">
<UserIcon size={15} />
<span>این کاربر هنوز عکسی آپلود نکرده.</span>
</div>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2.5">
{profile.photos.map((p) => (
<a
key={p.id}
href={photoUrl(p.url)}
target="_blank"
rel="noopener noreferrer"
className="relative aspect-square rounded-(--radius-md) overflow-hidden bg-(--color-bg-tertiary) block"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photoUrl(p.url)} alt="" className="w-full h-full object-cover" />
</a>
))}
</div>
)}
</SectionCard>
<SectionCard icon={<Ruler size={15} />} title="قد و وزن">
<div className="flex gap-4 text-[14px]">
<span className="flex items-center gap-1.5">
+17 -3
View File
@@ -11,10 +11,10 @@ import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { formatApiError } from "@/lib/hooks";
import { calcAge } from "@/lib/utils";
import { UserProfile, listPendingUsers } from "@/lib/api";
import { PendingUserView, listPendingUsers, photoUrl } from "@/lib/api";
export default function PendingUsersPage() {
const [users, setUsers] = useState<UserProfile[] | null>(null);
const [users, setUsers] = useState<PendingUserView[] | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
@@ -70,7 +70,16 @@ export default function PendingUsersPage() {
i !== users.length - 1 ? "border-b border-(--color-border-subtle)" : ""
}`}
>
<Avatar name={u.first_name || u.phone} />
{u.photos.length > 0 ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={photoUrl(u.photos[0])}
alt=""
className="w-11 h-11 rounded-(--radius-md) object-cover shrink-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 || "بدون نام"}
@@ -80,6 +89,11 @@ export default function PendingUsersPage() {
{u.phone}
</p>
</div>
{u.photos.length === 0 && (
<Badge tone="warning" className="shrink-0">
بدون عکس
</Badge>
)}
{u.gender && (
<Badge tone="neutral" className="shrink-0">
{u.gender === "male" ? "مرد" : "زن"}
+20 -1
View File
@@ -82,11 +82,19 @@ export interface ProfileAnswer {
question?: ProfileQuestion;
}
export interface UserPhoto {
id: number;
user_id: number;
url: string;
sort_order: number;
}
export interface FullProfile {
user: UserProfile;
onboarding_status: OnboardingStatus;
selections: SelectionSummary[];
profile_answers: ProfileAnswer[];
photos: UserPhoto[];
}
export interface TokenPair {
@@ -302,8 +310,12 @@ export function deleteQuestion(id: number) {
// ---------------- ادمین: بررسی و تایید کاربران ----------------
export interface PendingUserView extends UserProfile {
photos: string[];
}
export function listPendingUsers() {
return authRequest<UserProfile[]>("/admin/users/pending");
return authRequest<PendingUserView[]>("/admin/users/pending");
}
export function getUserDetail(id: number) {
@@ -443,6 +455,13 @@ export function deletePhotoAdmin(id: number) {
return authRequest<null>(`/admin/photos/${id}`, { method: "DELETE" });
}
export function reportPhoto(id: number, reason: string) {
return authRequest<Report>(`/admin/photos/${id}/report`, {
method: "POST",
body: JSON.stringify({ reason }),
});
}
// ---------------- ادمین: بک‌آپ دیتابیس ----------------
export interface BackupInfo {