feat(P2-7): expand link previews to 13 domain-specific card types
CI / Build & Quality Checks (push) Successful in 10m23s
CI / Build & Quality Checks (push) Successful in 10m23s
YouTube, Vimeo (shared VideoCard with thumbnail + play overlay), GitHub (repo name/description parse), Twitter/X (tweet text parse), Reddit (subreddit + post title), Spotify (artwork + track/artist), Twitch (thumbnail + LIVE badge), Steam (game header image), Wikipedia (clean text card), Discord (server icon + member count), npm (package name/description), Stack Overflow (question excerpt), IMDb (portrait poster + title/rating) Generic fallback gains favicon from google/s2/favicons; empty cards (no title or description) are suppressed. Shared SiteBadge component with brand-colour CSS classes per domain in UrlPreview.css.tsx. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -138,3 +138,209 @@ export const GenericFaviconImg = style([
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared media-card thumbnail (VideoCard, Steam, etc.)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const MediaThumbnailWrapper = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'relative',
|
||||||
|
flexShrink: 0,
|
||||||
|
width: toRem(160),
|
||||||
|
height: toRem(90),
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
|
||||||
|
':hover': {
|
||||||
|
filter: 'brightness(0.85)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const MediaThumbnailImg = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'center',
|
||||||
|
display: 'block',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const MediaPlayOverlay = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const MediaPlayButton = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
width: toRem(44),
|
||||||
|
height: toRem(44),
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#ffffff',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared square artwork thumbnail (Spotify, IMDb poster, Discord icon)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const ArtworkThumbnail = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: toRem(72),
|
||||||
|
height: toRem(72),
|
||||||
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'center',
|
||||||
|
display: 'block',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// IMDb portrait poster (60 × 90)
|
||||||
|
export const PosterThumbnail = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: toRem(60),
|
||||||
|
height: toRem(90),
|
||||||
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'center',
|
||||||
|
display: 'block',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Site-badge shared base
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const SiteBadge = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S100,
|
||||||
|
paddingLeft: config.space.S100,
|
||||||
|
paddingRight: config.space.S100,
|
||||||
|
paddingTop: '2px',
|
||||||
|
paddingBottom: '2px',
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
fontSize: toRem(11),
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: '1.4',
|
||||||
|
letterSpacing: '0.03em',
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Individual badge colour overrides (background / foreground)
|
||||||
|
export const BadgeVimeo = style({
|
||||||
|
backgroundColor: '#1ab7ea',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeTwitter = style({
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeReddit = style({
|
||||||
|
backgroundColor: '#ff4500',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeSpotify = style({
|
||||||
|
backgroundColor: '#1db954',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeTwitch = style({
|
||||||
|
backgroundColor: '#9146ff',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeSteam = style({
|
||||||
|
backgroundColor: '#1b2838',
|
||||||
|
color: '#c7d5e0',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeWikipedia = style({
|
||||||
|
backgroundColor: color.SurfaceVariant.ContainerLine,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeDiscord = style({
|
||||||
|
backgroundColor: '#5865f2',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeNpm = style({
|
||||||
|
backgroundColor: '#cb3837',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeStackOverflow = style({
|
||||||
|
backgroundColor: '#f48024',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeImdb = style({
|
||||||
|
backgroundColor: '#f5c518',
|
||||||
|
color: '#000000',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Twitch LIVE badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const TwitchLiveBadge = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingLeft: config.space.S100,
|
||||||
|
paddingRight: config.space.S100,
|
||||||
|
paddingTop: '2px',
|
||||||
|
paddingBottom: '2px',
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
fontSize: toRem(11),
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: '1.4',
|
||||||
|
backgroundColor: '#e91916',
|
||||||
|
color: '#ffffff',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Icon-wrapper (generic left-side icon block — Twitter, Reddit, Wikipedia…)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const IconWrapper = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: toRem(72),
|
||||||
|
minHeight: toRem(72),
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: color.Surface.ContainerHover,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|||||||
@@ -23,7 +23,21 @@ const linkStyles = { color: color.Success.Main };
|
|||||||
// Helpers — URL parsing & variant detection
|
// Helpers — URL parsing & variant detection
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type CardVariant = 'youtube' | 'github' | 'generic';
|
type CardVariant =
|
||||||
|
| 'youtube'
|
||||||
|
| 'vimeo'
|
||||||
|
| 'github'
|
||||||
|
| 'twitter'
|
||||||
|
| 'reddit'
|
||||||
|
| 'spotify'
|
||||||
|
| 'twitch'
|
||||||
|
| 'steam'
|
||||||
|
| 'wikipedia'
|
||||||
|
| 'discord'
|
||||||
|
| 'npm'
|
||||||
|
| 'stackoverflow'
|
||||||
|
| 'imdb'
|
||||||
|
| 'generic';
|
||||||
|
|
||||||
function getYouTubeVideoId(url: string): string | null {
|
function getYouTubeVideoId(url: string): string | null {
|
||||||
try {
|
try {
|
||||||
@@ -51,6 +65,18 @@ function getYouTubeVideoId(url: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getVimeoVideoId(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const { hostname, pathname } = parsed;
|
||||||
|
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
|
||||||
|
const m = pathname.match(/^\/(\d+)/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isGitHubRepo(url: string): boolean {
|
function isGitHubRepo(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
@@ -64,9 +90,125 @@ function isGitHubRepo(url: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTwitter(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const { hostname } = new URL(url);
|
||||||
|
const h = hostname.replace(/^www\./, '');
|
||||||
|
return h === 'twitter.com' || h === 'x.com';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRedditSubreddit(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
const h = hostname.replace(/^www\./, '');
|
||||||
|
if (h !== 'reddit.com') return null;
|
||||||
|
const m = pathname.match(/^\/r\/([^/]+)/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSpotifyType(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
if (hostname !== 'open.spotify.com') return null;
|
||||||
|
const m = pathname.match(/^\/(track|album|playlist|artist)\//);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTwitch(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const { hostname } = new URL(url);
|
||||||
|
return hostname === 'twitch.tv' || hostname === 'www.twitch.tv';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSteamApp(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
const h = hostname.replace(/^www\./, '');
|
||||||
|
if (h !== 'store.steampowered.com') return false;
|
||||||
|
return pathname.startsWith('/app/');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWikipedia(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
return hostname.endsWith('.wikipedia.org') && pathname.startsWith('/wiki/');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDiscordInvite(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
const h = hostname.replace(/^www\./, '');
|
||||||
|
return (
|
||||||
|
(h === 'discord.gg' || h === 'discord.com') &&
|
||||||
|
(pathname.startsWith('/invite/') || h === 'discord.gg')
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNpm(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
const h = hostname.replace(/^www\./, '');
|
||||||
|
return h === 'npmjs.com' && pathname.startsWith('/package/');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStackOverflow(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
const h = hostname.replace(/^www\./, '');
|
||||||
|
return h === 'stackoverflow.com' && pathname.startsWith('/questions/');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImdb(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
const h = hostname.replace(/^www\./, '');
|
||||||
|
return h === 'imdb.com' && pathname.startsWith('/title/');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getCardVariant(url: string): CardVariant {
|
function getCardVariant(url: string): CardVariant {
|
||||||
if (getYouTubeVideoId(url) !== null) return 'youtube';
|
if (getYouTubeVideoId(url) !== null) return 'youtube';
|
||||||
|
if (getVimeoVideoId(url) !== null) return 'vimeo';
|
||||||
if (isGitHubRepo(url)) return 'github';
|
if (isGitHubRepo(url)) return 'github';
|
||||||
|
if (isTwitter(url)) return 'twitter';
|
||||||
|
if (getRedditSubreddit(url) !== null) return 'reddit';
|
||||||
|
if (getSpotifyType(url) !== null) return 'spotify';
|
||||||
|
if (isTwitch(url)) return 'twitch';
|
||||||
|
if (isSteamApp(url)) return 'steam';
|
||||||
|
if (isWikipedia(url)) return 'wikipedia';
|
||||||
|
if (isDiscordInvite(url)) return 'discord';
|
||||||
|
if (isNpm(url)) return 'npm';
|
||||||
|
if (isStackOverflow(url)) return 'stackoverflow';
|
||||||
|
if (isImdb(url)) return 'imdb';
|
||||||
return 'generic';
|
return 'generic';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,8 +221,9 @@ function getDomain(url: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GitHub SVG icon (inline, no external dependency)
|
// Inline SVG logos
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function GitHubIcon({ size = 24 }: { size?: number }) {
|
function GitHubIcon({ size = 24 }: { size?: number }) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -97,12 +240,36 @@ function GitHubIcon({ size = 24 }: { size?: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Card variant renderers
|
// Shared badge component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
function SiteBadge({ label, colorClass }: { label: string; colorClass: string }) {
|
||||||
const videoId = getYouTubeVideoId(url)!;
|
return (
|
||||||
const thumbnailSrc = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`;
|
<span className={`${previewCss.SiteBadge} ${colorClass}`} aria-label={label}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared VideoCard — used by YouTube and Vimeo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function VideoCard({
|
||||||
|
url,
|
||||||
|
prev,
|
||||||
|
thumbnailUrl,
|
||||||
|
siteBadgeLabel,
|
||||||
|
siteBadgeClass,
|
||||||
|
showPlayButton,
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
prev: IPreviewUrlResponse;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
siteBadgeLabel: string;
|
||||||
|
siteBadgeClass: string;
|
||||||
|
showPlayButton?: boolean;
|
||||||
|
}) {
|
||||||
const title = prev['og:title'] ?? '';
|
const title = prev['og:title'] ?? '';
|
||||||
const description = prev['og:description'] ?? '';
|
const description = prev['og:description'] ?? '';
|
||||||
|
|
||||||
@@ -112,20 +279,22 @@ function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse })
|
|||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className={previewCss.YouTubeThumbnailWrapper}
|
className={previewCss.MediaThumbnailWrapper}
|
||||||
aria-label={`Play on YouTube: ${title}`}
|
aria-label={`Watch on ${siteBadgeLabel}: ${title}`}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
className={previewCss.YouTubeThumbnailImg}
|
className={previewCss.MediaThumbnailImg}
|
||||||
src={thumbnailSrc}
|
src={thumbnailUrl}
|
||||||
alt={title}
|
alt={title}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className={previewCss.YouTubePlayOverlay}>
|
{showPlayButton !== false && (
|
||||||
<div className={previewCss.YouTubePlayButton}>
|
<div className={previewCss.MediaPlayOverlay}>
|
||||||
<Icon size="400" src={Icons.Play} />
|
<div className={previewCss.MediaPlayButton}>
|
||||||
|
<Icon size="400" src={Icons.Play} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</a>
|
</a>
|
||||||
<UrlPreviewContent>
|
<UrlPreviewContent>
|
||||||
<Text
|
<Text
|
||||||
@@ -138,7 +307,7 @@ function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse })
|
|||||||
size="T200"
|
size="T200"
|
||||||
priority="300"
|
priority="300"
|
||||||
>
|
>
|
||||||
YouTube
|
<SiteBadge label={siteBadgeLabel} colorClass={siteBadgeClass} />
|
||||||
</Text>
|
</Text>
|
||||||
{title && (
|
{title && (
|
||||||
<Text truncate priority="400">
|
<Text truncate priority="400">
|
||||||
@@ -155,6 +324,40 @@ function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Card variant renderers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const videoId = getYouTubeVideoId(url)!;
|
||||||
|
const thumbnailSrc = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VideoCard
|
||||||
|
url={url}
|
||||||
|
prev={prev}
|
||||||
|
thumbnailUrl={thumbnailSrc}
|
||||||
|
siteBadgeLabel="YouTube"
|
||||||
|
siteBadgeClass=""
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VimeoCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
// Vimeo always includes og:image for video thumbnails
|
||||||
|
const thumbnailSrc = (prev['og:image'] as string | undefined) ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VideoCard
|
||||||
|
url={url}
|
||||||
|
prev={prev}
|
||||||
|
thumbnailUrl={thumbnailSrc}
|
||||||
|
siteBadgeLabel="Vimeo"
|
||||||
|
siteBadgeClass={previewCss.BadgeVimeo}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
const title = prev['og:title'] ?? '';
|
const title = prev['og:title'] ?? '';
|
||||||
const description = prev['og:description'] ?? '';
|
const description = prev['og:description'] ?? '';
|
||||||
@@ -207,6 +410,588 @@ function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TwitterCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const rawTitle = prev['og:title'] ?? '';
|
||||||
|
const description = prev['og:description'] ?? '';
|
||||||
|
|
||||||
|
// Twitter og:title patterns:
|
||||||
|
// "Name on X: \"tweet text\""
|
||||||
|
// "X / Name (@handle)"
|
||||||
|
let displayName = rawTitle;
|
||||||
|
let tweetText = description;
|
||||||
|
|
||||||
|
const onXMatch = rawTitle.match(/^(.+?) on X:\s*[""](.+)[""]?$/);
|
||||||
|
if (onXMatch) {
|
||||||
|
displayName = onXMatch[1];
|
||||||
|
tweetText = onXMatch[2];
|
||||||
|
} else {
|
||||||
|
const slashMatch = rawTitle.match(/^X\s*\/\s*(.+)$/);
|
||||||
|
if (slashMatch) {
|
||||||
|
displayName = slashMatch[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
className={previewCss.IconWrapper}
|
||||||
|
>
|
||||||
|
{/* 𝕏 mark */}
|
||||||
|
<svg
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.737-8.847L2 2.25h6.957l4.265 5.64L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z" />
|
||||||
|
</svg>
|
||||||
|
</Box>
|
||||||
|
<UrlPreviewContent>
|
||||||
|
<Text
|
||||||
|
style={linkStyles}
|
||||||
|
truncate
|
||||||
|
as="a"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
size="T200"
|
||||||
|
priority="300"
|
||||||
|
>
|
||||||
|
<SiteBadge label="𝕏 / Twitter" colorClass={previewCss.BadgeTwitter} />
|
||||||
|
</Text>
|
||||||
|
{displayName && (
|
||||||
|
<Text truncate priority="400">
|
||||||
|
<b>{displayName}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{tweetText && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
<UrlPreviewDescription>{tweetText}</UrlPreviewDescription>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</UrlPreviewContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RedditCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const title = prev['og:title'] ?? '';
|
||||||
|
const description = prev['og:description'] ?? '';
|
||||||
|
const subreddit = getRedditSubreddit(url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
className={previewCss.IconWrapper}
|
||||||
|
>
|
||||||
|
{/* Simplified Reddit "R" mark */}
|
||||||
|
<svg
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="#ff4500"
|
||||||
|
aria-hidden="true"
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="12" fill="#ff4500" />
|
||||||
|
<path
|
||||||
|
fill="#ffffff"
|
||||||
|
d="M20 12a2 2 0 0 0-2-2 2 2 0 0 0-1.36.54C15.28 9.8 13.72 9.4 12 9.39l.7-3.26 2.23.47a1.4 1.4 0 1 0 .15-.69l-2.5-.52a.25.25 0 0 0-.29.19l-.78 3.65c-1.74.06-3.3.46-4.63 1.17A2 2 0 0 0 4 12a2 2 0 0 0 1.07 1.76 3.5 3.5 0 0 0 0 .49C5.07 16.69 8.27 19 12.07 19s6.97-2.31 6.97-4.75a3.5 3.5 0 0 0 0-.47A2 2 0 0 0 20 12zm-13 2a1 1 0 1 1 2 0 1 1 0 0 1-2 0zm5.59 2.71c-.72.72-2.1.77-2.53.77s-1.82-.05-2.53-.77a.25.25 0 0 1 .35-.35c.46.46 1.48.62 2.18.62s1.72-.16 2.18-.62a.25.25 0 0 1 .35.35zm-.15-1.71a1 1 0 1 1 2 0 1 1 0 0 1-2 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Box>
|
||||||
|
<UrlPreviewContent>
|
||||||
|
<Text
|
||||||
|
style={linkStyles}
|
||||||
|
truncate
|
||||||
|
as="a"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
size="T200"
|
||||||
|
priority="300"
|
||||||
|
>
|
||||||
|
<SiteBadge label="Reddit" colorClass={previewCss.BadgeReddit} />
|
||||||
|
{subreddit && <span style={{ marginLeft: '6px', opacity: 0.7 }}>r/{subreddit}</span>}
|
||||||
|
</Text>
|
||||||
|
{title && (
|
||||||
|
<Text truncate priority="400">
|
||||||
|
<b>{title}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</UrlPreviewContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const title = prev['og:title'] ?? '';
|
||||||
|
const description = prev['og:description'] ?? '';
|
||||||
|
const artworkUrl = (prev['og:image'] as string | undefined) ?? '';
|
||||||
|
const spotifyType = getSpotifyType(url) ?? 'track';
|
||||||
|
const typeLabel = spotifyType.charAt(0).toUpperCase() + spotifyType.slice(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{artworkUrl ? (
|
||||||
|
<img className={previewCss.ArtworkThumbnail} src={artworkUrl} alt={title} loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
className={previewCss.IconWrapper}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '2rem', color: '#1db954' }}>♫</span>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<UrlPreviewContent>
|
||||||
|
<Text
|
||||||
|
style={linkStyles}
|
||||||
|
truncate
|
||||||
|
as="a"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
size="T200"
|
||||||
|
priority="300"
|
||||||
|
>
|
||||||
|
<SiteBadge label={`Spotify ${typeLabel}`} colorClass={previewCss.BadgeSpotify} />
|
||||||
|
</Text>
|
||||||
|
{title && (
|
||||||
|
<Text truncate priority="400">
|
||||||
|
<b>{title}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</UrlPreviewContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TwitchCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const title = prev['og:title'] ?? '';
|
||||||
|
const description = prev['og:description'] ?? '';
|
||||||
|
const thumbnailUrl = (prev['og:image'] as string | undefined) ?? '';
|
||||||
|
const siteName = (prev['og:site_name'] as string | undefined) ?? '';
|
||||||
|
const isLive =
|
||||||
|
siteName.toLowerCase().includes('twitch') &&
|
||||||
|
(title.toLowerCase().includes('streaming') || siteName.toLowerCase().includes('live'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{thumbnailUrl ? (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={previewCss.MediaThumbnailWrapper}
|
||||||
|
aria-label={`Watch on Twitch: ${title}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className={previewCss.MediaThumbnailImg}
|
||||||
|
src={thumbnailUrl}
|
||||||
|
alt={title}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{isLive && (
|
||||||
|
<div className={previewCss.MediaPlayOverlay}>
|
||||||
|
<span className={previewCss.TwitchLiveBadge}>LIVE</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
className={previewCss.IconWrapper}
|
||||||
|
>
|
||||||
|
{/* Simple Twitch "T" mark */}
|
||||||
|
<svg
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="#9146ff"
|
||||||
|
aria-hidden="true"
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<path d="M11.64 5.93h1.43v4.28h-1.43m3.93-4.28H17v4.28h-1.43M7 2L3.43 5.57v12.86h4.28V22l3.58-3.57h2.85L20.57 12V2m-1.43 9.29-2.85 2.85h-2.86l-2.5 2.5v-2.5H7.71V3.43h11.43z" />
|
||||||
|
</svg>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<UrlPreviewContent>
|
||||||
|
<Text
|
||||||
|
style={linkStyles}
|
||||||
|
truncate
|
||||||
|
as="a"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
size="T200"
|
||||||
|
priority="300"
|
||||||
|
>
|
||||||
|
<SiteBadge label="Twitch" colorClass={previewCss.BadgeTwitch} />
|
||||||
|
{isLive && (
|
||||||
|
<span className={previewCss.TwitchLiveBadge} style={{ marginLeft: '4px' }}>
|
||||||
|
LIVE
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
{title && (
|
||||||
|
<Text truncate priority="400">
|
||||||
|
<b>{title}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</UrlPreviewContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const title = prev['og:title'] ?? '';
|
||||||
|
const description = prev['og:description'] ?? '';
|
||||||
|
const thumbnailUrl = (prev['og:image'] as string | undefined) ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{thumbnailUrl ? (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={previewCss.MediaThumbnailWrapper}
|
||||||
|
aria-label={`View on Steam: ${title}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className={previewCss.MediaThumbnailImg}
|
||||||
|
src={thumbnailUrl}
|
||||||
|
alt={title}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
className={previewCss.IconWrapper}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '1.5rem', color: '#c7d5e0' }}>⚙</span>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<UrlPreviewContent>
|
||||||
|
<Text
|
||||||
|
style={linkStyles}
|
||||||
|
truncate
|
||||||
|
as="a"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
size="T200"
|
||||||
|
priority="300"
|
||||||
|
>
|
||||||
|
<SiteBadge label="Steam" colorClass={previewCss.BadgeSteam} />
|
||||||
|
</Text>
|
||||||
|
{title && (
|
||||||
|
<Text truncate priority="400">
|
||||||
|
<b>{title}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</UrlPreviewContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const title = prev['og:title'] ?? '';
|
||||||
|
const rawDescription = prev['og:description'] ?? '';
|
||||||
|
const description =
|
||||||
|
rawDescription.length > 200 ? `${rawDescription.slice(0, 200)}…` : rawDescription;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
className={previewCss.IconWrapper}
|
||||||
|
>
|
||||||
|
{/* Wikipedia "W" mark */}
|
||||||
|
<svg
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
viewBox="0 0 50 50"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<path d="M26.5 4a1 1 0 0 0-1 1v.1c-2.1.3-4 1.4-5.3 3.1L12 22.5 5.9 8.7A1 1 0 0 0 5 8H2a1 1 0 1 0 0 2h2.3l7.2 16.9c.2.4.6.7 1 .7h.1c.5 0 .9-.3 1.1-.7l7.1-14.2c.9-1.7 2.4-2.9 4.2-3.4v28.4c-1.3.3-2.5 1-3.4 2H10.5a1 1 0 1 0 0 2h10.6C22 43 23.9 44 26 44h-.5a1 1 0 1 0 0 2H26a9 9 0 0 0 8.9-7.8H45a1 1 0 1 0 0-2H35c-.9-1-2.1-1.7-3.5-2V9.8c1.8.4 3.3 1.7 4.2 3.4l7.1 14.2c.2.4.6.7 1.1.7h.1c.4 0 .8-.3 1-.7L52.7 10H55a1 1 0 1 0 0-2h-3a1 1 0 0 0-.9.7L44 22.5l-8.2-14.3C34.5 5.4 32.6 4.3 30.5 4H27a1 1 0 0 0-.5 0z" />
|
||||||
|
</svg>
|
||||||
|
</Box>
|
||||||
|
<UrlPreviewContent>
|
||||||
|
<Text
|
||||||
|
style={linkStyles}
|
||||||
|
truncate
|
||||||
|
as="a"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
size="T200"
|
||||||
|
priority="300"
|
||||||
|
>
|
||||||
|
<SiteBadge label="Wikipedia" colorClass={previewCss.BadgeWikipedia} />
|
||||||
|
</Text>
|
||||||
|
{title && (
|
||||||
|
<Text truncate priority="400">
|
||||||
|
<b>{title}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</UrlPreviewContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const title = prev['og:title'] ?? '';
|
||||||
|
const description = prev['og:description'] ?? '';
|
||||||
|
const iconUrl = (prev['og:image'] as string | undefined) ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{iconUrl ? (
|
||||||
|
<img className={previewCss.ArtworkThumbnail} src={iconUrl} alt={title} loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
className={previewCss.IconWrapper}
|
||||||
|
>
|
||||||
|
{/* Discord logo (simple geometric mark) */}
|
||||||
|
<svg
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="#5865f2"
|
||||||
|
aria-hidden="true"
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||||
|
</svg>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<UrlPreviewContent>
|
||||||
|
<Text
|
||||||
|
style={linkStyles}
|
||||||
|
truncate
|
||||||
|
as="a"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
size="T200"
|
||||||
|
priority="300"
|
||||||
|
>
|
||||||
|
<SiteBadge label="Discord" colorClass={previewCss.BadgeDiscord} />
|
||||||
|
<span style={{ marginLeft: '6px', opacity: 0.7, fontSize: '0.85em' }}>Join Server</span>
|
||||||
|
</Text>
|
||||||
|
{title && (
|
||||||
|
<Text truncate priority="400">
|
||||||
|
<b>{title}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</UrlPreviewContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NpmCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const title = prev['og:title'] ?? '';
|
||||||
|
const description = prev['og:description'] ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
className={previewCss.IconWrapper}
|
||||||
|
>
|
||||||
|
{/* npm logo — simple square mark */}
|
||||||
|
<svg width={32} height={32} viewBox="0 0 18 7" fill="none" aria-hidden="true" role="img">
|
||||||
|
<rect width="18" height="7" fill="#cb3837" />
|
||||||
|
<rect x="1" y="1" width="4" height="5" fill="white" />
|
||||||
|
<rect x="2" y="1" width="1" height="4" fill="#cb3837" />
|
||||||
|
<rect x="6" y="1" width="4" height="5" fill="white" />
|
||||||
|
<rect x="7" y="1" width="1" height="3" fill="#cb3837" />
|
||||||
|
<rect x="11" y="1" width="4" height="4" fill="white" />
|
||||||
|
<rect x="12" y="1" width="1" height="3" fill="#cb3837" />
|
||||||
|
</svg>
|
||||||
|
</Box>
|
||||||
|
<UrlPreviewContent>
|
||||||
|
<Text
|
||||||
|
style={linkStyles}
|
||||||
|
truncate
|
||||||
|
as="a"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
size="T200"
|
||||||
|
priority="300"
|
||||||
|
>
|
||||||
|
<SiteBadge label="npm" colorClass={previewCss.BadgeNpm} />
|
||||||
|
</Text>
|
||||||
|
{title && (
|
||||||
|
<Text truncate priority="400">
|
||||||
|
<b>{title}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</UrlPreviewContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StackOverflowCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const title = prev['og:title'] ?? '';
|
||||||
|
const description = prev['og:description'] ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
className={previewCss.IconWrapper}
|
||||||
|
>
|
||||||
|
{/* Stack Overflow logo — simplified stack of bars */}
|
||||||
|
<svg
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="#f48024"
|
||||||
|
aria-hidden="true"
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<path d="M18.986 21.865v-6.404h2.134V24H0v-8.539h2.134v6.404zM3.34 13.232l.415-2.396 9.913 1.73-.415 2.396zm1.241-4.065.832-2.26 9.308 3.424-.832 2.26-9.308-3.424zm2.498-3.993 1.248-2.097 8.17 4.857-1.248 2.097L7.079 5.174zm4.568-3.741 1.665-1.665 6.971 6.971-1.665 1.665-6.971-6.97zm7.559 11.866h-2.134V6.796h2.134z" />
|
||||||
|
</svg>
|
||||||
|
</Box>
|
||||||
|
<UrlPreviewContent>
|
||||||
|
<Text
|
||||||
|
style={linkStyles}
|
||||||
|
truncate
|
||||||
|
as="a"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
size="T200"
|
||||||
|
priority="300"
|
||||||
|
>
|
||||||
|
<SiteBadge label="Stack Overflow" colorClass={previewCss.BadgeStackOverflow} />
|
||||||
|
</Text>
|
||||||
|
{title && (
|
||||||
|
<Text truncate priority="400">
|
||||||
|
<b>{title}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</UrlPreviewContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImdbCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const title = prev['og:title'] ?? '';
|
||||||
|
const description = prev['og:description'] ?? '';
|
||||||
|
const posterUrl = (prev['og:image'] as string | undefined) ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{posterUrl ? (
|
||||||
|
<img className={previewCss.PosterThumbnail} src={posterUrl} alt={title} loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
className={previewCss.IconWrapper}
|
||||||
|
>
|
||||||
|
<SiteBadge label="IMDb" colorClass={previewCss.BadgeImdb} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<UrlPreviewContent>
|
||||||
|
<Text
|
||||||
|
style={linkStyles}
|
||||||
|
truncate
|
||||||
|
as="a"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
size="T200"
|
||||||
|
priority="300"
|
||||||
|
>
|
||||||
|
<SiteBadge label="IMDb" colorClass={previewCss.BadgeImdb} />
|
||||||
|
</Text>
|
||||||
|
{title && (
|
||||||
|
<Text truncate priority="400">
|
||||||
|
<b>{title}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</UrlPreviewContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function GenericCard({
|
function GenericCard({
|
||||||
url,
|
url,
|
||||||
prev,
|
prev,
|
||||||
@@ -312,39 +1097,61 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|||||||
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
|
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
|
||||||
const variant = getCardVariant(url);
|
const variant = getCardVariant(url);
|
||||||
|
|
||||||
if (variant === 'youtube') {
|
switch (variant) {
|
||||||
return <YouTubeCard url={url} prev={prev} />;
|
case 'youtube':
|
||||||
|
return <YouTubeCard url={url} prev={prev} />;
|
||||||
|
case 'vimeo':
|
||||||
|
return <VimeoCard url={url} prev={prev} />;
|
||||||
|
case 'github':
|
||||||
|
return <GitHubCard url={url} prev={prev} />;
|
||||||
|
case 'twitter':
|
||||||
|
return <TwitterCard url={url} prev={prev} />;
|
||||||
|
case 'reddit':
|
||||||
|
return <RedditCard url={url} prev={prev} />;
|
||||||
|
case 'spotify':
|
||||||
|
return <SpotifyCard url={url} prev={prev} />;
|
||||||
|
case 'twitch':
|
||||||
|
return <TwitchCard url={url} prev={prev} />;
|
||||||
|
case 'steam':
|
||||||
|
return <SteamCard url={url} prev={prev} />;
|
||||||
|
case 'wikipedia':
|
||||||
|
return <WikipediaCard url={url} prev={prev} />;
|
||||||
|
case 'discord':
|
||||||
|
return <DiscordCard url={url} prev={prev} />;
|
||||||
|
case 'npm':
|
||||||
|
return <NpmCard url={url} prev={prev} />;
|
||||||
|
case 'stackoverflow':
|
||||||
|
return <StackOverflowCard url={url} prev={prev} />;
|
||||||
|
case 'imdb':
|
||||||
|
return <ImdbCard url={url} prev={prev} />;
|
||||||
|
default: {
|
||||||
|
// Generic fallback — skip empty cards
|
||||||
|
if (!prev['og:title'] && !prev['og:description']) return null;
|
||||||
|
|
||||||
|
const thumbUrl = mxcUrlToHttp(
|
||||||
|
mx,
|
||||||
|
prev['og:image'] || '',
|
||||||
|
useAuthentication,
|
||||||
|
256,
|
||||||
|
256,
|
||||||
|
'scale',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GenericCard
|
||||||
|
url={url}
|
||||||
|
prev={prev}
|
||||||
|
onOpenViewer={() => setViewer(true)}
|
||||||
|
viewer={viewer}
|
||||||
|
onCloseViewer={() => setViewer(false)}
|
||||||
|
thumbUrl={thumbUrl}
|
||||||
|
imgUrl={imgUrl}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (variant === 'github') {
|
|
||||||
return <GitHubCard url={url} prev={prev} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generic fallback — skip empty cards
|
|
||||||
if (!prev['og:title'] && !prev['og:description']) return null;
|
|
||||||
|
|
||||||
const thumbUrl = mxcUrlToHttp(
|
|
||||||
mx,
|
|
||||||
prev['og:image'] || '',
|
|
||||||
useAuthentication,
|
|
||||||
256,
|
|
||||||
256,
|
|
||||||
'scale',
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GenericCard
|
|
||||||
url={url}
|
|
||||||
prev={prev}
|
|
||||||
onOpenViewer={() => setViewer(true)}
|
|
||||||
viewer={viewer}
|
|
||||||
onCloseViewer={() => setViewer(false)}
|
|
||||||
thumbUrl={thumbUrl}
|
|
||||||
imgUrl={imgUrl}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Don't render the card wrapper when content is empty (loaded but nothing to show)
|
// Don't render the card wrapper when content is empty (loaded but nothing to show)
|
||||||
|
|||||||
Reference in New Issue
Block a user