feat(social): follows, favorites, comments, reactions, collections, public profiles
Follow/unfollow users. Recipe favorites. Threaded comments with emoji reactions. Collections (public/private) with shared member invite. Activity feed. Public profile pages at /u/[username].
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const REACTIONS: Record<string, string> = {
|
||||
like: "👍",
|
||||
love: "❤️",
|
||||
laugh: "😂",
|
||||
wow: "😮",
|
||||
sad: "😢",
|
||||
fire: "🔥",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
recipeId: string;
|
||||
commentId: string;
|
||||
initialCounts?: Record<string, number>;
|
||||
initialUserReactions?: string[];
|
||||
};
|
||||
|
||||
export function CommentReactions({ recipeId, commentId, initialCounts = {}, initialUserReactions = [] }: Props) {
|
||||
const [counts, setCounts] = useState<Record<string, number>>(initialCounts);
|
||||
const [userReactions, setUserReactions] = useState<string[]>(initialUserReactions);
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
|
||||
async function toggle(type: string) {
|
||||
if (pending) return;
|
||||
setPending(type);
|
||||
|
||||
// Optimistic update
|
||||
const hasReacted = userReactions.includes(type);
|
||||
setUserReactions((prev) =>
|
||||
hasReacted ? prev.filter((r) => r !== type) : [...prev, type],
|
||||
);
|
||||
setCounts((prev) => ({
|
||||
...prev,
|
||||
[type]: Math.max(0, (prev[type] ?? 0) + (hasReacted ? -1 : 1)),
|
||||
}));
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/recipes/${recipeId}/comments/${commentId}/reactions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Revert optimistic update
|
||||
setUserReactions((prev) =>
|
||||
hasReacted ? [...prev, type] : prev.filter((r) => r !== type),
|
||||
);
|
||||
setCounts((prev) => ({
|
||||
...prev,
|
||||
[type]: Math.max(0, (prev[type] ?? 0) + (hasReacted ? 1 : -1)),
|
||||
}));
|
||||
if (res.status === 401) {
|
||||
toast.error("Sign in to react to comments");
|
||||
} else {
|
||||
toast.error("Failed to update reaction");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json() as { counts: Record<string, number>; added: boolean };
|
||||
setCounts(data.counts);
|
||||
setUserReactions((prev) => {
|
||||
if (data.added && !prev.includes(type)) return [...prev, type];
|
||||
if (!data.added && prev.includes(type)) return prev.filter((r) => r !== type);
|
||||
return prev;
|
||||
});
|
||||
} catch {
|
||||
// Revert on network error
|
||||
setUserReactions((prev) =>
|
||||
hasReacted ? [...prev, type] : prev.filter((r) => r !== type),
|
||||
);
|
||||
setCounts((prev) => ({
|
||||
...prev,
|
||||
[type]: Math.max(0, (prev[type] ?? 0) + (hasReacted ? 1 : -1)),
|
||||
}));
|
||||
toast.error("Failed to update reaction");
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(REACTIONS).map(([type, emoji]) => {
|
||||
const reacted = userReactions.includes(type);
|
||||
const cnt = counts[type] ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => void toggle(type)}
|
||||
disabled={pending === type}
|
||||
className={[
|
||||
"inline-flex items-center gap-0.5 rounded-full border px-2 py-0.5 text-xs transition-colors",
|
||||
reacted
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-transparent text-muted-foreground hover:border-primary/50 hover:text-foreground",
|
||||
pending === type ? "opacity-60 cursor-not-allowed" : "cursor-pointer",
|
||||
].join(" ")}
|
||||
aria-label={`${reacted ? "Remove" : "Add"} ${type} reaction`}
|
||||
aria-pressed={reacted}
|
||||
>
|
||||
<span>{emoji}</span>
|
||||
{cnt > 0 && <span>{cnt}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user