- 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
544 lines
19 KiB
TypeScript
544 lines
19 KiB
TypeScript
"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>
|
||
);
|
||
}
|