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