From b1ddedd58664db3bdf901894e27ed762efc33582 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Fri, 13 Jun 2025 17:29:29 -0400 Subject: [PATCH 1/4] Embed URL cards --- prd/PRD-url-embed-functionality.md | 183 ++++++++ src/lib/components/DynamicPostContent.svelte | 100 +++++ src/lib/components/admin/Editor.svelte | 62 +++ .../components/admin/EditorWithUpload.svelte | 299 +++++++++---- .../components/admin/UniverseComposer.svelte | 52 +-- src/lib/components/edra/commands/commands.ts | 90 ++++ .../link-context-menu/LinkContextMenu.ts | 55 +++ .../edra/extensions/url-embed/UrlEmbed.ts | 249 +++++++++++ .../extensions/url-embed/UrlEmbedExtended.ts | 52 +++ .../url-embed/UrlEmbedPlaceholder.ts | 35 ++ .../components/EmbedContextMenu.svelte | 133 ++++++ .../components/LinkContextMenu.svelte | 133 ++++++ .../headless/components/LinkEditDialog.svelte | 189 +++++++++ .../components/UrlConvertDropdown.svelte | 97 +++++ .../components/UrlEmbedExtended.svelte | 401 ++++++++++++++++++ .../components/UrlEmbedPlaceholder.svelte | 272 ++++++++++++ src/lib/utils/content.ts | 48 +++ src/routes/admin/test-upload/+page.svelte | 371 ---------------- src/routes/api/og-metadata/+server.ts | 56 ++- 19 files changed, 2391 insertions(+), 486 deletions(-) create mode 100644 prd/PRD-url-embed-functionality.md create mode 100644 src/lib/components/edra/extensions/link-context-menu/LinkContextMenu.ts create mode 100644 src/lib/components/edra/extensions/url-embed/UrlEmbed.ts create mode 100644 src/lib/components/edra/extensions/url-embed/UrlEmbedExtended.ts create mode 100644 src/lib/components/edra/extensions/url-embed/UrlEmbedPlaceholder.ts create mode 100644 src/lib/components/edra/headless/components/EmbedContextMenu.svelte create mode 100644 src/lib/components/edra/headless/components/LinkContextMenu.svelte create mode 100644 src/lib/components/edra/headless/components/LinkEditDialog.svelte create mode 100644 src/lib/components/edra/headless/components/UrlConvertDropdown.svelte create mode 100644 src/lib/components/edra/headless/components/UrlEmbedExtended.svelte create mode 100644 src/lib/components/edra/headless/components/UrlEmbedPlaceholder.svelte delete mode 100644 src/routes/admin/test-upload/+page.svelte diff --git a/prd/PRD-url-embed-functionality.md b/prd/PRD-url-embed-functionality.md new file mode 100644 index 0000000..6ce8d05 --- /dev/null +++ b/prd/PRD-url-embed-functionality.md @@ -0,0 +1,183 @@ +# Product Requirements Document: URL Embed Functionality + +## Overview +This PRD outlines the implementation of URL paste functionality in the Editor that allows users to choose between displaying URLs as rich embed cards or simple links. + +## Background +Currently, the Editor supports various content types including text, images, and code blocks. Adding URL embed functionality will enhance the content creation experience by allowing users to share links with rich previews that include titles, descriptions, and images from the linked content. + +## Goals +1. Enable users to paste URLs and automatically convert them to rich embed cards +2. Provide flexibility to display URLs as either embed cards or simple links +3. Maintain consistency with existing UI/UX patterns +4. Ensure performance with proper loading states and error handling + +## User Stories +1. **As a content creator**, I want to paste a URL and have it automatically display as a rich preview card so that my content is more engaging. +2. **As a content creator**, I want to be able to choose between an embed card and a simple link so that I have control over how my content appears. +3. **As a content creator**, I want to edit or remove URL embeds after adding them so that I can correct mistakes or update content. +4. **As a reader**, I want to see rich previews of linked content so that I can decide whether to click through. + +## Functional Requirements + +### URL Detection and Conversion +1. **Automatic Detection**: When a user pastes a plain URL (e.g., `https://example.com`), the system should: + - Create a regular text link initially + - Display a dropdown menu next to the cursor with the option to "Convert to embed" + - If the user selects "Convert to embed", replace the link with an embed placeholder and fetch metadata + - If the user dismisses the dropdown or continues typing, keep it as a regular link +2. **Manual Entry**: Users should be able to manually add URL embeds through: + - Toolbar button (Insert → Link) + - Slash command (/url-embed) + - Direct input in placeholder + +### Embed Card Display +1. **Metadata Fetching**: The system should fetch OpenGraph metadata including: + - Title + - Description + - Preview image + - Site name + - Favicon +2. **Card Layout**: Display fetched metadata in a visually appealing card format that includes: + - Preview image (if available) + - Title (linked to URL) + - Description (truncated if too long) + - Site name and favicon +3. **Fallback**: If metadata fetching fails, display a simple card with the URL + +### User Interactions +1. **In-Editor Actions**: + - Refresh metadata + - Open link in new tab + - Remove embed + - Convert between embed and link +2. **Loading States**: Show spinner while fetching metadata +3. **Error Handling**: Display user-friendly error messages + +### Content Rendering +1. **Editor View**: Full interactive embed with action buttons +2. **Published View**: Static card with clickable elements +3. **Responsive Design**: Cards should adapt to different screen sizes + +## Technical Implementation + +### Architecture +1. **TipTap Extensions**: + - `UrlEmbed`: Main node extension for URL detection and schema + - `UrlEmbedPlaceholder`: Temporary node during loading + - `UrlEmbedExtended`: Final node with metadata + +2. **Components**: + - `UrlEmbedPlaceholder.svelte`: Loading/input UI + - `UrlEmbedExtended.svelte`: Rich preview card + +3. **API Integration**: + - Utilize existing `/api/og-metadata` endpoint + - Implement caching to reduce redundant fetches + +### Data Model +```typescript +interface UrlEmbedNode { + type: 'urlEmbed'; + attrs: { + url: string; + title?: string; + description?: string; + image?: string; + siteName?: string; + favicon?: string; + }; +} +``` + +## UI/UX Specifications + +### Visual Design +- Match existing `LinkCard` component styling +- Use established color variables and spacing +- Maintain consistency with overall site design + +### Interaction Patterns +1. **Paste Flow**: + - User pastes URL + - URL appears as regular link text + - Dropdown menu appears next to cursor with "Convert to embed" option + - If user selects "Convert to embed": + - Link is replaced with placeholder showing spinner + - Metadata loads and card renders + - User can interact with card + - If user dismisses dropdown: + - URL remains as regular link + +2. **Manual Entry Flow**: + - User clicks Insert → Link or types /url-embed + - Input field appears + - User enters URL and presses Enter + - Same loading/rendering flow as paste + +## Performance Considerations +1. **Lazy Loading**: Only fetch metadata when URL is added +2. **Caching**: Cache fetched metadata to avoid redundant API calls +3. **Timeout**: Implement reasonable timeout for metadata fetching +4. **Image Optimization**: Consider lazy loading preview images + +## Security Considerations +1. **URL Validation**: Validate URLs before fetching metadata +2. **Content Sanitization**: Sanitize fetched metadata to prevent XSS +3. **CORS Handling**: Properly handle cross-origin requests + +## Success Metrics +1. **Adoption Rate**: Percentage of posts using URL embeds +2. **Error Rate**: Frequency of metadata fetch failures +3. **Performance**: Average time to fetch and display metadata +4. **User Satisfaction**: Feedback on embed functionality + +## Future Enhancements +1. **Custom Previews**: Allow manual editing of metadata +2. **Platform-Specific Embeds**: Special handling for YouTube, Twitter, etc. +3. **Embed Templates**: Different card styles for different content types + +## Timeline + +### Phase 1: Core Functionality +**Status**: In Progress + +#### Completed Tasks: +- [x] Create TipTap extension for URL detection (`UrlEmbed.ts`) +- [x] Create placeholder component for loading state (`UrlEmbedPlaceholder.svelte`) +- [x] Create extended component for rich preview (`UrlEmbedExtended.svelte`) +- [x] Integrate with existing `/api/og-metadata` endpoint +- [x] Add URL embed to Insert menu in toolbar +- [x] Add URL embed to slash commands +- [x] Implement loading states and error handling +- [x] Style embed cards to match existing LinkCard design +- [x] Add content rendering for published posts + +#### Remaining Tasks: +- [x] Implement paste detection with dropdown menu +- [x] Create dropdown component for "Convert to embed" option +- [x] Add convert between embed/link functionality +- [x] Add keyboard shortcuts for dropdown interaction +- [x] Implement caching for metadata fetches +- [ ] Add tests for URL detection and conversion +- [ ] Update documentation + +### Phase 2: Platform-Specific Embeds +**Status**: Future +- [ ] YouTube video embeds with player +- [ ] Twitter/X post embeds +- [ ] Instagram post embeds +- [ ] GitHub repository/gist embeds + +### Phase 3: Advanced Customization +**Status**: Future +- [ ] Custom preview editing +- [ ] Multiple embed templates/styles +- [ ] Embed size options (compact/full) +- [ ] Custom CSS for embeds + +## Dependencies +- Existing `/api/og-metadata` endpoint +- TipTap editor framework +- Svelte 5 with runes mode +- Existing design system and CSS variables \ No newline at end of file diff --git a/src/lib/components/DynamicPostContent.svelte b/src/lib/components/DynamicPostContent.svelte index 2af519b..9be5984 100644 --- a/src/lib/components/DynamicPostContent.svelte +++ b/src/lib/components/DynamicPostContent.svelte @@ -305,5 +305,105 @@ border-radius: $unit; } } + + // URL Embed styles + :global(.url-embed-rendered) { + margin: $unit-4x 0; + max-width: 600px; + } + + :global(.url-embed-link) { + display: flex; + background: $grey-95; + border-radius: 8px; + overflow: hidden; + border: 1px solid $grey-85; + text-decoration: none; + transition: all 0.2s ease; + + &:hover { + border-color: $grey-60; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + } + + :global(.url-embed-image) { + flex-shrink: 0; + width: 200px; + height: 150px; + overflow: hidden; + background: $grey-90; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + :global(.url-embed-text) { + flex: 1; + padding: $unit-3x; + display: flex; + flex-direction: column; + gap: $unit; + min-width: 0; + } + + :global(.url-embed-meta) { + display: flex; + align-items: center; + gap: $unit; + font-size: 0.75rem; + color: $grey-40; + } + + :global(.url-embed-favicon) { + width: 16px; + height: 16px; + flex-shrink: 0; + } + + :global(.url-embed-domain) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + :global(.url-embed-title) { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: $grey-10; + line-height: 1.3; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + } + + :global(.url-embed-description) { + margin: 0; + font-size: 0.875rem; + color: $grey-30; + line-height: 1.4; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + } + + // Mobile styles for URL embeds + @media (max-width: 640px) { + :global(.url-embed-link) { + flex-direction: column; + } + + :global(.url-embed-image) { + width: 100%; + height: 200px; + } + } } diff --git a/src/lib/components/admin/Editor.svelte b/src/lib/components/admin/Editor.svelte index d829141..6278ca5 100644 --- a/src/lib/components/admin/Editor.svelte +++ b/src/lib/components/admin/Editor.svelte @@ -305,6 +305,16 @@ object-fit: contain; } + :global(.edra .ProseMirror .edra-url-embed-image img) { + width: 100%; + height: 100%; + max-width: auto; + max-height: auto; + margin: 0; + object-fit: cover; + border-radius: 0; + } + :global(.edra-media-placeholder-wrapper) { margin: $unit-2x 0; } @@ -359,6 +369,58 @@ } } + // URL Embed styles - ensure proper isolation + :global(.edra .edra-url-embed-wrapper) { + margin: $unit-3x 0; + width: 100%; + max-width: none; + } + + :global(.edra .edra-url-embed-card) { + max-width: 100%; + margin: 0 auto; + } + + :global(.edra .edra-url-embed-content) { + background: $grey-95; + border: 1px solid $grey-85; + + &:hover { + border-color: $grey-60; + } + } + + :global(.edra .edra-url-embed-title) { + color: $grey-10; + font-family: inherit; + margin: 0 !important; // Override ProseMirror h3 margins + font-size: 1rem !important; + font-weight: 600 !important; + line-height: 1.3 !important; + } + + :global(.edra .edra-url-embed-description) { + color: $grey-30; + font-family: inherit; + margin: 0 !important; // Override any inherited margins + } + + :global(.edra .edra-url-embed-meta) { + color: $grey-40; + font-family: inherit; + } + + // Override ProseMirror img styles for favicons only + :global(.edra .ProseMirror .edra-url-embed-favicon) { + width: 16px !important; + height: 16px !important; + margin: 0 !important; // Remove auto margins + display: inline-block !important; + max-width: 16px !important; + max-height: 16px !important; + border-radius: 0 !important; + } + :global(.edra-media-content) { width: 100%; height: auto; diff --git a/src/lib/components/admin/EditorWithUpload.svelte b/src/lib/components/admin/EditorWithUpload.svelte index 6dc0074..7894c6c 100644 --- a/src/lib/components/admin/EditorWithUpload.svelte +++ b/src/lib/components/admin/EditorWithUpload.svelte @@ -38,6 +38,15 @@ import GalleryPlaceholderComponent from '$lib/components/edra/headless/components/GalleryPlaceholder.svelte' import { GalleryExtended } from '$lib/components/edra/extensions/gallery/GalleryExtended.js' import GalleryExtendedComponent from '$lib/components/edra/headless/components/GalleryExtended.svelte' + import { UrlEmbed } from '$lib/components/edra/extensions/url-embed/UrlEmbed.js' + import { UrlEmbedPlaceholder } from '$lib/components/edra/extensions/url-embed/UrlEmbedPlaceholder.js' + import UrlEmbedPlaceholderComponent from '$lib/components/edra/headless/components/UrlEmbedPlaceholder.svelte' + import { UrlEmbedExtended } from '$lib/components/edra/extensions/url-embed/UrlEmbedExtended.js' + import UrlEmbedExtendedComponent from '$lib/components/edra/headless/components/UrlEmbedExtended.svelte' + import { LinkContextMenu } from '$lib/components/edra/extensions/link-context-menu/LinkContextMenu.js' + import UrlConvertDropdown from '$lib/components/edra/headless/components/UrlConvertDropdown.svelte' + import LinkContextMenuComponent from '$lib/components/edra/headless/components/LinkContextMenu.svelte' + import LinkEditDialog from '$lib/components/edra/headless/components/LinkEditDialog.svelte' // Import Edra styles import '$lib/components/edra/headless/style.css' @@ -74,6 +83,23 @@ let mediaDropdownTriggerRef = $state() let dropdownPosition = $state({ top: 0, left: 0 }) let mediaDropdownPosition = $state({ top: 0, left: 0 }) + + // URL convert dropdown state + let showUrlConvertDropdown = $state(false) + let urlConvertDropdownPosition = $state({ x: 0, y: 0 }) + let urlConvertPos = $state(null) + + // Link context menu state + let showLinkContextMenu = $state(false) + let linkContextMenuPosition = $state({ x: 0, y: 0 }) + let linkContextUrl = $state(null) + let linkContextPos = $state(null) + + // Link edit dialog state + let showLinkEditDialog = $state(false) + let linkEditDialogPosition = $state({ x: 0, y: 0 }) + let linkEditUrl = $state('') + let linkEditPos = $state(null) // Filter out unwanted commands const getFilteredCommands = () => { @@ -203,10 +229,103 @@ if (!mediaDropdownTriggerRef?.contains(target) && !target.closest('.media-dropdown-portal')) { showMediaDropdown = false } + if (!target.closest('.url-convert-dropdown')) { + showUrlConvertDropdown = false + } + if (!target.closest('.link-context-menu')) { + showLinkContextMenu = false + } + if (!target.closest('.link-edit-dialog')) { + showLinkEditDialog = false + } + } + + // Handle URL convert dropdown + const handleShowUrlConvertDropdown = (pos: number, url: string) => { + if (!editor) return + + // Get the cursor coordinates + const coords = editor.view.coordsAtPos(pos) + urlConvertDropdownPosition = { x: coords.left, y: coords.bottom + 5 } + urlConvertPos = pos + showUrlConvertDropdown = true + } + + // Handle link context menu + const handleShowLinkContextMenu = (pos: number, url: string, coords: { x: number, y: number }) => { + if (!editor) return + + linkContextMenuPosition = { x: coords.x, y: coords.y + 5 } + linkContextUrl = url + linkContextPos = pos + showLinkContextMenu = true + } + + const handleConvertToEmbed = () => { + if (!editor || urlConvertPos === null) return + + editor.commands.convertLinkToEmbed(urlConvertPos) + showUrlConvertDropdown = false + urlConvertPos = null + } + + const handleConvertLinkToEmbed = () => { + if (!editor || linkContextPos === null) return + + editor.commands.convertLinkToEmbed(linkContextPos) + showLinkContextMenu = false + linkContextPos = null + linkContextUrl = null + } + + const handleEditLink = () => { + if (!editor || !linkContextUrl) return + + linkEditUrl = linkContextUrl + linkEditPos = linkContextPos + linkEditDialogPosition = { ...linkContextMenuPosition } + showLinkEditDialog = true + showLinkContextMenu = false + } + + const handleSaveLink = (newUrl: string) => { + if (!editor) return + + editor.chain().focus().extendMarkRange('link').setLink({ href: newUrl }).run() + showLinkEditDialog = false + linkEditPos = null + linkEditUrl = '' + } + + const handleCopyLink = () => { + if (!linkContextUrl) return + + navigator.clipboard.writeText(linkContextUrl) + showLinkContextMenu = false + linkContextPos = null + linkContextUrl = null + } + + const handleRemoveLink = () => { + if (!editor) return + + editor.chain().focus().extendMarkRange('link').unsetLink().run() + showLinkContextMenu = false + linkContextPos = null + linkContextUrl = null + } + + const handleOpenLink = () => { + if (!linkContextUrl) return + + window.open(linkContextUrl, '_blank', 'noopener,noreferrer') + showLinkContextMenu = false + linkContextPos = null + linkContextUrl = null } $effect(() => { - if (showTextStyleDropdown || showMediaDropdown) { + if (showTextStyleDropdown || showMediaDropdown || showUrlConvertDropdown || showLinkContextMenu || showLinkEditDialog) { document.addEventListener('click', handleClickOutside) return () => { document.removeEventListener('click', handleClickOutside) @@ -349,11 +468,37 @@ ImageExtended(ImageExtendedComponent), GalleryExtended(GalleryExtendedComponent), VideoExtended(VideoExtendedComponent), + UrlEmbed.configure({ + onShowDropdown: handleShowUrlConvertDropdown + }), + UrlEmbedPlaceholder(UrlEmbedPlaceholderComponent), + UrlEmbedExtended(UrlEmbedExtendedComponent), + LinkContextMenu.configure({ + onShowContextMenu: handleShowLinkContextMenu + }), ...(showSlashCommands ? [slashcommand(SlashCommandList)] : []) ], { editable, - onUpdate, + onUpdate: ({ editor: updatedEditor, transaction }) => { + // Dismiss URL convert dropdown if user types + if (showUrlConvertDropdown && transaction.docChanged) { + // Check if the change is actual typing (not just cursor movement) + const hasTextChange = transaction.steps.some(step => + step.toJSON().stepType === 'replace' || + step.toJSON().stepType === 'replaceAround' + ) + if (hasTextChange) { + showUrlConvertDropdown = false + urlConvertPos = null + } + } + + // Call the original onUpdate if provided + if (onUpdate) { + onUpdate({ editor: updatedEditor, transaction }) + } + }, editorProps: { attributes: { class: 'prose prose-sm max-w-none focus:outline-none' @@ -486,7 +631,7 @@ {/if} {#if editor} - {#if showLinkBubbleMenu} + {#if false && showLinkBubbleMenu} {/if} {#if showTableBubbleMenu} @@ -528,28 +673,7 @@ showMediaDropdown = false }} > - - - - - - Image + Image + + @@ -729,6 +811,53 @@ {/if} + +{#if showUrlConvertDropdown} + { + showUrlConvertDropdown = false + urlConvertPos = null + }} + /> +{/if} + + +{#if showLinkContextMenu && linkContextUrl} + { + showLinkContextMenu = false + linkContextPos = null + linkContextUrl = null + }} + /> +{/if} + + +{#if showLinkEditDialog} + { + showLinkEditDialog = false + linkEditPos = null + linkEditUrl = '' + }} + /> +{/if} + \ No newline at end of file diff --git a/src/lib/components/edra/headless/components/LinkContextMenu.svelte b/src/lib/components/edra/headless/components/LinkContextMenu.svelte new file mode 100644 index 0000000..f84803b --- /dev/null +++ b/src/lib/components/edra/headless/components/LinkContextMenu.svelte @@ -0,0 +1,133 @@ + + + + + \ No newline at end of file diff --git a/src/lib/components/edra/headless/components/LinkEditDialog.svelte b/src/lib/components/edra/headless/components/LinkEditDialog.svelte new file mode 100644 index 0000000..df5afe5 --- /dev/null +++ b/src/lib/components/edra/headless/components/LinkEditDialog.svelte @@ -0,0 +1,189 @@ + + + + + \ No newline at end of file diff --git a/src/lib/components/edra/headless/components/UrlConvertDropdown.svelte b/src/lib/components/edra/headless/components/UrlConvertDropdown.svelte new file mode 100644 index 0000000..7fa87ee --- /dev/null +++ b/src/lib/components/edra/headless/components/UrlConvertDropdown.svelte @@ -0,0 +1,97 @@ + + +
+ +
+ + \ No newline at end of file diff --git a/src/lib/components/edra/headless/components/UrlEmbedExtended.svelte b/src/lib/components/edra/headless/components/UrlEmbedExtended.svelte new file mode 100644 index 0000000..d0c208c --- /dev/null +++ b/src/lib/components/edra/headless/components/UrlEmbedExtended.svelte @@ -0,0 +1,401 @@ + + + +
showActions = true} + onmouseleave={() => showActions = false} + onkeydown={handleKeydown} + oncontextmenu={handleContextMenu} + tabindex="0" + role="article" + > + {#if showActions && editor.isEditable} +
+ +
+ {/if} + + +
+
+ +{#if showContextMenu} + { + convertToLink() + showContextMenu = false + }} + onCopyLink={copyLink} + onRefresh={() => { + refreshMetadata() + showContextMenu = false + }} + onOpenLink={() => { + openLink() + showContextMenu = false + }} + onRemove={() => { + deleteNode() + showContextMenu = false + }} + onDismiss={dismissContextMenu} + /> +{/if} + + \ 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 new file mode 100644 index 0000000..acdbc5e --- /dev/null +++ b/src/lib/components/edra/headless/components/UrlEmbedPlaceholder.svelte @@ -0,0 +1,272 @@ + + + + {#if showInput && !node.attrs.url} +
+ + +
+ {:else if loading} +
+ + Loading preview... +
+ {:else if error} +
+ +
+ {errorMessage} + +
+
+ {:else} + + + + Embed a link + + {/if} +
+ + \ No newline at end of file diff --git a/src/lib/utils/content.ts b/src/lib/utils/content.ts index 5ef0f0d..886d189 100644 --- a/src/lib/utils/content.ts +++ b/src/lib/utils/content.ts @@ -157,6 +157,54 @@ function renderTiptapContent(doc: any): string { return '
' } + case 'urlEmbed': { + const url = node.attrs?.url || '' + const title = node.attrs?.title || '' + const description = node.attrs?.description || '' + const image = node.attrs?.image || '' + const favicon = node.attrs?.favicon || '' + const siteName = node.attrs?.siteName || '' + + // Helper to get domain from URL + const getDomain = (url: string) => { + try { + const urlObj = new URL(url) + return urlObj.hostname.replace('www.', '') + } catch { + return '' + } + } + + let embedHtml = '' + + return embedHtml + } + default: { // For any unknown block types, try to render their content if (node.content) { diff --git a/src/routes/admin/test-upload/+page.svelte b/src/routes/admin/test-upload/+page.svelte deleted file mode 100644 index c435e89..0000000 --- a/src/routes/admin/test-upload/+page.svelte +++ /dev/null @@ -1,371 +0,0 @@ - - - -
-

Upload Test

- ← Back to Projects -
- -
-
-

Image Upload Test

-

This page helps you test that image uploads are working correctly.

- - {#if localUploadsExist} -
✅ Local uploads directory is configured
- {:else} -
⚠️ No local uploads found yet
- {/if} - -
-

How to test:

-
    -
  1. Copy an image to your clipboard
  2. -
  3. Click in the editor below and paste (Cmd+V)
  4. -
  5. Or click the image placeholder to browse files
  6. -
  7. Or drag and drop an image onto the placeholder
  8. -
-
-
- -
-

Editor with Image Upload

- -
- - {#if uploadedImages.length > 0} -
-

Uploaded Images

-
- {#each uploadedImages as image} -
- Uploaded -
- {image.timestamp} - {image.url} - {#if image.url.includes('/local-uploads/')} - Local - {:else if image.url.includes('cloudinary')} - Cloudinary - {:else} - Unknown - {/if} -
-
- {/each} -
-
- {/if} - -
-

Editor Content (JSON)

-
{JSON.stringify(testContent, null, 2)}
-
-
-
- - diff --git a/src/routes/api/og-metadata/+server.ts b/src/routes/api/og-metadata/+server.ts index 44e31c3..5055b8f 100644 --- a/src/routes/api/og-metadata/+server.ts +++ b/src/routes/api/og-metadata/+server.ts @@ -1,14 +1,30 @@ import { json } from '@sveltejs/kit' import type { RequestHandler } from './$types' +import redis from '../redis-client' export const GET: RequestHandler = async ({ url }) => { const targetUrl = url.searchParams.get('url') + const forceRefresh = url.searchParams.get('refresh') === 'true' if (!targetUrl) { return json({ error: 'URL parameter is required' }, { status: 400 }) } try { + // Check cache first (unless force refresh is requested) + const cacheKey = `og-metadata:${targetUrl}` + + if (!forceRefresh) { + const cached = await redis.get(cacheKey) + + if (cached) { + console.log(`Cache hit for ${targetUrl}`) + return json(JSON.parse(cached)) + } + } else { + console.log(`Force refresh requested for ${targetUrl}`) + } + // Fetch the HTML content const response = await fetch(targetUrl, { headers: { @@ -33,6 +49,10 @@ export const GET: RequestHandler = async ({ url }) => { favicon: extractFavicon(targetUrl, html) } + // Cache for 24 hours (86400 seconds) + await redis.set(cacheKey, JSON.stringify(ogData), 'EX', 86400) + console.log(`Cached metadata for ${targetUrl}`) + return json(ogData) } catch (error) { console.error('Error fetching OpenGraph data:', error) @@ -123,6 +143,26 @@ export const POST: RequestHandler = async ({ request }) => { } try { + // Check cache first - using same cache key format + const cacheKey = `og-metadata:${targetUrl}` + const cached = await redis.get(cacheKey) + + if (cached) { + console.log(`Cache hit for ${targetUrl} (POST)`) + const ogData = JSON.parse(cached) + return json({ + success: 1, + link: targetUrl, + meta: { + title: ogData.title || '', + description: ogData.description || '', + image: { + url: ogData.image || '' + } + } + }) + } + // Fetch the HTML content const response = await fetch(targetUrl, { headers: { @@ -136,11 +176,25 @@ export const POST: RequestHandler = async ({ request }) => { const html = await response.text() - // Parse OpenGraph tags and return in Editor.js format + // Parse OpenGraph tags const title = extractMetaContent(html, 'og:title') || extractTitle(html) const description = extractMetaContent(html, 'og:description') || extractMetaContent(html, 'description') const image = extractMetaContent(html, 'og:image') + const siteName = extractMetaContent(html, 'og:site_name') + const favicon = extractFavicon(targetUrl, html) + + // Cache the data in the same format as GET + const ogData = { + url: targetUrl, + title, + description, + image, + siteName, + favicon + } + await redis.set(cacheKey, JSON.stringify(ogData), 'EX', 86400) + console.log(`Cached metadata for ${targetUrl} (POST)`) return json({ success: 1, From 1f7b388a6cefd1063ad9feabb46725bdd82e3f58 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Fri, 13 Jun 2025 17:40:55 -0400 Subject: [PATCH 2/4] Clean up URL cards --- src/lib/components/DynamicPostContent.svelte | 22 +++++++++++-------- .../components/admin/CaseStudyEditor.svelte | 4 ++++ .../components/admin/UniverseComposer.svelte | 2 +- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/lib/components/DynamicPostContent.svelte b/src/lib/components/DynamicPostContent.svelte index 9be5984..845599f 100644 --- a/src/lib/components/DynamicPostContent.svelte +++ b/src/lib/components/DynamicPostContent.svelte @@ -308,14 +308,18 @@ // URL Embed styles :global(.url-embed-rendered) { - margin: $unit-4x 0; - max-width: 600px; + margin: $unit-2x 0; + width: 100%; + + &:first-child { + margin-top: 0; + } } :global(.url-embed-link) { display: flex; background: $grey-95; - border-radius: 8px; + border-radius: $card-corner-radius; overflow: hidden; border: 1px solid $grey-85; text-decoration: none; @@ -324,6 +328,7 @@ &:hover { border-color: $grey-60; transform: translateY(-1px); + text-decoration: none; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } } @@ -331,15 +336,14 @@ :global(.url-embed-image) { flex-shrink: 0; width: 200px; - height: 150px; overflow: hidden; background: $grey-90; + } - img { - width: 100%; - height: 100%; - object-fit: cover; - } + :global(.url-embed-image img) { + width: 100%; + height: 100%; + object-fit: cover; } :global(.url-embed-text) { diff --git a/src/lib/components/admin/CaseStudyEditor.svelte b/src/lib/components/admin/CaseStudyEditor.svelte index 09aab5a..6f540b8 100644 --- a/src/lib/components/admin/CaseStudyEditor.svelte +++ b/src/lib/components/admin/CaseStudyEditor.svelte @@ -38,6 +38,10 @@ export function getContent() { return editorRef?.getContent() } + + export function clear() { + editorRef?.clear() + }
diff --git a/src/lib/components/admin/UniverseComposer.svelte b/src/lib/components/admin/UniverseComposer.svelte index 633117f..6bd262e 100644 --- a/src/lib/components/admin/UniverseComposer.svelte +++ b/src/lib/components/admin/UniverseComposer.svelte @@ -29,7 +29,7 @@ content: [{ type: 'paragraph' }] } let characterCount = 0 - let editorInstance: Editor + let editorInstance: CaseStudyEditor // Essay metadata let essayTitle = '' From fe30f9e9b2d28f77c786fab584c3cdab07e69d8e Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Fri, 13 Jun 2025 18:13:43 -0400 Subject: [PATCH 3/4] 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 += ` +
+
+ {:else} + + {#if firstEmbed.image} +
+ {firstEmbed.title +
+ {/if} +
+
+ {#if firstEmbed.favicon} + + {/if} + {firstEmbed.siteName || getDomain(firstEmbed.url)} +
+ {#if firstEmbed.title} +

{firstEmbed.title}

+ {/if} + {#if firstEmbed.description} +

{firstEmbed.description}

+ {/if} +
+
+ {/if} + + {/if} + {#if post.postType === 'essay' && isContentTruncated}

Continue reading @@ -60,7 +115,7 @@ .link-preview { background: $grey-97; border: 1px solid $grey-90; - border-radius: $unit; + border-radius: $card-corner-radius; padding: $unit-2x; margin-bottom: $unit-3x; @@ -119,4 +174,116 @@ font-weight: 500; transition: all 0.2s ease; } + + // Embed preview styles + .embed-preview { + margin: $unit-2x 0; + } + + .youtube-embed-preview { + .youtube-player { + position: relative; + width: 100%; + padding-bottom: 56%; // 16:9 aspect ratio + height: 0; + overflow: hidden; + background: $grey-95; + border-radius: $card-corner-radius; + border: 1px solid $grey-85; + + iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; + border-radius: $unit; + } + } + } + + .url-embed-preview { + display: flex; + flex-direction: column; + background: $grey-97; + border-radius: $card-corner-radius; + overflow: hidden; + border: 1px solid $grey-80; + text-decoration: none; + transition: all 0.2s ease; + width: 100%; + + &:hover { + border-color: $grey-80; + transform: translateY(-1px); + box-shadow: 0 0 8px rgba(0, 0, 0, 0.08); + } + + .embed-image { + width: 100%; + aspect-ratio: 2 / 1; + overflow: hidden; + background: $grey-90; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + .embed-text { + flex: 1; + padding: $unit-2x $unit-3x $unit-3x; + display: flex; + flex-direction: column; + gap: $unit; + min-width: 0; + } + + .embed-meta { + display: flex; + align-items: center; + gap: $unit-half; + font-size: 0.8125rem; + color: $grey-40; + } + + .embed-favicon { + width: 16px; + height: 16px; + flex-shrink: 0; + } + + .embed-domain { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-transform: lowercase; + } + + .embed-title { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: $grey-10; + line-height: 1.3; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + } + + .embed-description { + margin: 0; + font-size: 0.9375rem; + color: $grey-30; + line-height: 1.5; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + } + } diff --git a/src/lib/components/admin/AlbumListItem.svelte b/src/lib/components/admin/AlbumListItem.svelte index 09f8e9a..a1ab5fa 100644 --- a/src/lib/components/admin/AlbumListItem.svelte +++ b/src/lib/components/admin/AlbumListItem.svelte @@ -297,7 +297,7 @@ .dropdown-divider { height: 1px; - background-color: $grey-90; + background-color: $grey-80; margin: $unit-half 0; } diff --git a/src/lib/components/admin/DropdownMenu.svelte b/src/lib/components/admin/DropdownMenu.svelte index 833612d..d834927 100644 --- a/src/lib/components/admin/DropdownMenu.svelte +++ b/src/lib/components/admin/DropdownMenu.svelte @@ -123,7 +123,7 @@ .dropdown-divider { height: 1px; - background-color: $grey-90; + background-color: $grey-80; margin: $unit-half 0; } diff --git a/src/lib/components/admin/PostListItem.svelte b/src/lib/components/admin/PostListItem.svelte index 76c016c..0f43852 100644 --- a/src/lib/components/admin/PostListItem.svelte +++ b/src/lib/components/admin/PostListItem.svelte @@ -306,7 +306,7 @@ .dropdown-divider { height: 1px; - background-color: $grey-90; + background-color: $grey-80; margin: $unit-half 0; } diff --git a/src/lib/components/admin/ProjectListItem.svelte b/src/lib/components/admin/ProjectListItem.svelte index 86495c5..707b64d 100644 --- a/src/lib/components/admin/ProjectListItem.svelte +++ b/src/lib/components/admin/ProjectListItem.svelte @@ -256,7 +256,7 @@ .dropdown-divider { height: 1px; - background-color: $grey-90; + background-color: $grey-80; margin: $unit-half 0; } diff --git a/src/lib/components/admin/StatusDropdown.svelte b/src/lib/components/admin/StatusDropdown.svelte index 34f9129..fc86556 100644 --- a/src/lib/components/admin/StatusDropdown.svelte +++ b/src/lib/components/admin/StatusDropdown.svelte @@ -134,7 +134,7 @@ .dropdown-divider { height: 1px; - background-color: $grey-90; + background-color: $grey-80; margin: $unit-half 0; } diff --git a/src/lib/utils/extractEmbeds.ts b/src/lib/utils/extractEmbeds.ts new file mode 100644 index 0000000..c92a675 --- /dev/null +++ b/src/lib/utils/extractEmbeds.ts @@ -0,0 +1,79 @@ +// Extract URL embeds from Tiptap content +export interface ExtractedEmbed { + type: 'urlEmbed' | 'youtube' + url: string + title?: string + description?: string + image?: string + favicon?: string + siteName?: string + videoId?: string +} + +export function extractEmbeds(content: any): ExtractedEmbed[] { + if (!content || !content.content) return [] + + const embeds: ExtractedEmbed[] = [] + + // 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 + } + + // Recursive function to find embed nodes + const findEmbeds = (node: any) => { + if (node.type === 'urlEmbed' && node.attrs?.url) { + const url = node.attrs.url + const isYouTube = /(?:youtube\.com|youtu\.be)/.test(url) + + if (isYouTube) { + const videoId = getYouTubeVideoId(url) + if (videoId) { + embeds.push({ + type: 'youtube', + url, + videoId, + title: node.attrs.title, + description: node.attrs.description, + image: node.attrs.image, + favicon: node.attrs.favicon, + siteName: node.attrs.siteName + }) + } + } else { + embeds.push({ + type: 'urlEmbed', + url, + title: node.attrs.title, + description: node.attrs.description, + image: node.attrs.image, + favicon: node.attrs.favicon, + siteName: node.attrs.siteName + }) + } + } + + // Recursively check child nodes + if (node.content && Array.isArray(node.content)) { + node.content.forEach(findEmbeds) + } + } + + // Start searching from the root + if (content.content && Array.isArray(content.content)) { + content.content.forEach(findEmbeds) + } + + return embeds +} \ No newline at end of file