- 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>
229 lines
7.2 KiB
TypeScript
229 lines
7.2 KiB
TypeScript
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>
|
||
);
|
||
}
|