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>
93 lines
4.0 KiB
TypeScript
93 lines
4.0 KiB
TypeScript
/**
|
|
* The platform track, as an ordered course.
|
|
*
|
|
* Product how-to has a running order — `sortOrder` is the curriculum, and
|
|
* "your first hour in PIG" is not interchangeable with the margin report. A
|
|
* grid of equal tiles says "pick one"; a numbered list says "start here", so
|
|
* the two tracks are rendered as different objects rather than one
|
|
* undifferentiated grid.
|
|
*/
|
|
import { ChevronRight } from 'lucide-react';
|
|
import { formatLearnDuration } from '@pig/core';
|
|
import { Badge, Card } from '@/components/ui';
|
|
import { ArchiveControl } from './ArchiveControl';
|
|
import { learnPlayback } from './model';
|
|
import { LearnPoster } from './LearnPoster';
|
|
import { bySortOrder, type LearnResourceView } from './model';
|
|
|
|
export function LearnWalkthroughList({
|
|
resources,
|
|
managing,
|
|
onPlay,
|
|
}: {
|
|
resources: readonly LearnResourceView[];
|
|
managing: boolean;
|
|
onPlay: (resource: LearnResourceView) => void;
|
|
}) {
|
|
const ordered = bySortOrder(resources);
|
|
|
|
return (
|
|
/* Width is the caller's business — this list sits in a 1024px page column
|
|
for a code-holder and in a capped column inside the shell for a member,
|
|
and a cap here would fight one of them. */
|
|
<ol className="flex min-w-0 list-none flex-col gap-3">
|
|
{ordered.map((resource, index) => {
|
|
const playback = learnPlayback(resource);
|
|
return (
|
|
<li key={resource.id} className="min-w-0">
|
|
<Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow hover:shadow-md sm:flex-row">
|
|
<button
|
|
type="button"
|
|
onClick={() => onPlay(resource)}
|
|
className="flex min-w-0 flex-1 items-center gap-3 p-3 text-left focus-visible:ring-inset focus-visible:ring-offset-0 sm:gap-4 sm:p-4"
|
|
>
|
|
<div className="w-28 shrink-0 overflow-hidden rounded-lg border border-border sm:w-44">
|
|
<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}`}
|
|
size="row"
|
|
/>
|
|
</div>
|
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
<p className="nums text-[0.6875rem] font-semibold uppercase tracking-[0.14em] text-accent-fg">
|
|
Step {index + 1}
|
|
</p>
|
|
<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>
|
|
<ChevronRight
|
|
className="hidden size-5 shrink-0 text-muted transition-transform group-hover:translate-x-0.5 sm:block"
|
|
aria-hidden
|
|
/>
|
|
</button>
|
|
|
|
{managing ? (
|
|
/* A right-hand rail on a desktop row, a strip underneath on a
|
|
phone — squeezed into 393px beside the text it left the title
|
|
wrapping one word to a line. */
|
|
<div className="flex min-w-0 shrink-0 flex-row items-center justify-between gap-2 border-t border-border p-2 sm:flex-col sm:items-end sm:justify-center sm:border-l sm:border-t-0">
|
|
<Badge tone={resource.visibility === 'code' ? 'accent' : 'neutral'}>
|
|
{resource.visibility === 'code' ? 'By code' : 'Members'}
|
|
</Badge>
|
|
<ArchiveControl id={resource.id} title={resource.title} />
|
|
</div>
|
|
) : null}
|
|
</Card>
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
);
|
|
}
|