feat(02-05): storiesRouter GREEN + PawRing + StoryViewer + StoryTray + StoryForm

This commit is contained in:
admin committed 2026-06-13 10:08:23 +02:00
1 parent 99bff88545
commit e30531f834
5 files changed
+708

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
interface PawRingProps {
petId: string;
/** The circular avatar to wrap */
children: React.ReactNode;
/** Avatar size in px — used to scale the paw outline (default: 32) */
size?: number;
/** Optional tap handler — rendered as <button> when hasActiveStory */
onPress?: () => void;
}
const RING_PAD = 8; // px of space outside avatar for the paw outline
/**
* PawRing — wraps an avatar with a paw-print outline when the pet has an active story.
*
* Design: the main pad is a circle slightly larger than the avatar; four toe beans sit
* above it, collectively forming a paw silhouette. The entire outline is orange-500.
* Activates only when stories.hasActiveStory returns true (STORY-02).
*/
export function PawRing({ petId, children, size = 32, onPress }: PawRingProps) {
const trpc = useTRPC();
const { data: hasStory = false } = useQuery(
trpc.stories.hasActiveStory.queryOptions({ petId })
);
const S = size + RING_PAD * 2; // total container px
// All SVG coordinates derived from container size S so the paw scales with the avatar.
// viewBox is fixed at 100×100; S px maps to 100 SVG units.
// Main pad: slightly larger than the avatar circle, centered horizontally,
// shifted down so the toe beans have room above.
const mainCx = 50;
const mainCy = 58;
const mainR = 44;
const strokeW = Math.max(4, (size / 8)); // thicker ring for larger avatars
// 4 toe beans above the main pad — positions as fractions of the 100-unit viewBox.
// They overlap the top of the main circle slightly (natural paw anatomy).
const beans = [
{ cx: 13, cy: 10, r: 8 }, // far left toe
{ cx: 31, cy: 1, r: 10 }, // inner left toe
{ cx: 69, cy: 1, r: 10 }, // inner right toe
{ cx: 87, cy: 10, r: 8 }, // far right toe
];
const Tag = hasStory && onPress ? "button" : "div";
return (
<Tag
className={`relative inline-flex items-center justify-center shrink-0${hasStory && onPress ? " cursor-pointer" : ""}`}
style={{ width: S, height: S }}
{...(hasStory && onPress ? { onClick: onPress } : {})}
{...(hasStory && onPress ? { "aria-label": "View story" } : {})}
>
{hasStory && (
<svg
width={S}
height={S}
viewBox="0 0 100 100"
className="absolute inset-0 pointer-events-none"
aria-hidden="true"
style={{ overflow: "visible" }}
>
{/* Main pad ring */}
<circle
cx={mainCx}
cy={mainCy}
r={mainR}
fill="none"
stroke="#f97316"
strokeWidth={strokeW}
/>
{/* Toe beans */}
{beans.map((b, i) => (
<circle key={i} cx={b.cx} cy={b.cy} r={b.r} fill="#f97316" />
))}
</svg>
)}
{children}
</Tag>
);
}
+229
View File
@@ -0,0 +1,229 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { Camera, Loader2, ImagePlus } from "lucide-react";
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 { Progress } from "@/components/ui/progress";
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 StoryFormProps {
onClose: () => void;
}
type UploadState = "idle" | "uploading" | "ready" | "submitting";
/**
* StoryForm — single-photo upload for 24-hour stories.
*
* Upload flow mirrors AvatarUpload / MultiImageUpload:
* 1. File validated (size, type)
* 2. stories.getPresignedUrl → signed URL
* 3. XHR PUT directly to Supabase Storage
* 4. stories.create({ petId, storageKey }) commits the story
*/
export function StoryForm({ onClose }: StoryFormProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const { activePetId } = useActivePet();
const fileInputRef = useRef<HTMLInputElement>(null);
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 [errorMessage, setErrorMessage] = useState<string | null>(null);
const getPresignedUrlMutation = useMutation(
trpc.stories.getPresignedUrl.mutationOptions()
);
const createStoryMutation = useMutation(
trpc.stories.create.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries(
trpc.stories.hasActiveStory.queryOptions({ petId: activePetId ?? "" })
);
queryClient.invalidateQueries(
trpc.stories.listActive.queryOptions({ petId: activePetId ?? "" })
);
toast.success("Story posted");
onClose();
},
onError: () => {
toast.error("Couldn't post your story. Please try again.");
setUploadState("ready");
},
})
);
const handleFile = useCallback(
async (file: File) => {
if (!activePetId) {
toast.error("No active pet selected.");
return;
}
setErrorMessage(null);
if (file.size > MAX_FILE_SIZE) {
setErrorMessage("Photo must be under 10 MB.");
return;
}
if (!ALLOWED_TYPES.includes(file.type as AllowedContentType)) {
setErrorMessage("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 {
setErrorMessage("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 || !uploadedKey) return;
setUploadState("submitting");
createStoryMutation.mutate({ petId: activePetId, storageKey: uploadedKey });
}, [activePetId, uploadedKey, createStoryMutation]);
return (
<div className="px-4 pb-6 space-y-5">
{/* Upload zone */}
<div
className={`relative flex flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed transition-colors cursor-pointer
${previewUrl ? "border-transparent p-0 overflow-hidden" : "border-muted hover:border-muted-foreground/50 p-10"}
`}
onClick={() => uploadState === "idle" && fileInputRef.current?.click()}
role="button"
tabIndex={uploadState === "idle" ? 0 : -1}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && uploadState === "idle")
fileInputRef.current?.click();
}}
aria-label="Add a photo"
>
<input
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
/>
{!previewUrl ? (
<>
<ImagePlus className="h-10 w-10 text-muted-foreground" aria-hidden="true" />
<div className="text-center">
<p className="text-sm font-medium">Add a photo</p>
<p className="text-xs text-muted-foreground mt-0.5">
Stories disappear after 24 hours
</p>
</div>
</>
) : (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewUrl}
alt="Story preview"
className="w-full max-h-[360px] object-contain rounded-xl"
/>
{/* Progress overlay during upload */}
{uploadState === "uploading" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/50 rounded-xl">
<Loader2 className="h-8 w-8 text-white animate-spin" />
<Progress
value={uploadProgress}
className="w-2/3 h-1.5 [&>div]:bg-orange-500"
/>
</div>
)}
{/* Tap to change when ready */}
{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={(e) => {
e.stopPropagation();
fileInputRef.current?.click();
}}
>
<Camera className="h-3 w-3 inline mr-1" aria-hidden="true" />
Change photo
</button>
)}
</>
)}
</div>
{errorMessage && (
<p className="text-xs text-destructive">{errorMessage}</p>
)}
<p className="text-xs text-muted-foreground text-center">
Stories disappear after 24 hours
</p>
<Button
className="w-full bg-orange-500 hover:bg-orange-600 text-white"
disabled={uploadState !== "ready" || !uploadedKey}
onClick={handleSubmit}
>
{uploadState === "submitting" ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" aria-hidden="true" />
Posting…
</>
) : (
"Post Story"
)}
</Button>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import { useState } from "react";
import { PlusCircle } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { PawRing } from "@/components/stories/PawRing";
import { StoryViewer } from "@/components/stories/StoryViewer";
import { StoryForm } from "@/components/stories/StoryForm";
import { getAvatarUrl } from "@/lib/avatar-url";
const TRAY_AVATAR_SIZE = 56; // px
/**
* StoryTray — horizontal scroll row at the top of the feed.
*
* First item: "Add story" button (orange) → opens StoryForm dialog.
* Remaining items: pets followed by the active pet that have active stories,
* each wrapped in PawRing. Tapping a pet opens the StoryViewer.
*/
export function StoryTray() {
const trpc = useTRPC();
const { activePetId } = useActivePet();
const [addStoryOpen, setAddStoryOpen] = useState(false);
const [viewingPet, setViewingPet] = useState<{
id: string;
name: string;
avatarKey: string | null;
} | null>(null);
// Fetch pets followed by the active pet that have active stories
const { data: followedWithStories = [] } = useQuery({
...trpc.follows.getPetsWithActiveStories.queryOptions({
followerPetId: activePetId ?? "",
}),
enabled: !!activePetId,
});
return (
<>
{/* Horizontal scroll tray */}
<div
className="flex gap-4 overflow-x-auto px-6 py-3 scrollbar-hide -mx-6"
role="list"
aria-label="Stories"
>
{/* Add story item */}
<div className="flex flex-col items-center gap-1 shrink-0" role="listitem">
<button
className="flex items-center justify-center rounded-full bg-orange-500 hover:bg-orange-600 transition-colors"
style={{ width: TRAY_AVATAR_SIZE, height: TRAY_AVATAR_SIZE }}
onClick={() => setAddStoryOpen(true)}
aria-label="Add story"
>
<PlusCircle className="h-7 w-7 text-white" aria-hidden="true" />
</button>
<span className="text-xs text-muted-foreground truncate max-w-[56px]">
Add story
</span>
</div>
{/* Followed pets with active stories */}
{followedWithStories.map((pet) => (
<div
key={pet.id}
className="flex flex-col items-center gap-1 shrink-0"
role="listitem"
>
<PawRing
petId={pet.id}
size={TRAY_AVATAR_SIZE}
onPress={() => setViewingPet(pet)}
>
<Avatar
className="border-2 border-background"
style={{ width: TRAY_AVATAR_SIZE, height: TRAY_AVATAR_SIZE }}
>
{pet.avatarKey && (
<AvatarImage
src={getAvatarUrl(pet.avatarKey) ?? ""}
alt={pet.name}
/>
)}
<AvatarFallback className="text-sm bg-muted">
{pet.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
</PawRing>
<span className="text-xs text-foreground truncate max-w-[72px]">
{pet.name}
</span>
</div>
))}
</div>
{/* StoryViewer */}
{viewingPet && (
<StoryViewer
petId={viewingPet.id}
petName={viewingPet.name}
petAvatarKey={viewingPet.avatarKey}
open={!!viewingPet}
onOpenChange={(o) => !o && setViewingPet(null)}
/>
)}
{/* Add story Dialog */}
<Dialog open={addStoryOpen} onOpenChange={setAddStoryOpen}>
<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 Story</DialogTitle>
</DialogHeader>
<StoryForm onClose={() => setAddStoryOpen(false)} />
</DialogContent>
</Dialog>
</>
);
}
+240
View File
@@ -0,0 +1,240 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { X, Eye } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { getAvatarUrl } from "@/lib/avatar-url";
import { getMediaUrl } from "@/lib/media-url";
interface StoryViewerProps {
petId: string;
petName: string;
petAvatarKey: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
const STORY_DURATION_MS = 5000;
/**
* StoryViewer — full-screen story viewer with 5-second progress bar.
*
* - Records a deduplicated view via stories.recordView on open (STORY-03)
* - Progress bar: orange fill transitioning 0→100% over 5s, auto-advances/closes
* - Viewer list: owner-only Sheet showing who has seen each story
*/
export function StoryViewer({
petId,
petName,
petAvatarKey,
open,
onOpenChange,
}: StoryViewerProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const { activePetId } = useActivePet();
const [currentIndex, setCurrentIndex] = useState(0);
const [progress, setProgress] = useState(0);
const [viewerSheetOpen, setViewerSheetOpen] = useState(false);
const { data: stories = [] } = useQuery(
trpc.stories.listActive.queryOptions({ petId })
);
const currentStory = stories[currentIndex];
const isOwnPet = activePetId === petId;
const recordViewMutation = useMutation(
trpc.stories.recordView.mutationOptions()
);
const { data: viewers = [] } = useQuery({
...trpc.stories.getViewers.queryOptions({
storyId: currentStory?.id ?? "",
}),
enabled: isOwnPet && viewerSheetOpen && !!currentStory,
});
// Record view when a story becomes visible
useEffect(() => {
if (!open || !currentStory || !activePetId || activePetId === petId) return;
recordViewMutation.mutate({
storyId: currentStory.id,
viewerPetId: activePetId,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentStory?.id, activePetId]);
// Progress bar + auto-advance
useEffect(() => {
if (!open || !currentStory) return;
setProgress(0);
const startTime = Date.now();
let rafId: number;
function tick() {
const elapsed = Date.now() - startTime;
const pct = Math.min((elapsed / STORY_DURATION_MS) * 100, 100);
setProgress(pct);
if (pct < 100) {
rafId = requestAnimationFrame(tick);
} else {
// advance to next story or close
if (currentIndex < stories.length - 1) {
setCurrentIndex((i) => i + 1);
} else {
onOpenChange(false);
}
}
}
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
}, [open, currentStory, currentIndex, stories.length, onOpenChange]);
// Reset index when viewer opens
useEffect(() => {
if (open) {
setCurrentIndex(0);
setProgress(0);
}
}, [open]);
const handleClose = useCallback(() => {
onOpenChange(false);
queryClient.invalidateQueries(trpc.stories.hasActiveStory.queryOptions({ petId }));
}, [onOpenChange, queryClient, trpc, petId]);
if (!currentStory) return null;
const mediaUrl = getMediaUrl(currentStory.storageKey);
return (
<>
{/* Full-screen overlay */}
{open && (
<div
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-label={`${petName}'s story`}
>
{/* Progress bars */}
<div className="absolute top-0 left-0 right-0 flex gap-1 px-3 pt-3">
{stories.map((s, i) => (
<div key={s.id} className="flex-1 h-0.5 bg-white/30 rounded-full overflow-hidden">
<div
className="h-full bg-orange-500 rounded-full"
style={{
width:
i < currentIndex
? "100%"
: i === currentIndex
? `${progress}%`
: "0%",
transition: i === currentIndex ? "none" : undefined,
}}
/>
</div>
))}
</div>
{/* Story image */}
{mediaUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={mediaUrl}
alt={`${petName}'s story`}
className="max-h-screen max-w-full object-contain"
/>
)}
{/* Pet info — bottom left */}
<div className="absolute bottom-6 left-4 flex items-center gap-2">
<Avatar className="w-8 h-8 border border-white/50">
{petAvatarKey && (
<AvatarImage src={getAvatarUrl(petAvatarKey) ?? ""} alt={petName} />
)}
<AvatarFallback className="bg-muted text-xs">
{petName[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
<span className="text-white text-sm font-semibold drop-shadow">
{petName}
</span>
</div>
{/* Close button — top right */}
<button
className="absolute top-8 right-4 flex items-center justify-center w-11 h-11 rounded-full bg-black/40 text-white hover:bg-black/60"
onClick={handleClose}
aria-label="Close story"
>
<X className="h-5 w-5" aria-hidden="true" />
</button>
{/* Viewer count — top left (owner only) */}
{isOwnPet && (
<button
className="absolute top-8 left-4 flex items-center gap-1.5 px-3 py-2 rounded-full bg-black/40 text-white text-xs hover:bg-black/60"
onClick={() => setViewerSheetOpen(true)}
aria-label={`Seen by ${viewers.length} pets`}
>
<Eye className="h-3.5 w-3.5" aria-hidden="true" />
<span>{viewers.length}</span>
</button>
)}
</div>
)}
{/* Viewer list sheet — owner only */}
<Sheet open={viewerSheetOpen} onOpenChange={setViewerSheetOpen}>
<SheetContent side="bottom" className="pb-8 max-h-[60dvh] overflow-y-auto">
<SheetHeader className="mb-4">
<SheetTitle className="text-lg font-semibold">
{viewers.length > 0
? `Seen by ${viewers.length} ${viewers.length === 1 ? "pet" : "pets"}`
: "No views yet"}
</SheetTitle>
</SheetHeader>
{viewers.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No one has viewed this story yet.
</p>
) : (
<ul className="space-y-3">
{viewers.map((v) => (
<li key={v.viewerPetId} className="flex items-center gap-3">
<Avatar className="w-9 h-9 shrink-0">
{v.viewerPet.avatarKey && (
<AvatarImage
src={getAvatarUrl(v.viewerPet.avatarKey) ?? ""}
alt={v.viewerPet.name}
/>
)}
<AvatarFallback className="text-xs bg-muted">
{v.viewerPet.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">{v.viewerPet.name}</span>
</li>
))}
</ul>
)}
</SheetContent>
</Sheet>
</>
);
}
+25
View File
@@ -162,6 +162,31 @@ export const followsRouter = router({
});
}),
/**
* follow.getPetsWithActiveStories — followed pets that currently have a live story.
* Used by StoryTray to populate the horizontal story scroll. (STORY-02)
*/
getPetsWithActiveStories: protectedProcedure
.input(z.object({ followerPetId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
await assertPetOwnership(ctx.prisma, input.followerPetId, ctx.userId);
const rows = await ctx.prisma.follow.findMany({
where: {
followerPetId: input.followerPetId,
followee: {
stories: { some: { expiresAt: { gt: new Date() } } },
},
},
select: {
followee: { select: { id: true, name: true, avatarKey: true } },
},
orderBy: { createdAt: "desc" },
});
return rows.map((r) => r.followee);
}),
/**
* follow.listFollowing — list pets a given pet is following (SOCL-03)
*