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:
1 parent
2568c32cf4
commit
dc4c196721
14 files changed
+1109
-306
No files matched your search
+99
-1
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user