feat: SPA bug fixes, interactivity, federation badges, admin reindex
Some checks failed
CI / Check / Test (push) Failing after 10m55s

- fix wrapup status "completed" → "Ready"
- fix unfollow sending {handle} instead of {actor_url}
- fix missing post import in users.ts
- fix feed/activity cache not invalidated on review delete/log
- add person_id to cast/crew types, link to /people pages
- add movie_id to wrapup MovieRef, link highlights to /movies pages
- add wrapup actor profile images + clickable person links
- add federated review globe badge in feed and movie detail
- add fediverse handle (@user@instance) in follower/following cards
- add admin reindex search button in settings
- add wrapup user picker for admins
- add username/display_name to user summary type
- use tmdbProfileUrl for person search results
This commit is contained in:
2026-06-04 14:43:41 +02:00
parent bd7dc648c4
commit 01c1082290
18 changed files with 159 additions and 30 deletions

View File

@@ -1,7 +1,7 @@
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { posterUrl } from "@/lib/api/client" import { tmdbProfileUrl } from "@/lib/api/client"
type PersonRowProps = { type PersonRowProps = {
id: string id: string
@@ -16,7 +16,7 @@ export function PersonRow({ id, name, subtitle, imagePath }: PersonRowProps) {
<Card size="sm"> <Card size="sm">
<CardContent className="flex items-center gap-3"> <CardContent className="flex items-center gap-3">
<Avatar> <Avatar>
{imagePath && <AvatarImage src={posterUrl(imagePath)} />} {imagePath && <AvatarImage src={tmdbProfileUrl(imagePath)} />}
<AvatarFallback>{name[0]?.toUpperCase()}</AvatarFallback> <AvatarFallback>{name[0]?.toUpperCase()}</AvatarFallback>
</Avatar> </Avatar>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">

View File

@@ -1,4 +1,5 @@
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import { Globe } from "lucide-react"
import { StarDisplay } from "@/components/star-display" import { StarDisplay } from "@/components/star-display"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { posterUrl } from "@/lib/api/client" import { posterUrl } from "@/lib/api/client"
@@ -9,9 +10,10 @@ type ReviewCardProps = {
review: ReviewDto review: ReviewDto
userName?: string userName?: string
userId?: string userId?: string
isFederated?: boolean
} }
export function ReviewCard({ movie, review, userName, userId }: ReviewCardProps) { export function ReviewCard({ movie, review, userName, userId, isFederated }: ReviewCardProps) {
return ( return (
<Card size="sm"> <Card size="sm">
<CardContent className="flex gap-3"> <CardContent className="flex gap-3">
@@ -28,6 +30,7 @@ export function ReviewCard({ movie, review, userName, userId }: ReviewCardProps)
) : ( ) : (
<span>{userName}</span> <span>{userName}</span>
)} )}
{isFederated && <Globe className="size-3 text-muted-foreground/60" />}
<span>·</span> <span>·</span>
<span>{review.watched_at.slice(0, 10)}</span> <span>{review.watched_at.slice(0, 10)}</span>
</div> </div>

View File

@@ -74,6 +74,7 @@ export function useLogReview() {
mutationFn: (data: LogReviewRequest) => logReview(data), mutationFn: (data: LogReviewRequest) => logReview(data),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: diaryKeys.all }) qc.invalidateQueries({ queryKey: diaryKeys.all })
qc.invalidateQueries({ queryKey: ["activity-feed"] })
}, },
}) })
} }
@@ -84,6 +85,7 @@ export function useDeleteReview() {
mutationFn: (id: string) => deleteReview(id), mutationFn: (id: string) => deleteReview(id),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: diaryKeys.all }) qc.invalidateQueries({ queryKey: diaryKeys.all })
qc.invalidateQueries({ queryKey: ["activity-feed"] })
}, },
}) })
} }

View File

@@ -83,7 +83,7 @@ export function useFollow() {
export function useUnfollow() { export function useUnfollow() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (data: FollowRequest) => unfollow(data), mutationFn: (data: ActorUrlRequest) => unfollow(data),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: socialKeys.following }) qc.invalidateQueries({ queryKey: socialKeys.following })
}, },

View File

@@ -31,6 +31,7 @@ export const feedEntryDtoSchema = z.object({
user_id: z.string().uuid(), user_id: z.string().uuid(),
user_email: z.string(), user_email: z.string(),
user_display_name: z.string(), user_display_name: z.string(),
is_federated: z.boolean(),
}) })
export type FeedEntryDto = z.infer<typeof feedEntryDtoSchema> export type FeedEntryDto = z.infer<typeof feedEntryDtoSchema>

View File

@@ -60,6 +60,7 @@ export const keywordDtoSchema = z.object({
}) })
export const castMemberDtoSchema = z.object({ export const castMemberDtoSchema = z.object({
person_id: z.string(),
tmdb_person_id: z.number(), tmdb_person_id: z.number(),
name: z.string(), name: z.string(),
character: z.string(), character: z.string(),
@@ -69,6 +70,7 @@ export const castMemberDtoSchema = z.object({
export type CastMemberDto = z.infer<typeof castMemberDtoSchema> export type CastMemberDto = z.infer<typeof castMemberDtoSchema>
export const crewMemberDtoSchema = z.object({ export const crewMemberDtoSchema = z.object({
person_id: z.string(),
tmdb_person_id: z.number(), tmdb_person_id: z.number(),
name: z.string(), name: z.string(),
job: z.string(), job: z.string(),

View File

@@ -68,7 +68,7 @@ export function follow(data: FollowRequest) {
return post("/social/follow", data) return post("/social/follow", data)
} }
export function unfollow(data: FollowRequest) { export function unfollow(data: ActorUrlRequest) {
return post("/social/unfollow", data) return post("/social/unfollow", data)
} }

View File

@@ -1,10 +1,12 @@
import { z } from "zod" import { z } from "zod"
import { diaryEntryDtoSchema, paginatedSchema } from "./common" import { diaryEntryDtoSchema, paginatedSchema } from "./common"
import { get, put, putForm } from "./client" import { get, post, put, putForm } from "./client"
export const userSummaryDtoSchema = z.object({ export const userSummaryDtoSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
email: z.string(), email: z.string(),
username: z.string(),
display_name: z.string().optional(),
total_movies: z.number(), total_movies: z.number(),
avg_rating: z.number().optional(), avg_rating: z.number().optional(),
}) })
@@ -130,3 +132,7 @@ export function updateProfile(data: UpdateProfileData) {
export function updateProfileFields(data: UpdateProfileFieldsRequest) { export function updateProfileFields(data: UpdateProfileFieldsRequest) {
return put("/profile/fields", data) return put("/profile/fields", data)
} }
export function reindexSearch() {
return post("/admin/reindex-search")
}

View File

@@ -47,6 +47,7 @@ export function deleteWrapUp(id: string) {
} }
export type MovieRef = { export type MovieRef = {
movie_id?: string
title: string title: string
year: number year: number
runtime_minutes?: number runtime_minutes?: number
@@ -54,6 +55,7 @@ export type MovieRef = {
} }
export type PersonStat = { export type PersonStat = {
person_id?: string
name: string name: string
count: number count: number
avg_rating: number avg_rating: number
@@ -91,6 +93,7 @@ export type WrapUpReport = {
first_movie_of_period?: MovieRef first_movie_of_period?: MovieRef
last_movie_of_period?: MovieRef last_movie_of_period?: MovieRef
poster_paths: string[] poster_paths: string[]
top_cast_profile_paths: string[]
} }
export function getWrapUpReport(id: string) { export function getWrapUpReport(id: string) {

View File

@@ -19,6 +19,7 @@
"continue": "Continue", "continue": "Continue",
"generate": "Generate", "generate": "Generate",
"generating": "Generating...", "generating": "Generating...",
"run": "Run",
"reviews": "{{count}} reviews", "reviews": "{{count}} reviews",
"films": "{{count}} films", "films": "{{count}} films",
"filmsAvg": "{{count}} films, avg {{avg}}" "filmsAvg": "{{count}} films, avg {{avg}}"
@@ -154,7 +155,11 @@
"account": "Account", "account": "Account",
"data": "Data", "data": "Data",
"integrations": "Integrations", "integrations": "Integrations",
"socialGroup": "Social" "socialGroup": "Social",
"admin": "Admin",
"rebuildSearch": "Rebuild Search Index",
"rebuildSearchDesc": "Re-index all movies and people",
"rebuildSearchDone": "Reindex queued"
}, },
"editProfile": { "editProfile": {
"title": "Edit Profile", "title": "Edit Profile",
@@ -201,6 +206,9 @@
"generateWrapUp": "Generate Wrap-Up", "generateWrapUp": "Generate Wrap-Up",
"startDate": "Start Date", "startDate": "Start Date",
"endDate": "End Date", "endDate": "End Date",
"generateFor": "Generate for",
"forSelf": "Myself",
"forGlobal": "Everyone (global)",
"heroSubtitle": "Your Year in Movies", "heroSubtitle": "Your Year in Movies",
"moviesWatched": "movies watched", "moviesWatched": "movies watched",
"watchHours": "{{hours}} hours of watch time", "watchHours": "{{hours}} hours of watch time",

View File

@@ -100,6 +100,7 @@ function FeedTab() {
review={entry.review} review={entry.review}
userName={entry.user_display_name} userName={entry.user_display_name}
userId={entry.user_id} userId={entry.user_id}
isFederated={entry.is_federated}
/> />
) )
return entry.user_id === auth?.user_id ? ( return entry.user_id === auth?.user_id ? (

View File

@@ -1,6 +1,6 @@
import { createFileRoute, Link } from "@tanstack/react-router" import { createFileRoute, Link } from "@tanstack/react-router"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { ArrowLeft, Bookmark, BookmarkCheck, Star, TrendingUp, User, Users } from "lucide-react" import { ArrowLeft, Bookmark, BookmarkCheck, Globe, Star, TrendingUp, User, Users } from "lucide-react"
import { StarDisplay } from "@/components/star-display" import { StarDisplay } from "@/components/star-display"
import { RatingHistogram } from "@/components/rating-histogram" import { RatingHistogram } from "@/components/rating-histogram"
import { EmptyState } from "@/components/empty-state" import { EmptyState } from "@/components/empty-state"
@@ -112,7 +112,10 @@ function MovieDetailPage() {
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<CardTitle className="text-sm">{r.user_display}</CardTitle> <CardTitle className="flex items-center gap-1.5 text-sm">
{r.user_display}
{r.is_federated && <Globe className="size-3 text-muted-foreground/60" />}
</CardTitle>
<CardDescription className="text-[10px]">{r.watched_at.slice(0, 10)}</CardDescription> <CardDescription className="text-[10px]">{r.watched_at.slice(0, 10)}</CardDescription>
</div> </div>
<StarDisplay rating={r.rating} size="xs" /> <StarDisplay rating={r.rating} size="xs" />
@@ -233,7 +236,7 @@ function PersonStrip({ items, type }: { items: (CastMemberDto | CrewMemberDto)[]
: (person as CrewMemberDto).job : (person as CrewMemberDto).job
return ( return (
<div key={`${person.tmdb_person_id}-${i}`} className="w-[72px] flex-shrink-0"> <Link key={`${person.tmdb_person_id}-${i}`} to="/people/$id" params={{ id: person.person_id }} className="w-[72px] flex-shrink-0">
<div className="aspect-[2/3] overflow-hidden rounded-lg bg-muted"> <div className="aspect-[2/3] overflow-hidden rounded-lg bg-muted">
{person.profile_path ? ( {person.profile_path ? (
<img src={tmdbProfileUrl(person.profile_path)} alt="" className="size-full object-cover" loading="lazy" /> <img src={tmdbProfileUrl(person.profile_path)} alt="" className="size-full object-cover" loading="lazy" />
@@ -245,7 +248,7 @@ function PersonStrip({ items, type }: { items: (CastMemberDto | CrewMemberDto)[]
</div> </div>
<p className="mt-1 truncate text-[11px] font-semibold leading-tight">{person.name}</p> <p className="mt-1 truncate text-[11px] font-semibold leading-tight">{person.name}</p>
<p className="truncate text-[10px] italic text-muted-foreground">{subtitle}</p> <p className="truncate text-[10px] italic text-muted-foreground">{subtitle}</p>
</div> </Link>
) )
})} })}
</div> </div>

View File

@@ -52,7 +52,7 @@ function ProfilePage() {
function WrapUpLink() { function WrapUpLink() {
const { t } = useTranslation() const { t } = useTranslation()
const { data } = useWrapUps() const { data } = useWrapUps()
const latest = data?.items?.find((w) => w.status === "completed") const latest = data?.items?.find((w) => w.status === "Ready")
if (!latest) return null if (!latest) return null

View File

@@ -1,5 +1,6 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { useMutation } from "@tanstack/react-query"
import { import {
ArrowLeft, ArrowLeft,
ChevronRight, ChevronRight,
@@ -7,11 +8,14 @@ import {
Globe, Globe,
Key, Key,
LogOut, LogOut,
RefreshCw,
ShieldBan, ShieldBan,
Sparkles, Sparkles,
User, User,
} from "lucide-react" } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useAuth, useIsAdmin } from "@/components/auth-provider" import { useAuth, useIsAdmin } from "@/components/auth-provider"
import { reindexSearch } from "@/lib/api/users"
export const Route = createFileRoute("/_app/settings/")({ export const Route = createFileRoute("/_app/settings/")({
component: SettingsPage, component: SettingsPage,
@@ -100,6 +104,8 @@ function SettingsPage() {
<SettingsGroup label={t("settings.integrations")} items={integrations} /> <SettingsGroup label={t("settings.integrations")} items={integrations} />
<SettingsGroup label={t("settings.socialGroup")} items={social} /> <SettingsGroup label={t("settings.socialGroup")} items={social} />
{isAdmin && <AdminActions />}
<button <button
onClick={handleLogout} onClick={handleLogout}
className="w-full rounded-xl bg-card p-3 text-sm font-medium text-red-400" className="w-full rounded-xl bg-card p-3 text-sm font-medium text-red-400"
@@ -113,6 +119,37 @@ function SettingsPage() {
) )
} }
function AdminActions() {
const { t } = useTranslation()
const reindex = useMutation({
mutationFn: reindexSearch,
})
return (
<div>
<p className="mb-1.5 px-1 text-xs font-medium text-muted-foreground">
{t("settings.admin")}
</p>
<div className="divide-y divide-border rounded-xl bg-card">
<div className="flex items-center gap-3 p-3">
<span className="text-muted-foreground">
<RefreshCw className={`size-4 ${reindex.isPending ? "animate-spin" : ""}`} />
</span>
<div className="flex-1">
<p className="text-sm font-medium">{t("settings.rebuildSearch")}</p>
<p className="text-xs text-muted-foreground">
{reindex.isSuccess ? t("settings.rebuildSearchDone") : t("settings.rebuildSearchDesc")}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => reindex.mutate()} disabled={reindex.isPending}>
{reindex.isPending ? t("common.generating") : t("common.run")}
</Button>
</div>
</div>
</div>
)
}
function SettingsGroup({ function SettingsGroup({
label, label,
items, items,

View File

@@ -15,12 +15,14 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { EmptyState } from "@/components/empty-state" import { EmptyState } from "@/components/empty-state"
import { useIsAdmin } from "@/components/auth-provider" import { useAuth, useIsAdmin } from "@/components/auth-provider"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { import {
useWrapUps, useWrapUps,
useGenerateWrapUp, useGenerateWrapUp,
useDeleteWrapUp, useDeleteWrapUp,
} from "@/hooks/use-wrapup" } from "@/hooks/use-wrapup"
import { useUsers } from "@/hooks/use-users"
export const Route = createFileRoute("/_app/settings/wrapup")({ export const Route = createFileRoute("/_app/settings/wrapup")({
component: WrapupPage, component: WrapupPage,
@@ -28,18 +30,22 @@ export const Route = createFileRoute("/_app/settings/wrapup")({
function WrapupPage() { function WrapupPage() {
const { t } = useTranslation() const { t } = useTranslation()
const { auth } = useAuth()
const isAdmin = useIsAdmin() const isAdmin = useIsAdmin()
const { data, isPending } = useWrapUps() const { data, isPending } = useWrapUps()
const generate = useGenerateWrapUp() const generate = useGenerateWrapUp()
const remove = useDeleteWrapUp() const remove = useDeleteWrapUp()
const { data: usersData } = useUsers()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [startDate, setStartDate] = useState("") const [startDate, setStartDate] = useState("")
const [endDate, setEndDate] = useState("") const [endDate, setEndDate] = useState("")
const [targetUserId, setTargetUserId] = useState<string>("self")
const handleGenerate = () => { const handleGenerate = () => {
const user_id = targetUserId === "global" ? undefined : targetUserId === "self" ? auth?.user_id : targetUserId
generate.mutate( generate.mutate(
{ start_date: startDate, end_date: endDate }, { start_date: startDate, end_date: endDate, user_id },
{ {
onSuccess: () => { onSuccess: () => {
setOpen(false) setOpen(false)
@@ -81,7 +87,7 @@ function WrapupPage() {
{items.map((w) => ( {items.map((w) => (
<Card key={w.id} size="sm"> <Card key={w.id} size="sm">
<CardContent className="flex items-center justify-between"> <CardContent className="flex items-center justify-between">
{w.status === "completed" ? ( {w.status === "Ready" ? (
<Link to="/wrapup/$id" params={{ id: w.id }} className="flex flex-1 items-center justify-between"> <Link to="/wrapup/$id" params={{ id: w.id }} className="flex flex-1 items-center justify-between">
<div> <div>
<p className="text-sm font-medium">{w.start_date} {w.end_date}</p> <p className="text-sm font-medium">{w.start_date} {w.end_date}</p>
@@ -133,6 +139,25 @@ function WrapupPage() {
onChange={(e) => setEndDate(e.target.value)} onChange={(e) => setEndDate(e.target.value)}
/> />
</div> </div>
{isAdmin && usersData?.users && (
<div className="space-y-1.5">
<Label>{t("wrapup.generateFor")}</Label>
<Select value={targetUserId} onValueChange={setTargetUserId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="self">{t("wrapup.forSelf")}</SelectItem>
<SelectItem value="global">{t("wrapup.forGlobal")}</SelectItem>
{usersData.users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.display_name ?? u.username ?? u.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<Button <Button
onClick={handleGenerate} onClick={handleGenerate}
disabled={generate.isPending || !startDate || !endDate} disabled={generate.isPending || !startDate || !endDate}

View File

@@ -105,7 +105,7 @@ function OwnFollowingTab() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => unfollowMutation.mutate({ handle: actor.handle })} onClick={() => unfollowMutation.mutate({ actor_url: actor.url })}
disabled={unfollowMutation.isPending} disabled={unfollowMutation.isPending}
> >
<UserMinus className="mr-1 size-3.5" /> <UserMinus className="mr-1 size-3.5" />
@@ -217,6 +217,15 @@ function UserFollowersTab({ userId }: { userId: string }) {
) )
} }
function actorHandle(actor: RemoteActorDto): string {
try {
const host = new URL(actor.url).host
return `@${actor.handle}@${host}`
} catch {
return `@${actor.handle}`
}
}
function ActorCard({ actor, action }: { actor: RemoteActorDto; action?: React.ReactNode }) { function ActorCard({ actor, action }: { actor: RemoteActorDto; action?: React.ReactNode }) {
const initial = (actor.display_name || actor.handle)[0]?.toUpperCase() ?? "?" const initial = (actor.display_name || actor.handle)[0]?.toUpperCase() ?? "?"
@@ -228,7 +237,7 @@ function ActorCard({ actor, action }: { actor: RemoteActorDto; action?: React.Re
</Avatar> </Avatar>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{actor.display_name || actor.handle}</p> <p className="truncate text-sm font-semibold">{actor.display_name || actor.handle}</p>
<p className="truncate text-xs text-muted-foreground">{actor.handle}</p> <p className="truncate text-xs text-muted-foreground">{actorHandle(actor)}</p>
</div> </div>
{action} {action}
</CardContent> </CardContent>

View File

@@ -41,7 +41,7 @@ function UserProfilePage() {
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => unfollowMutation.mutate({ handle: data.username })} onClick={() => unfollowMutation.mutate({ actor_url: followingData?.actors.find((a) => a.handle === data.username)?.url ?? "" })}
disabled={unfollowMutation.isPending} disabled={unfollowMutation.isPending}
> >
<UserCheck className="mr-1 size-3.5" /> <UserCheck className="mr-1 size-3.5" />

View File

@@ -5,7 +5,8 @@ import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { RatingHistogram } from "@/components/rating-histogram" import { RatingHistogram } from "@/components/rating-histogram"
import { posterUrl } from "@/lib/api/client" import { posterUrl, tmdbProfileUrl } from "@/lib/api/client"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { useWrapUpReport } from "@/hooks/use-wrapup" import { useWrapUpReport } from "@/hooks/use-wrapup"
import type { MovieRef, PersonStat } from "@/lib/api/wrapup" import type { MovieRef, PersonStat } from "@/lib/api/wrapup"
@@ -82,6 +83,7 @@ function WrapUpReportPage() {
title={t("wrapup.topActors")} title={t("wrapup.topActors")}
subtitle={t("wrapup.uniqueActors", { count: report.actor_diversity })} subtitle={t("wrapup.uniqueActors", { count: report.actor_diversity })}
items={report.top_actors.slice(0, 5)} items={report.top_actors.slice(0, 5)}
profilePaths={report.top_cast_profile_paths}
/> />
)} )}
@@ -175,7 +177,7 @@ function WrapUpReportPage() {
) )
} }
function RankCard({ title, subtitle, items }: { title: string; subtitle: string; items: PersonStat[] }) { function RankCard({ title, subtitle, items, profilePaths }: { title: string; subtitle: string; items: PersonStat[]; profilePaths?: string[] }) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<Card> <Card>
@@ -187,15 +189,38 @@ function RankCard({ title, subtitle, items }: { title: string; subtitle: string;
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ol className="space-y-2"> <ol className="space-y-2">
{items.map((item, i) => ( {items.map((item, i) => {
<li key={item.name} className="flex items-center gap-3"> const profilePath = profilePaths?.[i]
<span className="flex size-6 items-center justify-center rounded-full bg-muted text-xs font-bold">{i + 1}</span> return (
<div className="flex-1"> <li key={item.name}>
<p className="text-sm font-medium">{item.name}</p> {item.person_id ? (
<p className="text-xs text-muted-foreground">{t("common.filmsAvg", { count: item.count, avg: item.avg_rating.toFixed(1) })}</p> <Link to="/people/$id" params={{ id: item.person_id }} className="flex items-center gap-3">
</div> <span className="flex size-6 items-center justify-center rounded-full bg-muted text-xs font-bold">{i + 1}</span>
</li> <Avatar className="size-8">
))} {profilePath && <AvatarImage src={tmdbProfileUrl(profilePath)} />}
<AvatarFallback className="text-xs">{item.name[0]}</AvatarFallback>
</Avatar>
<div className="flex-1">
<p className="text-sm font-medium">{item.name}</p>
<p className="text-xs text-muted-foreground">{t("common.filmsAvg", { count: item.count, avg: item.avg_rating.toFixed(1) })}</p>
</div>
</Link>
) : (
<div className="flex items-center gap-3">
<span className="flex size-6 items-center justify-center rounded-full bg-muted text-xs font-bold">{i + 1}</span>
<Avatar className="size-8">
{profilePath && <AvatarImage src={tmdbProfileUrl(profilePath)} />}
<AvatarFallback className="text-xs">{item.name[0]}</AvatarFallback>
</Avatar>
<div className="flex-1">
<p className="text-sm font-medium">{item.name}</p>
<p className="text-xs text-muted-foreground">{t("common.filmsAvg", { count: item.count, avg: item.avg_rating.toFixed(1) })}</p>
</div>
</div>
)}
</li>
)
})}
</ol> </ol>
</CardContent> </CardContent>
</Card> </Card>
@@ -204,7 +229,7 @@ function RankCard({ title, subtitle, items }: { title: string; subtitle: string;
function MovieHighlight({ label, movie, showRuntime }: { label: string; movie?: MovieRef; showRuntime?: boolean }) { function MovieHighlight({ label, movie, showRuntime }: { label: string; movie?: MovieRef; showRuntime?: boolean }) {
if (!movie) return null if (!movie) return null
return ( const content = (
<div className="overflow-hidden rounded-xl bg-muted"> <div className="overflow-hidden rounded-xl bg-muted">
{movie.poster_path && ( {movie.poster_path && (
<div className="aspect-[2/3] w-full"> <div className="aspect-[2/3] w-full">
@@ -220,6 +245,10 @@ function MovieHighlight({ label, movie, showRuntime }: { label: string; movie?:
</div> </div>
</div> </div>
) )
if (movie.movie_id) {
return <Link to="/movies/$id" params={{ id: movie.movie_id }}>{content}</Link>
}
return content
} }
function ReportSkeleton() { function ReportSkeleton() {