feat(insights): make spotlight post/story cards clickable

Top-post and top-story cards in the pet insights dashboard now open the
real post (PostDetailDialog, same fetch-by-id pattern as the notifications
page) or story (StoryViewer, new initialStoryId prop to jump straight to
the right story instead of always starting at index 0).
This commit is contained in:
2026-08-24 18:57:33 +02:00
parent 45448573ea
commit 6e9e08be70
3 changed files with 90 additions and 7 deletions
+1 -1
View File
@@ -27,5 +27,5 @@ export default async function InsightsPage({ params }: PageProps) {
redirect(`/pets/${petId}`);
}
return <InsightsDashboard petId={petId} petName={pet.name} />;
return <InsightsDashboard petId={petId} petName={pet.name} petAvatarKey={pet.avatarKey} />;
}
+79 -4
View File
@@ -1,15 +1,20 @@
"use client";
import { useEffect, useState } from "react";
import { PawPrint, MessageCircle, ImageOff, Loader2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useTRPC } from "@/trpc/client";
import { getMediaUrl } from "@/lib/media-url";
import { BlurImage } from "@/components/ui/blur-image";
import { PostDetailDialog } from "@/components/feed/PostDetailDialog";
import type { PostCardPost } from "@/components/feed/PostCard";
import { StoryViewer } from "@/components/stories/StoryViewer";
interface InsightsDashboardProps {
petId: string;
petName: string;
petAvatarKey: string | null;
}
function StatTile({ label, value }: { label: string; value: number }) {
@@ -27,11 +32,22 @@ interface SpotlightCardProps {
thumbnailBlurhash: string | null;
subtitle: string | null;
emptyLabel: string;
onClick?: () => void;
}
function SpotlightCard({ title, thumbnailUrl, thumbnailBlurhash, subtitle, emptyLabel }: SpotlightCardProps) {
/**
* Gitea #30 follow-up — spotlight items are clickable when they point at a
* real post/story (onClick set); the empty state (no item yet) renders the
* same card as a disabled, non-interactive button.
*/
function SpotlightCard({ title, thumbnailUrl, thumbnailBlurhash, subtitle, emptyLabel, onClick }: SpotlightCardProps) {
return (
<div className="flex items-center gap-3 rounded-lg border bg-card p-3">
<button
type="button"
onClick={onClick}
disabled={!onClick}
className="flex w-full items-center gap-3 rounded-lg border bg-card p-3 text-left transition-colors enabled:hover:bg-muted/60 enabled:cursor-pointer disabled:cursor-default"
>
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-md bg-muted">
{thumbnailUrl ? (
<BlurImage
@@ -54,7 +70,7 @@ function SpotlightCard({ title, thumbnailUrl, thumbnailBlurhash, subtitle, empty
<p className="text-sm text-muted-foreground">{emptyLabel}</p>
)}
</div>
</div>
</button>
);
}
@@ -63,12 +79,33 @@ function SpotlightCard({ title, thumbnailUrl, thumbnailBlurhash, subtitle, empty
* and comments across a pet's posts + active stories, plus which post/story
* is resonating most. Reads insights.getPetInsights (owner-only).
*/
export function InsightsDashboard({ petId, petName }: InsightsDashboardProps) {
export function InsightsDashboard({ petId, petName, petAvatarKey }: InsightsDashboardProps) {
const t = useTranslations("Insights");
const trpc = useTRPC();
const { data, isLoading } = useQuery(trpc.insights.getPetInsights.queryOptions({ petId }));
// Post spotlight click — same "fetch by id, open in dialog" pattern as the
// notifications page (PostDetailDialog needs the full post shape, not the
// trimmed insights payload).
const [openPostId, setOpenPostId] = useState<string | null>(null);
const [selectedPost, setSelectedPost] = useState<PostCardPost | null>(null);
const { data: fetchedPost } = useQuery({
...trpc.posts.getById.queryOptions({ postId: openPostId ?? "" }),
enabled: !!openPostId,
});
useEffect(() => {
if (fetchedPost) setSelectedPost(fetchedPost as PostCardPost);
}, [fetchedPost]);
function closePostDialog() {
setOpenPostId(null);
setSelectedPost(null);
}
// Story spotlight click — StoryViewer takes an initialStoryId to jump
// straight to the right story instead of always starting at index 0.
const [openStoryId, setOpenStoryId] = useState<string | null>(null);
if (isLoading || !data) {
return (
<div className="flex justify-center py-24">
@@ -109,6 +146,11 @@ export function InsightsDashboard({ petId, petName }: InsightsDashboardProps) {
: null
}
emptyLabel={t("noPostsYet")}
onClick={
data.topPostByReactions
? () => setOpenPostId(data.topPostByReactions!.id)
: undefined
}
/>
<SpotlightCard
title={t("mostCommentedPost")}
@@ -120,6 +162,11 @@ export function InsightsDashboard({ petId, petName }: InsightsDashboardProps) {
: null
}
emptyLabel={t("noPostsYet")}
onClick={
data.topPostByComments
? () => setOpenPostId(data.topPostByComments!.id)
: undefined
}
/>
</div>
</section>
@@ -146,6 +193,11 @@ export function InsightsDashboard({ petId, petName }: InsightsDashboardProps) {
: null
}
emptyLabel={t("noActiveStoriesYet")}
onClick={
data.topStoryByReactions
? () => setOpenStoryId(data.topStoryByReactions!.id)
: undefined
}
/>
<SpotlightCard
title={t("mostCommentedStory")}
@@ -159,10 +211,33 @@ export function InsightsDashboard({ petId, petName }: InsightsDashboardProps) {
: null
}
emptyLabel={t("noActiveStoriesYet")}
onClick={
data.topStoryByComments
? () => setOpenStoryId(data.topStoryByComments!.id)
: undefined
}
/>
</div>
<p className="mt-3 text-xs text-muted-foreground">{t("storiesExpireHint")}</p>
</section>
<PostDetailDialog
post={selectedPost}
open={!!selectedPost}
onClose={closePostDialog}
onDeleted={closePostDialog}
/>
{openStoryId && (
<StoryViewer
petId={petId}
petName={petName}
petAvatarKey={petAvatarKey}
open={!!openStoryId}
onOpenChange={(o) => !o && setOpenStoryId(null)}
initialStoryId={openStoryId}
/>
)}
</div>
);
}
+10 -2
View File
@@ -35,6 +35,8 @@ interface StoryViewerProps {
petAvatarKey: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Gitea #30 — jump straight to this story (e.g. from the Insights spotlight) instead of always starting at index 0. Falls back to 0 if not found among the active stories. */
initialStoryId?: string;
}
const IMAGE_STORY_DURATION_MS = 5000;
@@ -56,6 +58,7 @@ export function StoryViewer({
petAvatarKey,
open,
onOpenChange,
initialStoryId,
}: StoryViewerProps) {
const t = useTranslations("StoryViewer");
const trpc = useTRPC();
@@ -174,14 +177,19 @@ export function StoryViewer({
advance();
}, [advance]);
// Reset index when viewer opens
// Reset index when viewer opens — jump straight to initialStoryId (if given
// and already among the loaded stories) instead of always starting at 0.
useEffect(() => {
if (open) {
setCurrentIndex(0);
const initialIndex = initialStoryId
? Math.max(0, stories.findIndex((s) => s.id === initialStoryId))
: 0;
setCurrentIndex(initialIndex);
setProgress(0);
setIsMuted(true);
setCommentsOpen(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const handleClose = useCallback(() => {