Files
petfeed/src/components/feed/PawButton.tsx
T
admin e39b3f1f6e feat(feed): spring/fill/burst animation on paw reactions
Adds a CSS-only spring-scale + expanding-burst-overlay animation
triggered when a pet paw-reacts to a post, respecting
prefers-reduced-motion. Only plays on the like transition, not unlike.

Gitea #15
2026-08-12 12:52:07 +02:00

97 lines
3.1 KiB
TypeScript

"use client";
import { useState, useCallback } from "react";
import { PawPrint } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
interface PawButtonProps {
postId: string;
petId: string;
initialCount: number;
initialReacted: boolean;
}
export function PawButton({ postId, petId, initialCount, initialReacted }: PawButtonProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const [reacted, setReacted] = useState(initialReacted);
const [count, setCount] = useState(initialCount);
const [animationKey, setAnimationKey] = useState(0);
const [showBurst, setShowBurst] = useState(false);
const toggleMutation = useMutation(
trpc.reactions.toggle.mutationOptions({
onMutate: () => {
const prevReacted = reacted;
const prevCount = count;
const nextReacted = !reacted;
// Optimistically flip the icon only — `reacted` can be stale (callers
// currently always pass initialReacted={false}), so count is corrected
// precisely from the server's actual `data.reacted` in onSuccess
// instead of guessing a +/-1 delta off a possibly-wrong prior state.
setReacted(nextReacted);
if (nextReacted) {
setAnimationKey((key) => key + 1);
setShowBurst(true);
}
return { prevReacted, prevCount };
},
onSuccess: (data, _vars, context) => {
setReacted(data.reacted);
if (context) {
setCount(data.reacted ? context.prevCount + 1 : Math.max(0, context.prevCount - 1));
}
},
onError: (_err, _vars, context) => {
if (context) {
setReacted(context.prevReacted);
setCount(context.prevCount);
}
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: [["trpc", "feed", "getFeed"]] });
},
})
);
const handleClick = useCallback(() => {
if (toggleMutation.isPending) return;
toggleMutation.mutate({ postId, petId });
}, [postId, petId, toggleMutation]);
return (
<button
type="button"
onClick={handleClick}
disabled={toggleMutation.isPending}
aria-label={reacted ? "Remove paw" : "Paw this post"}
className="flex items-center gap-1.5 text-sm transition-colors disabled:opacity-60"
>
<span className="relative inline-flex h-4 w-4 items-center justify-center">
<PawPrint
key={animationKey}
className={`h-4 w-4 transition-colors ${
reacted
? "fill-orange-500 text-orange-500 animate-paw-spring"
: "text-muted-foreground hover:text-orange-500"
}`}
aria-hidden="true"
/>
{showBurst && (
<PawPrint
className="absolute inset-0 h-4 w-4 fill-orange-500 text-orange-500 animate-paw-burst"
aria-hidden="true"
onAnimationEnd={() => setShowBurst(false)}
/>
)}
</span>
{count > 0 && (
<span className={reacted ? "text-orange-500" : "text-muted-foreground"}>
{count}
</span>
)}
</button>
);
}