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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user