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:
1 parent
15f51825d9
commit
e4b619671f
5 files changed
+697
-5
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user