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
97 lines
3.1 KiB
TypeScript
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>
|
|
);
|
|
}
|