From fe30f9e9b2d28f77c786fab584c3cdab07e69d8e Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Fri, 13 Jun 2025 18:13:43 -0400 Subject: [PATCH] Youtube embeds too --- src/lib/components/DynamicPostContent.svelte | 24 ++ .../edra/extensions/url-embed/UrlEmbed.ts | 27 +- .../components/UrlEmbedExtended.svelte | 282 ++++++++++++++---- .../components/UrlEmbedPlaceholder.svelte | 25 +- src/lib/utils/content.ts | 35 +++ src/routes/api/og-metadata/+server.ts | 84 ++++++ 6 files changed, 393 insertions(+), 84 deletions(-) diff --git a/src/lib/components/DynamicPostContent.svelte b/src/lib/components/DynamicPostContent.svelte index 845599f..441bde7 100644 --- a/src/lib/components/DynamicPostContent.svelte +++ b/src/lib/components/DynamicPostContent.svelte @@ -398,6 +398,30 @@ overflow: hidden; } + // YouTube embed styles + :global(.url-embed-youtube) { + margin: $unit-3x 0; + border-radius: $card-corner-radius; + overflow: hidden; + background: $grey-95; + } + + :global(.youtube-embed-wrapper) { + position: relative; + padding-bottom: 56.25%; // 16:9 aspect ratio + height: 0; + overflow: hidden; + } + + :global(.youtube-embed-wrapper iframe) { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; + } + // Mobile styles for URL embeds @media (max-width: 640px) { :global(.url-embed-link) { diff --git a/src/lib/components/edra/extensions/url-embed/UrlEmbed.ts b/src/lib/components/edra/extensions/url-embed/UrlEmbed.ts index 42205b5..e52b7dd 100644 --- a/src/lib/components/edra/extensions/url-embed/UrlEmbed.ts +++ b/src/lib/components/edra/extensions/url-embed/UrlEmbed.ts @@ -100,8 +100,8 @@ export const UrlEmbed = Node.create({ }, convertLinkToEmbed: (pos) => - ({ state, dispatch }) => { - const { doc, schema, tr } = state + ({ state, commands, chain }) => { + const { doc } = state // Find the link mark at the given position const $pos = doc.resolve(pos) @@ -131,15 +131,20 @@ export const UrlEmbed = Node.create({ } }) - // Create the embed node - const node = schema.nodes.urlEmbedPlaceholder.create({ url }) - - // Replace the range with the embed - if (dispatch) { - dispatch(tr.replaceRangeWith(from, to, node)) - } - - return true + // Use Tiptap's chain commands to replace content + return chain() + .focus() + .deleteRange({ from, to }) + .insertContent([ + { + type: 'urlEmbedPlaceholder', + attrs: { url } + }, + { + type: 'paragraph' + } + ]) + .run() } } }, diff --git a/src/lib/components/edra/headless/components/UrlEmbedExtended.svelte b/src/lib/components/edra/headless/components/UrlEmbedExtended.svelte index d0c208c..d43cce3 100644 --- a/src/lib/components/edra/headless/components/UrlEmbedExtended.svelte +++ b/src/lib/components/edra/headless/components/UrlEmbedExtended.svelte @@ -5,12 +5,31 @@ import EmbedContextMenu from './EmbedContextMenu.svelte' const { editor, node, deleteNode, getPos, selected }: NodeViewProps = $props() - + let loading = $state(false) let showActions = $state(false) let showContextMenu = $state(false) let contextMenuPosition = $state({ x: 0, y: 0 }) - + + // Check if this is a YouTube URL + const isYouTube = $derived(/(?:youtube\.com|youtu\.be)/.test(node.attrs.url || '')) + + // Extract video ID from YouTube URL + const getYouTubeVideoId = (url: string): string | null => { + const patterns = [ + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/, + /youtube\.com\/watch\?.*v=([^&\n?#]+)/ + ] + + for (const pattern of patterns) { + const match = url.match(pattern) + if (match && match[1]) { + return match[1] + } + } + return null + } + const getDomain = (url: string) => { try { const urlObj = new URL(url) @@ -19,7 +38,7 @@ return '' } } - + const decodeHtmlEntities = (text: string) => { if (!text) return '' const textarea = document.createElement('textarea') @@ -32,13 +51,15 @@ loading = true try { - const response = await fetch(`/api/og-metadata?url=${encodeURIComponent(node.attrs.url)}&refresh=true`) + const response = await fetch( + `/api/og-metadata?url=${encodeURIComponent(node.attrs.url)}&refresh=true` + ) if (!response.ok) { throw new Error('Failed to fetch metadata') } const metadata = await response.json() - + // Update the node attributes const pos = getPos() if (typeof pos === 'number') { @@ -72,20 +93,20 @@ deleteNode() } } - + function convertToLink() { const pos = getPos() if (typeof pos !== 'number') return - + // Get the URL and title const url = node.attrs.url if (!url) { console.error('No URL found in embed node') return } - + const text = node.attrs.title || url - + // Delete the embed node and insert a link editor .chain() @@ -107,10 +128,10 @@ }) .run() } - + function handleContextMenu(event: MouseEvent) { if (!editor.isEditable) return - + event.preventDefault() contextMenuPosition = { x: event.clientX, @@ -118,75 +139,128 @@ } showContextMenu = true } - + function copyLink() { if (node.attrs.url) { navigator.clipboard.writeText(node.attrs.url) } showContextMenu = false } - + function dismissContextMenu() { showContextMenu = false } - -
showActions = true} - onmouseleave={() => showActions = false} - onkeydown={handleKeydown} - oncontextmenu={handleContextMenu} - tabindex="0" - role="article" - > - {#if showActions && editor.isEditable} -
- -
- {/if} - -
{/if} -
-
- {#if node.attrs.favicon} - - {/if} - {node.attrs.siteName ? decodeHtmlEntities(node.attrs.siteName) : getDomain(node.attrs.url)} + + {#if videoId} +
+
- {#if node.attrs.title} -

{decodeHtmlEntities(node.attrs.title)}

+ {:else} +
+

Invalid YouTube URL

+
+ {/if} +
+ {:else} +
(showActions = true)} + onmouseleave={() => (showActions = false)} + onkeydown={handleKeydown} + oncontextmenu={handleContextMenu} + tabindex="0" + role="article" + > + {#if showActions && editor.isEditable} +
+ +
+ {/if} + +
- -
+
+
+ {#if node.attrs.favicon} + + {/if} + {node.attrs.siteName + ? decodeHtmlEntities(node.attrs.siteName) + : getDomain(node.attrs.url)} +
+ {#if node.attrs.title} +

{decodeHtmlEntities(node.attrs.title)}

+ {/if} + {#if node.attrs.description} +

{decodeHtmlEntities(node.attrs.description)}

+ {/if} +
+ + + {/if}
{#if showContextMenu} @@ -387,6 +461,88 @@ animation: spin 1s linear infinite; } + /* YouTube embed styles */ + .edra-youtube-embed-card { + position: relative; + width: 100%; + margin: 0 auto; + } + + .edra-youtube-embed-actions { + position: absolute; + top: 0.5rem; + right: 0.5rem; + display: flex; + gap: 0.25rem; + background: white; + padding: 0.25rem; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + z-index: 10; + } + + .edra-youtube-embed-action-button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + color: $grey-40; + + &:hover { + background: $grey-95; + color: $grey-20; + } + + svg { + width: 16px; + height: 16px; + } + } + + .edra-youtube-embed-player { + position: relative; + padding-bottom: 56.25%; // 16:9 aspect ratio + height: 0; + overflow: hidden; + background: $grey-95; + border-radius: $corner-radius; + border: 1px solid $grey-85; + + iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; + border-radius: $corner-radius; + } + } + + .edra-youtube-embed-error { + padding: 3rem; + text-align: center; + background: $grey-95; + border: 1px solid $grey-85; + border-radius: $corner-radius; + color: $grey-40; + } + + .edra-url-embed-wrapper.selected { + .edra-youtube-embed-player, + .edra-youtube-embed-error { + border-color: $primary-color; + box-shadow: 0 0 0 3px rgba($primary-color, 0.1); + } + } + /* Mobile styles */ @media (max-width: 640px) { .edra-url-embed-content { @@ -398,4 +554,4 @@ height: 200px; } } - \ No newline at end of file + diff --git a/src/lib/components/edra/headless/components/UrlEmbedPlaceholder.svelte b/src/lib/components/edra/headless/components/UrlEmbedPlaceholder.svelte index acdbc5e..b1aed7b 100644 --- a/src/lib/components/edra/headless/components/UrlEmbedPlaceholder.svelte +++ b/src/lib/components/edra/headless/components/UrlEmbedPlaceholder.svelte @@ -35,17 +35,22 @@ .focus() .insertContentAt( { from: pos, to: pos + node.nodeSize }, - { - type: 'urlEmbed', - attrs: { - url: url, - title: metadata.title, - description: metadata.description, - image: metadata.image, - favicon: metadata.favicon, - siteName: metadata.siteName + [ + { + type: 'urlEmbed', + attrs: { + url: url, + title: metadata.title, + description: metadata.description, + image: metadata.image, + favicon: metadata.favicon, + siteName: metadata.siteName + } + }, + { + type: 'paragraph' } - } + ] ) .run() } diff --git a/src/lib/utils/content.ts b/src/lib/utils/content.ts index 886d189..90f2f7f 100644 --- a/src/lib/utils/content.ts +++ b/src/lib/utils/content.ts @@ -175,6 +175,41 @@ function renderTiptapContent(doc: any): string { } } + // Helper to extract YouTube video ID + const getYouTubeVideoId = (url: string): string | null => { + const patterns = [ + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/, + /youtube\.com\/watch\?.*v=([^&\n?#]+)/ + ] + + for (const pattern of patterns) { + const match = url.match(pattern) + if (match && match[1]) { + return match[1] + } + } + return null + } + + // Check if it's a YouTube URL + const isYouTube = /(?:youtube\.com|youtu\.be)/.test(url) + const videoId = isYouTube ? getYouTubeVideoId(url) : null + + if (isYouTube && videoId) { + // Render YouTube embed + let embedHtml = '
' + embedHtml += '
' + embedHtml += `