Files
pig/apps/web/src/components/learn/LearnPoster.tsx
T
karti 19dd30acbe
CI / verify (push) Successful in 3m23s
CI / publish (push) Has been skipped
Give the Learn cards a real frame instead of a gradient
The preview cards led with a generated gradient. It was a deliberate fallback —
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 — but
for videos PIG serves itself the frame is right there in the file.

The poster is named after the VIDEO's content hash, not its own:
`overview.4d4581ae.mp4` -> `overview.4d4581ae.jpg`. Re-rendering a clip changes
both names together, so a thumbnail cannot outlive what it claims to show. It
needs no schema column and no manifest entry, because the name is derivable.

`learnPoster.sh` cuts the frame with `thumbnail=90` starting four seconds in
rather than taking frame 0: the first frame of a Playwright capture is often
mid-paint, and a poster of a half-rendered page is worse than no poster.

The resolver ASSERTS the poster rather than verifying it — @pig/core is pure and
has no filesystem. That is safe in both directions: a missing poster 404s, which
`<video poster>` renders exactly as it renders no poster, and which the card
falls back from via onError. Claiming a poster that is absent is free; omitting
one that exists would cost every card its thumbnail.

Cap-hosted rows are unchanged and still get the gradient, verified by there
being exactly five <img> elements on a page with nine resources.

Also widens the media allowlist to jpg/webp. The filename pattern, the
traversal rules and the symlink check are untouched and still cover them,
because extension is the only axis that changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 17:53:30 -07:00

162 lines
5.5 KiB
TypeScript

/**
* 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<LearnTrack, typeof Play> = {
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 (
<div
className={cn(
'relative aspect-video w-full min-w-0 overflow-hidden bg-gradient-to-br',
tintFor(seed),
className,
)}
>
{showImage ? (
<>
<img
src={poster as string}
alt={alt ?? ''}
loading="lazy"
decoding="async"
className="absolute inset-0 size-full object-cover object-top"
onError={() => 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.
*/}
<div
className="absolute inset-0 bg-gradient-to-t from-black/35 via-transparent to-black/10"
aria-hidden
/>
</>
) : 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 : <div
className="absolute inset-0 bg-[repeating-linear-gradient(135deg,hsl(var(--fg)/0.04)_0px,hsl(var(--fg)/0.04)_1px,transparent_1px,transparent_10px)]"
aria-hidden
/>}
{showImage ? null : (
<div
className="absolute inset-0 bg-[radial-gradient(circle_at_28%_18%,hsl(var(--surface)/0.8),transparent_62%)]"
aria-hidden
/>
)}
{/* 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 : (
<Glyph
className="absolute -bottom-6 -right-4 size-32 text-fg/[0.06]"
strokeWidth={1.25}
aria-hidden
/>
)}
<div className="absolute inset-0 flex items-center justify-center">
<span
className={cn(
'inline-flex items-center justify-center rounded-full border border-border',
'bg-surface/85 text-fg shadow-sm backdrop-blur-sm',
'transition-transform duration-200 group-hover:scale-105 group-focus-visible:scale-105',
compact ? 'size-9' : 'size-14',
)}
aria-hidden
>
<Play className={compact ? 'size-4' : 'size-6'} fill="currentColor" strokeWidth={0} />
</span>
</div>
{duration ? (
<span
className={cn(
'nums absolute rounded-md bg-fg/85 px-1.5 py-0.5 text-xs font-medium text-bg',
compact ? 'bottom-1 right-1' : 'bottom-2 right-2',
)}
>
{duration}
</span>
) : null}
</div>
);
}