From 5a670bc6ad6ea6835f501d79afd79330b182b0a9 Mon Sep 17 00:00:00 2001
From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com>
Date: Fri, 20 Mar 2026 23:45:32 +0100
Subject: [PATCH] feat: add embed sub-components (Generic, Video, Image, Rich)
and dispatcher
---
.../web/src/components/chat/EmbedRenderer.tsx | 49 ++++++++++
.../components/chat/embeds/GenericEmbed.tsx | 55 +++++++++++
.../src/components/chat/embeds/ImageEmbed.tsx | 25 +++++
.../src/components/chat/embeds/RichEmbed.tsx | 90 +++++++++++++++++
.../src/components/chat/embeds/VideoEmbed.tsx | 96 +++++++++++++++++++
5 files changed, 315 insertions(+)
create mode 100644 packages/web/src/components/chat/EmbedRenderer.tsx
create mode 100644 packages/web/src/components/chat/embeds/GenericEmbed.tsx
create mode 100644 packages/web/src/components/chat/embeds/ImageEmbed.tsx
create mode 100644 packages/web/src/components/chat/embeds/RichEmbed.tsx
create mode 100644 packages/web/src/components/chat/embeds/VideoEmbed.tsx
diff --git a/packages/web/src/components/chat/EmbedRenderer.tsx b/packages/web/src/components/chat/EmbedRenderer.tsx
new file mode 100644
index 00000000..440941b5
--- /dev/null
+++ b/packages/web/src/components/chat/EmbedRenderer.tsx
@@ -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 ;
+
+ case 'image':
+ return ;
+
+ case 'audio':
+ return (
+
+ );
+
+ case 'rich':
+ return ;
+
+ case 'generic':
+ default:
+ return ;
+ }
+}
diff --git a/packages/web/src/components/chat/embeds/GenericEmbed.tsx b/packages/web/src/components/chat/embeds/GenericEmbed.tsx
new file mode 100644
index 00000000..586ac8bd
--- /dev/null
+++ b/packages/web/src/components/chat/embeds/GenericEmbed.tsx
@@ -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 (
+
+
+ {providerName && (
+
+ {providerName}
+
+ )}
+
+ {embed.title}
+
+ {embed.description && (
+
+ {embed.description}
+
+ )}
+
+ {embed.image && (
+
+

+
+ )}
+
+ );
+}
diff --git a/packages/web/src/components/chat/embeds/ImageEmbed.tsx b/packages/web/src/components/chat/embeds/ImageEmbed.tsx
new file mode 100644
index 00000000..a0ff8075
--- /dev/null
+++ b/packages/web/src/components/chat/embeds/ImageEmbed.tsx
@@ -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 (
+
+

openImagePreview(imageUrl)}
+ />
+
+ );
+}
diff --git a/packages/web/src/components/chat/embeds/RichEmbed.tsx b/packages/web/src/components/chat/embeds/RichEmbed.tsx
new file mode 100644
index 00000000..eb0aa7f9
--- /dev/null
+++ b/packages/web/src/components/chat/embeds/RichEmbed.tsx
@@ -0,0 +1,90 @@
+import React, { useState } from 'react';
+import type { Embed } from '@backspace/shared';
+
+interface RichEmbedProps {
+ embed: Embed;
+}
+
+const PROVIDER_HEIGHTS: Record = {
+ 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 (
+
+ {isLoaded ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/packages/web/src/components/chat/embeds/VideoEmbed.tsx b/packages/web/src/components/chat/embeds/VideoEmbed.tsx
new file mode 100644
index 00000000..68213854
--- /dev/null
+++ b/packages/web/src/components/chat/embeds/VideoEmbed.tsx
@@ -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 (
+
+
+
+ );
+ }
+
+ // Provider iframe (YouTube / Vimeo)
+ const providerLabel = embed.provider
+ ? embed.provider.charAt(0).toUpperCase() + embed.provider.slice(1)
+ : null;
+
+ return (
+
+ {/* 16:9 video area */}
+
+ {isPlaying && embed.embedUrl ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Title / provider footer */}
+ {(embed.title || providerLabel) && (
+
+ {providerLabel && (
+
+ {providerLabel}
+
+ )}
+ {embed.title && (
+
+ {embed.title}
+
+ )}
+
+ )}
+
+ );
+}