Files
petfeed/src/components/feed/PostDetailDialog.tsx
T
admin 1293f7c879 feat(pets): add optional nickname/Rufname shown alongside the pet's real name
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.
2026-08-15 13:25:54 +02:00

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)}
/>
)}
</>
);
}