Files
sofa/apps/web/src/components/title-card.tsx
T
jakeandClaude Opus 4.6 2b0c683b7b refactor: replace Biome with oxlint + oxfmt
Migrate the entire monorepo from Biome 2.4.7 to oxlint 1.56.0 (linter)
and oxfmt 0.41.0 (formatter) for faster lint/format and broader rule
coverage.

- Add `.oxlintrc.json` with React, TypeScript, unicorn, import plugins
  and correctness/suspicious categories
- Add `.oxfmtrc.json` with 2-space indent, import sorting, and Tailwind
  class sorting (all 30+ custom className attributes migrated)
- Add `docs/.oxlintrc.json` and `docs/.oxfmtrc.json` with Next.js plugin
- Update all 12 workspace package.json scripts: `oxlint`, `oxfmt`,
  `oxfmt --check`
- Add `format:check` turbo task and CI step
- Update VS Code settings/extensions to use `oxc.oxc-vscode`
- Update CI path triggers from `biome.json` to new config files
- Remove all `biome-ignore` comments and fix shadowed variables
- Delete `biome.json` and `docs/biome.json`
- Reformat entire codebase with oxfmt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:26:50 -04:00

313 lines
10 KiB
TypeScript

import { useLingui } from "@lingui/react/macro";
import {
IconBookmarkFilled,
IconCircleCheckFilled,
IconDeviceTv,
IconLoader,
IconMovie,
IconPlayerPlayFilled,
IconPlus,
IconStarFilled,
} from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { type MotionStyle, type MotionValue, motion } from "motion/react";
import { useEffect, useState } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useTiltEffect } from "@/hooks/use-tilt-effect";
import { orpc } from "@/lib/orpc/client";
import { thumbHashToUrl } from "@/lib/thumbhash";
export function TitleCardSkeleton() {
return (
<div className="bg-card overflow-hidden rounded-xl ring-1 ring-white/[0.06]">
<Skeleton className="aspect-[2/3] w-full rounded-none" />
<div className="px-3 pt-2.5 pb-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="mt-1.5 h-3 w-1/2" />
</div>
</div>
);
}
type TitleStatus = "watchlist" | "in_progress" | "completed";
interface TiltStyles {
imageStyle: MotionStyle;
glareBackground: MotionValue<string>;
glareOpacity: MotionValue<number>;
}
interface CardInnerProps {
title: string;
type: string;
posterPath: string | null;
posterThumbHash?: string | null;
releaseDate?: string | null;
voteAverage?: number | null;
userStatus?: TitleStatus | null;
episodeProgress?: { watched: number; total: number } | null;
tiltStyles?: TiltStyles;
}
export interface TitleCardProps extends CardInnerProps {
id: string;
}
function useStatusConfig() {
const { t } = useLingui();
return {
watchlist: {
icon: IconBookmarkFilled,
label: t`On Watchlist`,
badgeClass: "bg-status-watching/90 text-white",
},
in_progress: {
icon: IconPlayerPlayFilled,
label: t`Watching`,
badgeClass: "bg-status-watching/90 text-white",
},
completed: {
icon: IconCircleCheckFilled,
label: t`Completed`,
badgeClass: "bg-status-completed/90 text-white",
},
} as const;
}
function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStatus | null }) {
const { t } = useLingui();
const statusConfig = useStatusConfig();
const [addedStatus, setAddedStatus] = useState<TitleStatus | null>(userStatus ?? null);
// Sync local state when prop changes (e.g. after navigation or SWR revalidation)
useEffect(() => {
if (userStatus) {
setAddedStatus(userStatus);
}
}, [userStatus]);
const quickAddMutation = useMutation(
orpc.titles.quickAdd.mutationOptions({
onSuccess: () => setAddedStatus("watchlist"),
}),
);
const isAdded = addedStatus != null;
const config = addedStatus ? statusConfig[addedStatus] : null;
function handleClick(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (quickAddMutation.isPending || isAdded) return;
quickAddMutation.mutate({ id });
}
if (isAdded && config) {
const StatusIcon = config.icon;
return (
<Tooltip>
<TooltipTrigger
className="absolute top-2 right-2 z-10 flex size-8 cursor-default items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-sm"
render={<div />}
>
<StatusIcon className="size-4" />
</TooltipTrigger>
<TooltipContent side="bottom">{config.label}</TooltipContent>
</Tooltip>
);
}
return (
<Tooltip>
<TooltipTrigger
onClick={handleClick}
className="absolute top-2 right-2 z-10 flex size-8 items-center justify-center rounded-full bg-black/50 text-white opacity-60 backdrop-blur-sm transition-opacity hover:bg-black/70 focus-visible:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
render={<button type="button" />}
>
{!quickAddMutation.isPending && <IconPlus className="size-4" />}
{quickAddMutation.isPending && <IconLoader className="size-4 animate-spin" />}
</TooltipTrigger>
<TooltipContent side="bottom">{t`Add to Watchlist`}</TooltipContent>
</Tooltip>
);
}
function ProgressBar({ watched, total }: { watched: number; total: number }) {
const { t } = useLingui();
const pct = total > 0 ? (watched / total) * 100 : 0;
return (
<Tooltip>
<TooltipTrigger
className="absolute right-0 bottom-0 left-0 z-10 h-1 cursor-default bg-white/10"
render={<div />}
>
<div
className="bg-status-watching h-full transition-[width] duration-500 ease-out"
style={{ width: `${pct}%` }}
/>
</TooltipTrigger>
<TooltipContent side="top">{t`${watched}/${total} episodes`}</TooltipContent>
</Tooltip>
);
}
function CardInner({
title,
type,
posterPath,
posterThumbHash,
releaseDate,
voteAverage,
userStatus,
episodeProgress,
tiltStyles,
}: CardInnerProps) {
const statusConfig = useStatusConfig();
const year = releaseDate?.slice(0, 4);
const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv;
const placeholderUrl = thumbHashToUrl(posterThumbHash);
const ringClass = userStatus ? "ring-primary/25 shadow-sm shadow-primary/5" : "ring-white/[0.06]";
return (
<div
className={`bg-card hover:shadow-primary/5 hover:ring-primary/25 relative overflow-hidden rounded-xl ring-1 transition-[box-shadow,ring-color] duration-200 ease-out hover:shadow-lg ${ringClass}`}
>
<div
className="bg-card aspect-[2/3] overflow-hidden"
style={
placeholderUrl
? {
backgroundImage: `url(${placeholderUrl})`,
backgroundSize: "cover",
}
: undefined
}
>
{posterPath ? (
<motion.div style={tiltStyles?.imageStyle}>
<img
src={posterPath}
alt={title}
width={300}
height={450}
loading="lazy"
decoding="async"
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</motion.div>
) : (
<div className="from-card via-secondary to-muted relative flex h-full items-center justify-center overflow-hidden bg-gradient-to-br">
<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="from-primary/10 absolute inset-0 bg-gradient-to-t via-transparent to-transparent" />
<div className="relative px-3 text-center">
<p className="font-display text-foreground/70 text-sm leading-snug tracking-tight">
{title}
</p>
</div>
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
{tiltStyles && (
<motion.div
className="pointer-events-none absolute inset-0 z-[5] rounded-xl"
style={{
background: tiltStyles.glareBackground,
opacity: tiltStyles.glareOpacity,
}}
/>
)}
</div>
<div className="px-3 pt-2.5 pb-3">
<div className="flex items-center gap-1.5">
{userStatus && (
<Tooltip>
<TooltipTrigger
className="shrink-0 cursor-default"
render={<div className="flex items-center" />}
>
<span className="relative flex size-2">
<span
className={`absolute inline-flex h-full w-full rounded-full opacity-40 ${userStatus === "completed" ? "bg-status-completed" : "bg-status-watching"}`}
/>
<span
className={`relative inline-flex size-2 rounded-full ${userStatus === "completed" ? "bg-status-completed" : "bg-status-watching"}`}
/>
</span>
</TooltipTrigger>
<TooltipContent>{statusConfig[userStatus].label}</TooltipContent>
</Tooltip>
)}
<p className="line-clamp-1 text-sm leading-snug font-medium">{title}</p>
</div>
<div className="text-muted-foreground mt-1.5 flex items-center gap-2 text-xs">
<TypeIcon aria-hidden={true} className="text-primary/60 size-3.5 shrink-0" />
{year && <span>{year}</span>}
{voteAverage != null && voteAverage > 0 && (
<span className="text-primary/80 ml-auto flex items-center gap-0.5">
<IconStarFilled aria-hidden={true} className="size-[11px]" />
{voteAverage.toFixed(1)}
</span>
)}
</div>
</div>
{episodeProgress && episodeProgress.watched > 0 && (
<ProgressBar watched={episodeProgress.watched} total={episodeProgress.total} />
)}
</div>
);
}
export function TitleCard({
id,
type,
title,
posterPath,
posterThumbHash,
releaseDate,
voteAverage,
userStatus,
episodeProgress,
}: TitleCardProps) {
const tilt = useTiltEffect();
const cardContent = (
<motion.div ref={tilt.ref} style={tilt.containerStyle} {...tilt.handlers}>
<CardInner
title={title}
type={type}
posterPath={posterPath}
posterThumbHash={posterThumbHash}
releaseDate={releaseDate}
voteAverage={voteAverage}
userStatus={userStatus}
episodeProgress={episodeProgress}
tiltStyles={{
imageStyle: tilt.imageStyle,
glareBackground: tilt.glareBackground,
glareOpacity: tilt.glareOpacity,
}}
/>
</motion.div>
);
return (
<div className="group relative">
<QuickAddButton id={id} userStatus={userStatus} />
<Link to="/titles/$id" params={{ id }}>
{cardContent}
</Link>
</div>
);
}