From 3fa62d2ce02241a6334df9e0c9d2ecc3dcb5abcf Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sat, 13 Jun 2026 10:22:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(02-06):=20MilestoneForm=20+=20PostTypeShee?= =?UTF-8?q?t=20wiring=20+=20ProfileTabs=20+=20profile=20page=20=E2=80=94?= =?UTF-8?q?=20Phase=202=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(app)/pets/[petId]/page.tsx | 10 +- .../post-creation/MilestoneForm.tsx | 294 ++++++++++++++++++ .../post-creation/PostTypeSheet.tsx | 17 +- src/components/profile/ProfileTabs.tsx | 110 +++++++ 4 files changed, 417 insertions(+), 14 deletions(-) create mode 100644 src/components/post-creation/MilestoneForm.tsx create mode 100644 src/components/profile/ProfileTabs.tsx diff --git a/src/app/(app)/pets/[petId]/page.tsx b/src/app/(app)/pets/[petId]/page.tsx index dd0c78c..b7e939b 100644 --- a/src/app/(app)/pets/[petId]/page.tsx +++ b/src/app/(app)/pets/[petId]/page.tsx @@ -8,6 +8,7 @@ import { buttonVariants } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { getAvatarUrl } from "@/lib/avatar-url"; import { ProfileActions } from "@/app/(app)/pets/[petId]/ProfileActions"; +import { ProfileTabs } from "@/components/profile/ProfileTabs"; /** * Pet profile view — /pets/[petId] @@ -155,14 +156,9 @@ export default async function PetProfilePage({ params }: PageProps) { )} - {/* Posts placeholder — Phase 2 posts router implementation in 02-03+ */} + {/* Posts + Milestones tabs (MILE-02) */} -
-

Posts

-

- No posts yet -

-
+ ); } diff --git a/src/components/post-creation/MilestoneForm.tsx b/src/components/post-creation/MilestoneForm.tsx new file mode 100644 index 0000000..cc0fac2 --- /dev/null +++ b/src/components/post-creation/MilestoneForm.tsx @@ -0,0 +1,294 @@ +"use client"; + +import { useState, useRef, useCallback } from "react"; +import { ChevronDown, Loader2, ImagePlus, Camera } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { MilestoneType } from "@prisma/client"; +import { useTRPC } from "@/trpc/client"; +import { useActivePet } from "@/context/ActivePetContext"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { MILESTONE_META, USER_INITIATED_MILESTONE_TYPES } from "@/lib/milestone-meta"; + +const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"] as const; +type AllowedContentType = (typeof ALLOWED_TYPES)[number]; +const MAX_FILE_SIZE = 10 * 1024 * 1024; + +interface MilestoneFormProps { + onClose: () => void; +} + +type UploadState = "idle" | "uploading" | "ready"; + +/** + * MilestoneForm — type picker + optional photo + caption for milestone posts. + * + * Picker shows only the 5 user-initiated types (D-10 — follower thresholds excluded). + * LITTER type reveals a free-text notes field (D-09). + * Optional single photo via posts.getPresignedUrl (posts/ prefix). + * On success: invalidate feed, close dialog, route to /feed (D-03). + */ +export function MilestoneForm({ onClose }: MilestoneFormProps) { + const trpc = useTRPC(); + const router = useRouter(); + const queryClient = useQueryClient(); + const { activePetId } = useActivePet(); + const fileInputRef = useRef(null); + + const [pickerOpen, setPickerOpen] = useState(false); + const [selectedType, setSelectedType] = useState(null); + const [notes, setNotes] = useState(""); + const [caption, setCaption] = useState(""); + + const [uploadState, setUploadState] = useState("idle"); + const [uploadProgress, setUploadProgress] = useState(0); + const [previewUrl, setPreviewUrl] = useState(null); + const [uploadedKey, setUploadedKey] = useState(null); + const [uploadError, setUploadError] = useState(null); + + const getPresignedUrlMutation = useMutation( + trpc.posts.getPresignedUrl.mutationOptions() + ); + + const createMutation = useMutation( + trpc.milestones.create.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [["trpc", "feed", "getFeed"]] }); + queryClient.invalidateQueries({ queryKey: [["trpc", "milestones", "listByPet"]] }); + onClose(); + router.push("/feed"); + toast.success("Milestone shared"); + }, + onError: () => { + toast.error("Couldn't share this milestone. Please try again."); + }, + }) + ); + + const handleFile = useCallback( + async (file: File) => { + if (!activePetId) return; + setUploadError(null); + + if (file.size > MAX_FILE_SIZE) { + setUploadError("Photo must be under 10 MB."); + return; + } + if (!ALLOWED_TYPES.includes(file.type as AllowedContentType)) { + setUploadError("Upload a JPG, PNG, or WebP image."); + return; + } + + setPreviewUrl(URL.createObjectURL(file)); + setUploadState("uploading"); + setUploadProgress(0); + + try { + const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({ + petId: activePetId, + contentType: file.type as AllowedContentType, + fileSize: file.size, + }); + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) setUploadProgress(Math.round((e.loaded / e.total) * 100)); + }; + xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject()); + xhr.onerror = () => reject(new Error("Network error")); + xhr.open("PUT", presignedUrl); + xhr.setRequestHeader("Content-Type", file.type); + xhr.send(file); + }); + + setUploadedKey(key); + setUploadState("ready"); + } catch { + setUploadError("Upload failed — please try again."); + setUploadState("idle"); + setPreviewUrl(null); + } + }, + [activePetId, getPresignedUrlMutation] + ); + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) void handleFile(file); + e.target.value = ""; + }, + [handleFile] + ); + + const handleSubmit = useCallback(() => { + if (!activePetId || !selectedType) return; + + createMutation.mutate({ + petId: activePetId, + milestoneType: selectedType, + notes: selectedType === MilestoneType.LITTER ? notes.trim() || undefined : undefined, + caption: caption.trim() || undefined, + imageKey: uploadedKey ?? undefined, + }); + }, [activePetId, selectedType, notes, caption, uploadedKey, createMutation]); + + const selectedMeta = selectedType ? MILESTONE_META[selectedType] : null; + + return ( +
+ {/* Type picker */} +
+ + + + {selectedMeta ? ( + + + ) : ( + Select a milestone… + )} + + + {USER_INITIATED_MILESTONE_TYPES.map((type) => { + const meta = MILESTONE_META[type]; + const Icon = meta.icon; + return ( + + ); + })} + + +
+ + {/* Notes — only for LITTER (D-09) */} + {selectedType === MilestoneType.LITTER && ( +
+ +