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:
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user