fix: split collaboration request controllers
هذا الالتزام موجود في:
@@ -1,14 +1,16 @@
|
|||||||
"use client";
|
"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 { NoPermissionState } from "@/components/auth/no-permission-state";
|
||||||
import { useSuperAdminSession } from "@/components/auth/session-context";
|
import { useSuperAdminSession } from "@/components/auth/session-context";
|
||||||
import { PageHeader } from "@/components/dashboard/page-header";
|
import { PageHeader } from "@/components/dashboard/page-header";
|
||||||
|
import { MediaInspector, getMediaHealth } from "@/components/dashboard/media-inspector";
|
||||||
import { PaginationControls } from "@/components/dashboard/pagination-controls";
|
import { PaginationControls } from "@/components/dashboard/pagination-controls";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Drawer } from "@/components/ui/drawer";
|
||||||
import { EmptyState } from "@/components/ui/empty-state";
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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 [bulkAction, setBulkAction] = useState<BulkAction>("flag");
|
||||||
const [bulkReason, setBulkReason] = useState("");
|
const [bulkReason, setBulkReason] = useState("");
|
||||||
const [bulkLoading, setBulkLoading] = useState(false);
|
const [bulkLoading, setBulkLoading] = useState(false);
|
||||||
|
const [selectedPost, setSelectedPost] = useState<ApiPost | null>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const filtersRef = useRef({ query, postType, visibility, moderationStatus });
|
const filtersRef = useRef({ query, postType, visibility, moderationStatus });
|
||||||
|
|
||||||
@@ -165,6 +168,13 @@ export default function ContentPage() {
|
|||||||
const posts = getItems(postsResponse) as ApiPost[];
|
const posts = getItems(postsResponse) as ApiPost[];
|
||||||
const comments = getItems(commentsResponse) as ApiComment[];
|
const comments = getItems(commentsResponse) as ApiComment[];
|
||||||
const bulkIds = bulkTarget === "post" ? selectedPostIds : selectedCommentIds;
|
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 updatePostStatus = async (postId: string, status: ModerationStatus) => {
|
||||||
const reason = promptReason(`post ${status}`);
|
const reason = promptReason(`post ${status}`);
|
||||||
@@ -328,6 +338,33 @@ export default function ContentPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 ? (
|
{canUseBulkActions ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -420,7 +457,14 @@ export default function ContentPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
<TableCell className="max-w-[240px] truncate">{post.content || "-"}</TableCell>
|
<TableCell className="max-w-[240px] truncate">{post.content || "-"}</TableCell>
|
||||||
<TableCell>{getUserLabel(getPostAuthor(post))}</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>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
@@ -438,6 +482,9 @@ export default function ContentPage() {
|
|||||||
{post.likesCount ?? 0}/{post.commentsCount ?? 0}/{post.shareCount ?? 0}
|
{post.likesCount ?? 0}/{post.commentsCount ?? 0}/{post.shareCount ?? 0}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="flex flex-wrap gap-2">
|
<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")}>
|
<Button size="sm" variant="outline" onClick={() => void updatePostStatus(post._id, "active")}>
|
||||||
Activate
|
Activate
|
||||||
</Button>
|
</Button>
|
||||||
@@ -546,6 +593,36 @@ export default function ContentPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,36 @@ export default function MessagesPage() {
|
|||||||
subtitle="A consolidated view of message-related alerts, mentions, and recent comments."
|
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">
|
<section className="grid gap-4 xl:grid-cols-12">
|
||||||
{canReadNotifications ? (
|
{canReadNotifications ? (
|
||||||
<Card className={canModerateContent ? "xl:col-span-5" : "xl:col-span-12"}>
|
<Card className={canModerateContent ? "xl:col-span-5" : "xl:col-span-12"}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { NoPermissionState } from "@/components/auth/no-permission-state";
|
||||||
import { useSuperAdminSession } from "@/components/auth/session-context";
|
import { useSuperAdminSession } from "@/components/auth/session-context";
|
||||||
@@ -82,6 +82,16 @@ export default function NotificationsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const items = getItems(response) as NotificationItem[];
|
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) {
|
if (!canReadNotifications) {
|
||||||
return (
|
return (
|
||||||
@@ -130,6 +140,9 @@ export default function NotificationsPage() {
|
|||||||
<SelectItem value="save">save</SelectItem>
|
<SelectItem value="save">save</SelectItem>
|
||||||
<SelectItem value="share">share</SelectItem>
|
<SelectItem value="share">share</SelectItem>
|
||||||
<SelectItem value="mention">mention</SelectItem>
|
<SelectItem value="mention">mention</SelectItem>
|
||||||
|
<SelectItem value="reply">reply</SelectItem>
|
||||||
|
<SelectItem value="system">system</SelectItem>
|
||||||
|
<SelectItem value="collaboration_request">collaboration_request</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={resourceTypeFilter} onValueChange={setResourceTypeFilter}>
|
<Select value={resourceTypeFilter} onValueChange={setResourceTypeFilter}>
|
||||||
@@ -142,6 +155,7 @@ export default function NotificationsPage() {
|
|||||||
<SelectItem value="comment">comment</SelectItem>
|
<SelectItem value="comment">comment</SelectItem>
|
||||||
<SelectItem value="conversation">conversation</SelectItem>
|
<SelectItem value="conversation">conversation</SelectItem>
|
||||||
<SelectItem value="user">user</SelectItem>
|
<SelectItem value="user">user</SelectItem>
|
||||||
|
<SelectItem value="system">system</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button variant="outline" onClick={applyFilters}>
|
<Button variant="outline" onClick={applyFilters}>
|
||||||
@@ -150,6 +164,27 @@ export default function NotificationsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Notifications</CardTitle>
|
<CardTitle>Notifications</CardTitle>
|
||||||
|
|||||||
@@ -64,6 +64,18 @@ function statusVariant(status: ReportStatus): "success" | "muted" | "warning" |
|
|||||||
return "danger";
|
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() {
|
export default function ReportsPage() {
|
||||||
const { permissions } = useSuperAdminSession();
|
const { permissions } = useSuperAdminSession();
|
||||||
const [statusFilter, setStatusFilter] = useState("open");
|
const [statusFilter, setStatusFilter] = useState("open");
|
||||||
@@ -311,6 +323,11 @@ export default function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<Textarea
|
||||||
placeholder="Resolution note"
|
placeholder="Resolution note"
|
||||||
value={resolutionNote}
|
value={resolutionNote}
|
||||||
|
|||||||
@@ -153,6 +153,42 @@ export default function SecurityPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Granted permissions</CardTitle>
|
<CardTitle>Granted permissions</CardTitle>
|
||||||
|
|||||||
162
oudelaa_dashboard/components/dashboard/media-inspector.tsx
Normal file
162
oudelaa_dashboard/components/dashboard/media-inspector.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { FileAudio, FileImage, FileVideo, LinkIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { resolveMediaUrl } from "@/lib/media-url";
|
||||||
|
import type { ApiPost } from "@/types/api";
|
||||||
|
|
||||||
|
function pickImageUrl(post: ApiPost) {
|
||||||
|
const variant = Array.isArray(post.imageVariants)
|
||||||
|
? post.imageVariants[0]
|
||||||
|
: post.imageVariants;
|
||||||
|
|
||||||
|
return (
|
||||||
|
post.imageItems?.find((item) => item.variants?.medium || item.url)?.variants?.medium ??
|
||||||
|
post.imageItems?.find((item) => item.url)?.url ??
|
||||||
|
variant?.medium ??
|
||||||
|
variant?.thumbnail ??
|
||||||
|
post.imageUrls?.[0] ??
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPostMedia(post: ApiPost) {
|
||||||
|
const imageUrl = pickImageUrl(post);
|
||||||
|
const thumbnailUrl = post.thumbnailVariants?.medium ?? post.thumbnailVariants?.thumbnail ?? post.thumbnailUrl ?? "";
|
||||||
|
const playbackUrl = post.hlsUrl || post.videoUrl || "";
|
||||||
|
const audioUrl = post.audioUrl ?? "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageUrl: resolveMediaUrl(imageUrl),
|
||||||
|
thumbnailUrl: resolveMediaUrl(thumbnailUrl),
|
||||||
|
playbackUrl: resolveMediaUrl(playbackUrl),
|
||||||
|
audioUrl: resolveMediaUrl(audioUrl),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMediaHealth(post: ApiPost) {
|
||||||
|
const checks = {
|
||||||
|
hasImage: Boolean(pickImageUrl(post)),
|
||||||
|
hasVideo: Boolean(post.videoUrl || post.hlsUrl),
|
||||||
|
hasAudio: Boolean(post.audioUrl),
|
||||||
|
hasThumbnail: Boolean(post.thumbnailUrl || post.thumbnailVariants?.thumbnail || post.thumbnailVariants?.medium),
|
||||||
|
hasDuration: typeof post.durationSeconds === "number" && post.durationSeconds > 0,
|
||||||
|
hasOptimizedImage:
|
||||||
|
Boolean(post.imageItems?.some((item) => item.variants && Object.keys(item.variants).length > 0)) ||
|
||||||
|
Boolean(post.imageVariants && Object.keys(post.imageVariants).length > 0),
|
||||||
|
hasHls: Boolean(post.hlsUrl),
|
||||||
|
};
|
||||||
|
|
||||||
|
const problems: string[] = [];
|
||||||
|
if (post.postType === "image" && !checks.hasImage) problems.push("missing image");
|
||||||
|
if (post.postType === "image" && !checks.hasOptimizedImage) problems.push("no image variants");
|
||||||
|
if (post.postType === "video" && !checks.hasVideo) problems.push("missing video");
|
||||||
|
if (post.postType === "video" && !checks.hasThumbnail) problems.push("missing thumbnail");
|
||||||
|
if (post.postType === "video" && !checks.hasHls) problems.push("no HLS");
|
||||||
|
if ((post.postType === "video" || post.postType === "audio") && !checks.hasDuration) {
|
||||||
|
problems.push("missing duration");
|
||||||
|
}
|
||||||
|
if (post.postType === "audio" && !checks.hasAudio) problems.push("missing audio");
|
||||||
|
|
||||||
|
return {
|
||||||
|
checks,
|
||||||
|
problems,
|
||||||
|
score: Math.max(0, 100 - problems.length * 20),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaInspector({ post, compact = false }: { post: ApiPost; compact?: boolean }) {
|
||||||
|
const media = getPostMedia(post);
|
||||||
|
const health = getMediaHealth(post);
|
||||||
|
const badges = [
|
||||||
|
post.hlsUrl ? "HLS" : null,
|
||||||
|
post.thumbnailUrl ? "thumbnail" : null,
|
||||||
|
post.durationSeconds ? `${Math.round(post.durationSeconds)}s` : null,
|
||||||
|
post.processingStatus ? post.processingStatus : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border/70 bg-background/30">
|
||||||
|
{post.postType === "video" && media.playbackUrl ? (
|
||||||
|
<video
|
||||||
|
className="aspect-video w-full bg-black object-contain"
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
poster={media.thumbnailUrl || undefined}
|
||||||
|
>
|
||||||
|
<source src={media.playbackUrl} />
|
||||||
|
</video>
|
||||||
|
) : post.postType === "audio" && media.audioUrl ? (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
{media.thumbnailUrl ? (
|
||||||
|
<Image
|
||||||
|
src={media.thumbnailUrl}
|
||||||
|
alt={post.content || "audio thumbnail"}
|
||||||
|
width={640}
|
||||||
|
height={360}
|
||||||
|
unoptimized
|
||||||
|
className="aspect-video w-full rounded-md object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex aspect-video w-full items-center justify-center rounded-md bg-secondary/50">
|
||||||
|
<FileAudio className="h-8 w-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<audio className="w-full" controls preload="metadata" src={media.audioUrl} />
|
||||||
|
</div>
|
||||||
|
) : media.imageUrl ? (
|
||||||
|
<Image
|
||||||
|
src={media.imageUrl}
|
||||||
|
alt={post.imageItems?.[0]?.altText || post.content || "post image"}
|
||||||
|
width={720}
|
||||||
|
height={720}
|
||||||
|
unoptimized
|
||||||
|
className={compact ? "aspect-video w-full object-cover" : "max-h-[520px] w-full object-contain"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex aspect-video w-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
{post.postType === "video" ? <FileVideo className="h-5 w-5" /> : <FileImage className="h-5 w-5" />}
|
||||||
|
No preview media
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant={health.problems.length ? "warning" : "success"}>
|
||||||
|
media health {health.score}%
|
||||||
|
</Badge>
|
||||||
|
{badges.map((badge) => (
|
||||||
|
<Badge key={badge} variant="muted">
|
||||||
|
{badge}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{health.problems.length ? (
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||||
|
{health.problems.map((problem) => (
|
||||||
|
<span key={problem} className="rounded-md border border-border/70 px-2 py-1">
|
||||||
|
{problem}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!compact ? (
|
||||||
|
<div className="grid gap-2 text-xs text-muted-foreground">
|
||||||
|
{[media.imageUrl, media.playbackUrl, media.audioUrl, media.thumbnailUrl].filter(Boolean).map((url) => (
|
||||||
|
<div key={url} className="flex min-w-0 items-center gap-2 rounded-md border border-border/60 px-2 py-1">
|
||||||
|
<LinkIcon className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span dir="ltr" className="truncate font-mono">
|
||||||
|
{url}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -137,10 +137,21 @@ export type ApiPost = {
|
|||||||
visibility?: ApiPostVisibility;
|
visibility?: ApiPostVisibility;
|
||||||
postType?: ApiPostType;
|
postType?: ApiPostType;
|
||||||
imageUrls?: string[];
|
imageUrls?: string[];
|
||||||
|
imageVariants?: Record<string, string | undefined> | Array<Record<string, string | undefined>>;
|
||||||
|
imageItems?: Array<{
|
||||||
|
url?: string;
|
||||||
|
caption?: string;
|
||||||
|
altText?: string;
|
||||||
|
order?: number;
|
||||||
|
variants?: Record<string, string | undefined>;
|
||||||
|
}>;
|
||||||
videoUrl?: string;
|
videoUrl?: string;
|
||||||
|
hlsUrl?: string;
|
||||||
audioUrl?: string;
|
audioUrl?: string;
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
|
thumbnailVariants?: Record<string, string | undefined>;
|
||||||
durationSeconds?: number | null;
|
durationSeconds?: number | null;
|
||||||
|
processingStatus?: "pending" | "processing" | "ready" | "failed" | string;
|
||||||
style?: string;
|
style?: string;
|
||||||
maqam?: string;
|
maqam?: string;
|
||||||
rhythmSignature?: string;
|
rhythmSignature?: string;
|
||||||
|
|||||||
@@ -1,38 +1,29 @@
|
|||||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Param, Patch, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
|
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||||
import { CollaborationRequestsService } from './collaboration-requests.service';
|
import { CollaborationRequestsService } from './collaboration-requests.service';
|
||||||
import { CreateCollaborationRequestDto } from './dto/create-collaboration-request.dto';
|
|
||||||
|
|
||||||
@ApiTags('Collaboration Requests')
|
@ApiTags('Collaboration Requests')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('collaboration-requests')
|
||||||
export class CollaborationRequestsController {
|
export class CollaborationRequestsController {
|
||||||
constructor(private readonly collaborationRequestsService: CollaborationRequestsService) {}
|
constructor(private readonly collaborationRequestsService: CollaborationRequestsService) {}
|
||||||
|
|
||||||
@Post('posts/:postId/collaboration-requests')
|
@Get()
|
||||||
async create(
|
|
||||||
@CurrentUser() user: JwtPayload,
|
|
||||||
@Param('postId') postId: string,
|
|
||||||
@Body() dto: CreateCollaborationRequestDto,
|
|
||||||
) {
|
|
||||||
return this.collaborationRequestsService.create(user.sub, postId, dto.targetUserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('collaboration-requests')
|
|
||||||
async mine(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
|
async mine(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
|
||||||
return this.collaborationRequestsService.getMine(user.sub, query);
|
return this.collaborationRequestsService.getMine(user.sub, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('collaboration-requests/:requestId/approve')
|
@Patch(':requestId/approve')
|
||||||
async approve(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
|
async approve(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
|
||||||
return this.collaborationRequestsService.approve(user.sub, requestId);
|
return this.collaborationRequestsService.approve(user.sub, requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('collaboration-requests/:requestId/reject')
|
@Patch(':requestId/reject')
|
||||||
async reject(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
|
async reject(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
|
||||||
return this.collaborationRequestsService.reject(user.sub, requestId);
|
return this.collaborationRequestsService.reject(user.sub, requestId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { PostsModule } from '../posts/posts.module';
|
|||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { CollaborationRequestsController } from './collaboration-requests.controller';
|
import { CollaborationRequestsController } from './collaboration-requests.controller';
|
||||||
import { CollaborationRequestsService } from './collaboration-requests.service';
|
import { CollaborationRequestsService } from './collaboration-requests.service';
|
||||||
|
import { PostsCollaborationRequestsController } from './posts-collaboration-requests.controller';
|
||||||
import {
|
import {
|
||||||
CollaborationRequest,
|
CollaborationRequest,
|
||||||
CollaborationRequestSchema,
|
CollaborationRequestSchema,
|
||||||
@@ -19,7 +20,7 @@ import {
|
|||||||
UsersModule,
|
UsersModule,
|
||||||
MongooseModule.forFeature([{ name: CollaborationRequest.name, schema: CollaborationRequestSchema }]),
|
MongooseModule.forFeature([{ name: CollaborationRequest.name, schema: CollaborationRequestSchema }]),
|
||||||
],
|
],
|
||||||
controllers: [CollaborationRequestsController],
|
controllers: [CollaborationRequestsController, PostsCollaborationRequestsController],
|
||||||
providers: [CollaborationRequestsService],
|
providers: [CollaborationRequestsService],
|
||||||
})
|
})
|
||||||
export class CollaborationRequestsModule {}
|
export class CollaborationRequestsModule {}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||||
|
import { CollaborationRequestsService } from './collaboration-requests.service';
|
||||||
|
import { CreateCollaborationRequestDto } from './dto/create-collaboration-request.dto';
|
||||||
|
|
||||||
|
@ApiTags('Post Collaboration Requests')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('posts')
|
||||||
|
export class PostsCollaborationRequestsController {
|
||||||
|
constructor(private readonly collaborationRequestsService: CollaborationRequestsService) {}
|
||||||
|
|
||||||
|
@Post(':postId/collaboration-requests')
|
||||||
|
async create(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('postId') postId: string,
|
||||||
|
@Body() dto: CreateCollaborationRequestDto,
|
||||||
|
) {
|
||||||
|
return this.collaborationRequestsService.create(user.sub, postId, dto.targetUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
المرجع في مشكلة جديدة
حظر مستخدم