From 9e9b021611b967a9c474367e23fd4a7b3717460c Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 4 Jun 2026 09:42:26 -0400 Subject: [PATCH] feat(P2-7): deep link preview cards for TikTok, X, Twitch, Reddit, YT Shorts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YouTube Shorts: portrait 9:16 thumbnail, red Shorts badge, channel parse TikTok: portrait thumbnail, @user extract, caption parse (3 OG formats), hashtag chips, dark โ™ซ placeholder fallback Twitter/X: tweet text parse from all og:title formats, media image when og:image:width>=300, profile vs tweet URL distinction, ๐• SVG badge Twitch: live/clip/VOD detection, pulsing LIVE badge with CSS keyframes, game extraction from og:description, channel from URL Reddit: r/subreddit badge, u/author + upvote + comment count parsed from og:description, post thumbnail 80x60, redd.it short URL support Shared PortraitThumbnail (80x142) reused by TikTok + Shorts. All brand hex colors in CSS file only, never in TSX. Co-Authored-By: Claude Sonnet 4.6 --- .../components/url-preview/UrlPreview.css.tsx | 333 +++++- .../components/url-preview/UrlPreviewCard.tsx | 978 ++++++++++++++---- 2 files changed, 1087 insertions(+), 224 deletions(-) diff --git a/src/app/components/url-preview/UrlPreview.css.tsx b/src/app/components/url-preview/UrlPreview.css.tsx index e302be702..13581773e 100644 --- a/src/app/components/url-preview/UrlPreview.css.tsx +++ b/src/app/components/url-preview/UrlPreview.css.tsx @@ -1,4 +1,4 @@ -import { style } from '@vanilla-extract/css'; +import { keyframes, style } from '@vanilla-extract/css'; import { DefaultReset, color, config, toRem } from 'folds'; export const UrlPreview = style([ @@ -305,6 +305,16 @@ export const BadgeImdb = style({ color: '#000000', }); +export const BadgeYouTubeShorts = style({ + backgroundColor: '#FF0000', + color: '#ffffff', +}); + +export const BadgeTikTok = style({ + backgroundColor: '#000000', + color: '#ffffff', +}); + // --------------------------------------------------------------------------- // Twitch LIVE badge // --------------------------------------------------------------------------- @@ -344,3 +354,324 @@ export const IconWrapper = style([ backgroundColor: color.Surface.ContainerHover, }, ]); + +// --------------------------------------------------------------------------- +// Portrait thumbnail โ€” 9:16 for Shorts & TikTok +// --------------------------------------------------------------------------- + +export const PortraitThumbnail = style([ + DefaultReset, + { + position: 'relative', + flexShrink: 0, + width: toRem(80), + height: toRem(142), + borderRadius: config.radii.R300, + overflow: 'hidden', + cursor: 'pointer', + backgroundColor: color.Surface.Container, + + ':hover': { + filter: 'brightness(0.85)', + }, + }, +]); + +export const PortraitThumbnailImg = style([ + DefaultReset, + { + width: '100%', + height: '100%', + objectFit: 'cover', + objectPosition: 'center', + display: 'block', + }, +]); + +export const PortraitPlayOverlay = style([ + DefaultReset, + { + position: 'absolute', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + pointerEvents: 'none', + }, +]); + +export const PortraitPlayButton = style([ + DefaultReset, + { + width: toRem(36), + height: toRem(36), + borderRadius: '50%', + backgroundColor: 'rgba(0, 0, 0, 0.72)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: '#ffffff', + fontSize: toRem(16), + }, +]); + +export const PortraitPlaceholder = style([ + DefaultReset, + { + flexShrink: 0, + width: toRem(80), + height: toRem(142), + borderRadius: config.radii.R300, + overflow: 'hidden', + backgroundColor: '#111111', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: toRem(32), + }, +]); + +// --------------------------------------------------------------------------- +// Twitter / X card +// --------------------------------------------------------------------------- + +export const TweetBlock = style([ + DefaultReset, + { + backgroundColor: 'rgba(0,0,0,0.15)', + borderRadius: config.radii.R300, + padding: config.space.S200, + display: '-webkit-box', + WebkitLineClamp: 4, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + fontSize: toRem(13), + lineHeight: '1.5', + }, +]); + +export const TweetMediaImg = style([ + DefaultReset, + { + width: '100%', + maxHeight: toRem(200), + objectFit: 'cover', + objectPosition: 'center', + display: 'block', + borderRadius: config.radii.R300, + marginTop: config.space.S100, + }, +]); + +export const TwitterHeader = style([ + DefaultReset, + { + display: 'flex', + alignItems: 'center', + gap: config.space.S100, + padding: `${config.space.S100} ${config.space.S200}`, + paddingTop: config.space.S200, + }, +]); + +export const ProfileAvatarImg = style([ + DefaultReset, + { + width: toRem(36), + height: toRem(36), + borderRadius: '50%', + objectFit: 'cover', + objectPosition: 'center', + flexShrink: 0, + display: 'block', + }, +]); + +// --------------------------------------------------------------------------- +// Twitch card +// --------------------------------------------------------------------------- + +export const TwitchThumbnailWrapper = style([ + DefaultReset, + { + position: 'relative', + width: '100%', + maxWidth: toRem(240), + aspectRatio: '16 / 9', + overflow: 'hidden', + flexShrink: 0, + backgroundColor: '#0e0e10', + cursor: 'pointer', + + ':hover': { + filter: 'brightness(0.85)', + }, + }, +]); + +export const TwitchThumbnailImg = style([ + DefaultReset, + { + width: '100%', + height: '100%', + objectFit: 'cover', + objectPosition: 'center', + display: 'block', + }, +]); + +export const TwitchLiveOverlay = style([ + DefaultReset, + { + position: 'absolute', + top: config.space.S100, + right: config.space.S100, + display: 'flex', + alignItems: 'center', + gap: '4px', + backgroundColor: '#e91916', + color: '#ffffff', + borderRadius: config.radii.R300, + paddingLeft: config.space.S100, + paddingRight: config.space.S100, + paddingTop: '2px', + paddingBottom: '2px', + fontSize: toRem(11), + fontWeight: 700, + letterSpacing: '0.04em', + pointerEvents: 'none', + }, +]); + +const livePulseKeyframes = keyframes({ + '0%': { opacity: 1 }, + '100%': { opacity: 0.4 }, +}); + +export const LiveDot = style([ + DefaultReset, + { + width: '8px', + height: '8px', + borderRadius: '50%', + backgroundColor: '#ffffff', + animation: `${livePulseKeyframes} 1s ease-in-out infinite alternate`, + }, +]); + +export const BadgeTwitchPurple = style({ + backgroundColor: '#9146FF', + color: '#ffffff', +}); + +// --------------------------------------------------------------------------- +// Reddit card +// --------------------------------------------------------------------------- + +export const RedditPostLayout = style([ + DefaultReset, + { + display: 'flex', + gap: config.space.S200, + padding: config.space.S200, + alignItems: 'flex-start', + }, +]); + +export const RedditThumb = style([ + DefaultReset, + { + flexShrink: 0, + width: toRem(80), + height: toRem(60), + borderRadius: config.radii.R300, + objectFit: 'cover', + objectPosition: 'center', + display: 'block', + }, +]); + +export const RedditSubBadge = 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: '#FF4500', + color: '#ffffff', + letterSpacing: '0.02em', + }, +]); + +export const RedditUpvote = style([ + DefaultReset, + { + color: '#FF4500', + fontWeight: 700, + fontSize: toRem(12), + }, +]); + +export const RedditMeta = style([ + DefaultReset, + { + display: 'flex', + alignItems: 'center', + gap: config.space.S200, + marginTop: config.space.S100, + fontSize: toRem(12), + opacity: 0.75, + }, +]); + +// --------------------------------------------------------------------------- +// TikTok hashtag chip +// --------------------------------------------------------------------------- + +export const HashtagChip = style([ + DefaultReset, + { + display: 'inline-flex', + alignItems: 'center', + fontSize: toRem(11), + opacity: 0.7, + marginRight: '4px', + }, +]); + +// --------------------------------------------------------------------------- +// Shorts card header bar +// --------------------------------------------------------------------------- + +export const ShortsHeader = style([ + DefaultReset, + { + display: 'flex', + alignItems: 'center', + gap: config.space.S100, + padding: `${config.space.S100} ${config.space.S200}`, + backgroundColor: color.Surface.ContainerHover, + borderBottom: `1px solid ${color.SurfaceVariant.ContainerLine}`, + }, +]); + +// --------------------------------------------------------------------------- +// Side-by-side portrait layout (Shorts, TikTok) +// --------------------------------------------------------------------------- + +export const PortraitSideLayout = style([ + DefaultReset, + { + display: 'flex', + gap: config.space.S200, + padding: config.space.S200, + alignItems: 'flex-start', + }, +]); diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index e1a1cc740..5246ce05b 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -24,7 +24,9 @@ const linkStyles = { color: color.Success.Main }; // --------------------------------------------------------------------------- type CardVariant = + | 'youtube-shorts' | 'youtube' + | 'tiktok' | 'vimeo' | 'github' | 'twitter' @@ -55,7 +57,7 @@ function getYouTubeVideoId(url: string): string | null { if (pathname === '/watch') { return searchParams.get('v'); } - // youtube.com/shorts/ + // youtube.com/shorts/ โ€” handled by isYouTubeShorts const shortsMatch = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/); if (shortsMatch) return shortsMatch[1]; } @@ -65,6 +67,47 @@ function getYouTubeVideoId(url: string): string | null { return null; } +function isYouTubeShorts(url: string): boolean { + try { + const { hostname, pathname } = new URL(url); + if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return false; + return /^\/shorts\/[A-Za-z0-9_-]+/.test(pathname); + } catch { + return false; + } +} + +function getYoutubeShortsId(url: string): string | null { + try { + const { hostname, pathname } = new URL(url); + if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return null; + const m = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/); + return m ? m[1] : null; + } catch { + return null; + } +} + +function isTikTok(url: string): boolean { + try { + const { hostname } = new URL(url); + const h = hostname.replace(/^www\./, ''); + return h === 'tiktok.com' || h === 'vm.tiktok.com'; + } catch { + return false; + } +} + +function getTikTokUsername(url: string): string | null { + try { + const { pathname } = new URL(url); + const m = pathname.match(/\/((@[^/]+))/); + return m ? m[1] : null; + } catch { + return null; + } +} + function getVimeoVideoId(url: string): string | null { try { const parsed = new URL(url); @@ -100,18 +143,49 @@ function isTwitter(url: string): boolean { } } -function getRedditSubreddit(url: string): string | null { +function isTwitterTweet(url: string): boolean { 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; + if (h !== 'twitter.com' && h !== 'x.com') return false; + return /\/status\/\d+/.test(pathname); } catch { - return null; + return false; } } +function getRedditInfo(url: string): { + subreddit: string | null; + isPost: boolean; + isUser: boolean; + postId: string | null; +} { + try { + const { hostname, pathname } = new URL(url); + const h = hostname.replace(/^www\./, ''); + if (h !== 'reddit.com' && h !== 'redd.it') { + return { subreddit: null, isPost: false, isUser: false, postId: null }; + } + const subMatch = pathname.match(/^\/r\/([^/]+)/); + const subreddit = subMatch ? subMatch[1] : null; + const postMatch = pathname.match(/\/comments\/([^/]+)/); + const postId = postMatch ? postMatch[1] : null; + const userMatch = pathname.match(/^\/(u|user)\/([^/]+)/); + return { + subreddit, + isPost: !!postId, + isUser: !!userMatch, + postId, + }; + } catch { + return { subreddit: null, isPost: false, isUser: false, postId: null }; + } +} + +function getRedditSubreddit(url: string): string | null { + return getRedditInfo(url).subreddit; +} + function getSpotifyType(url: string): string | null { try { const { hostname, pathname } = new URL(url); @@ -123,10 +197,41 @@ function getSpotifyType(url: string): string | null { } } +function getTwitchType(url: string): 'live' | 'clip' | 'vod' | null { + try { + const { hostname, pathname } = new URL(url); + const h = hostname.replace(/^www\./, ''); + if (h === 'clips.twitch.tv') return 'clip'; + if (h !== 'twitch.tv') return null; + if (pathname.match(/\/[^/]+\/clip\//)) return 'clip'; + if (pathname.match(/\/[^/]+\/v(?:ideos?)?\//)) return 'vod'; + // Single path segment = channel + const parts = pathname.replace(/\/$/, '').split('/').filter(Boolean); + if (parts.length === 1) return 'live'; + return null; + } catch { + return null; + } +} + +function getTwitchChannel(url: string): string | null { + try { + const { hostname, pathname } = new URL(url); + const h = hostname.replace(/^www\./, ''); + if (h === 'clips.twitch.tv') return null; + if (h !== 'twitch.tv') return null; + const parts = pathname.replace(/\/$/, '').split('/').filter(Boolean); + return parts[0] ?? null; + } catch { + return null; + } +} + function isTwitch(url: string): boolean { try { const { hostname } = new URL(url); - return hostname === 'twitch.tv' || hostname === 'www.twitch.tv'; + const h = hostname.replace(/^www\./, ''); + return h === 'twitch.tv' || h === 'clips.twitch.tv'; } catch { return false; } @@ -196,7 +301,10 @@ function isImdb(url: string): boolean { } function getCardVariant(url: string): CardVariant { + // Shorts must be detected before generic youtube + if (isYouTubeShorts(url)) return 'youtube-shorts'; if (getYouTubeVideoId(url) !== null) return 'youtube'; + if (isTikTok(url)) return 'tiktok'; if (getVimeoVideoId(url) !== null) return 'vimeo'; if (isGitHubRepo(url)) return 'github'; if (isTwitter(url)) return 'twitter'; @@ -220,6 +328,82 @@ function getDomain(url: string): string { } } +// --------------------------------------------------------------------------- +// Parsing helpers for individual cards +// --------------------------------------------------------------------------- + +interface ParsedTweet { + name: string; + handle?: string; + text?: string; +} + +function parseTweetTitle(ogTitle: string): ParsedTweet { + // Pattern 1: "Name on X: \"tweet text\"" or "Name on Twitter: \"tweet text\"" + const onXMatch = ogTitle.match(/^(.+?) on (?:X|Twitter):\s*["""](.+)["""]?$/s); + if (onXMatch) return { name: onXMatch[1], text: onXMatch[2] }; + + // Pattern 2: "X / Name (@handle)" or "Twitter / Name (@handle)" + const xSlashMatch = ogTitle.match(/^(?:X|Twitter)\s*\/\s*(.+?)\s*\(@(.+?)\)/); + if (xSlashMatch) return { name: xSlashMatch[1], handle: xSlashMatch[2] }; + + // Pattern 3: "Name (@handle): text" or "Name (@handle)" + const handleMatch = ogTitle.match(/^(.+?)\s*\(@(.+?)\)(?::\s*(.+))?$/s); + if (handleMatch) return { name: handleMatch[1], handle: handleMatch[2], text: handleMatch[3] }; + + return { name: ogTitle }; +} + +function parseTikTokTitle(ogTitle: string): { username: string | null; caption: string | null } { + // Format 1: "@username on TikTok: "caption"" + const fmt1 = ogTitle.match(/^(@\S+)\s+on\s+TikTok:\s*["""](.+)["""]?$/s); + if (fmt1) return { username: fmt1[1], caption: fmt1[2] }; + + // Format 2: "username TikTok | caption" + const fmt2 = ogTitle.match(/^(.+?)\s+TikTok\s*\|\s*(.+)$/s); + if (fmt2) return { username: fmt2[1], caption: fmt2[2] }; + + // Format 3: just caption + return { username: null, caption: ogTitle || null }; +} + +function extractHashtags(text: string): string[] { + const found: string[] = []; + const re = /#\w+/g; + let m = re.exec(text); + while (m !== null && found.length < 5) { + found.push(m[0]); + m = re.exec(text); + } + return found; +} + +function parseRedditMeta(ogDescription: string): { + author: string | null; + upvotes: string | null; + comments: string | null; +} { + let author: string | null = null; + let upvotes: string | null = null; + let comments: string | null = null; + + const authorM = ogDescription.match(/Posted\s+by\s+u\/(\S+)/i); + if (authorM) author = authorM[1]; + + const pointsM = ogDescription.match(/([\d,.]+[kKmM]?)\s+(?:points?|upvotes?)/i); + if (pointsM) upvotes = pointsM[1]; + + const commentsM = ogDescription.match(/([\d,.]+[kKmM]?)\s+comments?/i); + if (commentsM) comments = commentsM[1]; + + return { author, upvotes, comments }; +} + +function parseTwitchGame(ogDescription: string): string | null { + const m = ogDescription.match(/play(?:ing)?\s+(.+?)(?:\s*[-โ€“โ€”]|\s*\d+\s+viewer|$)/i); + return m ? m[1].trim() : null; +} + // --------------------------------------------------------------------------- // Inline SVG logos // --------------------------------------------------------------------------- @@ -239,6 +423,21 @@ function GitHubIcon({ size = 24 }: { size?: number }) { ); } +function XLogoIcon({ size = 20 }: { size?: number }) { + return ( + + ); +} + // --------------------------------------------------------------------------- // Shared badge component // --------------------------------------------------------------------------- @@ -252,7 +451,7 @@ function SiteBadge({ label, colorClass }: { label: string; colorClass: string }) } // --------------------------------------------------------------------------- -// Shared VideoCard โ€” used by YouTube and Vimeo +// Shared VideoCard โ€” used by YouTube (non-Shorts) and Vimeo // --------------------------------------------------------------------------- function VideoCard({ @@ -325,7 +524,545 @@ function VideoCard({ } // --------------------------------------------------------------------------- -// Card variant renderers +// Card 1: YouTube Shorts +// --------------------------------------------------------------------------- + +function YouTubeShortsCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { + const videoId = getYoutubeShortsId(url) ?? ''; + const thumbnailSrc = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`; + const title = prev['og:title'] ?? ''; + // YouTube Shorts og:description is often the channel name + const rawDescription = (prev['og:description'] as string | undefined) ?? ''; + const channelName = rawDescription.length < 80 ? rawDescription : null; + + return ( + <> + {/* Header bar with Shorts badge */} +
+ + + YouTube + +
+ {/* Side-by-side layout */} +
+ + {title} +
+
โ–ถ
+
+
+ + {title && ( + + {title} + + )} + {channelName && ( + + {channelName} + + )} + + youtube.com + + +
+ + ); +} + +// --------------------------------------------------------------------------- +// Card 2: TikTok +// --------------------------------------------------------------------------- + +function TikTokCard({ + url, + prev, + mx, + useAuthentication, +}: { + url: string; + prev: IPreviewUrlResponse; + mx: ReturnType; + useAuthentication: boolean; +}) { + const rawTitle = (prev['og:title'] as string | undefined) ?? ''; + const rawDescription = (prev['og:description'] as string | undefined) ?? ''; + const mxcImage = prev['og:image'] as string | undefined; + + const thumbSrc = mxcImage + ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 160, 284, 'scale', false) + : null; + + const { username: parsedUsername, caption: parsedCaption } = parseTikTokTitle(rawTitle); + const usernameFromUrl = getTikTokUsername(url); + const username = parsedUsername ?? usernameFromUrl; + const caption = parsedCaption ?? rawDescription; + + // Extract hashtags from caption and description combined + const hashtagSource = `${caption ?? ''} ${rawDescription}`; + const hashtags = extractHashtags(hashtagSource); + + // Strip hashtags from caption display + const captionDisplay = caption ? caption.replace(/#\w+/g, '').trim() : ''; + + return ( + <> + {/* Header */} +
+ {/* TikTok badge with musical note icon */} + + โ™ซ + TikTok + + {username && ( + + {username} + + )} + + + tiktok.com + +
+ {/* Body */} +
+ {thumbSrc ? ( + + {captionDisplay} +
+
โ–ถ
+
+
+ ) : ( +
+ โ™ซ +
+ )} + + {captionDisplay && ( + + {captionDisplay} + + )} + {hashtags.length > 0 && ( +
+ {hashtags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+
+ + ); +} + +// --------------------------------------------------------------------------- +// Card 3: Twitter / X +// --------------------------------------------------------------------------- + +function TwitterCard({ + url, + prev, + mx, + useAuthentication, +}: { + url: string; + prev: IPreviewUrlResponse; + mx: ReturnType; + useAuthentication: boolean; +}) { + const rawTitle = (prev['og:title'] as string | undefined) ?? ''; + const rawDescription = (prev['og:description'] as string | undefined) ?? ''; + const mxcImage = prev['og:image'] as string | undefined; + const imageWidth = prev['og:image:width'] as number | undefined; + + const isTweet = isTwitterTweet(url); + const { name, handle, text } = parseTweetTitle(rawTitle); + + const tweetText = text ?? (isTweet ? rawDescription : undefined); + + // Show media image if og:image is wide (>= 300px or unspecified with a tweet URL) + const showMedia = !!mxcImage && (imageWidth === undefined || imageWidth >= 300) && isTweet; + const mediaThumbSrc = showMedia + ? mxcUrlToHttp(mx, mxcImage!, useAuthentication, 400, 200, 'scale', false) + : null; + + return ( + <> + {/* Header row */} +
+ + + {name} + + {handle && ( + + @{handle} + + )} + + +
+ + + {isTweet && tweetText ? ( +
{tweetText}
+ ) : ( + !isTweet && + rawDescription && ( + + {rawDescription} + + ) + )} + + {mediaThumbSrc && ( + {tweetText + )} + + + {getDomain(url)} + +
+ + ); +} + +// --------------------------------------------------------------------------- +// Card 4: Twitch +// --------------------------------------------------------------------------- + +function TwitchCard({ + url, + prev, + mx, + useAuthentication, +}: { + url: string; + prev: IPreviewUrlResponse; + mx: ReturnType; + useAuthentication: boolean; +}) { + const title = (prev['og:title'] as string | undefined) ?? ''; + const description = (prev['og:description'] as string | undefined) ?? ''; + const mxcImage = prev['og:image'] as string | undefined; + + const twitchType = getTwitchType(url) ?? 'live'; + const channel = getTwitchChannel(url); + const isLive = twitchType === 'live'; + + const game = description ? parseTwitchGame(description) : null; + + const thumbSrc = mxcImage + ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 480, 270, 'scale', false) + : null; + + return ( + <> + {/* Thumbnail */} + {thumbSrc ? ( + + {title} + {isLive ? ( +
+
+ LIVE +
+ ) : ( +
+
+ +
+
+ )} +
+ ) : ( + + + + )} + + {/* Content */} + + + + {channel && ( + + {channel} + + )} + {isLive && ( + + LIVE + + )} + + {title && ( + + {title} + + )} + {game && ( + + ๐ŸŽฎ {game} + + )} + {!game && description && ( + + {description} + + )} + + + ); +} + +// --------------------------------------------------------------------------- +// Card 5: Reddit +// --------------------------------------------------------------------------- + +function RedditCard({ + url, + prev, + mx, + useAuthentication, +}: { + url: string; + prev: IPreviewUrlResponse; + mx: ReturnType; + useAuthentication: boolean; +}) { + const title = (prev['og:title'] as string | undefined) ?? ''; + const description = (prev['og:description'] as string | undefined) ?? ''; + const mxcImage = prev['og:image'] as string | undefined; + + const { subreddit, isPost, isUser } = getRedditInfo(url); + const { author, upvotes, comments } = parseRedditMeta(description); + + // Only render post thumbnail if we have a real image + const thumbSrc = + mxcImage && isPost + ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 160, 120, 'scale', false) + : null; + + // Text preview: strip "Posted by u/..." metadata prefix + const textPreview = description + .replace(/Posted\s+by\s+u\/\S+\s*[ยทโ€ข]?\s*/i, '') + .replace(/\s*\d+[\d,.]*(k|M)?\s+(points?|upvotes?)\s*[ยทโ€ข]?\s*/gi, '') + .replace(/\s*\d+[\d,.]*(k|M)?\s+comments?\s*/gi, '') + .trim(); + + if (isUser) { + // Simple user card + return ( + <> + + + + + + + + {title && ( + + {title} + + )} + {description && ( + + {description} + + )} + + + ); + } + + return ( + + {/* Meta header */} + + {subreddit && r/{subreddit}} + {author && ( + + ยท Posted by u/{author} + + )} + + + {/* Main content row */} +
+ + {title && ( + + {title} + + )} + {textPreview && textPreview !== title && ( + + {textPreview} + + )} + {/* Stats row */} +
+ {upvotes && โ–ฒ {upvotes}} + {comments && ๐Ÿ’ฌ {comments}} +
+
+ + {thumbSrc && ( + {title} + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Remaining card variants (unchanged from original) // --------------------------------------------------------------------------- function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { @@ -410,133 +1147,6 @@ 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 ( - <> - - {/* ๐• mark */} - - - - - - - {displayName && ( - - {displayName} - - )} - {tweetText && ( - - {tweetText} - - )} - - - ); -} - -function RedditCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { - const title = prev['og:title'] ?? ''; - const description = prev['og:description'] ?? ''; - const subreddit = getRedditSubreddit(url); - - return ( - <> - - {/* Simplified Reddit "R" mark */} - - - - - - {subreddit && r/{subreddit}} - - {title && ( - - {title} - - )} - {description && ( - - {description} - - )} - - - ); -} - function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { const title = prev['og:title'] ?? ''; const description = prev['og:description'] ?? ''; @@ -586,90 +1196,6 @@ function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) ); } -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 ? ( - - {title} - {isLive && ( -
- LIVE -
- )} -
- ) : ( - - {/* Simple Twitch "T" mark */} - - - )} - - - - {isLive && ( - - LIVE - - )} - - {title && ( - - {title} - - )} - {description && ( - - {description} - - )} - - - ); -} - function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { const title = prev['og:title'] ?? ''; const description = prev['og:description'] ?? ''; @@ -1098,20 +1624,26 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>( const variant = getCardVariant(url); switch (variant) { + case 'youtube-shorts': + return ; case 'youtube': return ; + case 'tiktok': + return ; case 'vimeo': return ; case 'github': return ; case 'twitter': - return ; + return ( + + ); case 'reddit': - return ; + return ; case 'spotify': return ; case 'twitch': - return ; + return ; case 'steam': return ; case 'wikipedia':