diff --git a/src/app/components/url-preview/UrlPreview.css.tsx b/src/app/components/url-preview/UrlPreview.css.tsx index ad34cc73f..e302be702 100644 --- a/src/app/components/url-preview/UrlPreview.css.tsx +++ b/src/app/components/url-preview/UrlPreview.css.tsx @@ -138,3 +138,209 @@ export const GenericFaviconImg = style([ 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, + }, +]); diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 7d984de72..e1a1cc740 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -23,7 +23,21 @@ const linkStyles = { color: color.Success.Main }; // 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 { try { @@ -51,6 +65,18 @@ function getYouTubeVideoId(url: string): string | 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 { try { 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 { if (getYouTubeVideoId(url) !== null) return 'youtube'; + if (getVimeoVideoId(url) !== null) return 'vimeo'; 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'; } @@ -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 }) { return ( + {label} + + ); +} + +// --------------------------------------------------------------------------- +// 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 description = prev['og:description'] ?? ''; @@ -112,20 +279,22 @@ function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) href={url} target="_blank" rel="noreferrer" - className={previewCss.YouTubeThumbnailWrapper} - aria-label={`Play on YouTube: ${title}`} + className={previewCss.MediaThumbnailWrapper} + aria-label={`Watch on ${siteBadgeLabel}: ${title}`} > {title} -
-
- + {showPlayButton !== false && ( +
+
+ +
-
+ )} - YouTube + {title && ( @@ -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 ( + + ); +} + +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 ( + + ); +} + function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { const title = prev['og:title'] ?? ''; 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 ( + <> + + {/* 𝕏 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'] ?? ''; + const artworkUrl = (prev['og:image'] as string | undefined) ?? ''; + const spotifyType = getSpotifyType(url) ?? 'track'; + const typeLabel = spotifyType.charAt(0).toUpperCase() + spotifyType.slice(1); + + return ( + <> + {artworkUrl ? ( + {title} + ) : ( + + + + )} + + + + + {title && ( + + {title} + + )} + {description && ( + + {description} + + )} + + + ); +} + +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'] ?? ''; + const thumbnailUrl = (prev['og:image'] as string | undefined) ?? ''; + + return ( + <> + {thumbnailUrl ? ( + + {title} + + ) : ( + + + + )} + + + + + {title && ( + + {title} + + )} + {description && ( + + {description} + + )} + + + ); +} + +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 ( + <> + + {/* Wikipedia "W" mark */} + + + + + + + {title && ( + + {title} + + )} + {description && ( + + {description} + + )} + + + ); +} + +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 ? ( + {title} + ) : ( + + {/* Discord logo (simple geometric mark) */} + + + )} + + + + Join Server + + {title && ( + + {title} + + )} + {description && ( + + {description} + + )} + + + ); +} + +function NpmCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { + const title = prev['og:title'] ?? ''; + const description = prev['og:description'] ?? ''; + + return ( + <> + + {/* npm logo — simple square mark */} + + + + + + + {title && ( + + {title} + + )} + {description && ( + + {description} + + )} + + + ); +} + +function StackOverflowCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { + const title = prev['og:title'] ?? ''; + const description = prev['og:description'] ?? ''; + + return ( + <> + + {/* Stack Overflow logo — simplified stack of bars */} + + + + + + + {title && ( + + {title} + + )} + {description && ( + + {description} + + )} + + + ); +} + +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 ? ( + {title} + ) : ( + + + + )} + + + + + {title && ( + + {title} + + )} + {description && ( + + {description} + + )} + + + ); +} + function GenericCard({ url, prev, @@ -312,39 +1097,61 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>( const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => { const variant = getCardVariant(url); - if (variant === 'youtube') { - return ; + switch (variant) { + case 'youtube': + return ; + case 'vimeo': + return ; + case 'github': + return ; + case 'twitter': + return ; + case 'reddit': + return ; + case 'spotify': + return ; + case 'twitch': + return ; + case 'steam': + return ; + case 'wikipedia': + return ; + case 'discord': + return ; + case 'npm': + return ; + case 'stackoverflow': + return ; + case 'imdb': + return ; + 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 ( + setViewer(true)} + viewer={viewer} + onCloseViewer={() => setViewer(false)} + thumbUrl={thumbUrl} + imgUrl={imgUrl} + /> + ); + } } - - if (variant === 'github') { - return ; - } - - // 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 ( - 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)