Adds Pet.nickname (nullable, max 30 chars) and a shared formatPetName()
helper so the display format ("Rex (Rexi)") stays consistent across
feed, admin panel, messages, sidebar/nav, and mentions instead of
duplicating the formatting logic at each read site.
570 lines
23 KiB
TypeScript
570 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useCallback, useEffect } from "react";
|
|
import { MoreHorizontal, Trash2, Loader2 } from "lucide-react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
import { useTRPC } from "@/trpc/client";
|
|
import { formatPetName } from "@/lib/pet-display-name";
|
|
import { useActivePet } from "@/context/ActivePetContext";
|
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import { PetAvatar } from "@/components/ui/pet-avatar";
|
|
import { Button } from "@/components/ui/button";
|
|
import { MentionTextarea } from "@/components/ui/mention-textarea";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import {
|
|
Carousel,
|
|
CarouselContent,
|
|
CarouselItem,
|
|
CarouselNext,
|
|
CarouselPrevious,
|
|
type CarouselApi,
|
|
} from "@/components/ui/carousel";
|
|
import Link from "next/link";
|
|
import { getMediaUrl } from "@/lib/media-url";
|
|
import { BlurImage } from "@/components/ui/blur-image";
|
|
import { parseCaption } from "@/lib/parse-caption";
|
|
import { formatRelativeCompact } from "@/lib/format-relative-time";
|
|
import { PawButton } from "@/components/feed/PawButton";
|
|
import { PawBackButton } from "@/components/feed/PawBackButton";
|
|
import { PawRing } from "@/components/stories/PawRing";
|
|
import { VideoCard } from "@/components/video/VideoCard";
|
|
import type { PostCardPost } from "@/components/feed/PostCard";
|
|
import { useTranslations } from "next-intl";
|
|
import { BlockDialog } from "@/components/safety/BlockDialog";
|
|
import { ReportSheet } from "@/components/safety/ReportSheet";
|
|
import { AiDisclosureBadge } from "@/components/content/AiDisclosureBadge";
|
|
|
|
interface PostDetailDialogProps {
|
|
post: PostCardPost | null;
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onDeleted?: (postId: string) => void;
|
|
}
|
|
|
|
export function PostDetailDialog({ post, open, onClose, onDeleted }: PostDetailDialogProps) {
|
|
const t = useTranslations("PostCard");
|
|
const tc = useTranslations("Comments");
|
|
const trt = useTranslations("RelativeTime");
|
|
const trpc = useTRPC();
|
|
const { activePetId } = useActivePet();
|
|
const queryClient = useQueryClient();
|
|
|
|
const [carouselApi, setCarouselApi] = useState<CarouselApi>();
|
|
const [currentSlide, setCurrentSlide] = useState(1);
|
|
const [isEditingCaption, setIsEditingCaption] = useState(false);
|
|
const [editCaptionValue, setEditCaptionValue] = useState("");
|
|
const [currentCaption, setCurrentCaption] = useState<string | null>(null);
|
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
const [showReportSheet, setShowReportSheet] = useState(false);
|
|
const [showBlockDialog, setShowBlockDialog] = useState(false);
|
|
const [body, setBody] = useState("");
|
|
const [pendingBody, setPendingBody] = useState<string | null>(null);
|
|
const commentsEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!post) return;
|
|
setCurrentCaption(post.caption);
|
|
setEditCaptionValue(post.caption ?? "");
|
|
setIsEditingCaption(false);
|
|
setBody("");
|
|
setCurrentSlide(1);
|
|
}, [post?.id]);
|
|
|
|
useEffect(() => {
|
|
if (!carouselApi) return;
|
|
setCurrentSlide(carouselApi.selectedScrollSnap() + 1);
|
|
carouselApi.on("select", () => setCurrentSlide(carouselApi.selectedScrollSnap() + 1));
|
|
}, [carouselApi]);
|
|
|
|
const { data: commentsData, isLoading: commentsLoading } = useQuery({
|
|
...trpc.comments.listByPost.queryOptions({ postId: post?.id ?? "", limit: 50 }),
|
|
enabled: !!post?.id && open,
|
|
});
|
|
|
|
const updateCaptionMutation = useMutation(
|
|
trpc.posts.updateCaption.mutationOptions({
|
|
onSuccess: (updated) => {
|
|
setCurrentCaption(updated.caption);
|
|
setIsEditingCaption(false);
|
|
toast.success(t("captionUpdated"));
|
|
},
|
|
onError: () => toast.error(t("captionUpdateError")),
|
|
})
|
|
);
|
|
|
|
const deleteMutation = useMutation(
|
|
trpc.posts.delete.mutationOptions({
|
|
onSuccess: () => {
|
|
setShowDeleteDialog(false);
|
|
toast.success(t("postDeleted"));
|
|
onClose();
|
|
if (post) onDeleted?.(post.id);
|
|
},
|
|
onError: () => toast.error(t("deleteError")),
|
|
})
|
|
);
|
|
|
|
const createCommentMutation = useMutation(
|
|
trpc.comments.create.mutationOptions({
|
|
onSuccess: () => {
|
|
setPendingBody(null);
|
|
if (post?.id) {
|
|
void queryClient.invalidateQueries({
|
|
queryKey: trpc.comments.listByPost.queryOptions({ postId: post.id, limit: 50 }).queryKey,
|
|
});
|
|
}
|
|
setTimeout(() => commentsEndRef.current?.scrollIntoView({ behavior: "smooth" }), 150);
|
|
},
|
|
onError: () => {
|
|
setBody((prev) => prev || (pendingBody ?? ""));
|
|
setPendingBody(null);
|
|
toast.error(tc("postError"));
|
|
},
|
|
})
|
|
);
|
|
|
|
const deleteCommentMutation = useMutation(
|
|
trpc.comments.delete.mutationOptions({
|
|
onSuccess: () => {
|
|
if (post?.id) {
|
|
void queryClient.invalidateQueries({
|
|
queryKey: trpc.comments.listByPost.queryOptions({ postId: post.id, limit: 50 }).queryKey,
|
|
});
|
|
}
|
|
},
|
|
onError: () => toast.error(tc("deleteError")),
|
|
})
|
|
);
|
|
|
|
const handleSubmitComment = useCallback(() => {
|
|
if (!body.trim() || !post || !activePetId) return;
|
|
const trimmed = body.trim();
|
|
setPendingBody(trimmed);
|
|
setBody("");
|
|
createCommentMutation.mutate({ postId: post.id, petId: activePetId, body: trimmed });
|
|
setTimeout(() => commentsEndRef.current?.scrollIntoView({ behavior: "smooth" }), 50);
|
|
}, [body, post, activePetId, createCommentMutation]);
|
|
|
|
const handleCommentKeyDown = useCallback(
|
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
handleSubmitComment();
|
|
}
|
|
},
|
|
[handleSubmitComment]
|
|
);
|
|
|
|
const formatRelativeTime = (date: Date | string) => formatRelativeCompact(date, trt);
|
|
|
|
if (!post) return null;
|
|
|
|
const { pet, images } = post;
|
|
const isCarousel = images.length > 1;
|
|
const isOwnPost = activePetId === pet.id;
|
|
const comments = commentsData?.comments ?? [];
|
|
|
|
// Extracted so it renders identically in the mobile bar and the desktop right-panel
|
|
const headerInner = (
|
|
<>
|
|
<Link
|
|
href={`/pets/${pet.id}`}
|
|
className="flex items-center gap-3 flex-1 min-w-0 hover:opacity-80 transition-opacity"
|
|
onClick={onClose}
|
|
>
|
|
<PawRing petId={pet.id} size={32}>
|
|
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} className="w-8 h-8" />
|
|
</PawRing>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-semibold text-foreground leading-tight truncate">{formatPetName(pet)}</p>
|
|
<p className="text-xs text-muted-foreground">{formatRelativeTime(post.createdAt)}</p>
|
|
</div>
|
|
</Link>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
className="flex items-center justify-center w-10 h-10 rounded-full hover:bg-muted transition-colors cursor-pointer"
|
|
aria-label="Post options"
|
|
>
|
|
<MoreHorizontal className="h-5 w-5 text-muted-foreground" aria-hidden="true" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
{isOwnPost ? (
|
|
<>
|
|
<DropdownMenuItem
|
|
onClick={() => { setEditCaptionValue(currentCaption ?? ""); setIsEditingCaption(true); }}
|
|
className="cursor-pointer"
|
|
>
|
|
{t("editCaption")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => setShowDeleteDialog(true)}
|
|
className="cursor-pointer text-destructive focus:text-destructive"
|
|
>
|
|
{t("deletePost")}
|
|
</DropdownMenuItem>
|
|
</>
|
|
) : (
|
|
<>
|
|
<DropdownMenuItem
|
|
onClick={() => setShowReportSheet(true)}
|
|
className="cursor-pointer"
|
|
>
|
|
{t("reportPost")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => setShowBlockDialog(true)}
|
|
className="cursor-pointer text-destructive focus:text-destructive"
|
|
>
|
|
{t("blockPet", { name: pet.name })}
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</>
|
|
);
|
|
|
|
// Image wrappers below fill the (definitely-sized) media column and let
|
|
// object-contain handle aspect-ratio scaling/centering. An earlier
|
|
// "shrink-wrap to the image's own rendered box" approach relied on
|
|
// percentage max-height/max-width resolving against an intrinsically-sized
|
|
// (auto/fit-content) parent, which CSS treats as unresolvable — portrait
|
|
// images ended up either uncentered or rendered at full natural size
|
|
// (clipped) depending on which axis lost. Filling a definite-size parent
|
|
// avoids that class of bug entirely.
|
|
const mediaContent = post.type === "VIDEO" && post.videoPost ? (
|
|
<div className="relative h-full w-full">
|
|
<VideoCard
|
|
postId={post.id}
|
|
initialStatus={post.videoPost.status}
|
|
initialPlaybackId={post.videoPost.muxPlaybackId}
|
|
/>
|
|
<AiDisclosureBadge value={post.aiDisclosure} className="h-8" />
|
|
</div>
|
|
) : images.length > 0 && (
|
|
isCarousel ? (
|
|
<div className="relative w-full h-full">
|
|
<AiDisclosureBadge value={post.aiDisclosure} className="h-8" />
|
|
{/* CarouselContent's own ref div (src/components/ui/carousel.tsx)
|
|
was hardcoded to "overflow-hidden" with no way to give it a
|
|
height — every className/grid/flex trick attached to its
|
|
ancestors or CarouselItem still had nothing but an indefinite
|
|
(percentage-against-auto-parent) height to work with at that
|
|
div specifically, so shorter images in a mixed-aspect-ratio
|
|
carousel rendered pinned to the top instead of centered no
|
|
matter what. Fixed at the source via CarouselContent's new
|
|
viewportClassName prop, which reaches that div directly —
|
|
h-full there now resolves against Carousel's own (genuinely
|
|
definite) height instead of falling back to auto. */}
|
|
<Carousel setApi={setCarouselApi} className="h-full">
|
|
<CarouselContent viewportClassName="h-full" className="h-full">
|
|
{images.map((img) => (
|
|
<CarouselItem key={img.id} className="basis-full h-full flex items-center justify-center">
|
|
<BlurImage
|
|
src={getMediaUrl(img.storageKey) ?? ""}
|
|
blurhash={img.blurhash}
|
|
alt={currentCaption ?? `${pet.name}'s post`}
|
|
className="block max-h-full max-w-full object-contain"
|
|
wrapperClassName="relative block w-fit h-fit"
|
|
/>
|
|
</CarouselItem>
|
|
))}
|
|
</CarouselContent>
|
|
<CarouselPrevious className="left-2" />
|
|
<CarouselNext className="right-2" />
|
|
</Carousel>
|
|
<span className="absolute top-2 right-2 z-10 text-xs text-white bg-black/50 rounded px-1.5 py-0.5 select-none">
|
|
{currentSlide} / {images.length}
|
|
</span>
|
|
<div className="absolute bottom-2 left-0 right-0 z-10 flex justify-center gap-2">
|
|
{images.map((_, i) => (
|
|
<button
|
|
key={i}
|
|
className={`w-1.5 h-1.5 rounded-full transition-colors ${i === currentSlide - 1 ? "bg-white" : "bg-white/40"}`}
|
|
onClick={() => carouselApi?.scrollTo(i)}
|
|
aria-label={`Go to image ${i + 1}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="relative w-full h-full">
|
|
<BlurImage
|
|
src={getMediaUrl(images[0].storageKey) ?? ""}
|
|
blurhash={images[0].blurhash}
|
|
alt={currentCaption ?? `${pet.name}'s post`}
|
|
className="block w-full h-full object-contain"
|
|
wrapperClassName="relative block w-full h-full"
|
|
/>
|
|
<AiDisclosureBadge value={post.aiDisclosure} className="h-8" />
|
|
</div>
|
|
)
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
|
{/*
|
|
Layout:
|
|
Mobile → flex-col: [header][image 45dvh][right-panel scrollable]
|
|
Desktop → flex-row: [image flex-1 left][right-panel 360px right]
|
|
*/}
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
className="p-0 max-w-[960px] sm:max-w-[960px] w-full max-h-[90dvh] md:h-[90dvh] flex flex-col md:flex-row overflow-hidden rounded-xl gap-0"
|
|
>
|
|
{/* Mobile-only header — sits above the image on small screens */}
|
|
<div className="md:hidden flex items-center gap-3 px-4 py-3 border-b shrink-0">
|
|
{headerInner}
|
|
</div>
|
|
|
|
{/* Image column — fixed 45dvh strip on mobile, flex-1 full-height on desktop */}
|
|
<div className="relative shrink-0 h-[45dvh] md:h-full md:flex-1 md:min-w-0 bg-black flex items-center justify-center">
|
|
{mediaContent}
|
|
</div>
|
|
|
|
{/* Right panel: desktop header + scrollable body + sticky input */}
|
|
<div className="flex flex-col md:w-[360px] md:shrink-0 min-h-0 md:flex-none md:border-l">
|
|
{/* Desktop-only header */}
|
|
<div className="hidden md:flex items-center gap-3 px-4 py-3 border-b shrink-0">
|
|
{headerInner}
|
|
</div>
|
|
|
|
{/* Scrollable body */}
|
|
<div className="overflow-y-auto min-h-0 max-h-[30dvh] md:max-h-none md:flex-1">
|
|
<div className="px-4 pt-3 pb-2 space-y-2">
|
|
{isEditingCaption ? (
|
|
<div className="space-y-2">
|
|
<MentionTextarea
|
|
value={editCaptionValue}
|
|
onChange={setEditCaptionValue}
|
|
maxLength={500}
|
|
rows={3}
|
|
className="text-sm resize-none"
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
onClick={() =>
|
|
updateCaptionMutation.mutate({ postId: post.id, caption: editCaptionValue.trim() || null })
|
|
}
|
|
disabled={updateCaptionMutation.isPending}
|
|
className="bg-orange-500 hover:bg-orange-600 text-white text-xs h-8"
|
|
>
|
|
{t("saveCaption")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setIsEditingCaption(false)}
|
|
className="text-xs h-8"
|
|
>
|
|
{t("cancel")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
currentCaption && (
|
|
<p className="text-sm text-foreground">
|
|
{parseCaption(currentCaption).map((seg, i) =>
|
|
seg.type === "hashtag" ? (
|
|
<Link key={i} href={`/hashtag/${seg.tag}`} className="text-orange-500 hover:underline" onClick={onClose}>
|
|
#{seg.tag}
|
|
</Link>
|
|
) : seg.type === "mention" ? (
|
|
<Link key={i} href={`/search?q=${seg.name}`} className="text-blue-500 hover:underline" onClick={onClose}>
|
|
@{seg.name}
|
|
</Link>
|
|
) : (
|
|
<span key={i}>{seg.text}</span>
|
|
)
|
|
)}
|
|
</p>
|
|
)
|
|
)}
|
|
|
|
{activePetId && (
|
|
<div className="flex items-center gap-4 pt-1 pb-2 border-b">
|
|
<PawButton
|
|
postId={post.id}
|
|
petId={activePetId}
|
|
initialCount={post.reactionCount ?? 0}
|
|
initialReacted={false}
|
|
/>
|
|
<PawBackButton postId={post.id} petId={activePetId} />
|
|
{comments.length > 0 && (
|
|
<span className="text-xs text-muted-foreground ml-auto">
|
|
{comments.length === 1 ? tc("countSingular") : tc("countPlural", { count: comments.length })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="px-4 pb-3 space-y-3">
|
|
{commentsLoading && (
|
|
<div className="flex justify-center py-4">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
{!commentsLoading && comments.length === 0 && (
|
|
<p className="text-sm text-muted-foreground text-center py-3">{tc("empty")}</p>
|
|
)}
|
|
{comments.map((comment) => {
|
|
const isOwnComment = comment.pet.id === activePetId;
|
|
return (
|
|
<div key={comment.id} className="flex gap-2 group">
|
|
<PetAvatar
|
|
name={comment.pet.name}
|
|
avatarKey={comment.pet.avatarKey}
|
|
className="h-7 w-7 shrink-0 mt-0.5"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-baseline gap-1.5">
|
|
<Link
|
|
href={`/pets/${comment.pet.id}`}
|
|
className="text-xs font-semibold hover:underline"
|
|
onClick={onClose}
|
|
>
|
|
{comment.pet.name}
|
|
</Link>
|
|
<span className="text-xs text-muted-foreground">
|
|
{formatRelativeTime(comment.createdAt)}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm mt-0.5 break-words">
|
|
{parseCaption(comment.body).map((seg, i) =>
|
|
seg.type === "hashtag" ? (
|
|
<Link key={i} href={`/hashtag/${seg.tag}`} className="text-orange-500 hover:underline">#{seg.tag}</Link>
|
|
) : seg.type === "mention" ? (
|
|
<Link key={i} href={`/search?q=${seg.name}`} className="text-blue-500 hover:underline">@{seg.name}</Link>
|
|
) : (
|
|
<span key={i}>{seg.text}</span>
|
|
)
|
|
)}
|
|
</p>
|
|
</div>
|
|
{isOwnComment && activePetId && (
|
|
<button
|
|
type="button"
|
|
aria-label="Delete comment"
|
|
onClick={() => deleteCommentMutation.mutate({ commentId: comment.id, petId: activePetId })}
|
|
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive shrink-0 self-start mt-0.5"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
{pendingBody && (
|
|
<div className="flex gap-2 opacity-60">
|
|
<Avatar className="h-7 w-7 shrink-0 mt-0.5">
|
|
<AvatarFallback className="text-xs bg-orange-100 text-orange-600">
|
|
✓
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0">
|
|
<span className="text-xs text-muted-foreground">{tc("sending")}</span>
|
|
<p className="text-sm mt-0.5 break-words">{pendingBody}</p>
|
|
</div>
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground shrink-0 self-start mt-1" />
|
|
</div>
|
|
)}
|
|
<div ref={commentsEndRef} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sticky comment input */}
|
|
{activePetId && (
|
|
<div className="flex gap-2 px-4 py-3 border-t bg-background shrink-0">
|
|
<MentionTextarea
|
|
rows={1}
|
|
maxLength={500}
|
|
placeholder={tc("placeholder")}
|
|
value={body}
|
|
onChange={setBody}
|
|
onKeyDown={handleCommentKeyDown}
|
|
className="flex-1 resize-none min-h-[40px] max-h-[120px] text-sm"
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
disabled={!body.trim() || createCommentMutation.isPending}
|
|
onClick={handleSubmitComment}
|
|
className="bg-orange-500 hover:bg-orange-600 text-white self-end"
|
|
>
|
|
{createCommentMutation.isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
tc("post")
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t("deleteTitle")}</AlertDialogTitle>
|
|
<AlertDialogDescription>{t("deleteDesc")}</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{t("keepPost")}</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() => deleteMutation.mutate({ postId: post.id })}
|
|
disabled={deleteMutation.isPending}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
{t("yesDelete")}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* ReportSheet — other's post */}
|
|
{activePetId && !isOwnPost && (
|
|
<ReportSheet
|
|
open={showReportSheet}
|
|
onOpenChange={setShowReportSheet}
|
|
reporterPetId={activePetId}
|
|
targetPostId={post.id}
|
|
/>
|
|
)}
|
|
|
|
{/* BlockDialog — other's post */}
|
|
{activePetId && !isOwnPost && (
|
|
<BlockDialog
|
|
open={showBlockDialog}
|
|
onOpenChange={setShowBlockDialog}
|
|
blockerPetId={activePetId}
|
|
blockedPetId={pet.id}
|
|
blockedPetName={formatPetName(pet)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|