feat(02-05): storiesRouter GREEN + PawRing + StoryViewer + StoryTray + StoryForm
This commit is contained in:
1 parent
99bff88545
commit
e30531f834
5 files changed
+708
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user