08ab9ac71f
Full sweep of hardcoded English strings across:
- Meal pairing and drink pairing dialogs (titles, descriptions, role/
type labels, regenerate/generate buttons, progress labels) — also
fixed drink type labels that had French text hardcoded regardless
of locale ("Sans alcool"/"Chaud").
- Nutrition panel — had no useTranslations at all.
- Comments: header, empty/loading state, post/reply/cancel/delete
buttons, relative timestamps (just now/Xm ago/Xh ago/Xd ago), and
comment-reactions' toasts + aria-labels.
- Rating stars toasts.
- Direct messages: thread, conversation list, message button, both
/messages pages.
- People search page and component.
- URL import dialog (title, description, buttons, toasts).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
131 lines
4.2 KiB
TypeScript
131 lines
4.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { toast } from "sonner";
|
|
import { useTranslations } from "next-intl";
|
|
|
|
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 t = useTranslations("social");
|
|
const [counts, setCounts] = useState<Record<string, number>>(initialCounts);
|
|
const [userReactions, setUserReactions] = useState<string[]>(initialUserReactions);
|
|
const [pending, setPending] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
fetch(`/api/v1/recipes/${recipeId}/comments/${commentId}/reactions`)
|
|
.then((res) => (res.ok ? res.json() : null))
|
|
.then((data: { counts: Record<string, number>; userReactions: string[] } | null) => {
|
|
if (!data || cancelled) return;
|
|
setCounts(data.counts);
|
|
setUserReactions(data.userReactions);
|
|
})
|
|
.catch(() => {});
|
|
return () => { cancelled = true; };
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [recipeId, commentId]);
|
|
|
|
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(t("signInToReact"));
|
|
} else {
|
|
toast.error(t("reactionFailed"));
|
|
}
|
|
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(t("reactionFailed"));
|
|
} 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 ? t("removeReaction", { type }) : t("addReaction", { type })}
|
|
aria-pressed={reacted}
|
|
>
|
|
<span>{emoji}</span>
|
|
{cnt > 0 && <span>{cnt}</span>}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|