feat(02-06): MilestoneForm + PostTypeSheet wiring + ProfileTabs + profile page — Phase 2 complete
This commit is contained in:
1 parent
9b02d3dc8b
commit
3fa62d2ce0
4 files changed
+417
-14
No files matched your search
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user