diff --git a/messages/de.json b/messages/de.json index 907b80e..8236d57 100644 --- a/messages/de.json +++ b/messages/de.json @@ -134,7 +134,9 @@ "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." + "deleteError": "Kommentar konnte nicht gelöscht werden. Bitte versuche es erneut.", + "countSingular": "1 Kommentar", + "countPlural": "{count} Kommentare" }, "Follow": { "follow": "Folgen", @@ -159,5 +161,101 @@ "forYou": "Für dich", "recent": "Aktuell", "allCaughtUp": "Du bist auf dem neuesten Stand. Folge mehr Tieren für weitere Beiträge." + }, + "ProfileTabs": { + "posts": "Beiträge", + "milestones": "Meilensteine", + "noPosts": "Noch keine Beiträge", + "noMilestones": "Noch keine Meilensteine" + }, + "ExploreCard": { + "followerSingular": "1 Follower", + "followerPlural": "{count} Follower", + "yourPet": "Dein Tier" + }, + "Health": { + "pageTitle": "Gesundheit — {petName}", + "shareCard": "Karte teilen", + "tabWeight": "Gewicht", + "tabVet": "Tierarzt", + "tabVaccines": "Impfungen", + "cancel": "Abbrechen", + "save": "Speichern", + "weight": { + "add": "Gewicht hinzufügen", + "noEntries": "Noch keine Gewichtseinträge", + "singular": "1 Eintrag", + "plural": "{count} Einträge", + "labelDate": "Datum", + "labelWeight": "Gewicht (kg)", + "placeholderWeight": "z.B. 4,2", + "labelNotes": "Notizen (optional)", + "placeholderNotes": "z.B. nach dem Morgenspaziergang", + "toastAdded": "Gewichtseintrag hinzugefügt", + "toastDeleted": "Eintrag gelöscht", + "toastError": "Gewichtseintrag konnte nicht gespeichert werden", + "toastDeleteError": "Eintrag konnte nicht gelöscht werden", + "toastValidation": "Bitte Datum und gültiges Gewicht eingeben" + }, + "vet": { + "add": "Besuch hinzufügen", + "noEntries": "Noch keine Tierarztbesuche", + "singular": "1 Besuch", + "plural": "{count} Besuche", + "labelDate": "Datum", + "labelVet": "Tierarzt / Klinik (optional)", + "placeholderVet": "z.B. Dr. Müller", + "labelReason": "Grund *", + "placeholderReason": "z.B. Jahresuntersuchung", + "labelNotes": "Notizen (optional)", + "placeholderNotes": "Diagnose, Behandlung, Medikamente…", + "toastAdded": "Tierarztbesuch hinzugefügt", + "toastDeleted": "Eintrag gelöscht", + "toastError": "Tierarztbesuch konnte nicht gespeichert werden", + "toastDeleteError": "Eintrag konnte nicht gelöscht werden", + "toastValidation": "Bitte Datum und Grund eingeben" + }, + "vaccines": { + "add": "Impfung hinzufügen", + "noEntries": "Noch keine Impfungen eingetragen", + "singular": "1 Impfung", + "plural": "{count} Impfungen", + "labelName": "Impfstoff *", + "placeholderName": "z.B. Tollwut, DHPP", + "labelDateGiven": "Gegeben am *", + "labelNextDue": "Nächste Fälligkeit (optional)", + "labelNotes": "Notizen (optional)", + "placeholderNotes": "Chargennummer, Klinik…", + "badgeOverdue": "Überfällig", + "badgeDue": "Fällig {date}", + "textGiven": "Gegeben {date}", + "toastAdded": "Impfung hinzugefügt", + "toastDeleted": "Eintrag gelöscht", + "toastError": "Impfung konnte nicht gespeichert werden", + "toastDeleteError": "Eintrag konnte nicht gelöscht werden", + "toastValidation": "Bitte Impfstoffname und Datum eingeben" + }, + "feeding": { + "sectionTitle": "Fütterung", + "edit": "Bearbeiten", + "add": "Fütterungsinfo hinzufügen", + "labelFoodType": "Futterart", + "labelFeedingTimes": "Mahlzeiten pro Tag", + "labelDailyAmount": "Tagesration (g)", + "labelBrand": "Marke (optional)", + "labelSpecialDiet": "Spezialdiät / Allergien (optional)", + "placeholderFoodType": "z.B. Trockenfutter, Nassfutter, BARF", + "placeholderFeedingTimes": "z.B. 2", + "placeholderDailyAmount": "z.B. 200", + "placeholderBrand": "z.B. Royal Canin", + "placeholderSpecialDiet": "z.B. Getreidefrei, Hühnchen-Allergie…", + "noInfo": "Noch keine Fütterungsinfos hinterlegt.", + "timesPerDay": "{count}x täglich", + "gramsPerDay": "{amount} g / Tag", + "toastSaved": "Fütterungsinfo gespeichert", + "toastDeleted": "Fütterungsinfo entfernt", + "toastError": "Fütterungsinfo konnte nicht gespeichert werden", + "toastDeleteError": "Fütterungsinfo konnte nicht entfernt werden" + } } } diff --git a/messages/en.json b/messages/en.json index 27e56d9..c00dab8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -134,7 +134,9 @@ "placeholder": "Add a comment…", "post": "Post", "postError": "Couldn't post comment. Please try again.", - "deleteError": "Couldn't delete comment. Please try again." + "deleteError": "Couldn't delete comment. Please try again.", + "countSingular": "1 comment", + "countPlural": "{count} comments" }, "Follow": { "follow": "Follow", @@ -159,5 +161,101 @@ "forYou": "For you", "recent": "Recent", "allCaughtUp": "You're all caught up. Follow more pets to see more posts." + }, + "ProfileTabs": { + "posts": "Posts", + "milestones": "Milestones", + "noPosts": "No posts yet", + "noMilestones": "No milestones yet" + }, + "ExploreCard": { + "followerSingular": "1 follower", + "followerPlural": "{count} followers", + "yourPet": "Your pet" + }, + "Health": { + "pageTitle": "Health — {petName}", + "shareCard": "Share Card", + "tabWeight": "Weight", + "tabVet": "Vet Visits", + "tabVaccines": "Vaccines", + "cancel": "Cancel", + "save": "Save", + "weight": { + "add": "Add Weight", + "noEntries": "No weight entries yet", + "singular": "1 entry", + "plural": "{count} entries", + "labelDate": "Date", + "labelWeight": "Weight (kg)", + "placeholderWeight": "e.g. 4.2", + "labelNotes": "Notes (optional)", + "placeholderNotes": "e.g. after morning walk", + "toastAdded": "Weight entry added", + "toastDeleted": "Entry deleted", + "toastError": "Failed to save weight entry", + "toastDeleteError": "Failed to delete entry", + "toastValidation": "Please fill in date and a valid weight" + }, + "vet": { + "add": "Add Visit", + "noEntries": "No vet visits yet", + "singular": "1 visit", + "plural": "{count} visits", + "labelDate": "Date", + "labelVet": "Vet / Clinic (optional)", + "placeholderVet": "e.g. Dr. Müller", + "labelReason": "Reason *", + "placeholderReason": "e.g. Annual checkup", + "labelNotes": "Notes (optional)", + "placeholderNotes": "Diagnosis, treatment, medications…", + "toastAdded": "Vet visit added", + "toastDeleted": "Entry deleted", + "toastError": "Failed to save vet visit", + "toastDeleteError": "Failed to delete entry", + "toastValidation": "Please fill in date and reason" + }, + "vaccines": { + "add": "Add Vaccine", + "noEntries": "No vaccines recorded yet", + "singular": "1 vaccine", + "plural": "{count} vaccines", + "labelName": "Vaccine Name *", + "placeholderName": "e.g. Rabies, DHPP", + "labelDateGiven": "Date Given *", + "labelNextDue": "Next Due (optional)", + "labelNotes": "Notes (optional)", + "placeholderNotes": "Batch number, clinic…", + "badgeOverdue": "Overdue", + "badgeDue": "Due {date}", + "textGiven": "Given {date}", + "toastAdded": "Vaccine added", + "toastDeleted": "Entry deleted", + "toastError": "Failed to save vaccine", + "toastDeleteError": "Failed to delete entry", + "toastValidation": "Please fill in vaccine name and date given" + }, + "feeding": { + "sectionTitle": "Feeding", + "edit": "Edit", + "add": "Add Feeding Info", + "labelFoodType": "Food type", + "labelFeedingTimes": "Feedings per day", + "labelDailyAmount": "Daily amount (g)", + "labelBrand": "Brand (optional)", + "labelSpecialDiet": "Special diet / allergies (optional)", + "placeholderFoodType": "e.g. Dry food, Wet food, BARF", + "placeholderFeedingTimes": "e.g. 2", + "placeholderDailyAmount": "e.g. 200", + "placeholderBrand": "e.g. Royal Canin", + "placeholderSpecialDiet": "e.g. Grain-free, chicken allergy…", + "noInfo": "No feeding info saved yet.", + "timesPerDay": "{count}x per day", + "gramsPerDay": "{amount} g / day", + "toastSaved": "Feeding info saved", + "toastDeleted": "Feeding info removed", + "toastError": "Could not save feeding info", + "toastDeleteError": "Could not remove feeding info" + } } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b74ba39..d742206 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -117,6 +117,7 @@ model Pet { vaccines Vaccine[] healthCard HealthCard? emergencyVet EmergencyVet? + feedingInfo PetFeedingInfo? // Ad interaction back-relations adReactions AdReaction[] @relation("AdReactions") @@ -459,6 +460,18 @@ model HealthCard { updatedAt DateTime @updatedAt } +model PetFeedingInfo { + id String @id @default(cuid()) + pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade) + petId String @unique + foodType String? @db.VarChar(100) + feedingTimes Int? + dailyAmountG Int? + foodBrand String? @db.VarChar(100) + specialDiet String? @db.VarChar(500) + updatedAt DateTime @updatedAt +} + // ─── Phase 4 Enums ─────────────────────────────────────────────────────────── enum NotificationType { diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 7f388d9..a6465fd 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -4,6 +4,7 @@ import { cookies } from "next/headers"; import { createTRPCCaller } from "@/trpc/server"; import { Sidebar } from "@/components/layout/Sidebar"; import { MobileNav } from "@/components/layout/MobileNav"; +import { ActivePetInitializer } from "@/components/layout/ActivePetInitializer"; /** * Protected app shell layout — applied to all routes under /(app)/. @@ -54,6 +55,7 @@ export default async function AppLayout({ return (
+ {/* Desktop sidebar — hidden on mobile */} diff --git a/src/app/health-card/[token]/page.tsx b/src/app/health-card/[token]/page.tsx index 2b77a13..6bd70aa 100644 --- a/src/app/health-card/[token]/page.tsx +++ b/src/app/health-card/[token]/page.tsx @@ -46,6 +46,7 @@ export default async function HealthCardPage({ params, searchParams }: PageProps weightLogs: { orderBy: { date: "desc" }, take: 20 }, vetVisits: { orderBy: { date: "desc" }, take: 10 }, vaccines: { orderBy: { dateGiven: "desc" } }, + feedingInfo: true, }, }, }, @@ -218,14 +219,56 @@ export default async function HealthCardPage({ params, searchParams }: PageProps
)} + {/* Feeding info */} + {pet.feedingInfo && ( +
+

+ + Fütterung +

+
+ {pet.feedingInfo.foodType && ( +
+

Futterart

+

{pet.feedingInfo.foodType}

+
+ )} + {(pet.feedingInfo.feedingTimes != null || pet.feedingInfo.dailyAmountG != null) && ( +
+

Ration

+

+ {pet.feedingInfo.feedingTimes != null && `${pet.feedingInfo.feedingTimes}x täglich`} + {pet.feedingInfo.feedingTimes != null && pet.feedingInfo.dailyAmountG != null && " · "} + {pet.feedingInfo.dailyAmountG != null && `${pet.feedingInfo.dailyAmountG} g / Tag`} +

+
+ )} + {pet.feedingInfo.foodBrand && ( +
+

Marke

+

{pet.feedingInfo.foodBrand}

+
+ )} + {pet.feedingInfo.specialDiet && ( +
+

Spezialdiät / Allergien

+

{pet.feedingInfo.specialDiet}

+
+ )} +
+
+ )} + {pet.vaccines.length === 0 && pet.weightLogs.length === 0 && - pet.vetVisits.length === 0 && ( + pet.vetVisits.length === 0 && + !pet.feedingInfo && (

Noch keine Gesundheitsdaten vorhanden.

)} + {/* Footer */}
Erstellt via PawFeed · {new Date().toLocaleDateString("de-DE")} diff --git a/src/app/onboarding/pet/page.tsx b/src/app/onboarding/pet/page.tsx index f621090..cc1e597 100644 --- a/src/app/onboarding/pet/page.tsx +++ b/src/app/onboarding/pet/page.tsx @@ -6,6 +6,7 @@ import { useTranslations } from "next-intl"; import { PetForm } from "@/components/pet/PetForm"; import { AvatarUpload } from "@/components/pet/AvatarUpload"; import { Button } from "@/components/ui/button"; +import { useActivePet } from "@/context/ActivePetContext"; /** * Onboarding pet page — /onboarding/pet @@ -19,6 +20,7 @@ import { Button } from "@/components/ui/button"; export default function OnboardingPetPage() { const router = useRouter(); const t = useTranslations("Onboarding"); + const { setActivePet } = useActivePet(); const [createdPetId, setCreatedPetId] = useState(null); if (createdPetId) { @@ -59,7 +61,10 @@ export default function OnboardingPetPage() {

{t("addFirstPetDesc")}

setCreatedPetId(petId)} + onSuccess={(petId) => { + setActivePet(petId); + setCreatedPetId(petId); + }} />
diff --git a/src/components/explore/ExploreCard.tsx b/src/components/explore/ExploreCard.tsx index df5ed2e..e35ec6e 100644 --- a/src/components/explore/ExploreCard.tsx +++ b/src/components/explore/ExploreCard.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import { useTranslations } from "next-intl"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { FollowButton } from "@/components/social/FollowButton"; @@ -21,6 +22,7 @@ interface ExploreCardProps { } export function ExploreCard({ pet, activePetId }: ExploreCardProps) { + const t = useTranslations("ExploreCard"); const avatarUrl = getAvatarUrl(pet.avatarKey); const isOwnPet = pet.id === activePetId; const labelName = pet.breed?.name ?? pet.species.name; @@ -46,11 +48,11 @@ export function ExploreCard({ pet, activePetId }: ExploreCardProps) {

- {followerCount === 1 ? "1 follower" : `${followerCount} followers`} + {followerCount === 1 ? t("followerSingular") : t("followerPlural", { count: followerCount })}

{isOwnPet ? ( -

Your pet

+

{t("yourPet")}

) : ( void; -} - -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(null); - - const { data, isLoading } = useQuery( - trpc.comments.listByPost.queryOptions({ postId, limit: 50 }) - ); - const comments = data?.comments ?? []; - - const createMutation = useMutation( - trpc.comments.create.mutationOptions({ - onSuccess: () => { - setBody(""); - void queryClient.invalidateQueries({ - queryKey: [["trpc", "comments", "listByPost"]], - }); - void queryClient.invalidateQueries({ - queryKey: [["trpc", "feed", "getFeed"]], - }); - }, - onError: () => toast.error(t("postError")), - }) - ); - - const deleteMutation = useMutation( - trpc.comments.delete.mutationOptions({ - onSuccess: () => { - void queryClient.invalidateQueries({ - queryKey: [["trpc", "comments", "listByPost"]], - }); - void queryClient.invalidateQueries({ - queryKey: [["trpc", "feed", "getFeed"]], - }); - }, - onError: () => toast.error(t("deleteError")), - }) - ); - - const handleSubmit = useCallback(() => { - const trimmed = body.trim(); - if (!trimmed || createMutation.isPending) return; - createMutation.mutate({ postId, petId, body: trimmed }); - }, [body, postId, petId, createMutation]); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - handleSubmit(); - } - }, - [handleSubmit] - ); - - // Auto-focus textarea when sheet opens - useEffect(() => { - if (open) { - setTimeout(() => textareaRef.current?.focus(), 100); - } - }, [open]); - - return ( - - - - {t("title")} - - - - - - {isLoading ? ( -
- -
- ) : comments.length === 0 ? ( -

- {t("empty")} -

- ) : ( -
- {comments.map((comment) => { - const isOwn = comment.pet.id === petId; - const avatarUrl = getAvatarUrl(comment.pet.avatarKey); - return ( -
- - {avatarUrl && ( - {comment.pet.name} - )} - - {comment.pet.name[0]?.toUpperCase() ?? "?"} - - -
-
- - {comment.pet.name} - - - {formatRelativeTime(comment.createdAt)} - -
-

- {parseCaption(comment.body).map((seg, i) => - seg.type === "hashtag" ? ( - #{seg.tag} - ) : seg.type === "mention" ? ( - @{seg.name} - ) : ( - {seg.text} - ) - )} -

-
- {isOwn && ( - - )} -
- ); - })} -
- )} -
- - - - {/* Comment input — sticky at bottom */} -
- setBody(val)} - onKeyDown={handleKeyDown} - className="flex-1 resize-none min-h-[40px] max-h-[120px] text-sm" - /> - -
-
-
- ); -} diff --git a/src/components/feed/PostDetailDialog.tsx b/src/components/feed/PostDetailDialog.tsx new file mode 100644 index 0000000..9fe7782 --- /dev/null +++ b/src/components/feed/PostDetailDialog.tsx @@ -0,0 +1,484 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; +import { MoreHorizontal, Trash2, Loader2 } from "lucide-react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useTRPC } from "@/trpc/client"; +import { useActivePet } from "@/context/ActivePetContext"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { MentionTextarea } from "@/components/ui/mention-textarea"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, + type CarouselApi, +} from "@/components/ui/carousel"; +import Link from "next/link"; +import { getAvatarUrl } from "@/lib/avatar-url"; +import { getMediaUrl } from "@/lib/media-url"; +import { parseCaption } from "@/lib/parse-caption"; +import { PawButton } from "@/components/feed/PawButton"; +import { PawBackButton } from "@/components/feed/PawBackButton"; +import { PawRing } from "@/components/stories/PawRing"; +import type { PostCardPost } from "@/components/feed/PostCard"; +import { useTranslations } from "next-intl"; + +interface PostDetailDialogProps { + post: PostCardPost | null; + open: boolean; + onClose: () => void; + onDeleted?: (postId: string) => void; +} + +export function PostDetailDialog({ post, open, onClose, onDeleted }: PostDetailDialogProps) { + const t = useTranslations("PostCard"); + const tc = useTranslations("Comments"); + const trpc = useTRPC(); + const { activePetId } = useActivePet(); + const queryClient = useQueryClient(); + + const [carouselApi, setCarouselApi] = useState(); + const [currentSlide, setCurrentSlide] = useState(1); + const [isEditingCaption, setIsEditingCaption] = useState(false); + const [editCaptionValue, setEditCaptionValue] = useState(""); + const [currentCaption, setCurrentCaption] = useState(null); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [body, setBody] = useState(""); + const commentsEndRef = useRef(null); + + useEffect(() => { + if (!post) return; + setCurrentCaption(post.caption); + setEditCaptionValue(post.caption ?? ""); + setIsEditingCaption(false); + setBody(""); + setCurrentSlide(1); + }, [post?.id]); + + useEffect(() => { + if (!carouselApi) return; + setCurrentSlide(carouselApi.selectedScrollSnap() + 1); + carouselApi.on("select", () => setCurrentSlide(carouselApi.selectedScrollSnap() + 1)); + }, [carouselApi]); + + const { data: commentsData, isLoading: commentsLoading } = useQuery({ + ...trpc.comments.listByPost.queryOptions({ postId: post?.id ?? "", limit: 50 }), + enabled: !!post?.id && open, + }); + + const updateCaptionMutation = useMutation( + trpc.posts.updateCaption.mutationOptions({ + onSuccess: (updated) => { + setCurrentCaption(updated.caption); + setIsEditingCaption(false); + toast.success(t("captionUpdated")); + }, + onError: () => toast.error(t("captionUpdateError")), + }) + ); + + const deleteMutation = useMutation( + trpc.posts.delete.mutationOptions({ + onSuccess: () => { + setShowDeleteDialog(false); + toast.success(t("postDeleted")); + onClose(); + if (post) onDeleted?.(post.id); + }, + onError: () => toast.error(t("deleteError")), + }) + ); + + const createCommentMutation = useMutation( + trpc.comments.create.mutationOptions({ + onSuccess: () => { + setBody(""); + void queryClient.invalidateQueries({ queryKey: [["trpc", "comments", "listByPost"]] }); + void queryClient.invalidateQueries({ queryKey: [["trpc", "feed", "getFeed"]] }); + setTimeout(() => commentsEndRef.current?.scrollIntoView({ behavior: "smooth" }), 150); + }, + onError: () => toast.error(tc("postError")), + }) + ); + + const deleteCommentMutation = useMutation( + trpc.comments.delete.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: [["trpc", "comments", "listByPost"]] }); + }, + onError: () => toast.error(tc("deleteError")), + }) + ); + + const handleSubmitComment = useCallback(() => { + if (!body.trim() || !post || !activePetId) return; + createCommentMutation.mutate({ postId: post.id, petId: activePetId, body: body.trim() }); + }, [body, post, activePetId, createCommentMutation]); + + const handleCommentKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSubmitComment(); + } + }, + [handleSubmitComment] + ); + + 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 tc("justNow"); + if (diffMins < 60) return `${diffMins}m`; + if (diffHours < 24) return `${diffHours}h`; + if (diffDays < 7) return `${diffDays}d`; + return new Date(date).toLocaleDateString(); + } + + if (!post) return null; + + const { pet, images } = post; + const isCarousel = images.length > 1; + const isOwnPost = activePetId === pet.id; + const avatarUrl = getAvatarUrl(pet.avatarKey); + const comments = commentsData?.comments ?? []; + + // Extracted so it renders identically in the mobile bar and the desktop right-panel + const headerInner = ( + <> + + + + {avatarUrl && } + + {pet.name[0]?.toUpperCase() ?? "?"} + + + +
+

{pet.name}

+

{formatRelativeTime(post.createdAt)}

+
+ + + + + + + {isOwnPost ? ( + <> + { setEditCaptionValue(currentCaption ?? ""); setIsEditingCaption(true); }} + className="cursor-pointer" + > + {t("editCaption")} + + setShowDeleteDialog(true)} + className="cursor-pointer text-destructive focus:text-destructive" + > + {t("deletePost")} + + + ) : ( + <> + {t("reportPost")} + + + {t("blockPet", { name: pet.name })} + + + )} + + + + ); + + const imageContent = images.length > 0 && ( + isCarousel ? ( +
+ + + {images.map((img) => ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + {currentCaption + + ))} + + + + + + {currentSlide} / {images.length} + +
+ {images.map((_, i) => ( +
+
+ ) : ( + // eslint-disable-next-line @next/next/no-img-element + {currentCaption + ) + ); + + return ( + <> + { if (!o) onClose(); }}> + {/* + Layout: + Mobile → flex-col: [header][image 45dvh][right-panel scrollable] + Desktop → flex-row: [image flex-1 left][right-panel 360px right] + */} + + {/* Mobile-only header — sits above the image on small screens */} +
+ {headerInner} +
+ + {/* Image column — fixed 45dvh strip on mobile, flex-1 full-height on desktop */} +
+ {imageContent} +
+ + {/* Right panel: desktop header + scrollable body + sticky input */} +
+ {/* Desktop-only header */} +
+ {headerInner} +
+ + {/* Scrollable body */} +
+
+ {isEditingCaption ? ( +
+ +
+ + +
+
+ ) : ( + currentCaption && ( +

+ {parseCaption(currentCaption).map((seg, i) => + seg.type === "hashtag" ? ( + + #{seg.tag} + + ) : seg.type === "mention" ? ( + + @{seg.name} + + ) : ( + {seg.text} + ) + )} +

+ ) + )} + + {activePetId && ( +
+ + + {comments.length > 0 && ( + + {comments.length === 1 ? tc("countSingular") : tc("countPlural", { count: comments.length })} + + )} +
+ )} +
+ +
+ {commentsLoading && ( +
+ +
+ )} + {!commentsLoading && comments.length === 0 && ( +

{tc("empty")}

+ )} + {comments.map((comment) => { + const commentAvatarUrl = getAvatarUrl(comment.pet.avatarKey); + const isOwnComment = comment.pet.id === activePetId; + return ( +
+ + {commentAvatarUrl && } + + {comment.pet.name[0]?.toUpperCase()} + + +
+
+ + {comment.pet.name} + + + {formatRelativeTime(comment.createdAt)} + +
+

+ {parseCaption(comment.body).map((seg, i) => + seg.type === "hashtag" ? ( + #{seg.tag} + ) : seg.type === "mention" ? ( + @{seg.name} + ) : ( + {seg.text} + ) + )} +

+
+ {isOwnComment && activePetId && ( + + )} +
+ ); + })} +
+
+
+ + {/* Sticky comment input */} + {activePetId && ( +
+ + +
+ )} +
+ +
+ + + + + {t("deleteTitle")} + {t("deleteDesc")} + + + {t("keepPost")} + deleteMutation.mutate({ postId: post.id })} + disabled={deleteMutation.isPending} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {t("yesDelete")} + + + + + + ); +} diff --git a/src/components/health/FeedingSection.tsx b/src/components/health/FeedingSection.tsx new file mode 100644 index 0000000..a642da9 --- /dev/null +++ b/src/components/health/FeedingSection.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { Pencil, Trash2, Loader2, UtensilsCrossed } from "lucide-react"; +import { useTRPC } from "@/trpc/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; + +interface Props { petId: string } + +export function FeedingSection({ petId }: Props) { + const t = useTranslations("Health.feeding"); + const tc = useTranslations("Health"); + const trpc = useTRPC(); + const qc = useQueryClient(); + + const { data: info, isLoading } = useQuery( + trpc.health.getFeedingInfo.queryOptions({ petId }) + ); + + const saveMutation = useMutation({ + ...trpc.health.setFeedingInfo.mutationOptions(), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: trpc.health.getFeedingInfo.queryKey({ petId }) }); + toast.success(t("toastSaved")); + setEditing(false); + }, + onError: () => toast.error(t("toastError")), + }); + + const deleteMutation = useMutation({ + ...trpc.health.deleteFeedingInfo.mutationOptions(), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: trpc.health.getFeedingInfo.queryKey({ petId }) }); + toast.success(t("toastDeleted")); + }, + onError: () => toast.error(t("toastDeleteError")), + }); + + const [editing, setEditing] = useState(false); + const [form, setForm] = useState({ + foodType: "", + feedingTimes: "", + dailyAmountG: "", + foodBrand: "", + specialDiet: "", + }); + + const openEdit = () => { + setForm({ + foodType: info?.foodType ?? "", + feedingTimes: info?.feedingTimes?.toString() ?? "", + dailyAmountG: info?.dailyAmountG?.toString() ?? "", + foodBrand: info?.foodBrand ?? "", + specialDiet: info?.specialDiet ?? "", + }); + setEditing(true); + }; + + const handleSave = () => { + saveMutation.mutate({ + petId, + foodType: form.foodType.trim() || null, + feedingTimes: form.feedingTimes ? parseInt(form.feedingTimes) : null, + dailyAmountG: form.dailyAmountG ? parseInt(form.dailyAmountG) : null, + foodBrand: form.foodBrand.trim() || null, + specialDiet: form.specialDiet.trim() || null, + }); + }; + + if (isLoading) return null; + + return ( +
+
+

+ + {t("sectionTitle")} +

+ {info && !editing && ( +
+ + +
+ )} + {!info && !editing && ( + + )} +
+ + {/* View mode */} + {info && !editing && ( +
+ {info.foodType && ( +
+

{t("labelFoodType")}

+

{info.foodType}

+
+ )} + {(info.feedingTimes != null || info.dailyAmountG != null) && ( +
+

{t("labelFeedingTimes")}

+

+ {info.feedingTimes != null && t("timesPerDay", { count: info.feedingTimes })} + {info.feedingTimes != null && info.dailyAmountG != null && " · "} + {info.dailyAmountG != null && t("gramsPerDay", { amount: info.dailyAmountG })} +

+
+ )} + {info.foodBrand && ( +
+

{t("labelBrand")}

+

{info.foodBrand}

+
+ )} + {info.specialDiet && ( +
+

{t("labelSpecialDiet")}

+

{info.specialDiet}

+
+ )} +
+ )} + + {/* Edit mode */} + {editing && ( +
+
+ + setForm((f) => ({ ...f, foodType: e.target.value }))} + /> +
+
+
+ + setForm((f) => ({ ...f, feedingTimes: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, dailyAmountG: e.target.value }))} + /> +
+
+
+ + setForm((f) => ({ ...f, foodBrand: e.target.value }))} + /> +
+
+ +