Fix updateTag during render, hide scrollbars, polish type badge and hover styles

- Add `revalidate` option to `refreshCredits` and `refreshRecommendations`;
  pass `{ revalidate: false }` from `ensureEnriched` to avoid calling
  `updateTag` outside Server Actions/Route Handlers
- Add `hideScrollbar` prop to all horizontal ScrollArea instances
  (continue-watching, title rows, filterable row, cast carousel)
- Replace text-only type badge in HeroBanner with icon + label using
  IconMovie / IconDeviceTv
- Fix StatusButton destructive hover overrides with `!important` so
  they correctly supersede the status-specific background/text/ring
This commit is contained in:
2026-03-08 11:24:07 -04:00
parent 1407571b64
commit 69f04937bc
8 changed files with 44 additions and 14 deletions
@@ -12,6 +12,7 @@ export function ContinueWatchingList({
return (
<ScrollArea
scrollFade
hideScrollbar
className="-mx-4 sm:-mx-0 [&_[data-slot=scroll-area-content]]:px-px"
>
<div className="flex gap-4 px-4 py-2 sm:px-0">
@@ -101,7 +101,7 @@ export function FilterableTitleRow({
</div>
{/* Genre chips */}
<ScrollArea scrollFade>
<ScrollArea scrollFade hideScrollbar>
<div className="flex gap-2">
{genres.map((genre) => (
<Button
@@ -148,6 +148,7 @@ export function FilterableTitleRow({
<ScrollArea
key={selectedGenre ?? "default"}
scrollFade
hideScrollbar
className="-mx-6 sm:-mx-2"
>
<div className="flex gap-4 px-6 py-2 sm:px-2">
@@ -1,6 +1,11 @@
"use client";
import { IconPlus, IconStar } from "@tabler/icons-react";
import {
IconDeviceTv,
IconMovie,
IconPlus,
IconStar,
} from "@tabler/icons-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
@@ -65,8 +70,18 @@ export function HeroBanner({
style={{ "--stagger-index": 3 } as React.CSSProperties}
>
<div className="mb-3 flex items-center gap-2">
<span className="rounded bg-primary/20 px-2 py-0.5 font-semibold text-[10px] text-primary uppercase tracking-wider">
{type}
<span className="inline-flex cursor-default items-center justify-center gap-1 rounded bg-primary/10 px-1.5 py-1 font-medium text-primary text-xs">
{type === "movie" ? (
<>
<IconMovie aria-hidden className="size-3.5" />
Movie
</>
) : (
<>
<IconDeviceTv aria-hidden className="size-3.5" />
TV
</>
)}
</span>
{voteAverage > 0 && (
<span className="flex items-center gap-1 text-primary text-sm">
@@ -35,7 +35,7 @@ export function TitleRow({
{heading}
</h2>
</div>
<ScrollArea scrollFade className="-mx-6 sm:-mx-2">
<ScrollArea scrollFade hideScrollbar className="-mx-6 sm:-mx-2">
<div className="flex gap-4 px-6 py-2 sm:px-2">
{items.map((item, i) => (
<div
@@ -18,7 +18,7 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
</div>
{actors.length > 0 && (
<ScrollArea scrollFade className="-mx-4 sm:-mx-0">
<ScrollArea scrollFade hideScrollbar className="-mx-4 sm:-mx-0">
<div className="flex gap-4 px-4 py-2 sm:px-0">
{actors.map((member, i) => (
<div key={member.id} className="w-[100px] shrink-0 sm:w-[120px]">
@@ -63,7 +63,7 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
title="Remove from library"
className={`group inline-flex h-9 items-center gap-2 rounded-lg px-4 font-medium text-sm ring-1 transition-all active:scale-[0.97] ${config.class} ${config.bgClass} ${config.borderClass} hover:bg-destructive/10 hover:text-destructive hover:ring-destructive/30`}
className={`group inline-flex h-9 items-center gap-2 rounded-lg px-4 font-medium text-sm ring-1 transition-all active:scale-[0.97] ${config.class} ${config.bgClass} ${config.borderClass} hover:!bg-destructive/10 hover:!text-destructive hover:!ring-destructive/30`}
>
<span className="grid [&>svg]:col-start-1 [&>svg]:row-start-1">
<config.icon
+6 -1
View File
@@ -81,7 +81,10 @@ function batchUpsertPersons(people: PersonData[]): Map<number, string> {
return idMap;
}
export async function refreshCredits(titleId: string) {
export async function refreshCredits(
titleId: string,
{ revalidate = true }: { revalidate?: boolean } = {},
) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title) return;
@@ -273,9 +276,11 @@ export async function refreshCredits(titleId: string) {
}
}
if (revalidate) {
for (const personId of personIds.values()) {
updateTag(`person-${personId}`);
}
}
log.debug(`Credits refreshed for "${title.title}"`);
+11 -3
View File
@@ -440,7 +440,10 @@ export async function refreshTvChildren(
}
}
export async function refreshRecommendations(titleId: string) {
export async function refreshRecommendations(
titleId: string,
{ revalidate = true }: { revalidate?: boolean } = {},
) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title) return;
@@ -575,7 +578,9 @@ export async function refreshRecommendations(titleId: string) {
}
});
if (revalidate) {
updateTag(`recs-${titleId}`);
}
}
/** Fetch seasons from the DB, building the Season[] structure. */
@@ -684,9 +689,12 @@ async function ensureEnriched(
): Promise<boolean> {
const tasks: Promise<void>[] = [];
// Called during render — skip updateTag calls (not allowed outside Server Actions/Route Handlers)
const noRevalidate = { revalidate: false } as const;
if (!existing.hasCast) {
tasks.push(
refreshCredits(titleId).catch((err) =>
refreshCredits(titleId, noRevalidate).catch((err) =>
log.debug("Credits enrichment failed:", err),
),
);
@@ -710,7 +718,7 @@ async function ensureEnriched(
.get() != null;
if (!hasRecommendations) {
tasks.push(
refreshRecommendations(titleId).catch((err) =>
refreshRecommendations(titleId, noRevalidate).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
),
);