feat(i18n): translate FeedList, PostCard, CommentSheet, FollowButton, MessageThread

Adds PostCard, Comments, Follow, Messages, FeedList namespaces to en/de
messages. Moves formatRelativeTime/formatDay into components so they can
use the t() hook. All hardcoded strings replaced with next-intl calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 4.6 committed 2026-06-22 14:18:18 +02:00
1 parent 4b17537a9a
commit 376ea74b36
7 files changed
+201 -79

No files matched your search

+57
View File
@@ -102,5 +102,62 @@
"switch": "Sprache",
"en": "English",
"de": "Deutsch"
},
"PostCard": {
"justNow": "gerade eben",
"minutesAgo": "vor {count} Min.",
"hoursAgo": "vor {count} Std.",
"daysAgo": "vor {count} T.",
"editCaption": "Beschreibung bearbeiten",
"deletePost": "Beitrag löschen",
"reportPost": "Beitrag melden",
"blockPet": "{name} blockieren",
"captionPlaceholder": "Beschreibung eingeben…",
"charsRemaining": "Noch {count} Zeichen",
"saveCaption": "Speichern",
"cancel": "Abbrechen",
"more": "mehr",
"less": "weniger",
"deleteTitle": "Beitrag löschen?",
"deleteDesc": "Dieser Beitrag und alle Fotos werden dauerhaft gelöscht. Das kann nicht rückgängig gemacht werden.",
"keepPost": "Behalten",
"yesDelete": "Ja, löschen",
"captionUpdated": "Beschreibung aktualisiert",
"captionUpdateError": "Beschreibung konnte nicht aktualisiert werden. Bitte versuche es erneut.",
"postDeleted": "Beitrag gelöscht",
"deleteError": "Beitrag konnte nicht gelöscht werden. Bitte versuche es erneut."
},
"Comments": {
"title": "Kommentare",
"justNow": "jetzt",
"empty": "Noch keine Kommentare — sei der Erste!",
"placeholder": "Kommentar hinzufügen…",
"post": "Senden",
"postError": "Kommentar konnte nicht gepostet werden. Bitte versuche es erneut.",
"deleteError": "Kommentar konnte nicht gelöscht werden. Bitte versuche es erneut."
},
"Follow": {
"follow": "Folgen",
"following": "Gefolgt",
"unfollow": "Entfolgen",
"unfollowTitle": "{name} entfolgen?",
"unfollowDesc": "Du siehst {name}'s Beiträge nicht mehr in deinem Feed.",
"keepFollowing": "Weiter folgen",
"followError": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
"nowFollowing": "Du folgst jetzt {name}",
"unfollowed": "{name} entfolgt"
},
"Messages": {
"today": "Heute",
"yesterday": "Gestern",
"empty": "Sag Hallo 👋",
"placeholder": "Nachricht…",
"charsLeft": "Noch {count} Zeichen",
"sendError": "Nachricht konnte nicht gesendet werden. Bitte versuche es erneut."
},
"FeedList": {
"forYou": "Für dich",
"recent": "Aktuell",
"allCaughtUp": "Du bist auf dem neuesten Stand. Folge mehr Tieren für weitere Beiträge."
}
}
+57
View File
@@ -102,5 +102,62 @@
"switch": "Language",
"en": "English",
"de": "Deutsch"
},
"PostCard": {
"justNow": "just now",
"minutesAgo": "{count}m ago",
"hoursAgo": "{count}h ago",
"daysAgo": "{count}d ago",
"editCaption": "Edit caption",
"deletePost": "Delete post",
"reportPost": "Report post",
"blockPet": "Block {name}",
"captionPlaceholder": "Write a caption…",
"charsRemaining": "{count} characters remaining",
"saveCaption": "Save caption",
"cancel": "Cancel",
"more": "more",
"less": "less",
"deleteTitle": "Delete this post?",
"deleteDesc": "This will permanently delete the post and all its photos. This can't be undone.",
"keepPost": "Keep post",
"yesDelete": "Yes, delete",
"captionUpdated": "Caption updated",
"captionUpdateError": "Couldn't update caption. Please try again.",
"postDeleted": "Post deleted",
"deleteError": "Couldn't delete post. Please try again."
},
"Comments": {
"title": "Comments",
"justNow": "now",
"empty": "No comments yet — be the first!",
"placeholder": "Add a comment…",
"post": "Post",
"postError": "Couldn't post comment. Please try again.",
"deleteError": "Couldn't delete comment. Please try again."
},
"Follow": {
"follow": "Follow",
"following": "Following",
"unfollow": "Unfollow",
"unfollowTitle": "Unfollow {name}?",
"unfollowDesc": "You'll stop seeing {name}'s posts in your feed.",
"keepFollowing": "Keep following",
"followError": "Something went wrong. Try again.",
"nowFollowing": "You're now following {name}",
"unfollowed": "Unfollowed {name}"
},
"Messages": {
"today": "Today",
"yesterday": "Yesterday",
"empty": "Say hello 👋",
"placeholder": "Message…",
"charsLeft": "{count} characters left",
"sendError": "Failed to send message. Try again."
},
"FeedList": {
"forYou": "For you",
"recent": "Recent",
"allCaughtUp": "You're all caught up. Follow more pets to see more posts."
}
}
+20 -18
View File
@@ -17,18 +17,7 @@ import {
SheetTitle,
} from "@/components/ui/sheet";
import { getAvatarUrl } from "@/lib/avatar-url";
function formatRelativeTime(date: Date | string): string {
const diffMs = Date.now() - new Date(date).getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m`;
if (diffHours < 24) return `${diffHours}h`;
if (diffDays < 7) return `${diffDays}d`;
return new Date(date).toLocaleDateString();
}
import { useTranslations } from "next-intl";
interface CommentSheetProps {
postId: string;
@@ -38,7 +27,20 @@ interface CommentSheetProps {
}
export function CommentSheet({ postId, petId, open, onOpenChange }: CommentSheetProps) {
const t = useTranslations("Comments");
const trpc = useTRPC();
function formatRelativeTime(date: Date | string): string {
const diffMs = Date.now() - new Date(date).getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return t("justNow");
if (diffMins < 60) return `${diffMins}m`;
if (diffHours < 24) return `${diffHours}h`;
if (diffDays < 7) return `${diffDays}d`;
return new Date(date).toLocaleDateString();
}
const queryClient = useQueryClient();
const [body, setBody] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -59,7 +61,7 @@ export function CommentSheet({ postId, petId, open, onOpenChange }: CommentSheet
queryKey: [["trpc", "feed", "getFeed"]],
});
},
onError: () => toast.error("Couldn't post comment. Please try again."),
onError: () => toast.error(t("postError")),
})
);
@@ -73,7 +75,7 @@ export function CommentSheet({ postId, petId, open, onOpenChange }: CommentSheet
queryKey: [["trpc", "feed", "getFeed"]],
});
},
onError: () => toast.error("Couldn't delete comment. Please try again."),
onError: () => toast.error(t("deleteError")),
})
);
@@ -104,7 +106,7 @@ export function CommentSheet({ postId, petId, open, onOpenChange }: CommentSheet
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="h-[80vh] flex flex-col p-0">
<SheetHeader className="px-4 pt-4 pb-2">
<SheetTitle className="text-left">Comments</SheetTitle>
<SheetTitle className="text-left">{t("title")}</SheetTitle>
</SheetHeader>
<Separator />
@@ -116,7 +118,7 @@ export function CommentSheet({ postId, petId, open, onOpenChange }: CommentSheet
</div>
) : comments.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
No comments yet — be the first!
{t("empty")}
</p>
) : (
<div className="py-2">
@@ -172,7 +174,7 @@ export function CommentSheet({ postId, petId, open, onOpenChange }: CommentSheet
ref={textareaRef}
rows={1}
maxLength={500}
placeholder="Add a comment…"
placeholder={t("placeholder")}
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={handleKeyDown}
@@ -187,7 +189,7 @@ export function CommentSheet({ postId, petId, open, onOpenChange }: CommentSheet
{createMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Post"
t("post")
)}
</Button>
</div>
+5 -3
View File
@@ -11,6 +11,7 @@ import { SkeletonCard } from "@/components/feed/SkeletonCard";
import { EmptyFeed } from "@/components/feed/EmptyFeed";
import { WelcomeCard } from "@/components/feed/WelcomeCard";
import { Sparkles, Clock } from "lucide-react";
import { useTranslations } from "next-intl";
const AD_INTERVAL = 7;
type FeedMode = "chrono" | "algo";
@@ -37,6 +38,7 @@ export function FeedList() {
const { activePetId } = useActivePet();
const trpc = useTRPC();
const { mode, changeMode } = useFeedMode();
const t = useTranslations("FeedList");
const { ref: sentinelRef, inView } = useInView({ threshold: 0 });
@@ -111,7 +113,7 @@ export function FeedList() {
}`}
>
<Sparkles className="h-3.5 w-3.5" />
Für dich
{t("forYou")}
</button>
<button
onClick={() => changeMode("chrono")}
@@ -122,7 +124,7 @@ export function FeedList() {
}`}
>
<Clock className="h-3.5 w-3.5" />
Aktuell
{t("recent")}
</button>
</div>
@@ -152,7 +154,7 @@ export function FeedList() {
{!hasNextPage && !isFetchingNextPage && allPosts.length > 0 && (
<p className="text-center text-sm text-muted-foreground py-8">
{"You're all caught up. Follow more pets to see more posts."}
{t("allCaughtUp")}
</p>
)}
+33 -33
View File
@@ -47,6 +47,7 @@ import { CommentSheet } from "@/components/feed/CommentSheet";
import { RepostCard } from "@/components/feed/RepostCard";
import { BlockDialog } from "@/components/safety/BlockDialog";
import { ReportSheet } from "@/components/safety/ReportSheet";
import { useTranslations } from "next-intl";
/**
* PostCard — full photo post card with carousel, caption clamp, and options menu.
@@ -117,25 +118,25 @@ export type PostCardProps = {
variant?: "feed" | "detail";
};
function formatRelativeTime(date: Date | string): string {
const now = Date.now();
const diffMs = now - new Date(date).getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return new Date(date).toLocaleDateString();
}
export function PostCard({ post, onDeleted, variant = "feed" }: PostCardProps) {
const t = useTranslations("PostCard");
const trpc = useTRPC();
const { activePetId } = useActivePet();
function formatRelativeTime(date: Date | string): string {
const now = Date.now();
const diffMs = now - new Date(date).getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return t("justNow");
if (diffMins < 60) return t("minutesAgo", { count: diffMins });
if (diffHours < 24) return t("hoursAgo", { count: diffHours });
if (diffDays < 7) return t("daysAgo", { count: diffDays });
return new Date(date).toLocaleDateString();
}
const { pet, images, createdAt } = post;
const [currentCaption, setCurrentCaption] = useState(post.caption);
@@ -183,10 +184,10 @@ export function PostCard({ post, onDeleted, variant = "feed" }: PostCardProps) {
onSuccess: (updated) => {
setCurrentCaption(updated.caption);
setIsEditingCaption(false);
toast.success("Caption updated");
toast.success(t("captionUpdated"));
},
onError: () => {
toast.error("Couldn't update caption. Please try again.");
toast.error(t("captionUpdateError"));
},
})
);
@@ -195,11 +196,11 @@ export function PostCard({ post, onDeleted, variant = "feed" }: PostCardProps) {
trpc.posts.delete.mutationOptions({
onSuccess: () => {
setShowDeleteDialog(false);
toast.success("Post deleted");
toast.success(t("postDeleted"));
onDeleted?.(post.id);
},
onError: () => {
toast.error("Couldn't delete post. Please try again.");
toast.error(t("deleteError"));
},
})
);
@@ -280,13 +281,13 @@ export function PostCard({ post, onDeleted, variant = "feed" }: PostCardProps) {
}}
className="cursor-pointer"
>
Edit caption
{t("editCaption")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setShowDeleteDialog(true)}
className="cursor-pointer text-destructive focus:text-destructive"
>
Delete post
{t("deletePost")}
</DropdownMenuItem>
</>
) : (
@@ -296,14 +297,14 @@ export function PostCard({ post, onDeleted, variant = "feed" }: PostCardProps) {
onClick={() => setShowReportSheet(true)}
className="cursor-pointer"
>
Report post
{t("reportPost")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowBlockDialog(true)}
className="cursor-pointer text-destructive focus:text-destructive"
>
Block {pet.name}
{t("blockPet", { name: pet.name })}
</DropdownMenuItem>
</>
)}
@@ -386,7 +387,7 @@ export function PostCard({ post, onDeleted, variant = "feed" }: PostCardProps) {
<Textarea
value={editCaptionValue}
onChange={(e) => setEditCaptionValue(e.target.value)}
placeholder="Write a caption…"
placeholder={t("captionPlaceholder")}
maxLength={500}
rows={3}
className="text-sm resize-none"
@@ -394,7 +395,7 @@ export function PostCard({ post, onDeleted, variant = "feed" }: PostCardProps) {
/>
{editCaptionValue.length > 450 && (
<p className="text-xs text-muted-foreground text-right">
{500 - editCaptionValue.length} characters remaining
{t("charsRemaining", { count: 500 - editCaptionValue.length })}
</p>
)}
<div className="flex gap-2">
@@ -404,7 +405,7 @@ export function PostCard({ post, onDeleted, variant = "feed" }: PostCardProps) {
disabled={updateCaptionMutation.isPending}
className="bg-orange-500 hover:bg-orange-600 text-white text-xs h-8"
>
Save caption
{t("saveCaption")}
</Button>
<Button
size="sm"
@@ -413,7 +414,7 @@ export function PostCard({ post, onDeleted, variant = "feed" }: PostCardProps) {
disabled={updateCaptionMutation.isPending}
className="text-xs h-8"
>
Cancel
{t("cancel")}
</Button>
</div>
</div>
@@ -447,7 +448,7 @@ export function PostCard({ post, onDeleted, variant = "feed" }: PostCardProps) {
onClick={() => setIsCaptionExpanded((v) => !v)}
className="text-xs text-muted-foreground hover:text-foreground mt-0.5"
>
{isCaptionExpanded ? "less" : "more"}
{isCaptionExpanded ? t("less") : t("more")}
</button>
)}
</div>
@@ -483,20 +484,19 @@ export function PostCard({ post, onDeleted, variant = "feed" }: PostCardProps) {
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this post?</AlertDialogTitle>
<AlertDialogTitle>{t("deleteTitle")}</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the post and all its photos. This
can&apos;t be undone.
{t("deleteDesc")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep post</AlertDialogCancel>
<AlertDialogCancel>{t("keepPost")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Yes, delete
{t("yesDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+16 -14
View File
@@ -12,28 +12,30 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { getAvatarUrl } from "@/lib/avatar-url";
import Link from "next/link";
import { useTranslations } from "next-intl";
function formatTime(date: Date | string) {
return new Date(date).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function formatDay(date: Date | string) {
const d = new Date(date);
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (d.toDateString() === today.toDateString()) return "Today";
if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
return d.toLocaleDateString([], { weekday: "long", month: "short", day: "numeric" });
}
interface MessageThreadProps {
conversationId: string;
otherPet: { id: string; name: string; avatarKey: string | null };
}
export function MessageThread({ conversationId, otherPet }: MessageThreadProps) {
const t = useTranslations("Messages");
const trpc = useTRPC();
function formatDay(date: Date | string) {
const d = new Date(date);
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (d.toDateString() === today.toDateString()) return t("today");
if (d.toDateString() === yesterday.toDateString()) return t("yesterday");
return d.toLocaleDateString([], { weekday: "long", month: "short", day: "numeric" });
}
const router = useRouter();
const queryClient = useQueryClient();
const { activePetId } = useActivePet();
@@ -64,7 +66,7 @@ export function MessageThread({ conversationId, otherPet }: MessageThreadProps)
trpc.conversations.unreadCount.queryOptions({ petId: activePetId ?? "" })
);
},
onError: () => toast.error("Failed to send message. Try again."),
onError: () => toast.error(t("sendError")),
})
);
@@ -147,7 +149,7 @@ export function MessageThread({ conversationId, otherPet }: MessageThreadProps)
</AvatarFallback>
</Avatar>
<p className="text-sm font-medium mt-2">{otherPet.name}</p>
<p className="text-xs">Say hello 👋</p>
<p className="text-xs">{t("empty")}</p>
</div>
)}
@@ -218,7 +220,7 @@ export function MessageThread({ conversationId, otherPet }: MessageThreadProps)
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Message…"
placeholder={t("placeholder")}
maxLength={1000}
rows={1}
className="resize-none text-sm min-h-[40px] max-h-[120px] flex-1"
@@ -241,7 +243,7 @@ export function MessageThread({ conversationId, otherPet }: MessageThreadProps)
</div>
{body.length > 900 && (
<p className="text-xs text-muted-foreground text-right mt-1">
{1000 - body.length} characters left
{t("charsLeft", { count: 1000 - body.length })}
</p>
)}
</div>
+13 -11
View File
@@ -16,6 +16,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useTRPC } from "@/trpc/client";
import { useTranslations } from "next-intl";
/**
* FollowButton — optimistic follow/unfollow toggle (SOCL-01, SOCL-02)
@@ -51,6 +52,7 @@ export function FollowButton({
followeePetId,
followeeName,
}: FollowButtonProps) {
const t = useTranslations("Follow");
const trpc = useTRPC();
const queryClient = useQueryClient();
const [showUnfollowDialog, setShowUnfollowDialog] = useState(false);
@@ -84,13 +86,13 @@ export function FollowButton({
isFollowingQueryOptions.queryKey,
context?.prev
);
toast.error("Something went wrong. Try again.");
toast.error(t("followError"));
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: isFollowingQueryOptions.queryKey });
},
onSuccess: () => {
toast.success(`You're now following ${followeeName}`);
toast.success(t("nowFollowing", { name: followeeName }));
},
})
);
@@ -108,13 +110,13 @@ export function FollowButton({
isFollowingQueryOptions.queryKey,
context?.prev
);
toast.error("Something went wrong. Try again.");
toast.error(t("followError"));
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: isFollowingQueryOptions.queryKey });
},
onSuccess: () => {
toast(`Unfollowed ${followeeName}`);
toast(t("unfollowed", { name: followeeName }));
},
})
);
@@ -148,17 +150,17 @@ export function FollowButton({
? "min-h-[44px] transition-colors duration-150"
: "min-h-[44px] bg-orange-500 hover:bg-orange-600 text-white transition-colors duration-150"
}
aria-label={isFollowing ? `Unfollow ${followeeName}` : `Follow ${followeeName}`}
aria-label={isFollowing ? `${t("unfollow")} ${followeeName}` : `${t("follow")} ${followeeName}`}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin opacity-60" />
) : isFollowing ? (
<>
<Check className="h-4 w-4 mr-1" />
Following
{t("following")}
</>
) : (
"Follow"
t("follow")
)}
</Button>
@@ -166,15 +168,15 @@ export function FollowButton({
<AlertDialog open={showUnfollowDialog} onOpenChange={setShowUnfollowDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unfollow {followeeName}?</AlertDialogTitle>
<AlertDialogTitle>{t("unfollowTitle", { name: followeeName })}</AlertDialogTitle>
<AlertDialogDescription>
You&apos;ll stop seeing {followeeName}&apos;s posts in your feed.
{t("unfollowDesc", { name: followeeName })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep following</AlertDialogCancel>
<AlertDialogCancel>{t("keepFollowing")}</AlertDialogCancel>
<AlertDialogAction onClick={handleUnfollowConfirm}>
Unfollow
{t("unfollow")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>