Files
petfeed/src/components/feed/PostCard.tsx
T
admin 58e2840fae fix(mobile+comments): Sprint 1 — nav visibility, messages button, optimistic comments
- MobileNav: fixed mail icon top-right corner (always reachable on mobile)
- MobileNav: iOS safe-area-inset-bottom padding so nav is never hidden
- MobileNav: larger icons (h-6) + better contrast on inactive tabs (text-foreground/60)
- PostDetailDialog: optimistic comment update — comment appears immediately on submit
  with pending indicator; rolls back input on error
- i18n: added Comments.sending key (EN/DE)
- PostCard: minor style cleanup (pre-existing session changes)
- Explore: pre-existing session changes
2026-06-27 07:27:47 +02:00

544 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback } from "react";
import { MoreHorizontal, MessageCircle } from "lucide-react";
import { toast } from "sonner";
import { useMutation } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
type CarouselApi,
} from "@/components/ui/carousel";
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 { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { getAvatarUrl } from "@/lib/avatar-url";
import { getMediaUrl } from "@/lib/media-url";
import { parseCaption } from "@/lib/parse-caption";
import { PawRing } from "@/components/stories/PawRing";
import { MilestoneCard } from "@/components/feed/MilestoneCard";
import { VideoCard } from "@/components/video/VideoCard";
import { PawButton } from "@/components/feed/PawButton";
import { PawBackButton } from "@/components/feed/PawBackButton";
import { PostDetailDialog } from "@/components/feed/PostDetailDialog";
import { RepostCard } from "@/components/feed/RepostCard";
import { BlockDialog } from "@/components/safety/BlockDialog";
import { ReportSheet } from "@/components/safety/ReportSheet";
import { useTranslations } from "next-intl";
/**
* PostCard — full photo post card with carousel, caption clamp, and options menu.
*
* UI-SPEC: PostCard (Phase 2 full implementation, replacing 02-03 placeholder)
* - Header: Avatar (story ring seam // TODO(02-05)), pet name text-base font-semibold,
* timestamp text-xs muted, MoreHorizontal options button 44px target
* - Image area: 1 image → single img; 2–10 → shadcn Carousel with index indicator + dots
* - Carousel dots: bg-muted inactive / bg-foreground active (NOT orange — accent reservation)
* - Footer: caption text-sm line-clamp-3 with more/less toggle (instant, no animation)
* - Options menu: own post → Edit caption + Delete; other's post → Report + Block
* - Ownership: activePetId === post.pet.id determines own-vs-other
*
* tRPC: posts.updateCaption (PHOTO-03), posts.delete (PHOTO-04)
*/
type PostImage = {
id: string;
storageKey: string;
position: number;
};
type PostPet = {
id: string;
name: string;
avatarKey: string | null;
};
type PostMilestone = { milestoneType: import("@prisma/client").MilestoneType; notes: string | null };
export type OriginalPostContent = {
id: string;
pet: PostPet;
images: PostImage[];
caption: string | null;
createdAt: Date | string;
milestone: PostMilestone | null;
};
export type PostCardPost = {
id: string;
type?: string | null;
caption: string | null;
createdAt: Date | string;
pet: PostPet;
images: PostImage[];
milestone: PostMilestone | null;
_count?: { reactions: number; comments: number } | null;
repost?: {
originalPostId: string;
reposterPetId: string;
originalPost: OriginalPostContent;
} | null;
videoPost?: {
status: "PROCESSING" | "READY" | "ERROR";
muxPlaybackId: string | null;
} | null;
};
export type PostCardProps = {
post: PostCardPost;
/** Called after successful delete so parent can remove from list */
onDeleted?: (postId: string) => void;
/**
* "feed" (default): images cropped to max-h-[560px] object-cover — consistent card heights.
* "detail": images shown in full original format (object-contain, black bg) — used in profile sheet.
*/
variant?: "feed" | "detail";
};
export function PostCard({ post, onDeleted, variant = "feed" }: PostCardProps) {
const t = useTranslations("PostCard");
const trpc = useTRPC();
const { activePetId } = useActivePet();
function formatRelativeTime(date: Date | string): string {
const now = Date.now();
const diffMs = now - new Date(date).getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return t("justNow");
if (diffMins < 60) return t("minutesAgo", { count: diffMins });
if (diffHours < 24) return t("hoursAgo", { count: diffHours });
if (diffDays < 7) return t("daysAgo", { count: diffDays });
return new Date(date).toLocaleDateString();
}
const { pet, images, createdAt } = post;
const [currentCaption, setCurrentCaption] = useState(post.caption);
// Carousel state
const [carouselApi, setCarouselApi] = useState<CarouselApi>();
const [currentSlide, setCurrentSlide] = useState(1);
// Caption clamp state
const [isCaptionExpanded, setIsCaptionExpanded] = useState(false);
// Edit caption inline state
const [isEditingCaption, setIsEditingCaption] = useState(false);
const [editCaptionValue, setEditCaptionValue] = useState(currentCaption ?? "");
// Delete confirmation dialog
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Report sheet state
const [showReportSheet, setShowReportSheet] = useState(false);
// Block dialog state
const [showBlockDialog, setShowBlockDialog] = useState(false);
// Detail dialog (replaces comment sheet)
const [showDetailDialog, setShowDetailDialog] = useState(false);
// Ownership: active pet's own post vs another pet's post
const isOwnPost = activePetId === pet.id;
const avatarUrl = getAvatarUrl(pet.avatarKey);
const isCarousel = images.length > 1;
// Carousel: track current slide index
useEffect(() => {
if (!carouselApi) return;
setCurrentSlide(carouselApi.selectedScrollSnap() + 1);
carouselApi.on("select", () => {
setCurrentSlide(carouselApi.selectedScrollSnap() + 1);
});
}, [carouselApi]);
// tRPC mutations
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"));
onDeleted?.(post.id);
},
onError: () => {
toast.error(t("deleteError"));
},
})
);
const handleSaveCaption = useCallback(() => {
updateCaptionMutation.mutate({
postId: post.id,
caption: editCaptionValue.trim() || null,
});
}, [post.id, editCaptionValue, updateCaptionMutation]);
const handleCancelEdit = useCallback(() => {
setIsEditingCaption(false);
setEditCaptionValue(currentCaption ?? "");
}, [currentCaption]);
const handleDelete = useCallback(() => {
deleteMutation.mutate({ postId: post.id });
}, [post.id, deleteMutation]);
// Milestone posts use their own distinct card (MILE-02)
if (post.type === "MILESTONE") {
return <MilestoneCard post={post} />;
}
// Repost posts delegate to RepostCard
if (post.type === "REPOST") {
return <RepostCard post={post} />;
}
return (
<article className="bg-card border border-border rounded-lg overflow-hidden">
{/* Header row — 16px horizontal padding, 12px vertical */}
<div className="flex items-center gap-3 px-4 py-3">
{/* Avatar + name link — navigates to pet profile */}
<Link
href={`/pets/${pet.id}`}
className="flex items-center gap-3 flex-1 min-w-0 hover:opacity-80 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<PawRing petId={pet.id} size={32}>
<Avatar className="w-8 h-8">
{avatarUrl && (
<AvatarImage src={avatarUrl} alt={pet.name} />
)}
<AvatarFallback className="text-xs bg-muted">
{pet.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
</PawRing>
<div className="flex-1 min-w-0">
<p className="text-base font-semibold text-foreground leading-tight truncate">
{pet.name}
</p>
<p className="text-xs text-muted-foreground">
{formatRelativeTime(createdAt)}
</p>
</div>
</Link>
{/* Options menu — 44px touch target, aria-label "Post options" */}
<DropdownMenu>
<DropdownMenuTrigger
className="flex items-center justify-center w-11 h-11 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 ? (
<>
{/* Own post options */}
<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>
</>
) : (
<>
{/* Other's post options */}
<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>
</div>
{/* Video area — VIDEO posts */}
{post.type === "VIDEO" && post.videoPost && (
<VideoCard
postId={post.id}
initialStatus={post.videoPost.status}
initialPlaybackId={post.videoPost.muxPlaybackId}
/>
)}
{/* Image area — PHOTO posts */}
{post.type !== "VIDEO" && images.length > 0 && (
<>
{isCarousel ? (
/* Carousel: 2–10 images — Embla via shadcn (PHOTO-02) */
<div className="relative">
<Carousel setApi={setCarouselApi}>
<CarouselContent>
{images.map((img) => (
<CarouselItem key={img.id} className="basis-full">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getMediaUrl(img.storageKey) ?? ""}
alt={currentCaption ?? `${pet.name}'s post`}
className="w-full object-cover max-h-[560px] cursor-pointer"
onClick={() => setShowDetailDialog(true)}
/>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-2" />
<CarouselNext className="right-2" />
</Carousel>
{/* Index indicator — top-right, Caption style (text-xs), NOT orange */}
<span className="absolute top-2 right-2 text-xs text-white bg-black/50 rounded px-1.5 py-0.5 select-none">
{currentSlide} / {images.length}
</span>
{/* Dot indicators — bg-muted inactive / bg-foreground active (NOT orange — accent reservation) */}
<div className="flex items-center justify-center gap-2 py-2">
{images.map((_, i) => (
<button
key={i}
className={`w-1.5 h-1.5 rounded-full transition-colors ${
i === currentSlide - 1
? "bg-foreground"
: "bg-muted-foreground/40"
}`}
onClick={() => carouselApi?.scrollTo(i)}
aria-label={`Go to image ${i + 1}`}
/>
))}
</div>
</div>
) : (
/* Single image */
// eslint-disable-next-line @next/next/no-img-element
<img
src={getMediaUrl(images[0].storageKey) ?? ""}
alt={currentCaption ?? `${pet.name}'s post`}
className="w-full object-cover max-h-[560px] cursor-pointer"
onClick={() => setShowDetailDialog(true)}
/>
)}
</>
)}
{/* Footer — caption + reactions placeholder */}
<div className="px-4 py-3 space-y-2">
{/* Caption edit mode */}
{isEditingCaption ? (
<div className="space-y-2">
<Textarea
value={editCaptionValue}
onChange={(e) => setEditCaptionValue(e.target.value)}
placeholder={t("captionPlaceholder")}
maxLength={500}
rows={3}
className="text-sm resize-none"
autoFocus
/>
{editCaptionValue.length > 450 && (
<p className="text-xs text-muted-foreground text-right">
{t("charsRemaining", { count: 500 - editCaptionValue.length })}
</p>
)}
<div className="flex gap-2">
<Button
size="sm"
onClick={handleSaveCaption}
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={handleCancelEdit}
disabled={updateCaptionMutation.isPending}
className="text-xs h-8"
>
{t("cancel")}
</Button>
</div>
</div>
) : (
/* Caption display with 3-line clamp + clickable hashtags + more/less toggle */
currentCaption && (
<div>
<p
className={`text-sm text-foreground ${
!isCaptionExpanded ? "line-clamp-3" : ""
}`}
>
{parseCaption(currentCaption).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>
{/* more/less toggle — instant, no animation (UI-SPEC) */}
{currentCaption.length > 140 && (
<button
type="button"
onClick={() => setIsCaptionExpanded((v) => !v)}
className="text-xs text-muted-foreground hover:text-foreground mt-0.5"
>
{isCaptionExpanded ? t("less") : t("more")}
</button>
)}
</div>
)
)}
{/* Action row — paw reaction + comment count + paw-back (Phase 3) */}
{post.type !== "REPOST" && activePetId && (
<div className="flex items-center gap-4 pt-1">
<PawButton
postId={post.id}
petId={activePetId}
initialCount={post._count?.reactions ?? 0}
initialReacted={false}
/>
<button
type="button"
aria-label="View comments"
onClick={() => setShowDetailDialog(true)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<MessageCircle className="h-4 w-4" aria-hidden="true" />
{(post._count?.comments ?? 0) > 0 && (
<span>{post._count!.comments}</span>
)}
</button>
<PawBackButton postId={post.id} petId={activePetId} />
</div>
)}
</div>
{/* Delete confirmation AlertDialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("deleteDesc")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("keepPost")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
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}
/>
)}
{/* PostDetailDialog — focused view with inline comments */}
<PostDetailDialog
post={post}
open={showDetailDialog}
onClose={() => setShowDetailDialog(false)}
onDeleted={onDeleted}
/>
{/* BlockDialog — other's post */}
{activePetId && !isOwnPost && (
<BlockDialog
open={showBlockDialog}
onOpenChange={setShowBlockDialog}
blockerPetId={activePetId}
blockedPetId={pet.id}
blockedPetName={pet.name}
/>
)}
</article>
);
}