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";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
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 { Card } from "@/components/ui/Card";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
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 { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||||
|
import { Modal } from "@/components/ui/Modal";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import { formatApiError } from "@/lib/hooks";
|
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;
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
@@ -22,6 +23,9 @@ export default function PhotosModerationPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [pendingDelete, setPendingDelete] = useState<AdminPhotoView | null>(null);
|
const [pendingDelete, setPendingDelete] = useState<AdminPhotoView | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
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) => {
|
const load = useCallback(async (p: number) => {
|
||||||
setError(null);
|
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));
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -102,13 +121,22 @@ export default function PhotosModerationPage() {
|
|||||||
{p.owner_phone}
|
{p.owner_phone}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="absolute top-1.5 left-1.5 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
onClick={() => setPendingDelete(p)}
|
<button
|
||||||
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"
|
onClick={() => setPendingReport(p)}
|
||||||
aria-label="حذف عکس"
|
className="w-7 h-7 rounded-full bg-black/50 hover:bg-amber-600 flex items-center justify-center text-white"
|
||||||
>
|
aria-label="گزارش عکس"
|
||||||
<Trash2 size={13} />
|
>
|
||||||
</button>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -149,6 +177,38 @@ export default function PhotosModerationPage() {
|
|||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
onCancel={() => setPendingDelete(null)}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Flag,
|
Flag,
|
||||||
Activity,
|
Activity,
|
||||||
|
Images,
|
||||||
|
User as UserIcon,
|
||||||
} 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";
|
||||||
@@ -39,6 +41,7 @@ import {
|
|||||||
rejectUser,
|
rejectUser,
|
||||||
listUserReports,
|
listUserReports,
|
||||||
getRecentRequests,
|
getRecentRequests,
|
||||||
|
photoUrl,
|
||||||
} 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" }> = {
|
||||||
@@ -188,6 +191,30 @@ export default function UserReviewPage() {
|
|||||||
<ErrorBanner message={`دلیل بلاک: ${user.blocked_reason}`} />
|
<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="قد و وزن">
|
<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">
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
|||||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
import { formatApiError } from "@/lib/hooks";
|
import { formatApiError } from "@/lib/hooks";
|
||||||
import { calcAge } from "@/lib/utils";
|
import { calcAge } from "@/lib/utils";
|
||||||
import { UserProfile, listPendingUsers } from "@/lib/api";
|
import { PendingUserView, listPendingUsers, photoUrl } from "@/lib/api";
|
||||||
|
|
||||||
export default function PendingUsersPage() {
|
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 [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -70,7 +70,16 @@ export default function PendingUsersPage() {
|
|||||||
i !== users.length - 1 ? "border-b border-(--color-border-subtle)" : ""
|
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">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-bold text-[14px] text-(--color-content-primary) truncate">
|
<p className="font-bold text-[14px] text-(--color-content-primary) truncate">
|
||||||
{u.first_name || "بدون نام"}
|
{u.first_name || "بدون نام"}
|
||||||
@@ -80,6 +89,11 @@ export default function PendingUsersPage() {
|
|||||||
{u.phone}
|
{u.phone}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{u.photos.length === 0 && (
|
||||||
|
<Badge tone="warning" className="shrink-0">
|
||||||
|
بدون عکس
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{u.gender && (
|
{u.gender && (
|
||||||
<Badge tone="neutral" className="shrink-0">
|
<Badge tone="neutral" className="shrink-0">
|
||||||
{u.gender === "male" ? "مرد" : "زن"}
|
{u.gender === "male" ? "مرد" : "زن"}
|
||||||
|
|||||||
+20
-1
@@ -82,11 +82,19 @@ export interface ProfileAnswer {
|
|||||||
question?: ProfileQuestion;
|
question?: ProfileQuestion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserPhoto {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
url: string;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FullProfile {
|
export interface FullProfile {
|
||||||
user: UserProfile;
|
user: UserProfile;
|
||||||
onboarding_status: OnboardingStatus;
|
onboarding_status: OnboardingStatus;
|
||||||
selections: SelectionSummary[];
|
selections: SelectionSummary[];
|
||||||
profile_answers: ProfileAnswer[];
|
profile_answers: ProfileAnswer[];
|
||||||
|
photos: UserPhoto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenPair {
|
export interface TokenPair {
|
||||||
@@ -302,8 +310,12 @@ export function deleteQuestion(id: number) {
|
|||||||
|
|
||||||
// ---------------- ادمین: بررسی و تایید کاربران ----------------
|
// ---------------- ادمین: بررسی و تایید کاربران ----------------
|
||||||
|
|
||||||
|
export interface PendingUserView extends UserProfile {
|
||||||
|
photos: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export function listPendingUsers() {
|
export function listPendingUsers() {
|
||||||
return authRequest<UserProfile[]>("/admin/users/pending");
|
return authRequest<PendingUserView[]>("/admin/users/pending");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUserDetail(id: number) {
|
export function getUserDetail(id: number) {
|
||||||
@@ -443,6 +455,13 @@ export function deletePhotoAdmin(id: number) {
|
|||||||
return authRequest<null>(`/admin/photos/${id}`, { method: "DELETE" });
|
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 {
|
export interface BackupInfo {
|
||||||
|
|||||||
Reference in New Issue
Block a user