Files
petfeed/src/components/feed/CommentSheet.tsx
T
adminandClaude Sonnet 4.6 376ea74b36 feat(i18n): translate FeedList, PostCard, CommentSheet, FollowButton, MessageThread
Adds PostCard, Comments, Follow, Messages, FeedList namespaces to en/de
messages. Moves formatRelativeTime/formatDay into components so they can
use the t() hook. All hardcoded strings replaced with next-intl calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 14:18:18 +02:00

200 lines
6.9 KiB
TypeScript

"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { Trash2, Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTRPC } from "@/trpc/client";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { getAvatarUrl } from "@/lib/avatar-url";
import { useTranslations } from "next-intl";
interface CommentSheetProps {
postId: string;
petId: string; // active pet (commenter)
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CommentSheet({ postId, petId, open, onOpenChange }: CommentSheetProps) {
const t = useTranslations("Comments");
const trpc = useTRPC();
function formatRelativeTime(date: Date | string): string {
const diffMs = Date.now() - new Date(date).getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return t("justNow");
if (diffMins < 60) return `${diffMins}m`;
if (diffHours < 24) return `${diffHours}h`;
if (diffDays < 7) return `${diffDays}d`;
return new Date(date).toLocaleDateString();
}
const queryClient = useQueryClient();
const [body, setBody] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { data, isLoading } = useQuery(
trpc.comments.listByPost.queryOptions({ postId, limit: 50 })
);
const comments = data?.comments ?? [];
const createMutation = useMutation(
trpc.comments.create.mutationOptions({
onSuccess: () => {
setBody("");
void queryClient.invalidateQueries({
queryKey: [["trpc", "comments", "listByPost"]],
});
void queryClient.invalidateQueries({
queryKey: [["trpc", "feed", "getFeed"]],
});
},
onError: () => toast.error(t("postError")),
})
);
const deleteMutation = useMutation(
trpc.comments.delete.mutationOptions({
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [["trpc", "comments", "listByPost"]],
});
void queryClient.invalidateQueries({
queryKey: [["trpc", "feed", "getFeed"]],
});
},
onError: () => toast.error(t("deleteError")),
})
);
const handleSubmit = useCallback(() => {
const trimmed = body.trim();
if (!trimmed || createMutation.isPending) return;
createMutation.mutate({ postId, petId, body: trimmed });
}, [body, postId, petId, createMutation]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit]
);
// Auto-focus textarea when sheet opens
useEffect(() => {
if (open) {
setTimeout(() => textareaRef.current?.focus(), 100);
}
}, [open]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="h-[80vh] flex flex-col p-0">
<SheetHeader className="px-4 pt-4 pb-2">
<SheetTitle className="text-left">{t("title")}</SheetTitle>
</SheetHeader>
<Separator />
<ScrollArea className="flex-1 px-4">
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : comments.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
{t("empty")}
</p>
) : (
<div className="py-2">
{comments.map((comment) => {
const isOwn = comment.pet.id === petId;
const avatarUrl = getAvatarUrl(comment.pet.avatarKey);
return (
<div key={comment.id} className="flex gap-3 py-3">
<Avatar className="w-8 h-8 shrink-0">
{avatarUrl && (
<img src={avatarUrl} alt={comment.pet.name} className="object-cover" />
)}
<AvatarFallback className="text-xs">
{comment.pet.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="text-sm font-medium truncate">
{comment.pet.name}
</span>
<span className="text-xs text-muted-foreground shrink-0">
{formatRelativeTime(comment.createdAt)}
</span>
</div>
<p className="text-sm mt-0.5 break-words">{comment.body}</p>
</div>
{isOwn && (
<button
type="button"
aria-label="Delete comment"
disabled={deleteMutation.isPending}
onClick={() =>
deleteMutation.mutate({ commentId: comment.id, petId })
}
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
);
})}
</div>
)}
</ScrollArea>
<Separator />
{/* Comment input — sticky at bottom */}
<div className="flex gap-2 px-4 py-3 bg-background">
<Textarea
ref={textareaRef}
rows={1}
maxLength={500}
placeholder={t("placeholder")}
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 resize-none min-h-[40px] max-h-[120px] text-sm"
/>
<Button
size="sm"
disabled={!body.trim() || createMutation.isPending}
onClick={handleSubmit}
className="bg-orange-500 hover:bg-orange-600 text-white self-end"
>
{createMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
t("post")
)}
</Button>
</div>
</SheetContent>
</Sheet>
);
}