feat(02-06): MilestoneForm + PostTypeSheet wiring + ProfileTabs + profile page — Phase 2 complete

This commit is contained in:
admin committed 2026-06-13 10:22:35 +02:00
1 parent 9b02d3dc8b
commit 3fa62d2ce0
4 files changed
+417 -14

No files matched your search

+3 -7
View File
@@ -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) */}
<Separator className="mb-6" />
<section>
<h2 className="text-base font-semibold mb-4">Posts</h2>
<p className="text-sm text-muted-foreground text-center py-8">
No posts yet
</p>
</section>
<ProfileTabs petId={pet.id} />
</div>
);
}
@@ -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<HTMLInputElement>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [selectedType, setSelectedType] = useState<MilestoneType | null>(null);
const [notes, setNotes] = useState("");
const [caption, setCaption] = useState("");
const [uploadState, setUploadState] = useState<UploadState>("idle");
const [uploadProgress, setUploadProgress] = useState(0);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [uploadedKey, setUploadedKey] = useState<string | null>(null);
const [uploadError, setUploadError] = useState<string | null>(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<void>((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<HTMLInputElement>) => {
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 (
<div className="px-4 pb-6 space-y-4">
{/* Type picker */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">
What&apos;s the milestone?
</label>
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger className="flex items-center justify-between w-full min-h-[44px] px-3 py-2 rounded-md border border-input bg-background text-sm hover:bg-accent hover:text-accent-foreground transition-colors">
{selectedMeta ? (
<span className="flex items-center gap-2 text-foreground">
<selectedMeta.icon className="h-4 w-4 text-orange-500 shrink-0" aria-hidden="true" />
{selectedMeta.label}
</span>
) : (
<span className="text-muted-foreground">Select a milestone…</span>
)}
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden="true" />
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-1" align="start">
{USER_INITIATED_MILESTONE_TYPES.map((type) => {
const meta = MILESTONE_META[type];
const Icon = meta.icon;
return (
<button
key={type}
className="flex items-center gap-3 w-full px-3 py-2.5 rounded-md text-sm hover:bg-muted transition-colors text-left"
onClick={() => {
setSelectedType(type);
setNotes("");
setPickerOpen(false);
}}
>
<Icon className="h-5 w-5 text-orange-500 shrink-0" aria-hidden="true" />
{meta.label}
</button>
);
})}
</PopoverContent>
</Popover>
</div>
{/* Notes — only for LITTER (D-09) */}
{selectedType === MilestoneType.LITTER && (
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">
Litter details <span className="text-muted-foreground font-normal">(optional)</span>
</label>
<Textarea
placeholder="e.g. 5 healthy kittens, born 13 June…"
value={notes}
onChange={(e) => setNotes(e.target.value)}
maxLength={500}
rows={2}
className="text-sm resize-none"
/>
{notes.length > 450 && (
<p className="text-xs text-muted-foreground text-right">
{500 - notes.length} characters remaining
</p>
)}
</div>
)}
{/* Optional photo */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">
Photo <span className="text-muted-foreground font-normal">(optional)</span>
</label>
<input
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
/>
{!previewUrl ? (
<button
className="flex flex-col items-center justify-center gap-2 w-full rounded-xl border-2 border-dashed border-muted hover:border-muted-foreground/50 transition-colors py-6 cursor-pointer"
onClick={() => fileInputRef.current?.click()}
>
<ImagePlus className="h-7 w-7 text-muted-foreground" aria-hidden="true" />
<span className="text-sm text-muted-foreground">Add a photo</span>
</button>
) : (
<div className="relative rounded-xl overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewUrl}
alt="Milestone photo preview"
className="w-full max-h-[280px] object-cover rounded-xl"
/>
{uploadState === "uploading" && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-xl">
<Loader2 className="h-6 w-6 text-white animate-spin" />
<span className="ml-2 text-white text-sm">{uploadProgress}%</span>
</div>
)}
{uploadState === "ready" && (
<button
className="absolute bottom-2 right-2 px-3 py-1.5 text-xs bg-black/60 text-white rounded-full hover:bg-black/80"
onClick={() => fileInputRef.current?.click()}
>
<Camera className="h-3 w-3 inline mr-1" aria-hidden="true" />
Change
</button>
)}
</div>
)}
{uploadError && <p className="text-xs text-destructive">{uploadError}</p>}
</div>
{/* Optional caption */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">
Add a note <span className="text-muted-foreground font-normal">(optional)</span>
</label>
<Textarea
placeholder="Add a note… (optional)"
value={caption}
onChange={(e) => setCaption(e.target.value)}
maxLength={500}
rows={2}
className="text-sm resize-none"
/>
{caption.length > 450 && (
<p className="text-xs text-muted-foreground text-right">
{500 - caption.length} characters remaining
</p>
)}
</div>
<Button
className="w-full bg-orange-500 hover:bg-orange-600 text-white min-h-[44px]"
disabled={!selectedType || uploadState === "uploading" || createMutation.isPending}
onClick={handleSubmit}
>
{createMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" aria-hidden="true" />
Sharing…
</>
) : (
"Share Milestone"
)}
</Button>
</div>
);
}
+10 -7
View File
@@ -17,6 +17,7 @@ import {
import { Button } from "@/components/ui/button";
import { PhotoPostForm } from "@/components/post-creation/PhotoPostForm";
import { StoryForm } from "@/components/stories/StoryForm";
import { MilestoneForm } from "@/components/post-creation/MilestoneForm";
/**
* PostTypeSheet — bottom Sheet with 3 post type choices (D-01).
@@ -56,6 +57,11 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) {
setSelectedType(null);
}
function handleMilestoneClose() {
setMilestoneDialogOpen(false);
setSelectedType(null);
}
function handleSelectType(type: PostType) {
setSelectedType(type);
onOpenChange(false); // close the Sheet
@@ -137,16 +143,13 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) {
</DialogContent>
</Dialog>
{/* Milestone Dialog seam — TODO(02-06): replace null with MilestoneForm */}
{/* Milestone Dialog (D-02) */}
<Dialog open={milestoneDialogOpen} onOpenChange={setMilestoneDialogOpen}>
<DialogContent className="sm:max-w-[512px]">
<DialogHeader>
<DialogContent className="sm:max-w-[512px] max-h-[90dvh] overflow-y-auto p-0">
<DialogHeader className="px-4 pt-4 pb-2">
<DialogTitle className="text-xl font-semibold">New Milestone</DialogTitle>
</DialogHeader>
<div className="p-4 text-center text-sm text-muted-foreground">
{/* TODO(02-06): MilestoneForm — swap this div with <MilestoneForm onClose={() => setMilestoneDialogOpen(false)} /> */}
Milestone creation coming soon.
</div>
<MilestoneForm onClose={handleMilestoneClose} />
</DialogContent>
</Dialog>
</>
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { useState, useCallback } from "react";
import { Layers2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { MilestoneCard } from "@/components/feed/MilestoneCard";
import { getMediaUrl } from "@/lib/media-url";
import type { PostCardPost } from "@/components/feed/PostCard";
interface ProfileTabsProps {
petId: string;
}
/**
* ProfileTabs — Posts (3-col photo grid) + Milestones (chronological list) (MILE-02).
*
* Posts tab: photo posts fetched via posts.byPetId; carousel posts get a Layers2 overlay.
* Milestones tab: milestone posts via milestones.listByPet rendered as MilestoneCard.
* Active tab has orange-500 underline (shadcn Tabs default with --primary: orange-500).
*/
export function ProfileTabs({ petId }: ProfileTabsProps) {
const trpc = useTRPC();
const [_activeTab, setActiveTab] = useState("posts");
const { data: postsData } = useQuery(
trpc.posts.byPetId.queryOptions({ petId, limit: 30 })
);
const { data: milestonesData } = useQuery(
trpc.milestones.listByPet.queryOptions({ petId, limit: 30 })
);
const posts = postsData?.posts ?? [];
const milestones = (milestonesData ?? []) as PostCardPost[];
const handleTabChange = useCallback((tab: string) => {
setActiveTab(tab);
}, []);
return (
<Tabs defaultValue="posts" onValueChange={handleTabChange}>
<TabsList className="w-full rounded-none border-b bg-transparent h-auto p-0">
<TabsTrigger
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
</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
</TabsTrigger>
</TabsList>
{/* Posts tab — 3-column photo grid */}
<TabsContent value="posts" className="mt-0">
{posts.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
No posts yet
</p>
) : (
<div className="grid grid-cols-3 gap-0.5 mt-0.5">
{posts.map((post) => {
const firstImage = post.images?.[0];
const isCarousel = (post.images?.length ?? 0) > 1;
const mediaUrl = firstImage ? getMediaUrl(firstImage.storageKey) : null;
return (
<div key={post.id} className="relative aspect-square overflow-hidden bg-muted">
{mediaUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={mediaUrl}
alt={post.caption ?? "Post"}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-muted" />
)}
{isCarousel && (
<div className="absolute top-1.5 right-1.5">
<Layers2 className="h-4 w-4 text-white drop-shadow" aria-hidden="true" />
</div>
)}
</div>
);
})}
</div>
)}
</TabsContent>
{/* Milestones tab — chronological MilestoneCard list */}
<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
</p>
) : (
milestones.map((post) => (
<MilestoneCard key={post.id} post={post} />
))
)}
</TabsContent>
</Tabs>
);
}