feat: add embed sub-components (Generic, Video, Image, Rich) and dispatcher

This commit is contained in:
Jannis Braun
2026-03-20 23:45:32 +01:00
parent a800d9ff25
commit 5a670bc6ad
5 changed files with 315 additions and 0 deletions
@@ -0,0 +1,49 @@
import React from 'react';
import type { Embed } from '@backspace/shared';
import { GenericEmbed } from './embeds/GenericEmbed';
import { VideoEmbed } from './embeds/VideoEmbed';
import { ImageEmbed } from './embeds/ImageEmbed';
import { RichEmbed } from './embeds/RichEmbed';
interface EmbedRendererProps {
embed: Embed;
}
export function EmbedRenderer({ embed }: EmbedRendererProps) {
switch (embed.embedType) {
case 'video':
return <VideoEmbed embed={embed} />;
case 'image':
return <ImageEmbed embed={embed} />;
case 'audio':
return (
<div className="mt-2 max-w-[520px] bg-surface-channel rounded-[4px] border-l-4 border-border-hard p-3">
{embed.title && (
<a
href={embed.url}
target="_blank"
rel="noopener noreferrer"
className="text-[14px] text-sky-400 font-semibold hover:underline block mb-2 truncate"
>
{embed.title}
</a>
)}
<audio
src={embed.url}
controls
preload="metadata"
className="w-full"
/>
</div>
);
case 'rich':
return <RichEmbed embed={embed} />;
case 'generic':
default:
return <GenericEmbed embed={embed} />;
}
}
@@ -0,0 +1,55 @@
import React from 'react';
import type { Embed } from '@backspace/shared';
interface GenericEmbedProps {
embed: Embed;
}
function hostnameFromUrl(url: string): string | null {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return null;
}
}
export function GenericEmbed({ embed }: GenericEmbedProps) {
if (!embed.title) return null;
const providerName = embed.provider ?? hostnameFromUrl(embed.url);
return (
<div className="mt-2 max-w-[520px] bg-surface-channel rounded-[4px] border-l-4 border-border-hard flex overflow-hidden">
<div className="flex-1 p-3 min-w-0">
{providerName && (
<div className="text-[12px] text-txt-muted font-medium mb-1 truncate">
{providerName}
</div>
)}
<a
href={embed.url}
target="_blank"
rel="noopener noreferrer"
className="text-[16px] text-sky-400 font-semibold hover:underline block mb-2 truncate"
>
{embed.title}
</a>
{embed.description && (
<div className="text-[14px] text-txt-message leading-[1.125rem] line-clamp-3">
{embed.description}
</div>
)}
</div>
{embed.image && (
<div className="w-[80px] h-[80px] m-3 flex-shrink-0">
<img
src={embed.image}
alt=""
className="w-full h-full object-cover rounded-[4px]"
referrerPolicy="no-referrer"
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,25 @@
import React from 'react';
import type { Embed } from '@backspace/shared';
import { useUIStore } from '../../../stores/uiStore';
interface ImageEmbedProps {
embed: Embed;
}
export function ImageEmbed({ embed }: ImageEmbedProps) {
const openImagePreview = useUIStore((s) => s.openImagePreview);
const imageUrl = embed.image ?? embed.url;
return (
<div className="mt-2">
<img
src={imageUrl}
alt={embed.title ?? ''}
className="max-w-[520px] max-h-[350px] object-contain rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
loading="lazy"
referrerPolicy="no-referrer"
onClick={() => openImagePreview(imageUrl)}
/>
</div>
);
}
@@ -0,0 +1,90 @@
import React, { useState } from 'react';
import type { Embed } from '@backspace/shared';
interface RichEmbedProps {
embed: Embed;
}
const PROVIDER_HEIGHTS: Record<string, number> = {
spotify: 152,
};
function getIframeHeight(embed: Embed): number {
if (embed.height != null) return embed.height;
if (embed.provider != null) {
const providerHeight = PROVIDER_HEIGHTS[embed.provider];
if (providerHeight != null) return providerHeight;
}
return 200;
}
export function RichEmbed({ embed }: RichEmbedProps) {
const [isLoaded, setIsLoaded] = useState(false);
const iframeHeight = getIframeHeight(embed);
const providerLabel = embed.provider
? embed.provider.charAt(0).toUpperCase() + embed.provider.slice(1)
: null;
if (!embed.embedUrl) return null;
return (
<div className="mt-2 max-w-[520px] rounded-lg overflow-hidden bg-surface-channel border border-white/[0.06]">
{isLoaded ? (
<iframe
src={embed.embedUrl}
title={embed.title ?? providerLabel ?? 'Embed'}
height={iframeHeight}
className="w-full block"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
sandbox="allow-scripts allow-same-origin allow-popups"
referrerPolicy="no-referrer"
loading="lazy"
/>
) : (
<button
type="button"
onClick={() => setIsLoaded(true)}
className="w-full text-left focus:outline-none group"
aria-label={`Load ${providerLabel ?? 'embed'}`}
>
<div className="flex items-start gap-3 p-3">
{embed.image && (
<div className="w-[80px] h-[80px] flex-shrink-0">
<img
src={embed.image}
alt=""
className="w-full h-full object-cover rounded"
referrerPolicy="no-referrer"
/>
</div>
)}
<div className="flex-1 min-w-0">
{providerLabel && (
<div className="text-[12px] text-txt-muted font-medium mb-1">
{providerLabel}
</div>
)}
{embed.title && (
<div className="text-[14px] text-sky-400 font-semibold group-hover:underline line-clamp-2">
{embed.title}
</div>
)}
{embed.description && (
<div className="text-[13px] text-txt-muted mt-1 line-clamp-2">
{embed.description}
</div>
)}
<div className="text-[12px] text-txt-muted mt-2 flex items-center gap-1.5">
<svg viewBox="0 0 24 24" fill="currentColor" className="w-3.5 h-3.5">
<path d="M8 5v14l11-7z" />
</svg>
Click to load
</div>
</div>
</div>
</button>
)}
</div>
);
}
@@ -0,0 +1,96 @@
import React, { useState } from 'react';
import type { Embed } from '@backspace/shared';
interface VideoEmbedProps {
embed: Embed;
}
export function VideoEmbed({ embed }: VideoEmbedProps) {
const [isPlaying, setIsPlaying] = useState(false);
// Direct video URL — no provider, no embedUrl
if (!embed.provider && !embed.embedUrl) {
return (
<div className="mt-2 max-w-[520px]">
<video
src={embed.url}
controls
preload="none"
className="w-full rounded-lg bg-black"
/>
</div>
);
}
// Provider iframe (YouTube / Vimeo)
const providerLabel = embed.provider
? embed.provider.charAt(0).toUpperCase() + embed.provider.slice(1)
: null;
return (
<div className="mt-2 max-w-[520px] rounded-lg overflow-hidden bg-surface-channel border border-white/[0.06]">
{/* 16:9 video area */}
<div className="relative w-full" style={{ paddingBottom: '56.25%' }}>
{isPlaying && embed.embedUrl ? (
<iframe
className="absolute inset-0 w-full h-full"
src={`${embed.embedUrl}?autoplay=1`}
title={embed.title ?? 'Video'}
allow="autoplay; fullscreen; picture-in-picture"
sandbox="allow-scripts allow-same-origin allow-popups"
referrerPolicy="no-referrer"
/>
) : (
<button
type="button"
onClick={() => setIsPlaying(true)}
className="absolute inset-0 w-full h-full flex items-center justify-center group focus:outline-none"
aria-label={`Play ${embed.title ?? 'video'}`}
>
{embed.image ? (
<img
src={embed.image}
alt=""
className="absolute inset-0 w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
) : (
<div className="absolute inset-0 bg-black/60" />
)}
{/* Play button — glass-bubble style */}
<span className="glass-bubble relative z-10 flex items-center justify-center w-14 h-14 rounded-full group-hover:scale-110 transition-transform duration-150">
<svg
viewBox="0 0 24 24"
fill="currentColor"
className="w-6 h-6 text-white ml-1"
>
<path d="M8 5v14l11-7z" />
</svg>
</span>
</button>
)}
</div>
{/* Title / provider footer */}
{(embed.title || providerLabel) && (
<div className="px-3 py-2">
{providerLabel && (
<div className="text-[12px] text-txt-muted font-medium mb-0.5">
{providerLabel}
</div>
)}
{embed.title && (
<a
href={embed.url}
target="_blank"
rel="noopener noreferrer"
className="text-[14px] text-sky-400 font-semibold hover:underline line-clamp-2"
>
{embed.title}
</a>
)}
</div>
)}
</div>
);
}