From e30531f834184931e2fa03634a1365094d568ee8 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sat, 13 Jun 2026 10:08:23 +0200 Subject: [PATCH] feat(02-05): storiesRouter GREEN + PawRing + StoryViewer + StoryTray + StoryForm --- src/components/stories/PawRing.tsx | 87 +++++++++ src/components/stories/StoryForm.tsx | 229 +++++++++++++++++++++++ src/components/stories/StoryTray.tsx | 127 +++++++++++++ src/components/stories/StoryViewer.tsx | 240 +++++++++++++++++++++++++ src/trpc/routers/follows.ts | 25 +++ 5 files changed, 708 insertions(+) create mode 100644 src/components/stories/PawRing.tsx create mode 100644 src/components/stories/StoryForm.tsx create mode 100644 src/components/stories/StoryTray.tsx create mode 100644 src/components/stories/StoryViewer.tsx diff --git a/src/components/stories/PawRing.tsx b/src/components/stories/PawRing.tsx new file mode 100644 index 0000000..8e4e913 --- /dev/null +++ b/src/components/stories/PawRing.tsx @@ -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 + )} + + )} + + + {errorMessage && ( +

{errorMessage}

+ )} + +

+ Stories disappear after 24 hours +

+ + + + ); +} diff --git a/src/components/stories/StoryTray.tsx b/src/components/stories/StoryTray.tsx new file mode 100644 index 0000000..89a1820 --- /dev/null +++ b/src/components/stories/StoryTray.tsx @@ -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 */} +
+ {/* Add story item */} +
+ + + Add story + +
+ + {/* Followed pets with active stories */} + {followedWithStories.map((pet) => ( +
+ setViewingPet(pet)} + > + + {pet.avatarKey && ( + + )} + + {pet.name[0]?.toUpperCase() ?? "?"} + + + + + {pet.name} + +
+ ))} +
+ + {/* StoryViewer */} + {viewingPet && ( + !o && setViewingPet(null)} + /> + )} + + {/* Add story Dialog */} + + + + New Story + + setAddStoryOpen(false)} /> + + + + ); +} diff --git a/src/components/stories/StoryViewer.tsx b/src/components/stories/StoryViewer.tsx new file mode 100644 index 0000000..2d91b35 --- /dev/null +++ b/src/components/stories/StoryViewer.tsx @@ -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 && ( +
+ {/* Progress bars */} +
+ {stories.map((s, i) => ( +
+
+
+ ))} +
+ + {/* Story image */} + {mediaUrl && ( + // eslint-disable-next-line @next/next/no-img-element + {`${petName}'s + )} + + {/* Pet info — bottom left */} +
+ + {petAvatarKey && ( + + )} + + {petName[0]?.toUpperCase() ?? "?"} + + + + {petName} + +
+ + {/* Close button — top right */} + + + {/* Viewer count — top left (owner only) */} + {isOwnPet && ( + + )} +
+ )} + + {/* Viewer list sheet — owner only */} + + + + + {viewers.length > 0 + ? `Seen by ${viewers.length} ${viewers.length === 1 ? "pet" : "pets"}` + : "No views yet"} + + + {viewers.length === 0 ? ( +

+ No one has viewed this story yet. +

+ ) : ( +
    + {viewers.map((v) => ( +
  • + + {v.viewerPet.avatarKey && ( + + )} + + {v.viewerPet.name[0]?.toUpperCase() ?? "?"} + + + {v.viewerPet.name} +
  • + ))} +
+ )} +
+
+ + ); +} diff --git a/src/trpc/routers/follows.ts b/src/trpc/routers/follows.ts index 82bbc6a..ea6e1da 100644 --- a/src/trpc/routers/follows.ts +++ b/src/trpc/routers/follows.ts @@ -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) *