/** * The 16:9 area a video card leads with. * * PIG-hosted videos carry a real frame, cut from the clip itself and named * after the clip's own content hash, so it cannot go stale against what it * claims to show. * * Everything else falls back to a generated poster, and deliberately: nothing * renders a frame of a Cap embed without loading the embed, and loading nine * embeds to decorate a grid is how a page becomes unusable on a phone. The * generated version is a gradient picked deterministically from the resource * id — deterministic, not random, because a card that re-tints on every render * reads as a bug and destroys the sense that these are distinct objects. * * The fallback is also the error path. A poster is asserted by the resolver * rather than verified on disk, so a 404 here is an ordinary condition and must * degrade to the gradient rather than to a broken-image glyph. */ import { useEffect, useState } from 'react'; import { BookOpen, LineChart, MonitorPlay, Play } from 'lucide-react'; import type { LearnTrack } from '@pig/core'; import { cn } from '@/components/ui'; /** * Every tint is a pair of semantic tokens, so the whole set re-tints with the * user's accent and inverts correctly in dark mode without a second palette. */ const TINTS = [ 'from-accent-subtle via-surface-2 to-surface', 'from-surface-2 via-accent-subtle to-surface', 'from-surface via-surface-2 to-accent-subtle', 'from-accent-subtle via-surface to-surface-2', ] as const; const TRACK_GLYPHS: Record = { supply: LineChart, demand: BookOpen, platform: MonitorPlay, }; function tintFor(seed: string): string { let hash = 0; for (let index = 0; index < seed.length; index += 1) { hash = (hash * 31 + seed.charCodeAt(index)) % 100_000; } return TINTS[hash % TINTS.length] as string; } export function LearnPoster({ seed, track, duration, poster, alt, size = 'card', className, }: { seed: string; track: LearnTrack; duration: string | null; /** A real frame, when the video is one PIG serves itself. */ poster?: string | null; /** Only used when a real frame is shown; the generated poster is decorative. */ alt?: string; /** `row` drops the ornament and shrinks the play button for a list thumbnail. */ size?: 'card' | 'row'; className?: string; }) { const Glyph = TRACK_GLYPHS[track]; const compact = size === 'row'; const [imageFailed, setImageFailed] = useState(false); // Reset when the card is reused for a different resource, or one missing // poster would suppress the next card's working one. useEffect(() => setImageFailed(false), [poster]); const showImage = Boolean(poster) && !imageFailed; return (
{showImage ? ( <> {alt setImageFailed(true)} /> {/* A screenshot is mostly near-white, and the play button and duration badge have to stay legible on top of it in both themes. A scrim at the corners costs nothing and removes the need to restyle either control per-poster. */}
) : null} {/* Texture, so a generated poster reads as an image rather than as a card that failed to load. All three layers are the palette's own tokens at low alpha, which is what keeps them legible in both themes without a second set of values for dark. */} {showImage ? null :
} {showImage ? null : (
)} {/* The track's glyph, at card size only — at thumbnail size it collides with the play button and reads as a second, broken control. */} {compact || showImage ? null : ( )}
{duration ? ( {duration} ) : null}
); }