fix: split collaboration request controllers
هذا الالتزام موجود في:
@@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { NoPermissionState } from "@/components/auth/no-permission-state";
|
||||
import { useSuperAdminSession } from "@/components/auth/session-context";
|
||||
import { PageHeader } from "@/components/dashboard/page-header";
|
||||
import { MediaInspector, getMediaHealth } from "@/components/dashboard/media-inspector";
|
||||
import { PaginationControls } from "@/components/dashboard/pagination-controls";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Drawer } from "@/components/ui/drawer";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -89,6 +91,7 @@ export default function ContentPage() {
|
||||
const [bulkAction, setBulkAction] = useState<BulkAction>("flag");
|
||||
const [bulkReason, setBulkReason] = useState("");
|
||||
const [bulkLoading, setBulkLoading] = useState(false);
|
||||
const [selectedPost, setSelectedPost] = useState<ApiPost | null>(null);
|
||||
const { toast } = useToast();
|
||||
const filtersRef = useRef({ query, postType, visibility, moderationStatus });
|
||||
|
||||
@@ -165,6 +168,13 @@ export default function ContentPage() {
|
||||
const posts = getItems(postsResponse) as ApiPost[];
|
||||
const comments = getItems(commentsResponse) as ApiComment[];
|
||||
const bulkIds = bulkTarget === "post" ? selectedPostIds : selectedCommentIds;
|
||||
const mediaSummary = useMemo(() => {
|
||||
const mediaPosts = posts.filter((post) => ["image", "video", "audio"].includes(post.postType ?? ""));
|
||||
const unhealthy = mediaPosts.filter((post) => getMediaHealth(post).problems.length > 0);
|
||||
const hlsReady = posts.filter((post) => post.postType === "video" && post.hlsUrl).length;
|
||||
const thumbnailsReady = posts.filter((post) => post.thumbnailUrl || post.thumbnailVariants?.thumbnail).length;
|
||||
return { mediaPosts: mediaPosts.length, unhealthy: unhealthy.length, hlsReady, thumbnailsReady };
|
||||
}, [posts]);
|
||||
|
||||
const updatePostStatus = async (postId: string, status: ModerationStatus) => {
|
||||
const reason = promptReason(`post ${status}`);
|
||||
@@ -328,6 +338,33 @@ export default function ContentPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Media posts</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold text-foreground">{mediaSummary.mediaPosts}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Needs media review</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold text-foreground">{mediaSummary.unhealthy}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>HLS ready videos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold text-foreground">{mediaSummary.hlsReady}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Thumbnails ready</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold text-foreground">{mediaSummary.thumbnailsReady}</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{canUseBulkActions ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -420,7 +457,14 @@ export default function ContentPage() {
|
||||
) : null}
|
||||
<TableCell className="max-w-[240px] truncate">{post.content || "-"}</TableCell>
|
||||
<TableCell>{getUserLabel(getPostAuthor(post))}</TableCell>
|
||||
<TableCell>{post.postType ?? "-"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div>{post.postType ?? "-"}</div>
|
||||
<Badge variant={getMediaHealth(post).problems.length ? "warning" : "muted"}>
|
||||
{getMediaHealth(post).problems.length ? "media check" : "ok"}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
@@ -438,6 +482,9 @@ export default function ContentPage() {
|
||||
{post.likesCount ?? 0}/{post.commentsCount ?? 0}/{post.shareCount ?? 0}
|
||||
</TableCell>
|
||||
<TableCell className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setSelectedPost(post)}>
|
||||
Details
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => void updatePostStatus(post._id, "active")}>
|
||||
Activate
|
||||
</Button>
|
||||
@@ -546,6 +593,36 @@ export default function ContentPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Drawer
|
||||
open={Boolean(selectedPost)}
|
||||
onClose={() => setSelectedPost(null)}
|
||||
title="Post media details"
|
||||
description="Preview the actual media, inspect generated URLs, and review moderation state."
|
||||
side="right"
|
||||
widthClassName="w-full sm:w-[92vw] sm:max-w-3xl"
|
||||
>
|
||||
{selectedPost ? (
|
||||
<div className="space-y-4">
|
||||
<MediaInspector post={selectedPost} />
|
||||
<Card className="border-border/70 bg-secondary/20">
|
||||
<CardHeader>
|
||||
<CardTitle>Post metadata</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 text-sm md:grid-cols-2">
|
||||
<div>Author: {getUserLabel(getPostAuthor(selectedPost))}</div>
|
||||
<div>Type: {selectedPost.postType ?? "-"}</div>
|
||||
<div>Status: {selectedPost.moderationStatus ?? "active"}</div>
|
||||
<div>Processing: {selectedPost.processingStatus ?? "-"}</div>
|
||||
<div>Visibility: {selectedPost.visibility ?? "-"}</div>
|
||||
<div>Created: {formatDateTime(selectedPost.createdAt)}</div>
|
||||
<div>Likes: {selectedPost.likesCount ?? 0}</div>
|
||||
<div>Comments: {selectedPost.commentsCount ?? 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,6 +97,36 @@ export default function MessagesPage() {
|
||||
subtitle="A consolidated view of message-related alerts, mentions, and recent comments."
|
||||
/>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Socket namespace</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<Badge variant="success">Socket.IO</Badge>
|
||||
<div dir="ltr" className="font-mono">/chat</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Message event</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<Badge variant="muted">new_message</Badge>
|
||||
<div>REST and socket sends broadcast to conversation rooms.</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Room pattern</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<Badge variant="muted">conversation:ID</Badge>
|
||||
<div>Flutter should join when opening a chat and leave when closing it.</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-12">
|
||||
{canReadNotifications ? (
|
||||
<Card className={canModerateContent ? "xl:col-span-5" : "xl:col-span-12"}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { NoPermissionState } from "@/components/auth/no-permission-state";
|
||||
import { useSuperAdminSession } from "@/components/auth/session-context";
|
||||
@@ -82,6 +82,16 @@ export default function NotificationsPage() {
|
||||
};
|
||||
|
||||
const items = getItems(response) as NotificationItem[];
|
||||
const health = useMemo(() => {
|
||||
const missingDeepLink = items.filter((item) => item.resourceType !== "system" && !item.deepLink).length;
|
||||
const messageLinks = items.filter(
|
||||
(item) => item.type === "message" && item.deepLink?.startsWith("/chat/conversations/"),
|
||||
).length;
|
||||
const postLinks = items.filter(
|
||||
(item) => ["like", "comment", "reply", "mention"].includes(item.type) && item.deepLink?.startsWith("/posts/"),
|
||||
).length;
|
||||
return { missingDeepLink, messageLinks, postLinks };
|
||||
}, [items]);
|
||||
|
||||
if (!canReadNotifications) {
|
||||
return (
|
||||
@@ -130,6 +140,9 @@ export default function NotificationsPage() {
|
||||
<SelectItem value="save">save</SelectItem>
|
||||
<SelectItem value="share">share</SelectItem>
|
||||
<SelectItem value="mention">mention</SelectItem>
|
||||
<SelectItem value="reply">reply</SelectItem>
|
||||
<SelectItem value="system">system</SelectItem>
|
||||
<SelectItem value="collaboration_request">collaboration_request</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={resourceTypeFilter} onValueChange={setResourceTypeFilter}>
|
||||
@@ -142,6 +155,7 @@ export default function NotificationsPage() {
|
||||
<SelectItem value="comment">comment</SelectItem>
|
||||
<SelectItem value="conversation">conversation</SelectItem>
|
||||
<SelectItem value="user">user</SelectItem>
|
||||
<SelectItem value="system">system</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" onClick={applyFilters}>
|
||||
@@ -150,6 +164,27 @@ export default function NotificationsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Missing deep links</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold text-foreground">{health.missingDeepLink}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Chat links</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold text-foreground">{health.messageLinks}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Post links</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold text-foreground">{health.postLinks}</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Notifications</CardTitle>
|
||||
|
||||
@@ -64,6 +64,18 @@ function statusVariant(status: ReportStatus): "success" | "muted" | "warning" |
|
||||
return "danger";
|
||||
}
|
||||
|
||||
function recommendedAction(report: PlatformReport | null) {
|
||||
if (!report) return "Select a report to see the recommended moderation action.";
|
||||
if (report.status === "resolved" || report.status === "rejected") return "No action required after final status.";
|
||||
if (["harassment", "hate_speech", "violence", "self_harm"].includes(report.reason)) {
|
||||
return "Prioritize review and consider restricting the target if evidence is clear.";
|
||||
}
|
||||
if (report.reason === "spam" || report.reason === "scam") {
|
||||
return "Check reporter history, target repetition, and related accounts before resolving.";
|
||||
}
|
||||
return "Review the target content and add a resolution note before changing status.";
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
const { permissions } = useSuperAdminSession();
|
||||
const [statusFilter, setStatusFilter] = useState("open");
|
||||
@@ -311,6 +323,11 @@ export default function ReportsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/70 bg-background/30 p-4">
|
||||
<div className="text-sm font-medium text-foreground">Recommended action</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{recommendedAction(selectedReport)}</p>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
placeholder="Resolution note"
|
||||
value={resolutionNote}
|
||||
|
||||
@@ -153,6 +153,42 @@ export default function SecurityPage() {
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{canReadOps ? (
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Storage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<div>Provider: {ops?.services.storage.provider ?? "local"}</div>
|
||||
<div dir="ltr" className="truncate font-mono">
|
||||
{ops?.services.storage.basePath ?? "-"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>WebSocket</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<Badge variant={ops?.services.websocket.redisAdapterEnabled ? "success" : "warning"}>
|
||||
{ops?.services.websocket.redisAdapterEnabled ? "Redis adapter" : "single instance"}
|
||||
</Badge>
|
||||
<div>Namespace used by mobile chat: /chat</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workload</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<div>Open cases: {ops?.workload.openCasesCount ?? 0}</div>
|
||||
<div>Active sessions: {ops?.workload.activeSuperAdminSessionsCount ?? sessions.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Granted permissions</CardTitle>
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم