Files
pig/apps/web/src/components/learn/LearnVideoCard.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

75 lines
2.8 KiB
TypeScript

/**
* A concept video, as a browsable card.
*
* Concepts are market education — someone scans the shelf and picks what they
* need — so this is a poster-led grid tile. The platform track is a course you
* work through in order and is rendered as a list instead; see
* `LearnWalkthroughList`.
*/
import { formatLearnDuration } from '@pig/core';
import { Badge, Card } from '@/components/ui';
import { ArchiveControl } from './ArchiveControl';
import { learnPlayback } from './model';
import { LearnPoster } from './LearnPoster';
import type { LearnResourceView } from './model';
export function LearnVideoCard({
resource,
managing,
onPlay,
}: {
resource: LearnResourceView;
managing: boolean;
onPlay: (resource: LearnResourceView) => void;
}) {
const playback = learnPlayback(resource);
return (
<Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow hover:shadow-md">
<button
type="button"
onClick={() => onPlay(resource)}
// The ring is inset because the card clips its overflow, and an offset
// ring on a clipped child is a focus indicator nobody can see.
className="flex min-w-0 flex-1 flex-col text-left focus-visible:ring-inset focus-visible:ring-offset-0"
>
<LearnPoster
seed={resource.id}
track={resource.track}
duration={formatLearnDuration(resource.durationSeconds)}
// Only a PIG-hosted clip has a real frame; a Cap embed resolves to
// an iframe and falls back to the generated poster.
poster={playback?.kind === 'video' ? playback.poster : null}
alt={`Still from ${resource.title}`}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1.5 p-4">
{/* break-words, not truncate: a title is the only way to tell two
walkthroughs apart, and an unbroken word at 393px is what drags
the whole page sideways. */}
<h3 className="min-w-0 break-words font-semibold leading-snug">{resource.title}</h3>
{resource.summary ? (
<p className="line-clamp-2 min-w-0 break-words text-sm leading-6 text-muted">
{resource.summary}
</p>
) : null}
</div>
</button>
{managing ? (
<div className="pointer-events-none absolute inset-x-0 top-0 flex min-w-0 items-start justify-between gap-2 p-2">
<Badge
tone={resource.visibility === 'code' ? 'accent' : 'neutral'}
className="pointer-events-auto bg-surface/90 backdrop-blur-sm"
>
{resource.visibility === 'code' ? 'Shared by code' : 'Members only'}
</Badge>
<ArchiveControl
id={resource.id}
title={resource.title}
className="pointer-events-auto"
/>
</div>
) : null}
</Card>
);
}