Polish mobile layouts, extract ExpandableText, fix image sizing

- Stack title-hero poster/metadata vertically on mobile (flex-col md:flex-row),
  switch backdrop tall breakpoint from sm to md
- Extract bio expand/collapse logic into reusable ExpandableText component;
  use it in PersonHero and TitleHero
- Fix ContinueWatchingCard image to use fill + sizes instead of fixed
  width/height to avoid layout shift
- Add fade-out gradient on genre chip scrollbar edge on mobile
- Show scaled-down ambient glow on mobile instead of hiding it entirely
- Humanize knownForDepartment labels (Acting→Actor, Directing→Director, etc.)
- Change cast member name from truncate to line-clamp-2 for wrapping
- Hide tooltip arrow via [&>:last-child]:hidden instead of color-matching it
- Apply safe-area-inset padding to HeroBanner content on notched devices
This commit is contained in:
2026-03-07 12:09:51 -05:00
parent 5d63ad699f
commit 383f0b819b
97 changed files with 767 additions and 608 deletions
@@ -35,16 +35,16 @@ export function ContinueWatchingCard({
return (
<Link
href={`/titles/${item.title.id}`}
className="group relative w-[calc(100vw-3rem)] shrink-0 overflow-hidden rounded-xl bg-card/50 ring-1 ring-white/[0.06] transition-shadow hover:shadow-lg hover:shadow-black/25 sm:w-72"
className="group relative inline-block w-64 shrink-0 overflow-hidden rounded-xl bg-card/50 ring-1 ring-white/[0.06] transition-shadow hover:shadow-black/25 hover:shadow-lg sm:w-72"
>
<div className="relative aspect-video overflow-hidden rounded-t-xl bg-muted">
{stillUrl ? (
<Image
src={stillUrl}
alt={item.nextEpisode?.name ?? item.title.title}
width={500}
height={281}
className="h-full w-full object-cover motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-105"
fill
sizes="(min-width: 640px) 288px, 256px"
className="object-cover motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-105"
/>
) : (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-card via-secondary to-muted">
@@ -56,13 +56,13 @@ export function ContinueWatchingCard({
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
{item.nextEpisode && (
<div className="absolute bottom-2.5 left-3 right-3">
<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 motion-safe:animate-pulse rounded-full bg-primary" />
<div className="absolute right-3 bottom-2.5 left-3">
<p className="flex items-center gap-1.5 font-medium text-[10px] text-primary uppercase tracking-wider">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary motion-safe:animate-pulse" />
Up next
</p>
<p className="mt-0.5 truncate text-sm font-medium text-white">
<span className="font-mono text-xs text-white/60 [word-spacing:-0.25em] mr-0.5">
<p className="mt-0.5 truncate font-medium text-sm text-white">
<span className="mr-0.5 font-mono text-white/60 text-xs [word-spacing:-0.25em]">
S{item.nextEpisode.seasonNumber} E
{item.nextEpisode.episodeNumber}
</span>{" "}
@@ -73,8 +73,8 @@ export function ContinueWatchingCard({
</div>
<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">
<p className="truncate font-medium text-sm">{item.title.title}</p>
<p className="text-muted-foreground text-xs">
{item.watchedEpisodes}/{item.totalEpisodes} episodes
</p>
</div>
@@ -83,7 +83,7 @@ export function ContinueWatchingCard({
</div>
</div>
{progress > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-muted">
<div className="absolute right-0 bottom-0 left-0 h-0.5 bg-muted">
<div
className="h-full bg-primary transition-[width]"
style={{ width: `${progress}%` }}
@@ -18,7 +18,7 @@ export function ContinueWatchingList({
return (
<Carousel
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }}
className="-mx-4 sm:-mx-0"
className="-mx-4 sm:-mx-0 [&>[data-slot=carousel-content]]:px-px"
>
<CarouselContent className="px-4 sm:px-0">
{items.map((item, i) => (
@@ -15,7 +15,7 @@ export function FeedSection({
<section className="space-y-4">
<div className="flex items-center gap-2">
<span aria-hidden={true}>{icon}</span>
<h2 className="font-display text-xl tracking-tight text-balance">
<h2 className="text-balance font-display text-xl tracking-tight">
{title}
</h2>
</div>
@@ -53,7 +53,7 @@ function StatCard({
}: StatCardProps) {
return (
<div
className="animate-stagger-item relative overflow-hidden rounded-xl border border-border/30 bg-card/50 p-4"
className="relative animate-stagger-item overflow-hidden rounded-xl border border-border/30 bg-card/50 p-4"
style={{ "--stagger-index": index } as React.CSSProperties}
>
{sparklineData && <Sparkline data={sparklineData} color={color} />}
@@ -63,7 +63,7 @@ function StatCard({
>
<Icon aria-hidden={true} className={`size-[13px] ${color}`} />
</div>
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
<span className="font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
{label}
</span>
</div>
@@ -97,7 +97,7 @@ function PeriodSelector({
onValueChange={(v) => v && onPeriodChange(v as TimePeriod)}
>
<SelectTrigger
className={`${inlineTriggerClass} uppercase text-foreground/80`}
className={`${inlineTriggerClass} text-foreground/80 uppercase`}
>
<SelectValue>
{(value: TimePeriod | null) => (value ? periodLabels[value] : null)}
@@ -132,11 +132,11 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
} | null>(null);
useEffect(() => {
getStatsAction("movies", moviePeriod).then(setMovieStats);
void getStatsAction("movies", moviePeriod).then(setMovieStats);
}, [moviePeriod]);
useEffect(() => {
getStatsAction("episodes", episodePeriod).then(setEpisodeStats);
void getStatsAction("episodes", episodePeriod).then(setEpisodeStats);
}, [episodePeriod]);
const movieCount = movieStats?.count ?? stats.moviesThisMonth;
@@ -16,19 +16,19 @@ export async function StatsSection({ userId }: { userId: string }) {
<>
<StatsDisplay stats={stats} />
{isEmpty && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed border-border/50 py-16 text-center">
<div className="flex flex-col items-center gap-4 rounded-xl border border-border/50 border-dashed py-16 text-center">
<div className="rounded-full bg-primary/10 p-4">
<IconDeviceTv aria-hidden={true} className="size-8 text-primary" />
</div>
<div className="space-y-1">
<p className="font-medium">Your library is empty</p>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Search for movies and TV shows to start tracking
</p>
</div>
<Link
href="/explore"
className="inline-flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-shadow hover:shadow-md hover:shadow-primary/20"
className="inline-flex h-9 items-center rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20"
>
Start exploring
</Link>
@@ -1,10 +1,10 @@
export function WelcomeHeader({ name }: { name?: string | null }) {
return (
<div>
<h1 className="font-display text-3xl tracking-tight text-balance">
<h1 className="text-balance font-display text-3xl tracking-tight">
Welcome back{name ? `, ${name}` : ""}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
<p className="mt-1 text-muted-foreground text-sm">
Here&apos;s what&apos;s happening with your library
</p>
</div>