A skate crew is defined by its footage. Crews can now feature a headline video:
- videoEmbed.ts: safe parser accepting ONLY YouTube and Instagram URLs (full
parts on YT, clips on IG). Extracts video id / shortcode; rejects everything
else so we never render an untrusted iframe. No native/self-hosted video.
- CrewVideoSection ("The Part"): cinematic section — YouTube click-to-load
facade (poster + play button, no heavy iframe/cookies until play) and an
Instagram permalink embed, framed with skate language.
- SkateCrew gains `videoUrl` (tag `video`), validated through the embed parser.
- CrewForm: "The Part" input with live source detection (YouTube/Instagram) and
validation; publishes the `video` tag.
- Crews grid: crews with footage flex a "▶ Part" badge as a discovery signal.
- NIP.md: document the `video` tag (YouTube/Instagram only) + example.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
113 lines
3.5 KiB
TypeScript
113 lines
3.5 KiB
TypeScript
/**
|
|
* Safe parsing of crew video links.
|
|
*
|
|
* Skate crews live and die by their **full part / montage**. We deliberately
|
|
* support only the two sources that matter for skateboarding right now:
|
|
*
|
|
* - **YouTube** — full-length parts, montages, edits (the "video").
|
|
* - **Instagram** — clips, reels, single tricks (the "clip").
|
|
*
|
|
* No native / self-hosted video. Anything else is rejected so we never render
|
|
* an untrusted iframe.
|
|
*/
|
|
|
|
export type CrewVideoSource = 'youtube' | 'instagram';
|
|
|
|
export interface ParsedCrewVideo {
|
|
source: CrewVideoSource;
|
|
/** Sandboxed embed URL safe to drop into an <iframe>. */
|
|
embedUrl: string;
|
|
/** Canonical watch/permalink URL (https) for "open externally". */
|
|
url: string;
|
|
/** YouTube video id / Instagram shortcode, when known. */
|
|
id?: string;
|
|
}
|
|
|
|
/** YouTube thumbnail (hqdefault) for a video id — nice poster before play. */
|
|
export function youtubeThumb(id: string): string {
|
|
return `https://i.ytimg.com/vi/${encodeURIComponent(id)}/hqdefault.jpg`;
|
|
}
|
|
|
|
function extractYouTubeId(u: URL): string | null {
|
|
const host = u.hostname.replace(/^www\./, '').toLowerCase();
|
|
|
|
// youtu.be/<id>
|
|
if (host === 'youtu.be') {
|
|
const id = u.pathname.split('/').filter(Boolean)[0];
|
|
return isYtId(id) ? id : null;
|
|
}
|
|
|
|
if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'youtube-nocookie.com') {
|
|
// watch?v=<id>
|
|
const v = u.searchParams.get('v');
|
|
if (v && isYtId(v)) return v;
|
|
// /embed/<id>, /shorts/<id>, /v/<id>, /live/<id>
|
|
const parts = u.pathname.split('/').filter(Boolean);
|
|
if (parts.length >= 2 && ['embed', 'shorts', 'v', 'live'].includes(parts[0])) {
|
|
return isYtId(parts[1]) ? parts[1] : null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isYtId(id: string | undefined | null): id is string {
|
|
return !!id && /^[a-zA-Z0-9_-]{6,20}$/.test(id);
|
|
}
|
|
|
|
/** Extract an Instagram post/reel shortcode. */
|
|
function extractInstagramShortcode(u: URL): string | null {
|
|
const host = u.hostname.replace(/^www\./, '').toLowerCase();
|
|
if (host !== 'instagram.com' && host !== 'instagr.am') return null;
|
|
const parts = u.pathname.split('/').filter(Boolean);
|
|
// /p/<code>, /reel/<code>, /tv/<code>
|
|
const idx = parts.findIndex((p) => ['p', 'reel', 'reels', 'tv'].includes(p));
|
|
if (idx === -1) return null;
|
|
const code = parts[idx + 1];
|
|
if (!code || !/^[a-zA-Z0-9_-]{5,30}$/.test(code)) return null;
|
|
return code;
|
|
}
|
|
|
|
/**
|
|
* Parse a user-supplied URL into a safe crew-video embed. Returns null unless the
|
|
* URL is a recognised YouTube or Instagram link over http(s).
|
|
*/
|
|
export function parseCrewVideo(raw: string | undefined | null): ParsedCrewVideo | null {
|
|
if (!raw) return null;
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return null;
|
|
|
|
let u: URL;
|
|
try {
|
|
u = new URL(trimmed);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
|
|
|
|
const ytId = extractYouTubeId(u);
|
|
if (ytId) {
|
|
return {
|
|
source: 'youtube',
|
|
id: ytId,
|
|
embedUrl: `https://www.youtube-nocookie.com/embed/${ytId}?rel=0&modestbranding=1`,
|
|
url: `https://www.youtube.com/watch?v=${ytId}`,
|
|
};
|
|
}
|
|
|
|
const igCode = extractInstagramShortcode(u);
|
|
if (igCode) {
|
|
return {
|
|
source: 'instagram',
|
|
id: igCode,
|
|
embedUrl: `https://www.instagram.com/p/${igCode}/embed`,
|
|
url: `https://www.instagram.com/p/${igCode}/`,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** Cheap validity check for form inputs. */
|
|
export function isCrewVideoUrl(raw: string | undefined | null): boolean {
|
|
return parseCrewVideo(raw) !== null;
|
|
}
|