@@ -158,6 +156,7 @@ export default function NotificationsPage() {
e.stopPropagation()}
>
{notif.actorPet.name}
diff --git a/src/app/(app)/search/page.tsx b/src/app/(app)/search/page.tsx
index a11c67c..d2fa968 100644
--- a/src/app/(app)/search/page.tsx
+++ b/src/app/(app)/search/page.tsx
@@ -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() {
Search
- {/* Search input */}
Pets
+ People
Tags
@@ -125,6 +130,99 @@ export default function SearchPage() {
)}
+ {/* People tab — search owners by Clerk username */}
+
+ {!peopleEnabled ? (
+
+ Type at least 2 characters to search
+
+ ) : loadingPeople ? (
+
+
+
+ ) : owners.length === 0 ? (
+
+
+
No people found for “{trimmed}”
+
+ ) : (
+
+ {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 (
+
+ {/* Owner header */}
+
+
+ {owner.imageUrl && (
+
+ )}
+
+ {displayName[0]?.toUpperCase() ?? "?"}
+
+
+
+
{displayName}
+ {subName && (
+
{subName}
+ )}
+
+
+
+ {/* Owner's pets */}
+ {owner.pets.length > 0 && (
+
+ {(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 (
+
+
+ {avatarUrl && }
+
+ {pet.name[0]?.toUpperCase() ?? "?"}
+
+
+
+
{pet.name}
+
+ {pet.breed?.name ?? pet.species.name} · {pet._count.followers} followers
+
+
+ {activePetId && activePetId !== pet.id && (
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
{/* Tags tab */}
{!tagsEnabled ? (
diff --git a/src/components/feed/PostCard.tsx b/src/components/feed/PostCard.tsx
index eee36ce..8c3f511 100644
--- a/src/components/feed/PostCard.tsx
+++ b/src/components/feed/PostCard.tsx
@@ -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) {
))}
@@ -357,12 +362,12 @@ export function PostCard({ post, onDeleted }: PostCardProps) {
) : (
/* Single image */
-
+
{/* eslint-disable-next-line @next/next/no-img-element */}
)}
diff --git a/src/components/profile/ProfileTabs.tsx b/src/components/profile/ProfileTabs.tsx
index 74a350b..5212a90 100644
--- a/src/components/profile/ProfileTabs.tsx
+++ b/src/components/profile/ProfileTabs.tsx
@@ -123,6 +123,7 @@ export function ProfileTabs({ petId }: ProfileTabsProps) {
{selectedPost && (
setSelectedPost(null)}
/>
)}
diff --git a/src/components/stories/PawRing.tsx b/src/components/stories/PawRing.tsx
index c6de01a..59560a1 100644
--- a/src/components/stories/PawRing.tsx
+++ b/src/components/stories/PawRing.tsx
@@ -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 */}
- {/* Toe beans */}
{beans.map((b, i) => (
-
+
))}
)}
diff --git a/src/trpc/routers/notifications.ts b/src/trpc/routers/notifications.ts
index 8282e6b..826ed37 100644
--- a/src/trpc/routers/notifications.ts
+++ b/src/trpc/routers/notifications.ts
@@ -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 } },
},
});
diff --git a/src/trpc/routers/search.ts b/src/trpc/routers/search.ts
index 28c400b..d46b68c 100644
--- a/src/trpc/routers/search.ts
+++ b/src/trpc/routers/search.ts
@@ -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,
+ };
+ }),
+ };
+ }),
});
diff --git a/src/trpc/routers/stories.ts b/src/trpc/routers/stories.ts
index 49574b6..f068db4 100644
--- a/src/trpc/routers/stories.ts
+++ b/src/trpc/routers/stories.ts
@@ -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 };
}),
/**
diff --git a/src/trpc/routers/videos.ts b/src/trpc/routers/videos.ts
index bf3118e..8ec6ebf 100644
--- a/src/trpc/routers/videos.ts
+++ b/src/trpc/routers/videos.ts
@@ -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;
}),