feat: add embed sub-components (Generic, Video, Image, Rich) and dispatcher
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user