19dd30acbe
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>
162 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|