mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
- 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
76 lines
2.6 KiB
TypeScript
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>
|
|
);
|
|
}
|