- 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>
85 lines
2.7 KiB
TypeScript
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>
|
|
);
|
|
}
|