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>
200 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|