mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
Enhance dashboard: richer continue watching cards, color-coded stats, styled poster fallback
- Continue Watching cards now show episode still images in 16:9 aspect with overlaid episode info, play button, and progress bar - Stats section uses per-stat color coding (primary/watching/watchlist/completed) with icon background pills and tabular-nums alignment - No-poster fallback replaced with textured gradient + title text overlay - Updated ContinueWatchingItem interface to include backdropPath, stillPath, overview Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ interface ContinueWatchingItem {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
posterPath: string | null;
|
posterPath: string | null;
|
||||||
|
backdropPath: string | null;
|
||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
nextEpisode: {
|
nextEpisode: {
|
||||||
@@ -27,6 +28,8 @@ interface ContinueWatchingItem {
|
|||||||
seasonNumber: number;
|
seasonNumber: number;
|
||||||
episodeNumber: number;
|
episodeNumber: number;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
stillPath: string | null;
|
||||||
|
overview: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
totalEpisodes: number;
|
totalEpisodes: number;
|
||||||
watchedEpisodes: number;
|
watchedEpisodes: number;
|
||||||
@@ -267,9 +270,11 @@ function FeedSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
||||||
const posterUrl = item.title.posterPath
|
const stillUrl = item.nextEpisode?.stillPath
|
||||||
? `https://image.tmdb.org/t/p/w300${item.title.posterPath}`
|
? `https://image.tmdb.org/t/p/w500${item.nextEpisode.stillPath}`
|
||||||
: null;
|
: item.title.backdropPath
|
||||||
|
? `https://image.tmdb.org/t/p/w500${item.title.backdropPath}`
|
||||||
|
: null;
|
||||||
const progress =
|
const progress =
|
||||||
item.totalEpisodes > 0
|
item.totalEpisodes > 0
|
||||||
? (item.watchedEpisodes / item.totalEpisodes) * 100
|
? (item.watchedEpisodes / item.totalEpisodes) * 100
|
||||||
@@ -278,46 +283,62 @@ function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/titles/${item.title.id}`}
|
href={`/titles/${item.title.id}`}
|
||||||
className="group flex w-56 shrink-0 gap-3 rounded-xl border border-border/30 bg-card/50 p-3 transition-all hover:border-primary/20 hover:bg-card"
|
className="group relative w-72 shrink-0 overflow-hidden rounded-xl border border-border/30 bg-card/50 transition-all hover:border-primary/20 hover:shadow-lg hover:shadow-black/25"
|
||||||
>
|
>
|
||||||
<div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-md bg-muted">
|
{/* Episode still / backdrop image */}
|
||||||
{posterUrl ? (
|
<div className="relative aspect-video overflow-hidden bg-muted">
|
||||||
|
{stillUrl ? (
|
||||||
<Image
|
<Image
|
||||||
src={posterUrl}
|
src={stillUrl}
|
||||||
alt={item.title.title}
|
alt={item.nextEpisode?.name ?? item.title.title}
|
||||||
width={56}
|
width={500}
|
||||||
height={80}
|
height={281}
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center text-[8px] text-muted-foreground">
|
<div className="flex h-full items-center justify-center bg-gradient-to-br from-card via-secondary to-muted">
|
||||||
?
|
<IconPlayerPlay size={32} className="text-muted-foreground/30" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Progress bar at bottom of poster */}
|
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
||||||
{progress > 0 && (
|
{/* Episode label overlay */}
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-muted">
|
|
||||||
<div
|
|
||||||
className="h-full bg-primary transition-all"
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
|
||||||
<p className="line-clamp-2 text-sm font-medium leading-snug">
|
|
||||||
{item.title.title}
|
|
||||||
</p>
|
|
||||||
{item.nextEpisode && (
|
{item.nextEpisode && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="absolute bottom-2.5 left-3 right-3">
|
||||||
S{item.nextEpisode.seasonNumber} E{item.nextEpisode.episodeNumber}
|
<p className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-primary">
|
||||||
</p>
|
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-primary" />
|
||||||
|
Up next
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 truncate text-sm font-medium text-white">
|
||||||
|
<span className="font-mono text-xs text-white/60">
|
||||||
|
S{item.nextEpisode.seasonNumber} E
|
||||||
|
{item.nextEpisode.episodeNumber}
|
||||||
|
</span>{" "}
|
||||||
|
{item.nextEpisode.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-primary">
|
|
||||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-primary" />
|
|
||||||
Up next
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/* Bottom bar with title + progress */}
|
||||||
|
<div className="flex items-center gap-3 p-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{item.title.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{item.watchedEpisodes}/{item.totalEpisodes} episodes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
||||||
|
<IconPlayerPlay size={14} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Progress bar */}
|
||||||
|
{progress > 0 && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,21 +21,29 @@ const statDefs = [
|
|||||||
key: "moviesThisMonth" as const,
|
key: "moviesThisMonth" as const,
|
||||||
label: "Movies This Month",
|
label: "Movies This Month",
|
||||||
icon: IconMovie,
|
icon: IconMovie,
|
||||||
|
color: "text-primary",
|
||||||
|
bgColor: "bg-primary/10",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "episodesThisWeek" as const,
|
key: "episodesThisWeek" as const,
|
||||||
label: "Episodes This Week",
|
label: "Episodes This Week",
|
||||||
icon: IconPlayerPlay,
|
icon: IconPlayerPlay,
|
||||||
|
color: "text-status-watching",
|
||||||
|
bgColor: "bg-status-watching/10",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "librarySize" as const,
|
key: "librarySize" as const,
|
||||||
label: "In Library",
|
label: "In Library",
|
||||||
icon: IconLibrary,
|
icon: IconLibrary,
|
||||||
|
color: "text-status-watchlist",
|
||||||
|
bgColor: "bg-status-watchlist/10",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "completed" as const,
|
key: "completed" as const,
|
||||||
label: "Completed",
|
label: "Completed",
|
||||||
icon: IconCheck,
|
icon: IconCheck,
|
||||||
|
color: "text-status-completed",
|
||||||
|
bgColor: "bg-status-completed/10",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -70,13 +78,17 @@ export function StatsSummary() {
|
|||||||
className="rounded-xl border border-border/30 bg-card/50 p-4"
|
className="rounded-xl border border-border/30 bg-card/50 p-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon size={14} className="text-primary" />
|
<div
|
||||||
|
className={`flex h-6 w-6 items-center justify-center rounded-md ${def.bgColor}`}
|
||||||
|
>
|
||||||
|
<Icon size={13} className={def.color} />
|
||||||
|
</div>
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
{def.label}
|
{def.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<motion.p
|
<motion.p
|
||||||
className="mt-2 font-display text-2xl tracking-tight"
|
className={`mt-2 font-display text-2xl tabular-nums tracking-tight ${def.color}`}
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ delay: i * 0.08 + 0.2 }}
|
transition={{ delay: i * 0.08 + 0.2 }}
|
||||||
|
|||||||
@@ -47,8 +47,19 @@ export function TitleCard({
|
|||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center bg-gradient-to-br from-card to-muted text-sm text-muted-foreground">
|
<div className="relative flex h-full items-center justify-center overflow-hidden bg-gradient-to-br from-card via-secondary to-muted">
|
||||||
No poster
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-[0.06]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-primary/10 via-transparent to-transparent" />
|
||||||
|
<div className="relative px-3 text-center">
|
||||||
|
<p className="font-display text-sm leading-snug tracking-tight text-foreground/70">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Hover gradient overlay with metadata */}
|
{/* Hover gradient overlay with metadata */}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface ContinueWatchingItem {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
posterPath: string | null;
|
posterPath: string | null;
|
||||||
|
backdropPath: string | null;
|
||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
nextEpisode: {
|
nextEpisode: {
|
||||||
@@ -23,6 +24,8 @@ export interface ContinueWatchingItem {
|
|||||||
seasonNumber: number;
|
seasonNumber: number;
|
||||||
episodeNumber: number;
|
episodeNumber: number;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
stillPath: string | null;
|
||||||
|
overview: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
lastWatchedAt: Date | null;
|
lastWatchedAt: Date | null;
|
||||||
totalEpisodes: number;
|
totalEpisodes: number;
|
||||||
@@ -108,6 +111,8 @@ export function getContinueWatchingFeed(
|
|||||||
seasonNumber: s.seasonNumber,
|
seasonNumber: s.seasonNumber,
|
||||||
episodeNumber: ep.episodeNumber,
|
episodeNumber: ep.episodeNumber,
|
||||||
name: ep.name,
|
name: ep.name,
|
||||||
|
stillPath: ep.stillPath,
|
||||||
|
overview: ep.overview,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,6 +124,7 @@ export function getContinueWatchingFeed(
|
|||||||
id: title.id,
|
id: title.id,
|
||||||
title: title.title,
|
title: title.title,
|
||||||
posterPath: title.posterPath,
|
posterPath: title.posterPath,
|
||||||
|
backdropPath: title.backdropPath,
|
||||||
type: title.type,
|
type: title.type,
|
||||||
},
|
},
|
||||||
nextEpisode,
|
nextEpisode,
|
||||||
|
|||||||
Reference in New Issue
Block a user