Files
sofa/apps/web/src/components/platforms/platform-grid.tsx
T
jake b0172a2b25 feat: refactor platforms to support multiple TMDB provider IDs per platform
- Replace `tmdbProviderId` (single int) with a many-to-many `platformTmdbProviders` join table so one platform entry can map to multiple TMDB provider IDs (e.g. a single "Max" platform covers several regional TMDB IDs)
- Add `isSubscription` flag to platforms to distinguish subscription services from transactional ones, replacing the old `displayOrder` field in API responses
- Add `getPlatformTmdbIds` / `getPlatformTmdbIdMap` helpers in `@sofa/core/platforms` and thread them through discover, explore, and platform list procedures
- Replace `providerId: number` with `platformId: string` (UUID) in discover input schema so the client never needs to resolve TMDB IDs
- Add `scripts/sync-tmdb-providers.ts` script to pull live provider data from TMDB and update `platforms.json`
- Split native title detail availability into separate "Stream" and "Buy or Rent" sections matching the web pattern
- Add two new DB migrations for the join table and platform schema changes
2026-03-24 20:54:34 -04:00

76 lines
2.6 KiB
TypeScript

import { useLingui } from "@lingui/react/macro";
import { IconCheck } from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react";
import type { Platform } from "@sofa/api/schemas";
interface PlatformGridProps {
platforms: Platform[];
selectedIds: Set<string>;
onToggle: (id: string) => void;
}
export function PlatformGrid({ platforms, selectedIds, onToggle }: PlatformGridProps) {
const { t } = useLingui();
return (
<div className="grid grid-cols-3 gap-2.5 sm:grid-cols-4 md:grid-cols-5">
{platforms.map((platform) => {
const isSelected = selectedIds.has(platform.id);
return (
<button
key={platform.id}
type="button"
onClick={() => onToggle(platform.id)}
className={`group relative flex flex-col items-center gap-2 rounded-xl border p-3 motion-safe:transition-colors motion-safe:duration-150 ${
isSelected
? "border-primary/50 bg-primary/8"
: "border-border/50 hover:border-primary/30 hover:bg-primary/5"
}`}
aria-label={(() => {
const name = platform.name;
return t`Toggle ${name}`;
})()}
aria-pressed={isSelected}
>
{/* Check badge */}
<AnimatePresence>
{isSelected && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ type: "spring", stiffness: 400, damping: 20 }}
className="bg-primary text-primary-foreground absolute -top-1.5 -right-1.5 flex size-4 items-center justify-center rounded-full"
>
<IconCheck className="size-2.5" strokeWidth={3} />
</motion.div>
)}
</AnimatePresence>
{/* Logo */}
{platform.logoPath ? (
<img
src={platform.logoPath}
alt=""
loading="lazy"
decoding="async"
className="size-10 rounded-lg object-cover"
/>
) : (
<div className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg text-xs font-medium">
{platform.name.slice(0, 2)}
</div>
)}
{/* Name */}
<span className="text-muted-foreground line-clamp-1 w-full text-center text-[11px] leading-tight">
{platform.name}
</span>
</button>
);
})}
</div>
);
}