fix: resolve 5 bugs from dev testing
- PostCard: add variant="detail" (object-contain + black bg) for profile sheet — full image in original format - VideoCard: getByPostId polls Mux API directly when PROCESSING, self-heals without webhooks in dev - PawRing: gray ring for all-seen stories, orange for unseen; reads viewerPetId from ActivePetContext - Search: add People tab via new search.byOwner (Clerk username search → owner's pets) - Notifications: rows now navigate to actor profile (FOLLOW) or pet profile (REACTION/COMMENT) - Notifications router: include post.petId for navigation target Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
60366691e8
commit
da0f9f2bb7
9 files changed
+261
-59
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Bell, UserPlus, Heart, MessageCircle } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
@@ -34,25 +35,10 @@ function notificationIcon(type: NotificationType) {
|
||||
}
|
||||
}
|
||||
|
||||
function notificationText(
|
||||
type: NotificationType,
|
||||
actorName: string | undefined,
|
||||
commentBody: string | undefined
|
||||
): string {
|
||||
const name = actorName ?? "Someone";
|
||||
switch (type) {
|
||||
case "FOLLOW": return `${name} started following you`;
|
||||
case "REACTION": return `${name} reacted to your post`;
|
||||
case "COMMENT": return commentBody
|
||||
? `${name} commented: "${commentBody.slice(0, 60)}${commentBody.length > 60 ? "…" : ""}"`
|
||||
: `${name} commented on your post`;
|
||||
case "MESSAGE": return `${name} sent you a message`;
|
||||
}
|
||||
}
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const { activePetId } = useActivePet();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -63,7 +49,6 @@ export default function NotificationsPage() {
|
||||
const markAllReadMutation = useMutation(
|
||||
trpc.notifications.markAllRead.mutationOptions({
|
||||
onSuccess: () => {
|
||||
// Invalidate badge count
|
||||
void queryClient.invalidateQueries(
|
||||
trpc.notifications.unreadCount.queryOptions({ petId: activePetId ?? "" })
|
||||
);
|
||||
@@ -71,7 +56,6 @@ export default function NotificationsPage() {
|
||||
})
|
||||
);
|
||||
|
||||
// Mark all read when landing on the page
|
||||
useEffect(() => {
|
||||
if (activePetId && data && data.notifications.some((n) => !n.read)) {
|
||||
markAllReadMutation.mutate({ petId: activePetId });
|
||||
@@ -81,6 +65,18 @@ export default function NotificationsPage() {
|
||||
|
||||
const notifications = data?.notifications ?? [];
|
||||
|
||||
function getHref(notif: (typeof notifications)[number]): string | null {
|
||||
switch (notif.type) {
|
||||
case "FOLLOW":
|
||||
return notif.actorPet ? `/pets/${notif.actorPet.id}` : null;
|
||||
case "REACTION":
|
||||
case "COMMENT":
|
||||
return notif.post?.petId ? `/pets/${notif.post.petId}` : null;
|
||||
case "MESSAGE":
|
||||
return null; // Phase 6
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-[640px] mx-auto px-4 py-8">
|
||||
<h1 className="text-xl font-semibold mb-6 flex items-center gap-2">
|
||||
@@ -119,13 +115,15 @@ export default function NotificationsPage() {
|
||||
const postThumbUrl = notif.post?.images?.[0]
|
||||
? getMediaUrl(notif.post.images[0].storageKey)
|
||||
: null;
|
||||
const href = getHref(notif);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={notif.id}
|
||||
onClick={href ? () => router.push(href) : undefined}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg transition-colors ${
|
||||
!notif.read ? "bg-orange-500/5" : "hover:bg-muted/50"
|
||||
}`}
|
||||
} ${href ? "cursor-pointer hover:bg-muted/70" : ""}`}
|
||||
>
|
||||
{/* Unread dot */}
|
||||
<div className="shrink-0 mt-3">
|
||||
@@ -158,6 +156,7 @@ export default function NotificationsPage() {
|
||||
<Link
|
||||
href={`/pets/${notif.actorPet.id}`}
|
||||
className="font-semibold hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{notif.actorPet.name}
|
||||
</Link>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Search, Layers2, Loader2 } from "lucide-react";
|
||||
import { Search, Layers2, Loader2, Users } from "lucide-react";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -29,7 +29,7 @@ export default function SearchPage() {
|
||||
|
||||
const trimmed = debouncedQuery.trim();
|
||||
const petsEnabled = trimmed.length >= 2;
|
||||
// Hashtag: strip leading # then require at least 1 char
|
||||
const peopleEnabled = trimmed.length >= 2;
|
||||
const normalizedTag = trimmed.replace(/^#/, "").toLowerCase();
|
||||
const tagsEnabled = normalizedTag.length >= 1;
|
||||
|
||||
@@ -38,6 +38,11 @@ export default function SearchPage() {
|
||||
);
|
||||
const pets = petResults?.pets ?? [];
|
||||
|
||||
const { data: ownerResults, isLoading: loadingPeople } = useQuery(
|
||||
trpc.search.byOwner.queryOptions({ query: trimmed }, { enabled: peopleEnabled })
|
||||
);
|
||||
const owners = ownerResults?.owners ?? [];
|
||||
|
||||
const { data: tagResults, isLoading: loadingTags } = useQuery(
|
||||
trpc.search.byHashtag.queryOptions(
|
||||
{ tag: normalizedTag },
|
||||
@@ -50,11 +55,10 @@ export default function SearchPage() {
|
||||
<div className="max-w-[640px] mx-auto px-4 py-6">
|
||||
<h1 className="text-xl font-semibold mb-4">Search</h1>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
<Input
|
||||
placeholder="Search pets or hashtags…"
|
||||
placeholder="Search pets, people or hashtags…"
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
className="pl-9"
|
||||
@@ -65,6 +69,7 @@ export default function SearchPage() {
|
||||
<Tabs defaultValue="pets">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="pets">Pets</TabsTrigger>
|
||||
<TabsTrigger value="people">People</TabsTrigger>
|
||||
<TabsTrigger value="tags">Tags</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -125,6 +130,99 @@ export default function SearchPage() {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* People tab — search owners by Clerk username */}
|
||||
<TabsContent value="people">
|
||||
{!peopleEnabled ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-10">
|
||||
Type at least 2 characters to search
|
||||
</p>
|
||||
) : loadingPeople ? (
|
||||
<div className="flex justify-center py-10">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : owners.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-muted-foreground">
|
||||
<Users className="h-8 w-8 opacity-30" />
|
||||
<p className="text-sm">No people found for “{trimmed}”</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{owners.map((owner) => {
|
||||
const displayName = owner.username
|
||||
? `@${owner.username}`
|
||||
: [owner.firstName, owner.lastName].filter(Boolean).join(" ") || "Unknown";
|
||||
const subName = owner.username && (owner.firstName || owner.lastName)
|
||||
? [owner.firstName, owner.lastName].filter(Boolean).join(" ")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={owner.id}>
|
||||
{/* Owner header */}
|
||||
<div className="flex items-center gap-3 px-3 py-2">
|
||||
<Avatar className="w-10 h-10 shrink-0">
|
||||
{owner.imageUrl && (
|
||||
<AvatarImage src={owner.imageUrl} alt={displayName} />
|
||||
)}
|
||||
<AvatarFallback className="text-sm bg-muted">
|
||||
{displayName[0]?.toUpperCase() ?? "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold truncate">{displayName}</p>
|
||||
{subName && (
|
||||
<p className="text-xs text-muted-foreground truncate">{subName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Owner's pets */}
|
||||
{owner.pets.length > 0 && (
|
||||
<div className="ml-4 border-l border-border pl-4 space-y-1">
|
||||
{(owner.pets as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
avatarKey: string | null;
|
||||
species: { name: string };
|
||||
breed: { name: string } | null;
|
||||
_count: { followers: number };
|
||||
}>).map((pet) => {
|
||||
const avatarUrl = getAvatarUrl(pet.avatarKey);
|
||||
return (
|
||||
<div
|
||||
key={pet.id}
|
||||
className="flex items-center gap-3 px-3 py-1.5 rounded-lg hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<Avatar className="w-8 h-8 shrink-0">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={pet.name} />}
|
||||
<AvatarFallback className="text-xs bg-muted">
|
||||
{pet.name[0]?.toUpperCase() ?? "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{pet.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pet.breed?.name ?? pet.species.name} · {pet._count.followers} followers
|
||||
</p>
|
||||
</div>
|
||||
{activePetId && activePetId !== pet.id && (
|
||||
<FollowButton
|
||||
followerPetId={activePetId}
|
||||
followeePetId={pet.id}
|
||||
followeeName={pet.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tags tab */}
|
||||
<TabsContent value="tags">
|
||||
{!tagsEnabled ? (
|
||||
|
||||
@@ -110,6 +110,11 @@ export type PostCardProps = {
|
||||
post: PostCardPost;
|
||||
/** Called after successful delete so parent can remove from list */
|
||||
onDeleted?: (postId: string) => void;
|
||||
/**
|
||||
* "feed" (default): images cropped to max-h-[560px] object-cover — consistent card heights.
|
||||
* "detail": images shown in full original format (object-contain, black bg) — used in profile sheet.
|
||||
*/
|
||||
variant?: "feed" | "detail";
|
||||
};
|
||||
|
||||
function formatRelativeTime(date: Date | string): string {
|
||||
@@ -127,7 +132,7 @@ function formatRelativeTime(date: Date | string): string {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
export function PostCard({ post, onDeleted }: PostCardProps) {
|
||||
export function PostCard({ post, onDeleted, variant = "feed" }: PostCardProps) {
|
||||
const trpc = useTRPC();
|
||||
const { activePetId } = useActivePet();
|
||||
|
||||
@@ -325,7 +330,7 @@ export function PostCard({ post, onDeleted }: PostCardProps) {
|
||||
<img
|
||||
src={getMediaUrl(img.storageKey) ?? ""}
|
||||
alt={currentCaption ?? `${pet.name}'s post`}
|
||||
className="w-full object-cover max-h-[560px]"
|
||||
className={variant === "detail" ? "w-full object-contain bg-black max-h-[70dvh]" : "w-full object-cover max-h-[560px]"}
|
||||
/>
|
||||
</CarouselItem>
|
||||
))}
|
||||
@@ -357,12 +362,12 @@ export function PostCard({ post, onDeleted }: PostCardProps) {
|
||||
</div>
|
||||
) : (
|
||||
/* Single image */
|
||||
<div className="w-full">
|
||||
<div className={variant === "detail" ? "w-full bg-black flex justify-center" : "w-full"}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getMediaUrl(images[0].storageKey) ?? ""}
|
||||
alt={currentCaption ?? `${pet.name}'s post`}
|
||||
className="w-full object-cover max-h-[560px]"
|
||||
className={variant === "detail" ? "max-w-full max-h-[70dvh] object-contain" : "w-full object-cover max-h-[560px]"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -123,6 +123,7 @@ export function ProfileTabs({ petId }: ProfileTabsProps) {
|
||||
{selectedPost && (
|
||||
<PostCard
|
||||
post={selectedPost}
|
||||
variant="detail"
|
||||
onDeleted={() => setSelectedPost(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
|
||||
interface PawRingProps {
|
||||
petId: string;
|
||||
@@ -13,39 +14,40 @@ interface PawRingProps {
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const RING_PAD = 8; // px of space outside avatar for the paw outline
|
||||
const RING_PAD = 8;
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Orange = unseen stories; gray = all stories already seen; no ring = no stories (STORY-02).
|
||||
* viewerPetId is read from ActivePetContext so callers don't need to pass it explicitly.
|
||||
*/
|
||||
export function PawRing({ petId, children, size = 32, onPress }: PawRingProps) {
|
||||
const trpc = useTRPC();
|
||||
const { data: hasStory = false } = useQuery(
|
||||
trpc.stories.hasActiveStory.queryOptions({ petId })
|
||||
const { activePetId } = useActivePet();
|
||||
|
||||
const { data = { hasStory: false, allSeen: false } } = useQuery(
|
||||
trpc.stories.hasActiveStory.queryOptions({
|
||||
petId,
|
||||
viewerPetId: activePetId ?? undefined,
|
||||
})
|
||||
);
|
||||
|
||||
const S = size + RING_PAD * 2; // total container px
|
||||
const { hasStory, allSeen } = data;
|
||||
const ringColor = allSeen ? "#9ca3af" : "#f97316"; // gray-400 if seen, orange-500 if unseen
|
||||
|
||||
const S = size + RING_PAD * 2;
|
||||
|
||||
// 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 = 50;
|
||||
const mainR = 44;
|
||||
const strokeW = Math.max(4, (size / 8)); // thicker ring for larger avatars
|
||||
const strokeW = Math.max(4, size / 8);
|
||||
|
||||
// 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
|
||||
{ cx: 13, cy: 10, r: 8 },
|
||||
{ cx: 31, cy: 1, r: 10 },
|
||||
{ cx: 69, cy: 1, r: 10 },
|
||||
{ cx: 87, cy: 10, r: 8 },
|
||||
];
|
||||
|
||||
const Tag = hasStory && onPress ? "button" : "div";
|
||||
@@ -66,18 +68,16 @@ export function PawRing({ petId, children, size = 32, onPress }: PawRingProps) {
|
||||
aria-hidden="true"
|
||||
style={{ overflow: "visible" }}
|
||||
>
|
||||
{/* Main pad ring */}
|
||||
<circle
|
||||
cx={mainCx}
|
||||
cy={mainCy}
|
||||
r={mainR}
|
||||
fill="none"
|
||||
stroke="#f97316"
|
||||
stroke={ringColor}
|
||||
strokeWidth={strokeW}
|
||||
/>
|
||||
{/* Toe beans */}
|
||||
{beans.map((b, i) => (
|
||||
<circle key={i} cx={b.cx} cy={b.cy} r={b.r} fill="#f97316" />
|
||||
<circle key={i} cx={b.cx} cy={b.cy} r={b.r} fill={ringColor} />
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ export const notificationsRouter = router({
|
||||
take: input.limit + 1,
|
||||
include: {
|
||||
actorPet: { select: { id: true, name: true, avatarKey: true } },
|
||||
post: { select: { id: true, images: { take: 1, orderBy: { position: "asc" } } } },
|
||||
post: { select: { id: true, petId: true, images: { take: 1, orderBy: { position: "asc" } } } },
|
||||
comment: { select: { id: true, body: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "server-only";
|
||||
import { z } from "zod";
|
||||
import { router, protectedProcedure } from "@/trpc/init";
|
||||
import { clerkClient } from "@clerk/nextjs/server";
|
||||
|
||||
export const searchRouter = router({
|
||||
pets: protectedProcedure
|
||||
@@ -80,4 +81,51 @@ export const searchRouter = router({
|
||||
nextCursor: hasNextPage ? posts[posts.length - 1]!.id : null,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Search for owner accounts by Clerk username/name, return their pets.
|
||||
* Useful when sharing a human's @username instead of individual pet names.
|
||||
*/
|
||||
byOwner: protectedProcedure
|
||||
.input(z.object({ query: z.string().min(1).max(100) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const client = await clerkClient();
|
||||
const { data: clerkUsers } = await client.users.getUserList({
|
||||
query: input.query,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
if (clerkUsers.length === 0) return { owners: [] };
|
||||
|
||||
const userIds = clerkUsers.map((u) => u.id);
|
||||
const owners = await ctx.prisma.owner.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
include: {
|
||||
pets: {
|
||||
include: {
|
||||
species: true,
|
||||
breed: true,
|
||||
_count: { select: { followers: true } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
owners: owners
|
||||
.filter((owner) => owner.pets.length > 0)
|
||||
.map((owner) => {
|
||||
const cu = clerkUsers.find((u) => u.id === owner.id)!;
|
||||
return {
|
||||
id: owner.id,
|
||||
username: cu.username ?? null,
|
||||
firstName: cu.firstName ?? null,
|
||||
lastName: cu.lastName ?? null,
|
||||
imageUrl: cu.imageUrl ?? null,
|
||||
pets: owner.pets,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -94,16 +94,32 @@ export const storiesRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Returns true if the pet has at least one non-expired story (STORY-02).
|
||||
* Used by PawRing to decide whether to show the paw outline.
|
||||
* Returns story ring state for a pet (STORY-02).
|
||||
* { hasStory: false } → no ring
|
||||
* { hasStory: true, allSeen: false } → orange ring (unseen stories)
|
||||
* { hasStory: true, allSeen: true } → gray ring (all stories seen)
|
||||
* viewerPetId is optional — without it allSeen is always false.
|
||||
*/
|
||||
hasActiveStory: protectedProcedure
|
||||
.input(z.object({ petId: z.string().min(1) }))
|
||||
.input(z.object({ petId: z.string().min(1), viewerPetId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const count = await ctx.prisma.story.count({
|
||||
const activeStories = await ctx.prisma.story.findMany({
|
||||
where: { petId: input.petId, expiresAt: { gt: new Date() } },
|
||||
select: { id: true },
|
||||
});
|
||||
return count > 0;
|
||||
|
||||
if (activeStories.length === 0) return { hasStory: false, allSeen: false };
|
||||
|
||||
if (!input.viewerPetId) return { hasStory: true, allSeen: false };
|
||||
|
||||
const viewedCount = await ctx.prisma.storyView.count({
|
||||
where: {
|
||||
storyId: { in: activeStories.map((s) => s.id) },
|
||||
viewerPetId: input.viewerPetId,
|
||||
},
|
||||
});
|
||||
|
||||
return { hasStory: true, allSeen: viewedCount >= activeStories.length };
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,12 +40,11 @@ export const videosRouter = router({
|
||||
|
||||
// Create Mux direct upload — client PUTs directly to the returned URL
|
||||
const upload = await mux.video.uploads.create({
|
||||
cors_origin: process.env.NEXT_PUBLIC_APP_URL ?? "*",
|
||||
// "*" allows uploads from any origin (dev + prod); restrict to APP_URL in production if needed
|
||||
cors_origin: "*",
|
||||
new_asset_settings: {
|
||||
playback_policy: ["public"],
|
||||
// max_resolution_tier tells Mux to cap at 1080p (cost control)
|
||||
max_resolution_tier: "1080p",
|
||||
passthrough: "", // will be set after post creation
|
||||
},
|
||||
});
|
||||
|
||||
@@ -76,7 +75,8 @@ export const videosRouter = router({
|
||||
|
||||
/**
|
||||
* Get the video post status — used to poll while video is PROCESSING.
|
||||
* Returns the VideoPost record so the UI can transition to the player when READY.
|
||||
* When PROCESSING, checks Mux directly so the status self-heals in dev
|
||||
* environments where webhooks can't reach localhost.
|
||||
*/
|
||||
getByPostId: protectedProcedure
|
||||
.input(z.object({ postId: z.string().min(1) }))
|
||||
@@ -85,6 +85,41 @@ export const videosRouter = router({
|
||||
where: { postId: input.postId },
|
||||
});
|
||||
if (!videoPost) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
|
||||
if (videoPost.status === "PROCESSING" && process.env.MUX_TOKEN_ID) {
|
||||
try {
|
||||
let assetId = videoPost.muxAssetId;
|
||||
|
||||
if (!assetId && videoPost.muxUploadId) {
|
||||
const upload = await mux.video.uploads.retrieve(videoPost.muxUploadId);
|
||||
if (upload.asset_id) assetId = upload.asset_id;
|
||||
}
|
||||
|
||||
if (assetId) {
|
||||
const asset = await mux.video.assets.retrieve(assetId);
|
||||
if (asset.status === "ready" && asset.playback_ids?.[0]) {
|
||||
return await ctx.prisma.videoPost.update({
|
||||
where: { postId: input.postId },
|
||||
data: {
|
||||
status: "READY",
|
||||
muxAssetId: assetId,
|
||||
muxPlaybackId: asset.playback_ids[0].id,
|
||||
durationSecs: asset.duration ? Math.round(asset.duration) : null,
|
||||
aspectRatio: asset.aspect_ratio ?? null,
|
||||
},
|
||||
});
|
||||
} else if (asset.status === "errored") {
|
||||
return await ctx.prisma.videoPost.update({
|
||||
where: { postId: input.postId },
|
||||
data: { status: "ERROR", muxAssetId: assetId },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Mux check failed — return current DB state unchanged
|
||||
}
|
||||
}
|
||||
|
||||
return videoPost;
|
||||
}),
|
||||
|
||||
|
||||
Reference in new issue
Block a user