Files
petfeed/src/components/feed/PawBackButton.tsx
T
adminandClaude Sonnet 4.6 8507fe9489 feat(03-04): Paw-Back/Repost — reposts router GREEN + RepostCard + PawBackButton
- reposts.create: ownership + repost-of-repost guard + idempotency + fanOutPost
- reposts.delete: ownership + cascade via post.delete
- reposts.isReposted: compound-key lookup, returns boolean
- PawBackButton: optimistic toggle, orange when reposted, invalidates feed
- RepostCard: attribution header + original pet/images/caption, no action row
- PostCard: REPOST type dispatches to RepostCard; stub replaced with PawBackButton
- feed.ts + posts.ts: repost include added to all post hydration queries
- PostCardPost type: OriginalPostContent + repost field

Tests: 3/3 reposts GREEN, 60 total passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 12:01:58 +02:00

85 lines
2.7 KiB
TypeScript

"use client";
import { useState, useCallback } from "react";
import { Repeat2 } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTRPC } from "@/trpc/client";
interface PawBackButtonProps {
postId: string; // original post ID
petId: string; // active pet (reposter)
}
export function PawBackButton({ postId, petId }: PawBackButtonProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const { data: serverReposted, isLoading } = useQuery(
trpc.reposts.isReposted.queryOptions({ postId, petId })
);
// Optimistic local override; null = use server state
const [localState, setLocalState] = useState<boolean | null>(null);
const reposted = localState !== null ? localState : (serverReposted ?? false);
const createMutation = useMutation(
trpc.reposts.create.mutationOptions({
onMutate: () => setLocalState(true),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: [["trpc", "feed"]] });
void queryClient.invalidateQueries({ queryKey: [["trpc", "reposts", "isReposted"]] });
},
onError: () => {
setLocalState(null);
toast.error("Couldn't Paw-Back. Please try again.");
},
})
);
const deleteMutation = useMutation(
trpc.reposts.delete.mutationOptions({
onMutate: () => setLocalState(false),
onSuccess: () => {
toast.success("Paw-Back removed");
void queryClient.invalidateQueries({ queryKey: [["trpc", "feed"]] });
void queryClient.invalidateQueries({ queryKey: [["trpc", "reposts", "isReposted"]] });
},
onError: () => {
setLocalState(null);
toast.error("Couldn't remove Paw-Back. Please try again.");
},
})
);
const isPending = createMutation.isPending || deleteMutation.isPending;
const handleClick = useCallback(() => {
if (isPending || isLoading) return;
if (reposted) {
deleteMutation.mutate({ originalPostId: postId, reposterPetId: petId });
} else {
createMutation.mutate({ originalPostId: postId, reposterPetId: petId });
}
}, [isPending, isLoading, reposted, postId, petId, createMutation, deleteMutation]);
return (
<button
type="button"
onClick={handleClick}
disabled={isPending || isLoading}
aria-label={reposted ? "Remove Paw-Back" : "Paw-Back this post"}
className="ml-auto flex items-center gap-1.5 text-sm transition-colors disabled:opacity-60"
>
<Repeat2
className={`h-4 w-4 transition-colors ${
reposted
? "text-orange-500"
: "text-muted-foreground hover:text-foreground"
}`}
aria-hidden="true"
/>
</button>
);
}