Vitron-Front/app/routes/admin.moderation.tsx
Arda Samadi 97948767a0 admin: Slice 3d moderation UI (comments + chat)
- hooks: useAdminComments/ChatMessages, useAdminCommentModerate/Delete,
  AdminCommentRow/AdminChatMessageRow types.
- /admin/moderation: tabbed page — comments (hide/unhide/delete, filter by
  visibility & top/reply) and read-only chat browse.
- sidebar "مدیریت محتوا" nav link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 15:05:07 +03:30

229 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from "react";
import { Eye, EyeOff, Trash2 } from "lucide-react";
import {
useAdminComments,
useAdminChatMessages,
useAdminCommentModerate,
useAdminCommentDelete,
type AdminCommentRow,
type AdminChatMessageRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
import { cn } from "~/lib/utils";
const PAGE_SIZE = 25;
export default function AdminModeration() {
const [tab, setTab] = useState<"comments" | "chat">("comments");
return (
<div>
<AdminPageTitle>مدیریت محتوا</AdminPageTitle>
<div className="flex gap-1 mb-5 border-b border-WHITE3">
{(["comments", "chat"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
"px-4 py-2 text-[13.5px] font-semibold border-b-2 -mb-px transition-colors",
tab === t ? "border-BLACK text-BLACK" : "border-transparent text-GRAY hover:text-BLACK2"
)}
>
{t === "comments" ? "نظرات" : "گفتگوها"}
</button>
))}
</div>
{tab === "comments" ? <CommentsTab /> : <ChatTab />}
</div>
);
}
function CommentsTab() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [hidden, setHidden] = useState("");
const [kind, setKind] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAdminComments({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
is_hidden: hidden,
kind,
});
const moderate = useAdminCommentModerate();
const del = useAdminCommentDelete();
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const onDelete = (r: AdminCommentRow) => {
if (confirm("حذف این نظر؟ این عمل بازگشت‌پذیر نیست.")) del.mutate(r.id);
};
const columns: Column<AdminCommentRow>[] = [
{
header: "نظر",
render: (r) => (
<div className="max-w-[340px]">
<p className={cn("text-[13px] break-words", r.isHidden && "text-GRAY line-through")}>
{r.comment}
</p>
<p className="text-GRAY text-[11px] mt-0.5 truncate">
{r.productTitle || "—"}
{r.isReply && <span className="text-VITROWN_BLUE"> · پاسخ</span>}
{!r.isReply && r.repliesCount > 0 && <span> · {r.repliesCount} پاسخ</span>}
</p>
</div>
),
},
{
header: "کاربر",
render: (r) => (
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{r.userPhone || "—"}
</span>
),
},
{
header: "وضعیت",
render: (r) =>
r.isHidden ? (
<StatusBadge label="پنهان" tone="red" />
) : (
<StatusBadge label="نمایش" tone="green" />
),
},
{ header: "تاریخ", render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span> },
{
header: "",
render: (r) => (
<div className="flex items-center gap-1 justify-end">
<button
onClick={() => moderate.mutate({ id: r.id, isHidden: !r.isHidden })}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2"
aria-label={r.isHidden ? "نمایش" : "پنهان کردن"}
title={r.isHidden ? "نمایش" : "پنهان کردن"}
>
{r.isHidden ? <Eye size={14} /> : <EyeOff size={14} />}
</button>
<button
onClick={() => onDelete(r)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-RED hover:bg-RED/5"
aria-label="حذف"
>
<Trash2 size={14} />
</button>
</div>
),
},
];
return (
<div>
<AdminToolbar>
<AdminSearch value={search} onChange={(v) => reset(() => setSearch(v))} placeholder="جستجوی متن نظر / کاربر…" />
<AdminSelect
value={hidden}
onChange={(v) => reset(() => setHidden(v))}
options={[
{ value: "", label: "همه" },
{ value: "false", label: "نمایش‌داده‌شده" },
{ value: "true", label: "پنهان‌شده" },
]}
/>
<AdminSelect
value={kind}
onChange={(v) => reset(() => setKind(v))}
options={[
{ value: "", label: "همه" },
{ value: "top", label: "نظر اصلی" },
{ value: "reply", label: "پاسخ" },
]}
/>
</AdminToolbar>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => r.id} emptyText="نظری ثبت نشده است" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}
function ChatTab() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [deleted, setDeleted] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAdminChatMessages({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
is_deleted: deleted,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const columns: Column<AdminChatMessageRow>[] = [
{
header: "پیام",
render: (r) => (
<div className="max-w-[360px]">
<p className={cn("text-[13px] break-words", r.isDeleted && "text-GRAY line-through")}>
{r.content || (r.imageUrl ? "🖼 تصویر" : "—")}
</p>
</div>
),
},
{
header: "فرستنده",
render: (r) => (
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{r.senderPhone || "—"}
</span>
),
},
{
header: "وضعیت",
render: (r) =>
r.isDeleted ? <StatusBadge label="حذف‌شده" tone="gray" /> : <StatusBadge label="فعال" tone="green" />,
},
{ header: "تاریخ", render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span> },
];
return (
<div>
<AdminToolbar>
<AdminSearch value={search} onChange={(v) => reset(() => setSearch(v))} placeholder="جستجوی متن پیام / فرستنده…" />
<AdminSelect
value={deleted}
onChange={(v) => reset(() => setDeleted(v))}
options={[
{ value: "", label: "همه" },
{ value: "false", label: "فعال" },
{ value: "true", label: "حذف‌شده" },
]}
/>
</AdminToolbar>
<p className="text-GRAY text-[12px] mb-2">مشاهدهی فقطخواندنی گفتگوها.</p>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => r.id} emptyText="پیامی یافت نشد" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}