feat: i18n translations, PostDetailDialog 2-col layout, feeding info health module

- Translate ProfileTabs, ExploreCard, PostDetailDialog, HealthDashboard (EN + DE)
- PostDetailDialog: Instagram-style two-column layout (image left, comments right)
- Fix sm:max-w-[960px] override for dialog width on all breakpoints
- ActivePetInitializer: auto-set first pet on login if activePetId is null
- Onboarding: set activePetId immediately after first pet creation
- Add PetFeedingInfo model (foodType, feedingTimes, dailyAmountG, foodBrand, specialDiet)
- FeedingSection component with view/edit/delete, shown in HealthDashboard
- Health Card public page includes feeding info block
- Delete CommentSheet.tsx (dead code)
This commit is contained in:
admin committed 2026-06-23 14:39:21 +02:00
1 parent 2568c32cf4
commit dc4c196721
14 files changed
+1109 -306

No files matched your search

+99 -1
View File
@@ -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"
}
}
}
+99 -1
View File
@@ -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"
}
}
}
+13
View File
@@ -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 {
+2
View File
@@ -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 (
<div className="flex min-h-screen">
<ActivePetInitializer firstPetId={pets[0].id} />
{/* Desktop sidebar — hidden on mobile */}
<Sidebar pets={pets} />
+44 -1
View File
@@ -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
</div>
)}
{/* Feeding info */}
{pet.feedingInfo && (
<div className="bg-card rounded-xl border p-5 space-y-3">
<h2 className="text-base font-semibold flex items-center gap-2">
<span className="w-1 h-5 rounded-full bg-amber-500 inline-block" />
Fütterung
</h2>
<div className="space-y-2">
{pet.feedingInfo.foodType && (
<div className="flex items-baseline justify-between gap-2 py-1 border-b last:border-0">
<p className="text-xs text-muted-foreground">Futterart</p>
<p className="text-sm font-medium">{pet.feedingInfo.foodType}</p>
</div>
)}
{(pet.feedingInfo.feedingTimes != null || pet.feedingInfo.dailyAmountG != null) && (
<div className="flex items-baseline justify-between gap-2 py-1 border-b last:border-0">
<p className="text-xs text-muted-foreground">Ration</p>
<p className="text-sm font-medium">
{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`}
</p>
</div>
)}
{pet.feedingInfo.foodBrand && (
<div className="flex items-baseline justify-between gap-2 py-1 border-b last:border-0">
<p className="text-xs text-muted-foreground">Marke</p>
<p className="text-sm font-medium">{pet.feedingInfo.foodBrand}</p>
</div>
)}
{pet.feedingInfo.specialDiet && (
<div className="py-1">
<p className="text-xs text-muted-foreground mb-0.5">Spezialdiät / Allergien</p>
<p className="text-sm">{pet.feedingInfo.specialDiet}</p>
</div>
)}
</div>
</div>
)}
{pet.vaccines.length === 0 &&
pet.weightLogs.length === 0 &&
pet.vetVisits.length === 0 && (
pet.vetVisits.length === 0 &&
!pet.feedingInfo && (
<div className="bg-card rounded-xl border p-8 text-center text-muted-foreground">
<p className="text-sm">Noch keine Gesundheitsdaten vorhanden.</p>
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between text-xs text-muted-foreground pb-4">
<span>Erstellt via PawFeed · {new Date().toLocaleDateString("de-DE")}</span>
+6 -1
View File
@@ -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<string | null>(null);
if (createdPetId) {
@@ -59,7 +61,10 @@ export default function OnboardingPetPage() {
<p className="text-sm text-muted-foreground mb-8">{t("addFirstPetDesc")}</p>
<PetForm
mode="create"
onSuccess={(petId) => setCreatedPetId(petId)}
onSuccess={(petId) => {
setActivePet(petId);
setCreatedPetId(petId);
}}
/>
</div>
</main>
+4 -2
View File
@@ -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) {
</Badge>
<p className="text-xs text-muted-foreground">
{followerCount === 1 ? "1 follower" : `${followerCount} followers`}
{followerCount === 1 ? t("followerSingular") : t("followerPlural", { count: followerCount })}
</p>
{isOwnPet ? (
<p className="text-xs text-muted-foreground">Your pet</p>
<p className="text-xs text-muted-foreground">{t("yourPet")}</p>
) : (
<FollowButton
followerPetId={activePetId}
-211
View File
@@ -1,211 +0,0 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { Trash2, Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTRPC } from "@/trpc/client";
import Link from "next/link";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { MentionTextarea } from "@/components/ui/mention-textarea";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { getAvatarUrl } from "@/lib/avatar-url";
import { parseCaption } from "@/lib/parse-caption";
import { useTranslations } from "next-intl";
interface CommentSheetProps {
postId: string;
petId: string; // active pet (commenter)
open: boolean;
onOpenChange: (open: boolean) => 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<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
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 (
<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">{t("title")}</SheetTitle>
</SheetHeader>
<Separator />
<ScrollArea className="flex-1 px-4">
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : comments.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
{t("empty")}
</p>
) : (
<div className="py-2">
{comments.map((comment) => {
const isOwn = comment.pet.id === petId;
const avatarUrl = getAvatarUrl(comment.pet.avatarKey);
return (
<div key={comment.id} className="flex gap-3 py-3">
<Avatar className="w-8 h-8 shrink-0">
{avatarUrl && (
<img src={avatarUrl} alt={comment.pet.name} className="object-cover" />
)}
<AvatarFallback className="text-xs">
{comment.pet.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="text-sm font-medium truncate">
{comment.pet.name}
</span>
<span className="text-xs text-muted-foreground shrink-0">
{formatRelativeTime(comment.createdAt)}
</span>
</div>
<p className="text-sm mt-0.5 break-words">
{parseCaption(comment.body).map((seg, i) =>
seg.type === "hashtag" ? (
<Link key={i} href={`/hashtag/${seg.tag}`} className="text-orange-500 hover:underline">#{seg.tag}</Link>
) : seg.type === "mention" ? (
<Link key={i} href={`/search?q=${seg.name}`} className="text-blue-500 hover:underline">@{seg.name}</Link>
) : (
<span key={i}>{seg.text}</span>
)
)}
</p>
</div>
{isOwn && (
<button
type="button"
aria-label="Delete comment"
disabled={deleteMutation.isPending}
onClick={() =>
deleteMutation.mutate({ commentId: comment.id, petId })
}
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
);
})}
</div>
)}
</ScrollArea>
<Separator />
{/* Comment input — sticky at bottom */}
<div className="flex gap-2 px-4 py-3 bg-background">
<MentionTextarea
ref={textareaRef}
rows={1}
maxLength={500}
placeholder={t("placeholder")}
value={body}
onChange={(val) => setBody(val)}
onKeyDown={handleKeyDown}
className="flex-1 resize-none min-h-[40px] max-h-[120px] text-sm"
/>
<Button
size="sm"
disabled={!body.trim() || createMutation.isPending}
onClick={handleSubmit}
className="bg-orange-500 hover:bg-orange-600 text-white self-end"
>
{createMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
t("post")
)}
</Button>
</div>
</SheetContent>
</Sheet>
);
}
+484
View File
@@ -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<CarouselApi>();
const [currentSlide, setCurrentSlide] = useState(1);
const [isEditingCaption, setIsEditingCaption] = useState(false);
const [editCaptionValue, setEditCaptionValue] = useState("");
const [currentCaption, setCurrentCaption] = useState<string | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [body, setBody] = useState("");
const commentsEndRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>) => {
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 = (
<>
<Link
href={`/pets/${pet.id}`}
className="flex items-center gap-3 flex-1 min-w-0 hover:opacity-80 transition-opacity"
onClick={onClose}
>
<PawRing petId={pet.id} size={32}>
<Avatar className="w-8 h-8">
{avatarUrl && <AvatarImage src={avatarUrl} alt={pet.name} />}
<AvatarFallback className="text-xs bg-muted">
{pet.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
</PawRing>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground leading-tight truncate">{pet.name}</p>
<p className="text-xs text-muted-foreground">{formatRelativeTime(post.createdAt)}</p>
</div>
</Link>
<DropdownMenu>
<DropdownMenuTrigger
className="flex items-center justify-center w-10 h-10 rounded-full hover:bg-muted transition-colors cursor-pointer"
aria-label="Post options"
>
<MoreHorizontal className="h-5 w-5 text-muted-foreground" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isOwnPost ? (
<>
<DropdownMenuItem
onClick={() => { setEditCaptionValue(currentCaption ?? ""); setIsEditingCaption(true); }}
className="cursor-pointer"
>
{t("editCaption")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setShowDeleteDialog(true)}
className="cursor-pointer text-destructive focus:text-destructive"
>
{t("deletePost")}
</DropdownMenuItem>
</>
) : (
<>
<DropdownMenuItem className="cursor-pointer">{t("reportPost")}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer text-destructive focus:text-destructive">
{t("blockPet", { name: pet.name })}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
);
const imageContent = images.length > 0 && (
isCarousel ? (
<div className="relative w-full h-full">
<Carousel setApi={setCarouselApi} className="h-full">
<CarouselContent className="h-full">
{images.map((img) => (
<CarouselItem key={img.id} className="basis-full flex items-center justify-center h-full">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getMediaUrl(img.storageKey) ?? ""}
alt={currentCaption ?? `${pet.name}'s post`}
className="max-h-full max-w-full object-contain"
/>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-2" />
<CarouselNext className="right-2" />
</Carousel>
<span className="absolute top-2 right-2 z-10 text-xs text-white bg-black/50 rounded px-1.5 py-0.5 select-none">
{currentSlide} / {images.length}
</span>
<div className="absolute bottom-2 left-0 right-0 z-10 flex justify-center gap-2">
{images.map((_, i) => (
<button
key={i}
className={`w-1.5 h-1.5 rounded-full transition-colors ${i === currentSlide - 1 ? "bg-white" : "bg-white/40"}`}
onClick={() => carouselApi?.scrollTo(i)}
aria-label={`Go to image ${i + 1}`}
/>
))}
</div>
</div>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getMediaUrl(images[0].storageKey) ?? ""}
alt={currentCaption ?? `${pet.name}'s post`}
className="max-h-full max-w-full object-contain"
/>
)
);
return (
<>
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
{/*
Layout:
Mobile → flex-col: [header][image 45dvh][right-panel scrollable]
Desktop → flex-row: [image flex-1 left][right-panel 360px right]
*/}
<DialogContent
showCloseButton={false}
className="p-0 max-w-[960px] sm:max-w-[960px] w-full h-[90dvh] flex flex-col md:flex-row overflow-hidden rounded-xl gap-0"
>
{/* Mobile-only header — sits above the image on small screens */}
<div className="md:hidden flex items-center gap-3 px-4 py-3 border-b shrink-0">
{headerInner}
</div>
{/* Image column — fixed 45dvh strip on mobile, flex-1 full-height on desktop */}
<div className="shrink-0 h-[45dvh] md:h-full md:flex-1 md:min-w-0 bg-black flex items-center justify-center">
{imageContent}
</div>
{/* Right panel: desktop header + scrollable body + sticky input */}
<div className="flex flex-col md:w-[360px] md:shrink-0 min-h-0 flex-1 md:flex-none md:border-l">
{/* Desktop-only header */}
<div className="hidden md:flex items-center gap-3 px-4 py-3 border-b shrink-0">
{headerInner}
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="px-4 pt-3 pb-2 space-y-2">
{isEditingCaption ? (
<div className="space-y-2">
<MentionTextarea
value={editCaptionValue}
onChange={setEditCaptionValue}
maxLength={500}
rows={3}
className="text-sm resize-none"
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={() =>
updateCaptionMutation.mutate({ postId: post.id, caption: editCaptionValue.trim() || null })
}
disabled={updateCaptionMutation.isPending}
className="bg-orange-500 hover:bg-orange-600 text-white text-xs h-8"
>
{t("saveCaption")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setIsEditingCaption(false)}
className="text-xs h-8"
>
{t("cancel")}
</Button>
</div>
</div>
) : (
currentCaption && (
<p className="text-sm text-foreground">
{parseCaption(currentCaption).map((seg, i) =>
seg.type === "hashtag" ? (
<Link key={i} href={`/hashtag/${seg.tag}`} className="text-orange-500 hover:underline" onClick={onClose}>
#{seg.tag}
</Link>
) : seg.type === "mention" ? (
<Link key={i} href={`/search?q=${seg.name}`} className="text-blue-500 hover:underline" onClick={onClose}>
@{seg.name}
</Link>
) : (
<span key={i}>{seg.text}</span>
)
)}
</p>
)
)}
{activePetId && (
<div className="flex items-center gap-4 pt-1 pb-2 border-b">
<PawButton
postId={post.id}
petId={activePetId}
initialCount={post._count?.reactions ?? 0}
initialReacted={false}
/>
<PawBackButton postId={post.id} petId={activePetId} />
{comments.length > 0 && (
<span className="text-xs text-muted-foreground ml-auto">
{comments.length === 1 ? tc("countSingular") : tc("countPlural", { count: comments.length })}
</span>
)}
</div>
)}
</div>
<div className="px-4 pb-3 space-y-3">
{commentsLoading && (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{!commentsLoading && comments.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-3">{tc("empty")}</p>
)}
{comments.map((comment) => {
const commentAvatarUrl = getAvatarUrl(comment.pet.avatarKey);
const isOwnComment = comment.pet.id === activePetId;
return (
<div key={comment.id} className="flex gap-2 group">
<Avatar className="h-7 w-7 shrink-0 mt-0.5">
{commentAvatarUrl && <AvatarImage src={commentAvatarUrl} alt={comment.pet.name} />}
<AvatarFallback className="text-xs bg-muted">
{comment.pet.name[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-1.5">
<Link
href={`/pets/${comment.pet.id}`}
className="text-xs font-semibold hover:underline"
onClick={onClose}
>
{comment.pet.name}
</Link>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(comment.createdAt)}
</span>
</div>
<p className="text-sm mt-0.5 break-words">
{parseCaption(comment.body).map((seg, i) =>
seg.type === "hashtag" ? (
<Link key={i} href={`/hashtag/${seg.tag}`} className="text-orange-500 hover:underline">#{seg.tag}</Link>
) : seg.type === "mention" ? (
<Link key={i} href={`/search?q=${seg.name}`} className="text-blue-500 hover:underline">@{seg.name}</Link>
) : (
<span key={i}>{seg.text}</span>
)
)}
</p>
</div>
{isOwnComment && activePetId && (
<button
type="button"
aria-label="Delete comment"
onClick={() => deleteCommentMutation.mutate({ commentId: comment.id, petId: activePetId })}
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive shrink-0 self-start mt-0.5"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
);
})}
<div ref={commentsEndRef} />
</div>
</div>
{/* Sticky comment input */}
{activePetId && (
<div className="flex gap-2 px-4 py-3 border-t bg-background shrink-0">
<MentionTextarea
rows={1}
maxLength={500}
placeholder={tc("placeholder")}
value={body}
onChange={setBody}
onKeyDown={handleCommentKeyDown}
className="flex-1 resize-none min-h-[40px] max-h-[120px] text-sm"
/>
<Button
size="sm"
disabled={!body.trim() || createCommentMutation.isPending}
onClick={handleSubmitComment}
className="bg-orange-500 hover:bg-orange-600 text-white self-end"
>
{createCommentMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
tc("post")
)}
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("deleteDesc")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("keepPost")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteMutation.mutate({ postId: post.id })}
disabled={deleteMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("yesDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+218
View File
@@ -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 (
<div className="mt-8 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold flex items-center gap-2">
<span className="w-1 h-4 rounded-full bg-amber-500 inline-block" />
{t("sectionTitle")}
</h2>
{info && !editing && (
<div className="flex gap-1">
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={openEdit}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm" variant="ghost"
className="h-7 px-2 text-muted-foreground hover:text-destructive"
onClick={() => deleteMutation.mutate({ petId })}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
{!info && !editing && (
<Button size="sm" variant="outline" onClick={openEdit}>
<UtensilsCrossed className="h-3.5 w-3.5 mr-1" />
{t("add")}
</Button>
)}
</div>
{/* View mode */}
{info && !editing && (
<div className="rounded-xl border bg-card p-4 space-y-2">
{info.foodType && (
<div className="flex items-baseline justify-between gap-2">
<p className="text-xs text-muted-foreground">{t("labelFoodType")}</p>
<p className="text-sm font-medium">{info.foodType}</p>
</div>
)}
{(info.feedingTimes != null || info.dailyAmountG != null) && (
<div className="flex items-baseline justify-between gap-2">
<p className="text-xs text-muted-foreground">{t("labelFeedingTimes")}</p>
<p className="text-sm font-medium">
{info.feedingTimes != null && t("timesPerDay", { count: info.feedingTimes })}
{info.feedingTimes != null && info.dailyAmountG != null && " · "}
{info.dailyAmountG != null && t("gramsPerDay", { amount: info.dailyAmountG })}
</p>
</div>
)}
{info.foodBrand && (
<div className="flex items-baseline justify-between gap-2">
<p className="text-xs text-muted-foreground">{t("labelBrand")}</p>
<p className="text-sm font-medium">{info.foodBrand}</p>
</div>
)}
{info.specialDiet && (
<div className="pt-1 border-t mt-1">
<p className="text-xs text-muted-foreground mb-0.5">{t("labelSpecialDiet")}</p>
<p className="text-xs">{info.specialDiet}</p>
</div>
)}
</div>
)}
{/* Edit mode */}
{editing && (
<div className="rounded-xl border bg-card p-4 space-y-3">
<div className="space-y-1.5">
<Label htmlFor="f-type" className="text-xs">{t("labelFoodType")}</Label>
<Input
id="f-type"
maxLength={100}
placeholder={t("placeholderFoodType")}
value={form.foodType}
onChange={(e) => setForm((f) => ({ ...f, foodType: e.target.value }))}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="f-times" className="text-xs">{t("labelFeedingTimes")}</Label>
<Input
id="f-times"
type="number"
min="1"
max="20"
placeholder={t("placeholderFeedingTimes")}
value={form.feedingTimes}
onChange={(e) => setForm((f) => ({ ...f, feedingTimes: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="f-amount" className="text-xs">{t("labelDailyAmount")}</Label>
<Input
id="f-amount"
type="number"
min="1"
max="100000"
placeholder={t("placeholderDailyAmount")}
value={form.dailyAmountG}
onChange={(e) => setForm((f) => ({ ...f, dailyAmountG: e.target.value }))}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="f-brand" className="text-xs">{t("labelBrand")}</Label>
<Input
id="f-brand"
maxLength={100}
placeholder={t("placeholderBrand")}
value={form.foodBrand}
onChange={(e) => setForm((f) => ({ ...f, foodBrand: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="f-special" className="text-xs">{t("labelSpecialDiet")}</Label>
<Textarea
id="f-special"
maxLength={500}
rows={2}
placeholder={t("placeholderSpecialDiet")}
value={form.specialDiet}
onChange={(e) => setForm((f) => ({ ...f, specialDiet: e.target.value }))}
/>
</div>
<div className="flex gap-2 justify-end">
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
{tc("cancel")}
</Button>
<Button size="sm" onClick={handleSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : tc("save")}
</Button>
</div>
</div>
)}
{!info && !editing && (
<p className="text-xs text-muted-foreground">{t("noInfo")}</p>
)}
</div>
);
}
+70 -68
View File
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Plus, Trash2, Share2, RefreshCw, Copy, Check, Loader2, AlertTriangle, ExternalLink } from "lucide-react";
import { useTRPC } from "@/trpc/client";
@@ -28,6 +29,7 @@ import {
} from "@/components/ui/select";
import { WeightChart } from "@/components/health/WeightChart";
import { EmergencyVetSection } from "@/components/health/EmergencyVetSection";
import { FeedingSection } from "@/components/health/FeedingSection";
interface HealthDashboardProps {
petId: string;
@@ -55,6 +57,7 @@ function isOverdue(date: Date | string): boolean {
// ── Weight Tab ────────────────────────────────────────────────────────────────
function WeightTab({ petId }: { petId: string }) {
const t = useTranslations("Health");
const trpc = useTRPC();
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
@@ -68,27 +71,27 @@ function WeightTab({ petId }: { petId: string }) {
...trpc.health.addWeightLog.mutationOptions(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.health.listWeightLogs.queryKey({ petId }) });
toast.success("Weight entry added");
toast.success(t("weight.toastAdded"));
setShowForm(false);
setWeightKg("");
setNotes("");
},
onError: () => toast.error("Failed to save weight entry"),
onError: () => toast.error(t("weight.toastError")),
});
const deleteMutation = useMutation({
...trpc.health.deleteWeightLog.mutationOptions(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.health.listWeightLogs.queryKey({ petId }) });
toast.success("Entry deleted");
toast.success(t("weight.toastDeleted"));
},
onError: () => toast.error("Failed to delete entry"),
onError: () => toast.error(t("weight.toastDeleteError")),
});
const handleAdd = () => {
const kg = parseFloat(weightKg);
if (!date || isNaN(kg) || kg <= 0) {
toast.error("Please fill in date and a valid weight");
toast.error(t("weight.toastValidation"));
return;
}
addMutation.mutate({ petId, date, weightKg: kg, notes: notes.trim() || undefined });
@@ -97,9 +100,11 @@ function WeightTab({ petId }: { petId: string }) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{data.length} {data.length === 1 ? "entry" : "entries"}</p>
<p className="text-sm text-muted-foreground">
{data.length === 1 ? t("weight.singular") : t("weight.plural", { count: data.length })}
</p>
<Button size="sm" variant="outline" onClick={() => setShowForm((v) => !v)}>
<Plus className="h-4 w-4 mr-1" /> Add Weight
<Plus className="h-4 w-4 mr-1" /> {t("weight.add")}
</Button>
</div>
@@ -107,31 +112,31 @@ function WeightTab({ petId }: { petId: string }) {
<div className="border rounded-lg p-4 space-y-3 bg-muted/30">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="w-date" className="text-xs">Date</Label>
<Label htmlFor="w-date" className="text-xs">{t("weight.labelDate")}</Label>
<Input id="w-date" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
<div className="space-y-1">
<Label htmlFor="w-kg" className="text-xs">Weight (kg)</Label>
<Label htmlFor="w-kg" className="text-xs">{t("weight.labelWeight")}</Label>
<Input
id="w-kg"
type="number"
step="0.001"
min="0.001"
max="500"
placeholder="e.g. 4.2"
placeholder={t("weight.placeholderWeight")}
value={weightKg}
onChange={(e) => setWeightKg(e.target.value)}
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="w-notes" className="text-xs">Notes (optional)</Label>
<Input id="w-notes" maxLength={200} placeholder="e.g. after morning walk" value={notes} onChange={(e) => setNotes(e.target.value)} />
<Label htmlFor="w-notes" className="text-xs">{t("weight.labelNotes")}</Label>
<Input id="w-notes" maxLength={200} placeholder={t("weight.placeholderNotes")} value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<div className="flex gap-2 justify-end">
<Button size="sm" variant="ghost" onClick={() => setShowForm(false)}>Cancel</Button>
<Button size="sm" variant="ghost" onClick={() => setShowForm(false)}>{t("cancel")}</Button>
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending}>
{addMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Save"}
{addMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : t("save")}
</Button>
</div>
</div>
@@ -140,10 +145,9 @@ function WeightTab({ petId }: { petId: string }) {
{isLoading && <div className="flex justify-center py-8"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>}
{!isLoading && data.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">No weight entries yet</p>
<p className="text-sm text-muted-foreground text-center py-8">{t("weight.noEntries")}</p>
)}
{/* Chart — shown when 2+ entries exist */}
{!isLoading && data.length >= 2 && <WeightChart data={data} />}
{data.length > 0 && (
@@ -176,6 +180,7 @@ function WeightTab({ petId }: { petId: string }) {
// ── Vet Visits Tab ────────────────────────────────────────────────────────────
function VetVisitsTab({ petId }: { petId: string }) {
const t = useTranslations("Health");
const trpc = useTRPC();
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
@@ -190,27 +195,27 @@ function VetVisitsTab({ petId }: { petId: string }) {
...trpc.health.addVetVisit.mutationOptions(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.health.listVetVisits.queryKey({ petId }) });
toast.success("Vet visit added");
toast.success(t("vet.toastAdded"));
setShowForm(false);
setReason("");
setVetName("");
setNotes("");
},
onError: () => toast.error("Failed to save vet visit"),
onError: () => toast.error(t("vet.toastError")),
});
const deleteMutation = useMutation({
...trpc.health.deleteVetVisit.mutationOptions(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.health.listVetVisits.queryKey({ petId }) });
toast.success("Entry deleted");
toast.success(t("vet.toastDeleted"));
},
onError: () => toast.error("Failed to delete entry"),
onError: () => toast.error(t("vet.toastDeleteError")),
});
const handleAdd = () => {
if (!date || !reason.trim()) {
toast.error("Please fill in date and reason");
toast.error(t("vet.toastValidation"));
return;
}
addMutation.mutate({
@@ -225,9 +230,11 @@ function VetVisitsTab({ petId }: { petId: string }) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{data.length} {data.length === 1 ? "visit" : "visits"}</p>
<p className="text-sm text-muted-foreground">
{data.length === 1 ? t("vet.singular") : t("vet.plural", { count: data.length })}
</p>
<Button size="sm" variant="outline" onClick={() => setShowForm((v) => !v)}>
<Plus className="h-4 w-4 mr-1" /> Add Visit
<Plus className="h-4 w-4 mr-1" /> {t("vet.add")}
</Button>
</div>
@@ -235,26 +242,26 @@ function VetVisitsTab({ petId }: { petId: string }) {
<div className="border rounded-lg p-4 space-y-3 bg-muted/30">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="v-date" className="text-xs">Date</Label>
<Label htmlFor="v-date" className="text-xs">{t("vet.labelDate")}</Label>
<Input id="v-date" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
<div className="space-y-1">
<Label htmlFor="v-vet" className="text-xs">Vet / Clinic (optional)</Label>
<Input id="v-vet" maxLength={100} placeholder="e.g. Dr. Müller" value={vetName} onChange={(e) => setVetName(e.target.value)} />
<Label htmlFor="v-vet" className="text-xs">{t("vet.labelVet")}</Label>
<Input id="v-vet" maxLength={100} placeholder={t("vet.placeholderVet")} value={vetName} onChange={(e) => setVetName(e.target.value)} />
</div>
</div>
<div className="space-y-1">
<Label htmlFor="v-reason" className="text-xs">Reason *</Label>
<Input id="v-reason" maxLength={100} placeholder="e.g. Annual checkup" value={reason} onChange={(e) => setReason(e.target.value)} />
<Label htmlFor="v-reason" className="text-xs">{t("vet.labelReason")}</Label>
<Input id="v-reason" maxLength={100} placeholder={t("vet.placeholderReason")} value={reason} onChange={(e) => setReason(e.target.value)} />
</div>
<div className="space-y-1">
<Label htmlFor="v-notes" className="text-xs">Notes (optional)</Label>
<Textarea id="v-notes" maxLength={500} placeholder="Diagnosis, treatment, medications…" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
<Label htmlFor="v-notes" className="text-xs">{t("vet.labelNotes")}</Label>
<Textarea id="v-notes" maxLength={500} placeholder={t("vet.placeholderNotes")} rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<div className="flex gap-2 justify-end">
<Button size="sm" variant="ghost" onClick={() => setShowForm(false)}>Cancel</Button>
<Button size="sm" variant="ghost" onClick={() => setShowForm(false)}>{t("cancel")}</Button>
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending}>
{addMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Save"}
{addMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : t("save")}
</Button>
</div>
</div>
@@ -263,7 +270,7 @@ function VetVisitsTab({ petId }: { petId: string }) {
{isLoading && <div className="flex justify-center py-8"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>}
{!isLoading && data.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">No vet visits yet</p>
<p className="text-sm text-muted-foreground text-center py-8">{t("vet.noEntries")}</p>
)}
{data.length > 0 && (
@@ -299,6 +306,7 @@ function VetVisitsTab({ petId }: { petId: string }) {
// ── Vaccines Tab ──────────────────────────────────────────────────────────────
function VaccinesTab({ petId }: { petId: string }) {
const t = useTranslations("Health");
const trpc = useTRPC();
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
@@ -313,27 +321,27 @@ function VaccinesTab({ petId }: { petId: string }) {
...trpc.health.addVaccine.mutationOptions(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.health.listVaccines.queryKey({ petId }) });
toast.success("Vaccine added");
toast.success(t("vaccines.toastAdded"));
setShowForm(false);
setName("");
setNextDueDate("");
setNotes("");
},
onError: () => toast.error("Failed to save vaccine"),
onError: () => toast.error(t("vaccines.toastError")),
});
const deleteMutation = useMutation({
...trpc.health.deleteVaccine.mutationOptions(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.health.listVaccines.queryKey({ petId }) });
toast.success("Entry deleted");
toast.success(t("vaccines.toastDeleted"));
},
onError: () => toast.error("Failed to delete entry"),
onError: () => toast.error(t("vaccines.toastDeleteError")),
});
const handleAdd = () => {
if (!name.trim() || !dateGiven) {
toast.error("Please fill in vaccine name and date given");
toast.error(t("vaccines.toastValidation"));
return;
}
addMutation.mutate({
@@ -348,36 +356,38 @@ function VaccinesTab({ petId }: { petId: string }) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{data.length} {data.length === 1 ? "vaccine" : "vaccines"}</p>
<p className="text-sm text-muted-foreground">
{data.length === 1 ? t("vaccines.singular") : t("vaccines.plural", { count: data.length })}
</p>
<Button size="sm" variant="outline" onClick={() => setShowForm((v) => !v)}>
<Plus className="h-4 w-4 mr-1" /> Add Vaccine
<Plus className="h-4 w-4 mr-1" /> {t("vaccines.add")}
</Button>
</div>
{showForm && (
<div className="border rounded-lg p-4 space-y-3 bg-muted/30">
<div className="space-y-1">
<Label htmlFor="vac-name" className="text-xs">Vaccine Name *</Label>
<Input id="vac-name" maxLength={100} placeholder="e.g. Rabies, DHPP" value={name} onChange={(e) => setName(e.target.value)} />
<Label htmlFor="vac-name" className="text-xs">{t("vaccines.labelName")}</Label>
<Input id="vac-name" maxLength={100} placeholder={t("vaccines.placeholderName")} value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="vac-given" className="text-xs">Date Given *</Label>
<Label htmlFor="vac-given" className="text-xs">{t("vaccines.labelDateGiven")}</Label>
<Input id="vac-given" type="date" value={dateGiven} onChange={(e) => setDateGiven(e.target.value)} />
</div>
<div className="space-y-1">
<Label htmlFor="vac-due" className="text-xs">Next Due (optional)</Label>
<Label htmlFor="vac-due" className="text-xs">{t("vaccines.labelNextDue")}</Label>
<Input id="vac-due" type="date" value={nextDueDate} onChange={(e) => setNextDueDate(e.target.value)} />
</div>
</div>
<div className="space-y-1">
<Label htmlFor="vac-notes" className="text-xs">Notes (optional)</Label>
<Input id="vac-notes" maxLength={200} placeholder="Batch number, clinic…" value={notes} onChange={(e) => setNotes(e.target.value)} />
<Label htmlFor="vac-notes" className="text-xs">{t("vaccines.labelNotes")}</Label>
<Input id="vac-notes" maxLength={200} placeholder={t("vaccines.placeholderNotes")} value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<div className="flex gap-2 justify-end">
<Button size="sm" variant="ghost" onClick={() => setShowForm(false)}>Cancel</Button>
<Button size="sm" variant="ghost" onClick={() => setShowForm(false)}>{t("cancel")}</Button>
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending}>
{addMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Save"}
{addMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : t("save")}
</Button>
</div>
</div>
@@ -386,7 +396,7 @@ function VaccinesTab({ petId }: { petId: string }) {
{isLoading && <div className="flex justify-center py-8"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>}
{!isLoading && data.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">No vaccines recorded yet</p>
<p className="text-sm text-muted-foreground text-center py-8">{t("vaccines.noEntries")}</p>
)}
{data.length > 0 && (
@@ -400,17 +410,17 @@ function VaccinesTab({ petId }: { petId: string }) {
<p className="text-sm font-medium">{vac.name}</p>
{overdue && (
<Badge variant="destructive" className="text-xs gap-1">
<AlertTriangle className="h-3 w-3" /> Overdue
<AlertTriangle className="h-3 w-3" /> {t("vaccines.badgeOverdue")}
</Badge>
)}
{vac.nextDueDate && !overdue && (
<Badge variant="secondary" className="text-xs">
Due {formatDate(vac.nextDueDate)}
{t("vaccines.badgeDue", { date: formatDate(vac.nextDueDate) })}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
Given {formatDate(vac.dateGiven)}{vac.notes ? ` · ${vac.notes}` : ""}
{t("vaccines.textGiven", { date: formatDate(vac.dateGiven) })}{vac.notes ? ` · ${vac.notes}` : ""}
</p>
</div>
<Button
@@ -509,7 +519,6 @@ function ShareDialog({ petId, petName, open, onOpenChange }: {
</DialogDescription>
</DialogHeader>
{/* Expiry selector + Generate */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Gültigkeitsdauer</Label>
<div className="flex gap-2">
@@ -537,7 +546,6 @@ function ShareDialog({ petId, petName, open, onOpenChange }: {
{cardUrl && (
<div className="space-y-4">
{/* Link + copy */}
<div className="flex gap-2">
<Input readOnly value={cardUrl} className="text-xs font-mono" />
<Button variant="outline" size="sm" onClick={handleCopy} className="shrink-0">
@@ -545,27 +553,19 @@ function ShareDialog({ petId, petName, open, onOpenChange }: {
</Button>
</div>
{/* Expiry info */}
{expiresAt && (
<p className="text-xs text-muted-foreground">
Gültig bis <span className="font-medium text-foreground">{formatDate(expiresAt)}</span>
</p>
)}
{/* PDF button */}
<Button
variant="secondary"
size="sm"
className="w-full gap-2"
onClick={handleOpenPdf}
>
<Button variant="secondary" size="sm" className="w-full gap-2" onClick={handleOpenPdf}>
<ExternalLink className="h-4 w-4" />
Als PDF öffnen / drucken
</Button>
<Separator />
{/* Regenerate */}
<div className="flex items-start gap-3">
<div className="flex-1">
<p className="text-sm font-medium">Link neu generieren</p>
@@ -596,23 +596,24 @@ function ShareDialog({ petId, petName, open, onOpenChange }: {
// ── Main Dashboard ────────────────────────────────────────────────────────────
export function HealthDashboard({ petId, petName }: HealthDashboardProps) {
const t = useTranslations("Health");
const [shareOpen, setShareOpen] = useState(false);
return (
<div className="max-w-[640px] mx-auto px-4 py-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-semibold">Health — {petName}</h1>
<h1 className="text-xl font-semibold">{t("pageTitle", { petName })}</h1>
<Button variant="outline" size="sm" onClick={() => setShareOpen(true)}>
<Share2 className="h-4 w-4 mr-1.5" />
Share Card
{t("shareCard")}
</Button>
</div>
<Tabs defaultValue="weight">
<TabsList className="mb-6">
<TabsTrigger value="weight">Weight</TabsTrigger>
<TabsTrigger value="vet">Vet Visits</TabsTrigger>
<TabsTrigger value="vaccines">Vaccines</TabsTrigger>
<TabsTrigger value="weight">{t("tabWeight")}</TabsTrigger>
<TabsTrigger value="vet">{t("tabVet")}</TabsTrigger>
<TabsTrigger value="vaccines">{t("tabVaccines")}</TabsTrigger>
</TabsList>
<TabsContent value="weight">
@@ -626,6 +627,7 @@ export function HealthDashboard({ petId, petName }: HealthDashboardProps) {
</TabsContent>
</Tabs>
<FeedingSection petId={petId} />
<EmergencyVetSection petId={petId} />
<ShareDialog
@@ -0,0 +1,20 @@
"use client";
import { useEffect } from "react";
import { useActivePet } from "@/context/ActivePetContext";
/**
* Mounts invisibly in the app shell and auto-sets activePetId to the owner's
* first pet when localStorage is empty (first login / cleared storage).
*/
export function ActivePetInitializer({ firstPetId }: { firstPetId: string }) {
const { activePetId, setActivePet } = useActivePet();
useEffect(() => {
if (!activePetId) {
setActivePet(firstPetId);
}
}, [activePetId, firstPetId, setActivePet]);
return null;
}
+14 -21
View File
@@ -3,11 +3,11 @@
import { useState, useCallback } from "react";
import { Layers2, Play } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useTRPC } from "@/trpc/client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { MilestoneCard } from "@/components/feed/MilestoneCard";
import { PostCard } from "@/components/feed/PostCard";
import { PostDetailDialog } from "@/components/feed/PostDetailDialog";
import { getMediaUrl } from "@/lib/media-url";
import type { PostCardPost } from "@/components/feed/PostCard";
@@ -23,6 +23,7 @@ interface ProfileTabsProps {
* Active tab has orange-500 underline (shadcn Tabs default with --primary: orange-500).
*/
export function ProfileTabs({ petId }: ProfileTabsProps) {
const t = useTranslations("ProfileTabs");
const trpc = useTRPC();
const [_activeTab, setActiveTab] = useState("posts");
const [selectedPost, setSelectedPost] = useState<PostCardPost | null>(null);
@@ -50,13 +51,13 @@ export function ProfileTabs({ petId }: ProfileTabsProps) {
value="posts"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-orange-500 data-[state=active]:text-foreground data-[state=active]:shadow-none pb-2"
>
Posts
{t("posts")}
</TabsTrigger>
<TabsTrigger
value="milestones"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-orange-500 data-[state=active]:text-foreground data-[state=active]:shadow-none pb-2"
>
Milestones
{t("milestones")}
</TabsTrigger>
</TabsList>
@@ -64,7 +65,7 @@ export function ProfileTabs({ petId }: ProfileTabsProps) {
<TabsContent value="posts" className="mt-0">
{posts.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
No posts yet
{t("noPosts")}
</p>
) : (
<div className="grid grid-cols-3 gap-0.5 mt-0.5">
@@ -118,7 +119,7 @@ export function ProfileTabs({ petId }: ProfileTabsProps) {
<TabsContent value="milestones" className="mt-4 space-y-4">
{milestones.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
No milestones yet
{t("noMilestones")}
</p>
) : (
milestones.map((post) => (
@@ -128,21 +129,13 @@ export function ProfileTabs({ petId }: ProfileTabsProps) {
</TabsContent>
</Tabs>
{/* Post detail dialog — centered overlay, no X button (3-dots in PostCard header handles options) */}
<Dialog open={!!selectedPost} onOpenChange={(open) => { if (!open) setSelectedPost(null); }}>
<DialogContent
showCloseButton={false}
className="max-w-[560px] w-full p-0 overflow-y-auto max-h-[85dvh] rounded-xl"
>
{selectedPost && (
<PostCard
post={selectedPost}
variant="detail"
onDeleted={() => setSelectedPost(null)}
/>
)}
</DialogContent>
</Dialog>
{/* Post detail dialog — full-screen overlay with inline comments */}
<PostDetailDialog
post={selectedPost}
open={!!selectedPost}
onClose={() => setSelectedPost(null)}
onDeleted={() => setSelectedPost(null)}
/>
</>
);
}
+36
View File
@@ -211,4 +211,40 @@ export const healthRouter = router({
});
return { token: card.token, expiresAt: card.expiresAt };
}),
// ── Feeding Info ─────────────────────────────────────────────────────────────
getFeedingInfo: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
return ctx.prisma.petFeedingInfo.findUnique({ where: { petId: input.petId } });
}),
setFeedingInfo: protectedProcedure
.input(z.object({
petId: z.string().min(1),
foodType: z.string().max(100).optional().nullable(),
feedingTimes: z.number().int().min(1).max(20).optional().nullable(),
dailyAmountG: z.number().int().min(1).max(100000).optional().nullable(),
foodBrand: z.string().max(100).optional().nullable(),
specialDiet: z.string().max(500).optional().nullable(),
}))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
const { petId, ...data } = input;
return ctx.prisma.petFeedingInfo.upsert({
where: { petId },
update: data,
create: { petId, ...data },
});
}),
deleteFeedingInfo: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
await ctx.prisma.petFeedingInfo.deleteMany({ where: { petId: input.petId } });
return { success: true };
}),
});