Files
cinny/src/app/components/url-preview/UrlPreview.css.tsx
T
Claude 4154cae55a
CI / Build & Quality Checks (push) Successful in 11m21s
CI / Trigger Desktop Build (push) Successful in 34s
fix(embeds): animate GIF previews; add Mixcloud/Deezer; misc embed fixes
GIF previews rendered but never played: Synapse's /thumbnail endpoint
flattens animated GIFs to a still first frame. GifCard and the generic OG
card now request the original via /download (no width/height) for GIFs, so
they animate. Guarded with shouldServeGifOriginal(): a matrix:image:size cap
(10 MB) keeps a huge self-hosted GIF on the frozen thumbnail, and the generic
card's eager <img> gains loading="lazy" (it was the one preview image missing
it) so originals stay off the wire until near the viewport.

Also adds Mixcloud + Deezer inline media embeds (iframe widgets via
parseMediaEmbed/MediaEmbedCard, matching the existing click-to-play pattern),
and fixes Deezer podcast links: they live at /show/<id>, not /podcast/<id>
(the latter 404s on Deezer's own oEmbed) — verified against the live API.

Reviewed by two agents; both findings (Deezer /show, GIF eager-load) fixed
and covered by tests. Desktop Tauri frame-src CSP updated separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 00:21:10 -04:00

987 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { keyframes, style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const UrlPreview = style([
DefaultReset,
{
// 25rem (=400px) on desktop, but shrink to fit narrow phones so a single
// card doesn't exceed the viewport (mirrors UrlPreviewWide's min()).
width: 'min(25rem, 92vw)',
minHeight: toRem(102),
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
overflow: 'hidden',
},
]);
// Wider, responsive card for interactive embeds (video players, tweets, posts)
// so the player chrome / tweet content isn't cramped or clipped. Defined after
// UrlPreview so its width wins the cascade.
export const UrlPreviewWide = style({
width: 'min(38rem, 94vw)',
});
// The Twitch/Twitter/TikTok-fallback cards lay their header/thumbnail out BESIDE
// the content as direct children of the UrlPreview flex row; on a phone that
// squeezes both. Stack them vertically on narrow viewports only. `UrlPreview`'s
// Box has no explicit direction (browser default row), so this override wins
// with nothing to compete against, and desktop (>750px) is unchanged.
export const StackOnMobile = style({
'@media': {
'(max-width: 750px)': {
flexDirection: 'column',
},
},
});
export const UrlPreviewImg = style([
DefaultReset,
{
width: toRem(100),
height: toRem(100),
objectFit: 'cover',
objectPosition: 'center',
flexShrink: 0,
overflow: 'hidden',
cursor: 'pointer',
':hover': {
filter: 'brightness(0.8)',
},
},
]);
export const UrlPreviewContent = style([
DefaultReset,
{
padding: config.space.S200,
},
]);
export const UrlPreviewDescription = style([
DefaultReset,
{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
},
]);
// YouTube card styles
export const YouTubeThumbnailWrapper = 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 YouTubeThumbnailImg = style([
DefaultReset,
{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center',
display: 'block',
},
]);
export const YouTubePlayOverlay = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
},
]);
export const YouTubePlayButton = 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',
},
]);
// GitHub card styles
export const GitHubIconWrapper = style([
DefaultReset,
{
flexShrink: 0,
width: toRem(72),
minHeight: toRem(72),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: color.SurfaceVariant.OnContainer,
opacity: 0.8,
backgroundColor: color.Surface.ContainerHover,
},
]);
// Generic card favicon styles
export const GenericFaviconWrapper = style([
DefaultReset,
{
display: 'inline-flex',
alignItems: 'center',
gap: config.space.S100,
verticalAlign: 'middle',
},
]);
export const GenericFaviconImg = style([
DefaultReset,
{
width: toRem(16),
height: toRem(16),
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',
},
]);
// ---------------------------------------------------------------------------
// Inline video embed (YouTube / Vimeo) — media-forward click-to-play facade
// ---------------------------------------------------------------------------
export const EmbedMediaLandscape = style([
DefaultReset,
{
position: 'relative',
width: '100%',
// 16:9 via the padding-top hack (56.25%), NOT `aspect-ratio`. An absolutely-
// positioned *replaced* element (the player <iframe>) collapses to its ~200px
// intrinsic size inside an `aspect-ratio` box; padding derives a definite height
// from the definite width so the iframe (inset:0) reliably fills it.
height: 0,
paddingTop: '56.25%',
overflow: 'hidden',
backgroundColor: color.Surface.Container,
selectors: {
'&:fullscreen': { width: '100vw', height: '100vh', paddingTop: 0 },
},
},
]);
export const EmbedMediaPortrait = style([
DefaultReset,
{
position: 'relative',
width: toRem(300),
maxWidth: '100%',
// 9:16 via the padding-top hack (177.78%) — see EmbedMediaLandscape.
height: 0,
paddingTop: '177.78%',
margin: '0 auto',
overflow: 'hidden',
backgroundColor: color.Surface.Container,
selectors: {
'&:fullscreen': { width: '100vw', height: '100vh', paddingTop: 0, margin: 0 },
},
},
]);
// Fills the aspect box; used for both the <button> facade and the <a> fallback.
export const EmbedFacade = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
display: 'block',
padding: 0,
border: 'none',
background: 'none',
cursor: 'pointer',
':hover': {
filter: 'brightness(0.85)',
},
selectors: {
'&:focus-visible': {
outline: `2px solid ${color.Primary.Main}`,
outlineOffset: '-2px',
},
},
},
]);
export const EmbedIframe = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
border: 0,
display: 'block',
},
]);
// Variable-height iframe that sizes itself (X/Twitter, Instagram, Reddit posts —
// height set inline). Capped ~550px (X/IG native max) + centered in the wide card.
export const EmbedIframeStatic = style([
DefaultReset,
{
width: '100%',
maxWidth: toRem(550),
margin: '0 auto',
border: 0,
display: 'block',
},
]);
export const EmbedPlaceholder = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#111111',
color: '#ffffff',
fontSize: toRem(28),
},
]);
// Audio embeds (Spotify / SoundCloud) — full-width, short fixed height (set inline).
export const EmbedMediaAudio = style([
DefaultReset,
{
position: 'relative',
width: '100%',
overflow: 'hidden',
backgroundColor: color.Surface.Container,
},
]);
// ---------------------------------------------------------------------------
// 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',
});
// ---------------------------------------------------------------------------
// Steam card — full-width header/banner image + official store widget iframe
// ---------------------------------------------------------------------------
export const SteamBannerWrapper = style([
DefaultReset,
{
position: 'relative',
display: 'block',
width: '100%',
// Steam header capsules are 460×215 (~2.14:1); news banners vary but crop
// fine to the same ratio.
aspectRatio: '460 / 215',
overflow: 'hidden',
flexShrink: 0,
backgroundColor: '#0e1520',
cursor: 'pointer',
':hover': {
filter: 'brightness(0.9)',
},
},
]);
export const SteamBannerImg = style([
DefaultReset,
{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center',
display: 'block',
},
]);
// The official Steam store widget is a compact banner (~646×190). Full-width,
// fixed height so the iframe doesn't collapse to its intrinsic size.
export const SteamWidget = style([
DefaultReset,
{
width: '100%',
height: toRem(190),
border: 0,
display: 'block',
borderRadius: config.radii.R300,
backgroundColor: '#1b2838',
marginTop: config.space.S100,
},
]);
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',
});
export const BadgeYouTubeShorts = style({
backgroundColor: '#FF0000',
color: '#ffffff',
});
export const BadgeTikTok = style({
backgroundColor: '#000000',
color: '#ffffff',
});
export const BadgeSoundCloud = style({
backgroundColor: '#ff5500',
color: '#ffffff',
});
export const BadgeDailymotion = style({
backgroundColor: '#0066dc',
color: '#ffffff',
});
export const BadgeStreamable = style({
backgroundColor: '#0f90fa',
color: '#ffffff',
});
export const BadgeAppleMusic = style({
backgroundColor: '#fa243c',
color: '#ffffff',
});
export const BadgeInstagram = style({
backgroundColor: '#e1306c',
color: '#ffffff',
});
export const BadgeBluesky = style({
backgroundColor: '#0085ff',
color: '#ffffff',
});
export const BadgeLoom = style({
backgroundColor: '#625df5',
color: '#ffffff',
});
export const BadgeKick = style({
backgroundColor: '#53fc18',
color: '#000000',
});
export const BadgeTidal = style({
backgroundColor: '#000000',
color: '#ffffff',
});
export const BadgeMixcloud = style({
backgroundColor: '#52aad8',
color: '#ffffff',
});
export const BadgeDeezer = style({
backgroundColor: '#a238ff',
color: '#ffffff',
});
// ---------------------------------------------------------------------------
// 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,
},
]);
// ---------------------------------------------------------------------------
// 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',
},
]);
// ---------------------------------------------------------------------------
// GIF card (Giphy / Tenor)
// ---------------------------------------------------------------------------
export const GifThumbnailWrapper = style([
DefaultReset,
{
position: 'relative',
width: '100%',
maxHeight: toRem(200),
overflow: 'hidden',
flexShrink: 0,
backgroundColor: color.Surface.Container,
cursor: 'pointer',
':hover': {
filter: 'brightness(0.9)',
},
},
]);
export const GifThumbnailImg = style([
DefaultReset,
{
width: '100%',
maxHeight: toRem(200),
objectFit: 'cover',
objectPosition: 'center',
display: 'block',
},
]);
export const GifBadge = style([
DefaultReset,
{
position: 'absolute',
top: config.space.S100,
right: config.space.S100,
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',
letterSpacing: '0.05em',
backgroundColor: 'rgba(0, 0, 0, 0.65)',
color: '#ffffff',
pointerEvents: 'none',
},
]);
export const BadgeGiphy = style({
backgroundColor: '#00c0c0',
color: '#ffffff',
});
export const BadgeTenor = style({
backgroundColor: '#0078d4',
color: '#ffffff',
});