173 أسطر
7.2 KiB
TypeScript
173 أسطر
7.2 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import { useEffect, useState } from "react";
|
|
import { AudioLines, Images, PlayCircle } from "lucide-react";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { formatDateTime } from "@/lib/format";
|
|
import { getPostAuthor, getPostPreviewMedia, getUserLabel } from "@/lib/post-utils";
|
|
import type { ApiPost } from "@/types/api";
|
|
|
|
function formatDuration(durationSeconds?: number | null) {
|
|
if (typeof durationSeconds !== "number" || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const minutes = Math.floor(durationSeconds / 60);
|
|
const seconds = Math.floor(durationSeconds % 60);
|
|
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
export function PostPreviewCard({ post }: { post: ApiPost }) {
|
|
const author = getPostAuthor(post);
|
|
const media = getPostPreviewMedia(post);
|
|
const likes = post.engagement?.likesCount ?? post.likesCount ?? 0;
|
|
const comments = post.engagement?.commentsCount ?? post.commentsCount ?? 0;
|
|
const shares = post.engagement?.shareCount ?? post.shareCount ?? 0;
|
|
const waveformPeaks = Array.isArray(post.waveformPeaks) && post.waveformPeaks.length ? post.waveformPeaks : [18, 32, 24, 44, 28, 40, 22, 36, 26, 30];
|
|
const durationLabel = formatDuration(post.durationSeconds);
|
|
const [imageFailed, setImageFailed] = useState(false);
|
|
const [sourceFailed, setSourceFailed] = useState(false);
|
|
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setImageFailed(false);
|
|
setSourceFailed(false);
|
|
setIsVideoPlaying(false);
|
|
}, [media.url, media.sourceUrl]);
|
|
|
|
const showAudioPreview = media.kind === "audio" && !!media.sourceUrl && !sourceFailed;
|
|
const showVideoPreview =
|
|
media.kind === "video" && !!media.sourceUrl && !sourceFailed && (isVideoPlaying || !media.url || imageFailed);
|
|
const showImagePreview = !!media.url && !imageFailed && !(media.kind === "video" && isVideoPlaying);
|
|
const showMediaShell = media.kind !== "text";
|
|
const showUnavailable = !showImagePreview && !showVideoPreview && !showAudioPreview;
|
|
|
|
return (
|
|
<Card className="overflow-hidden border-border/70">
|
|
<CardContent className="p-0">
|
|
{showMediaShell ? (
|
|
<div className="relative aspect-[16/9] border-b border-border/70 bg-secondary/30">
|
|
{showImagePreview ? (
|
|
<Image
|
|
src={media.url}
|
|
alt={post.content || post.postType || "post"}
|
|
fill
|
|
unoptimized
|
|
loading="lazy"
|
|
onError={() => setImageFailed(true)}
|
|
className="object-cover"
|
|
/>
|
|
) : null}
|
|
{showVideoPreview ? (
|
|
<video
|
|
src={media.sourceUrl}
|
|
preload="metadata"
|
|
controls={isVideoPlaying}
|
|
autoPlay={isVideoPlaying}
|
|
muted={!isVideoPlaying}
|
|
playsInline
|
|
onError={() => setSourceFailed(true)}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
) : null}
|
|
{showAudioPreview ? (
|
|
<div className="absolute inset-0 flex flex-col justify-between bg-gradient-to-br from-secondary/70 via-background/60 to-secondary/80 p-5">
|
|
<div className="flex items-center justify-between gap-3 text-sm text-foreground">
|
|
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-background/70 px-3 py-1">
|
|
<AudioLines className="h-4 w-4" />
|
|
<span>Audio preview</span>
|
|
</div>
|
|
{durationLabel ? <span className="text-xs text-muted-foreground">{durationLabel}</span> : null}
|
|
</div>
|
|
|
|
<div className="flex h-24 items-end justify-center gap-1.5 px-2">
|
|
{waveformPeaks.slice(0, 48).map((peak, index) => {
|
|
const safePeak = Number.isFinite(peak) ? Math.max(8, Math.min(100, peak)) : 20;
|
|
return (
|
|
<span
|
|
key={`${post._id}-peak-${index}`}
|
|
className="w-1.5 rounded-full bg-primary/80"
|
|
style={{ height: `${safePeak}%` }}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<audio
|
|
src={media.sourceUrl}
|
|
preload="metadata"
|
|
controls
|
|
onError={() => setSourceFailed(true)}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
) : null}
|
|
{showUnavailable ? (
|
|
<div className="absolute inset-0 flex items-center justify-center px-4 text-center text-sm text-muted-foreground">
|
|
Media preview unavailable
|
|
</div>
|
|
) : null}
|
|
<div className="absolute left-3 top-3 flex items-center gap-2">
|
|
<Badge variant="warning">{post.postType ?? "post"}</Badge>
|
|
{media.kind === "image" && media.count > 1 ? (
|
|
<Badge variant="muted">
|
|
<Images className="mr-1 h-3 w-3" />
|
|
{media.count}
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
{media.kind === "video" && media.sourceUrl && !isVideoPlaying && !sourceFailed ? (
|
|
<button
|
|
type="button"
|
|
aria-label="Play video"
|
|
onClick={() => setIsVideoPlaying(true)}
|
|
className="absolute inset-0 z-10 cursor-pointer bg-transparent"
|
|
>
|
|
<span className="sr-only">Play video</span>
|
|
</button>
|
|
) : null}
|
|
<div className="pointer-events-none absolute bottom-3 right-3 z-20 rounded-full border border-border/60 bg-background/85 p-2 text-foreground">
|
|
{media.kind === "audio" ? <AudioLines className="h-4 w-4" /> : <PlayCircle className="h-4 w-4" />}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="space-y-3 p-4">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Badge variant="muted">{post.visibility ?? "public"}</Badge>
|
|
<Badge
|
|
variant={
|
|
post.moderationStatus === "hidden"
|
|
? "danger"
|
|
: post.moderationStatus === "flagged"
|
|
? "warning"
|
|
: "success"
|
|
}
|
|
>
|
|
{post.moderationStatus ?? "active"}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="text-sm font-semibold text-foreground">{getUserLabel(author)}</div>
|
|
<div className="mt-1 text-xs text-muted-foreground">{formatDateTime(post.createdAt)}</div>
|
|
</div>
|
|
|
|
<p className="line-clamp-4 text-sm leading-6 text-foreground">
|
|
{post.content?.trim() || "Media post without caption."}
|
|
</p>
|
|
|
|
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
|
<span>{likes} likes</span>
|
|
<span>{comments} comments</span>
|
|
<span>{shares} shares</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|