feat(02-04): post creation flow — MultiImageUpload, PhotoPostForm, PostTypeSheet, + button nav

MultiImageUpload:
- Per-file: validate type(jpg/png/webp) + size(<10MB) with exact UI-SPEC error copy
- Rejects >10 total: "You can upload up to 10 photos per post."
- posts.getPresignedUrl → XHR PUT with exact Content-Type (Pitfall 4 prevention)
- Reorderable thumbnails with remove button and upload progress bar

PhotoPostForm:
- MultiImageUpload + caption Textarea (max 500, error "Caption must be 500 characters or fewer.")
- "Share Post" CTA (bg-orange-500) → posts.create({ petId: activePetId, imageKeys })
- onSuccess: invalidate feed query, close Dialog, router.push('/feed') (D-03)
- Toasts: "Post shared" / "Couldn't share your post. Please try again."

PostTypeSheet:
- Bottom Sheet titled "Create" with 3 options: Photo Post, Story, Milestone (D-01)
- Photo → full-screen Dialog with PhotoPostForm (D-02, no URL change)
- Story/Milestone: TODO(02-05)/TODO(02-06) seams with placeholder dialogs

Sidebar + MobileNav:
- + button opens PostTypeSheet (D-01)
- MobileNav center + button: was Link to /pets, now triggers PostTypeSheet
- Sidebar: New Post button added above owner identity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 4.6 committed 2026-06-07 12:48:56 +02:00
1 parent 15f51825d9
commit e4b619671f
5 files changed
+697 -5

No files matched your search

+15 -5
View File
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
@@ -10,6 +11,7 @@ import {
User,
ChevronDown,
} from "lucide-react";
import { PostTypeSheet } from "@/components/post-creation/PostTypeSheet";
import {
DropdownMenu,
DropdownMenuContent,
@@ -54,6 +56,7 @@ export function MobileNav({ pets }: MobileNavProps) {
const { activePetId, setActivePet } = useActivePet();
const activePet = pets.find((p) => p.id === activePetId) ?? pets[0];
const [postTypeSheetOpen, setPostTypeSheetOpen] = useState(false);
// D-05: Home → /feed; also highlight when on / (which redirects to /feed)
const isHomePage = pathname === "/feed" || pathname === "/";
@@ -153,16 +156,17 @@ export function MobileNav({ pets }: MobileNavProps) {
<span>Explore</span>
</span>
{/* Post — center, accent-filled. Phase 1: navigates to /pets as placeholder */}
<Link
href="/pets"
aria-label="My Pets"
{/* Post — center, accent-filled. D-01: opens PostTypeSheet */}
<button
type="button"
aria-label="Create post"
onClick={() => setPostTypeSheetOpen(true)}
className="flex flex-col items-center"
>
<span className="bg-orange-500 text-white rounded-full p-3 flex items-center justify-center">
<Plus className="h-5 w-5" aria-hidden="true" />
</span>
</Link>
</button>
{/* Notifications — placeholder, Phase 1 */}
<span
@@ -184,6 +188,12 @@ export function MobileNav({ pets }: MobileNavProps) {
<span>Profile</span>
</Link>
</nav>
{/* PostTypeSheet — D-01: bottom Sheet with Photo/Story/Milestone choices */}
<PostTypeSheet
open={postTypeSheetOpen}
onOpenChange={setPostTypeSheetOpen}
/>
</div>
);
}
+23
View File
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
@@ -10,7 +11,9 @@ import {
MessageCircle,
PawPrint,
ChevronDown,
Plus,
} from "lucide-react";
import { PostTypeSheet } from "@/components/post-creation/PostTypeSheet";
import { UserButton } from "@clerk/nextjs";
import {
DropdownMenu,
@@ -66,6 +69,7 @@ export function Sidebar({ pets }: SidebarProps) {
const { activePetId, setActivePet } = useActivePet();
const activePet = pets.find((p) => p.id === activePetId) ?? pets[0];
const [postTypeSheetOpen, setPostTypeSheetOpen] = useState(false);
return (
<nav className="hidden lg:flex flex-col w-[240px] min-h-screen bg-card border-r border shrink-0">
@@ -184,11 +188,30 @@ export function Sidebar({ pets }: SidebarProps) {
})}
</div>
{/* New Post button — D-01: opens PostTypeSheet */}
<div className="px-3 pb-3">
<button
type="button"
onClick={() => setPostTypeSheetOpen(true)}
className="flex items-center gap-3 w-full px-3 py-2 rounded-md text-sm font-medium bg-orange-500 text-white hover:bg-orange-600 transition-colors min-h-[44px]"
aria-label="Create post"
>
<Plus className="h-4 w-4 shrink-0" aria-hidden="true" />
<span>New Post</span>
</button>
</div>
{/* Bottom: owner identity */}
<div className="px-4 py-4 border-t border flex items-center gap-2">
<UserButton />
<span className="text-xs text-muted-foreground">Account</span>
</div>
{/* PostTypeSheet — D-01: bottom Sheet with Photo/Story/Milestone choices */}
<PostTypeSheet
open={postTypeSheetOpen}
onOpenChange={setPostTypeSheetOpen}
/>
</nav>
);
}
@@ -0,0 +1,357 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { ImagePlus, X, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useTRPC } from "@/trpc/client";
import { useMutation } from "@tanstack/react-query";
/**
* MultiImageUpload — multi-file upload zone for post images (up to 10).
*
* Adapts AvatarUpload to multiple files. For each selected file:
* 1. Validate type (jpg/png/webp) + size (<10MB) with exact UI-SPEC error copy
* 2. Reject >10 total: "You can upload up to 10 photos per post."
* 3. posts.getPresignedUrl → XHR PUT to Supabase signed URL with exact Content-Type (Pitfall 4)
* 4. Collect returned storage keys
*
* Pitfall 4 prevention: Content-Type header in XHR PUT MUST exactly match the contentType
* passed to getPresignedUrl (Supabase returns 400 SignatureDoesNotMatch if mismatched).
*
* UI-SPEC copy:
* Upload zone: "Add photos" / "Drag photos here or tap to browse — up to 10"
* File too large: "Photo must be under 10 MB."
* Wrong format: "Upload a JPG, PNG, or WebP image."
* Too many images: "You can upload up to 10 photos per post."
*/
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"] as const;
type AllowedContentType = (typeof ALLOWED_TYPES)[number];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
const MAX_IMAGES = 10;
interface UploadedImage {
/** Local preview URL (object URL) */
previewUrl: string;
/** Supabase storage key — only set after successful upload */
storageKey: string | null;
/** File size for display */
fileName: string;
/** Upload state */
status: "pending" | "uploading" | "done" | "error";
uploadProgress: number;
}
interface MultiImageUploadProps {
petId: string;
/** Called with all successfully uploaded storage keys */
onKeysChange: (keys: string[]) => void;
/** Max number of images (default 10) */
maxImages?: number;
}
export function MultiImageUpload({
petId,
onKeysChange,
maxImages = MAX_IMAGES,
}: MultiImageUploadProps) {
const trpc = useTRPC();
const fileInputRef = useRef<HTMLInputElement>(null);
const [images, setImages] = useState<UploadedImage[]>([]);
const [isDragging, setIsDragging] = useState(false);
const getPresignedUrlMutation = useMutation(
trpc.posts.getPresignedUrl.mutationOptions()
);
const uploadFile = useCallback(
async (file: File, index: number) => {
// Validate size
if (file.size > MAX_FILE_SIZE) {
setImages((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], status: "error" };
return updated;
});
toast.error("Photo must be under 10 MB.");
return null;
}
// Validate type
if (!ALLOWED_TYPES.includes(file.type as AllowedContentType)) {
setImages((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], status: "error" };
return updated;
});
toast.error("Upload a JPG, PNG, or WebP image.");
return null;
}
// Mark as uploading
setImages((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], status: "uploading", uploadProgress: 0 };
return updated;
});
try {
// Step 1: Get presigned URL
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
petId,
contentType: file.type as AllowedContentType,
fileSize: file.size,
});
// Step 2: PUT file directly to Supabase Storage via XHR (with progress)
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100);
setImages((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], uploadProgress: percent };
return updated;
});
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Storage PUT failed: ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error("Network error during upload"));
xhr.open("PUT", presignedUrl);
// Pitfall 4: Content-Type MUST exactly match what was sent to getPresignedUrl
xhr.setRequestHeader("Content-Type", file.type);
xhr.send(file);
});
// Step 3: Mark as done with storage key
setImages((prev) => {
const updated = [...prev];
updated[index] = {
...updated[index],
status: "done",
storageKey: key,
uploadProgress: 100,
};
// Notify parent with all successfully uploaded keys
const keys = updated
.filter((img) => img.status === "done" && img.storageKey)
.map((img) => img.storageKey as string);
onKeysChange(keys);
return updated;
});
return key;
} catch {
setImages((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], status: "error" };
return updated;
});
toast.error("Upload failed — please try again.");
return null;
}
},
[petId, getPresignedUrlMutation, onKeysChange]
);
const processFiles = useCallback(
async (files: File[]) => {
const currentCount = images.length;
const available = maxImages - currentCount;
if (available <= 0) {
toast.error("You can upload up to 10 photos per post.");
return;
}
const toUpload = files.slice(0, available);
if (files.length > available) {
toast.error("You can upload up to 10 photos per post.");
}
// Create preview entries immediately
const newImages: UploadedImage[] = toUpload.map((file) => ({
previewUrl: URL.createObjectURL(file),
storageKey: null,
fileName: file.name,
status: "pending",
uploadProgress: 0,
}));
setImages((prev) => {
const updated = [...prev, ...newImages];
return updated;
});
// Upload each file
const startIndex = currentCount;
await Promise.all(
toUpload.map((file, i) => uploadFile(file, startIndex + i))
);
},
[images.length, maxImages, uploadFile]
);
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
if (files.length > 0) {
void processFiles(files);
}
event.target.value = "";
},
[processFiles]
);
const handleRemove = useCallback(
(index: number) => {
setImages((prev) => {
const updated = prev.filter((_, i) => i !== index);
// Release object URL to avoid memory leaks
URL.revokeObjectURL(prev[index].previewUrl);
const keys = updated
.filter((img) => img.status === "done" && img.storageKey)
.map((img) => img.storageKey as string);
onKeysChange(keys);
return updated;
});
},
[onKeysChange]
);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback(() => {
setIsDragging(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
void processFiles(files);
}
},
[processFiles]
);
const canAddMore = images.length < maxImages;
return (
<div className="space-y-3">
{/* Upload zone — shown when can add more images */}
{canAddMore && (
<div
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
isDragging
? "border-orange-500 bg-orange-50/50"
: "border-muted hover:border-muted-foreground/50"
}`}
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
fileInputRef.current?.click();
}
}}
aria-label="Add photos"
>
<ImagePlus className="h-8 w-8 text-muted-foreground mx-auto mb-2" aria-hidden="true" />
<p className="text-sm font-medium text-foreground">Add photos</p>
<p className="text-xs text-muted-foreground mt-1">
Drag photos here or tap to browse — up to 10
</p>
</div>
)}
{/* Hidden file input */}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
/>
{/* Image thumbnails grid */}
{images.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{images.map((img, i) => (
<div key={i} className="relative aspect-square rounded-md overflow-hidden bg-muted">
{/* Preview image */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={img.previewUrl}
alt={`Image ${i + 1}`}
className="w-full h-full object-cover"
/>
{/* Uploading overlay */}
{img.status === "uploading" && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<Loader2 className="h-6 w-6 text-white animate-spin" />
</div>
)}
{/* Error overlay */}
{img.status === "error" && (
<div className="absolute inset-0 bg-red-500/40 flex items-center justify-center">
<span className="text-xs text-white font-medium">Error</span>
</div>
)}
{/* Progress bar at bottom */}
{img.status === "uploading" && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-muted">
<div
className="h-full bg-orange-500 transition-all"
style={{ width: `${img.uploadProgress}%` }}
/>
</div>
)}
{/* Remove button */}
{img.status !== "uploading" && (
<button
type="button"
onClick={() => handleRemove(i)}
className="absolute top-1 right-1 w-5 h-5 bg-black/60 rounded-full flex items-center justify-center hover:bg-black/80 transition-colors"
aria-label={`Remove image ${i + 1}`}
>
<X className="h-3 w-3 text-white" />
</button>
)}
{/* Order badge */}
<span className="absolute bottom-1 left-1 text-xs text-white bg-black/50 rounded px-1 select-none">
{i + 1}
</span>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,151 @@
"use client";
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { MultiImageUpload } from "@/components/post-creation/MultiImageUpload";
/**
* PhotoPostForm — multi-image upload + caption form for creating photo posts.
*
* Flow:
* 1. MultiImageUpload handles presigned URL + XHR PUT per image, collects storageKeys
* 2. Caption Textarea (optional, max 500)
* 3. "Share Post" CTA → posts.create({ petId, caption, imageKeys })
* 4. onSuccess: invalidate feed query, close Dialog, router.push('/feed') (D-03)
* 5. Toasts: "Post shared" on success, error copy on failure
*
* UI-SPEC copy:
* Caption placeholder: "Write a caption…"
* Caption error: "Caption must be 500 characters or fewer."
* CTA: "Share Post" (bg-orange-500)
* Success toast: "Post shared"
* Error toast: "Couldn't share your post. Please try again."
*
* D-02: Form lives inside a full-screen Dialog (no URL change).
* D-03: After posting, close Dialog and navigate to /feed.
*/
interface PhotoPostFormProps {
/** Called to close the parent Dialog */
onClose: () => void;
}
export function PhotoPostForm({ onClose }: PhotoPostFormProps) {
const trpc = useTRPC();
const router = useRouter();
const queryClient = useQueryClient();
const { activePetId } = useActivePet();
const [imageKeys, setImageKeys] = useState<string[]>([]);
const [caption, setCaption] = useState("");
const [captionError, setCaptionError] = useState<string | null>(null);
const createMutation = useMutation(
trpc.posts.create.mutationOptions({
onSuccess: () => {
// Invalidate feed query so new post appears at top (D-03)
queryClient.invalidateQueries({ queryKey: [["trpc", "feed", "getFeed"]] });
onClose();
// Navigate to /feed with new post at top (D-03)
router.push("/feed");
toast.success("Post shared");
},
onError: () => {
toast.error("Couldn't share your post. Please try again.");
},
})
);
const handleCaptionChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setCaption(value);
if (value.length > 500) {
setCaptionError("Caption must be 500 characters or fewer.");
} else {
setCaptionError(null);
}
},
[]
);
const handleSubmit = useCallback(() => {
if (!activePetId) {
toast.error("No active pet selected.");
return;
}
if (imageKeys.length === 0) {
toast.error("Please add at least one photo.");
return;
}
if (caption.length > 500) {
setCaptionError("Caption must be 500 characters or fewer.");
return;
}
createMutation.mutate({
petId: activePetId,
caption: caption.trim() || null,
imageKeys,
});
}, [activePetId, imageKeys, caption, createMutation]);
if (!activePetId) {
return (
<div className="p-6 text-center text-sm text-muted-foreground">
No active pet selected.
</div>
);
}
return (
<div className="flex flex-col gap-4 p-4">
{/* Multi-image upload zone */}
<MultiImageUpload
petId={activePetId}
onKeysChange={setImageKeys}
/>
{/* Caption textarea */}
<div className="space-y-1">
<Textarea
placeholder="Write a caption…"
value={caption}
onChange={handleCaptionChange}
maxLength={510} // let Zod catch exact limit; UI shows error at 500
rows={3}
className="text-sm resize-none"
/>
{captionError && (
<p className="text-xs text-destructive">{captionError}</p>
)}
{caption.length > 450 && !captionError && (
<p className="text-xs text-muted-foreground text-right">
{500 - caption.length} characters remaining
</p>
)}
</div>
{/* Share Post CTA — bg-orange-500 per UI-SPEC */}
<Button
onClick={handleSubmit}
disabled={
imageKeys.length === 0 ||
createMutation.isPending ||
!!captionError
}
className="w-full min-h-[44px] bg-orange-500 hover:bg-orange-600 text-white font-medium"
>
{createMutation.isPending ? "Sharing…" : "Share Post"}
</Button>
</div>
);
}
@@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { Camera, BookOpen, Trophy } from "lucide-react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { PhotoPostForm } from "@/components/post-creation/PhotoPostForm";
/**
* PostTypeSheet — bottom Sheet with 3 post type choices (D-01).
*
* Selecting Photo Post opens a full-screen Dialog with PhotoPostForm (D-02).
* Story and Milestone options are seams for 02-05 and 02-06.
*
* UI-SPEC:
* Sheet title: "Create"
* Options: "Photo Post" (Camera icon), "Story" (BookOpen icon), "Milestone" (Trophy icon)
* Each option: full-width Button with icon + label
*
* D-01: bottom Sheet opened from + button in nav
* D-02: full-screen Dialog, no URL navigation change
* D-03: after posting, PhotoPostForm closes Dialog + routes to /feed
*/
interface PostTypeSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
type PostType = "photo" | "story" | "milestone" | null;
export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) {
const [selectedType, setSelectedType] = useState<PostType>(null);
const [photoDialogOpen, setPhotoDialogOpen] = useState(false);
// TODO(02-05): StoryForm — open story dialog
const [storyDialogOpen, setStoryDialogOpen] = useState(false);
// TODO(02-06): MilestoneForm — open milestone dialog
const [milestoneDialogOpen, setMilestoneDialogOpen] = useState(false);
function handleSelectType(type: PostType) {
setSelectedType(type);
onOpenChange(false); // close the Sheet
if (type === "photo") {
setPhotoDialogOpen(true);
} else if (type === "story") {
setStoryDialogOpen(true);
} else if (type === "milestone") {
setMilestoneDialogOpen(true);
}
}
function handlePhotoClose() {
setPhotoDialogOpen(false);
setSelectedType(null);
}
return (
<>
{/* Bottom Sheet — post type picker (D-01) */}
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="pb-8">
<SheetHeader className="mb-4">
<SheetTitle className="text-xl font-semibold">Create</SheetTitle>
</SheetHeader>
<div className="space-y-3">
{/* Photo Post option */}
<Button
variant="outline"
className="w-full min-h-[52px] justify-start gap-3 text-sm font-medium"
onClick={() => handleSelectType("photo")}
>
<Camera className="h-5 w-5 text-orange-500 shrink-0" aria-hidden="true" />
<span>Photo Post</span>
</Button>
{/* Story option — seam for 02-05 */}
<Button
variant="outline"
className="w-full min-h-[52px] justify-start gap-3 text-sm font-medium"
onClick={() => handleSelectType("story")}
>
<BookOpen className="h-5 w-5 text-orange-500 shrink-0" aria-hidden="true" />
<span>Story</span>
</Button>
{/* Milestone option — seam for 02-06 */}
<Button
variant="outline"
className="w-full min-h-[52px] justify-start gap-3 text-sm font-medium"
onClick={() => handleSelectType("milestone")}
>
<Trophy className="h-5 w-5 text-orange-500 shrink-0" aria-hidden="true" />
<span>Milestone</span>
</Button>
</div>
</SheetContent>
</Sheet>
{/* Photo Post full-screen Dialog (D-02) — no URL navigation change */}
<Dialog open={photoDialogOpen} onOpenChange={setPhotoDialogOpen}>
<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 Post</DialogTitle>
</DialogHeader>
<PhotoPostForm onClose={handlePhotoClose} />
</DialogContent>
</Dialog>
{/* Story Dialog seam — TODO(02-05): replace null with StoryForm */}
<Dialog open={storyDialogOpen} onOpenChange={setStoryDialogOpen}>
<DialogContent className="sm:max-w-[512px]">
<DialogHeader>
<DialogTitle className="text-xl font-semibold">New Story</DialogTitle>
</DialogHeader>
<div className="p-4 text-center text-sm text-muted-foreground">
{/* TODO(02-05): StoryForm — swap this div with <StoryForm onClose={() => setStoryDialogOpen(false)} /> */}
Story creation coming soon.
</div>
</DialogContent>
</Dialog>
{/* Milestone Dialog seam — TODO(02-06): replace null with MilestoneForm */}
<Dialog open={milestoneDialogOpen} onOpenChange={setMilestoneDialogOpen}>
<DialogContent className="sm:max-w-[512px]">
<DialogHeader>
<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>
</DialogContent>
</Dialog>
</>
);
}