@@ -29,6 +44,46 @@
{/if}
+ {#if firstEmbed}
+
+ {/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/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/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/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
-
-
-
-
-
-
- Gallery
+ Gallery
-
-
-
-
- Video
+ Video
-
-
-
- Audio
+ Audio
+
+
+ {
+ editor?.chain().focus().insertUrlEmbedPlaceholder().run()
+ showMediaDropdown = false
+ }}
+ >
+ Link
@@ -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}
+
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/components/admin/UniverseComposer.svelte b/src/lib/components/admin/UniverseComposer.svelte
index 4ad302b..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 = ''
@@ -457,31 +457,33 @@
{:else}
-
-
-
-
-
-
+ {#if hasContent()}
+
+
+
+
+
+
+ {/if}
= {
}
}
]
+ },
+ lists: {
+ name: 'Lists',
+ label: 'Lists',
+ commands: [
+ {
+ iconName: 'List',
+ name: 'bulletList',
+ label: 'Bullet List',
+ shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+8`],
+ action: (editor) => {
+ editor.chain().focus().toggleBulletList().run()
+ },
+ isActive: (editor) => editor.isActive('bulletList')
+ },
+ {
+ iconName: 'ListOrdered',
+ name: 'orderedList',
+ label: 'Ordered List',
+ shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+7`],
+ action: (editor) => {
+ editor.chain().focus().toggleOrderedList().run()
+ },
+ isActive: (editor) => editor.isActive('orderedList')
+ },
+ {
+ iconName: 'ListTodo',
+ name: 'taskList',
+ label: 'Task List',
+ shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+9`],
+ action: (editor) => {
+ editor.chain().focus().toggleTaskList().run()
+ },
+ isActive: (editor) => editor.isActive('taskList')
+ }
+ ]
+ },
+ media: {
+ name: 'Media',
+ label: 'Media',
+ commands: [
+ {
+ iconName: 'Image',
+ name: 'image-placeholder',
+ label: 'Image',
+ action: (editor) => {
+ editor.chain().focus().insertImagePlaceholder().run()
+ }
+ },
+ {
+ iconName: 'Images',
+ name: 'gallery-placeholder',
+ label: 'Gallery',
+ action: (editor) => {
+ editor.chain().focus().insertGalleryPlaceholder().run()
+ }
+ },
+ {
+ iconName: 'Video',
+ name: 'video-placeholder',
+ label: 'Video',
+ action: (editor) => {
+ editor.chain().focus().insertVideoPlaceholder().run()
+ }
+ },
+ {
+ iconName: 'Mic',
+ name: 'audio-placeholder',
+ label: 'Audio',
+ action: (editor) => {
+ editor.chain().focus().insertAudioPlaceholder().run()
+ }
+ },
+ {
+ iconName: 'Code',
+ name: 'iframe-placeholder',
+ label: 'Iframe',
+ action: (editor) => {
+ editor.chain().focus().insertIframePlaceholder().run()
+ }
+ },
+ {
+ iconName: 'Link',
+ name: 'url-embed-placeholder',
+ label: 'URL Embed',
+ action: (editor) => {
+ editor.chain().focus().insertUrlEmbedPlaceholder().run()
+ }
+ }
+ ]
}
}
diff --git a/src/lib/components/edra/extensions/link-context-menu/LinkContextMenu.ts b/src/lib/components/edra/extensions/link-context-menu/LinkContextMenu.ts
new file mode 100644
index 0000000..c8b3626
--- /dev/null
+++ b/src/lib/components/edra/extensions/link-context-menu/LinkContextMenu.ts
@@ -0,0 +1,55 @@
+import { Extension } from '@tiptap/core'
+import { Plugin, PluginKey } from '@tiptap/pm/state'
+
+export interface LinkContextMenuOptions {
+ onShowContextMenu?: (pos: number, url: string, coords: { x: number, y: number }) => void
+}
+
+export const LinkContextMenu = Extension.create({
+ name: 'linkContextMenu',
+
+ addOptions() {
+ return {
+ onShowContextMenu: undefined
+ }
+ },
+
+ addProseMirrorPlugins() {
+ const options = this.options
+
+ return [
+ new Plugin({
+ key: new PluginKey('linkContextMenu'),
+ props: {
+ handleDOMEvents: {
+ contextmenu: (view, event) => {
+ const { state } = view
+ const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
+
+ if (!pos) return false
+
+ const $pos = state.doc.resolve(pos.pos)
+ const marks = $pos.marks()
+ const linkMark = marks.find(mark => mark.type.name === 'link')
+
+ if (linkMark && linkMark.attrs.href) {
+ event.preventDefault()
+
+ if (options.onShowContextMenu) {
+ options.onShowContextMenu(pos.pos, linkMark.attrs.href, {
+ x: event.clientX,
+ y: event.clientY
+ })
+ }
+
+ return true
+ }
+
+ return false
+ }
+ }
+ }
+ })
+ ]
+ }
+})
\ No newline at end of file
diff --git a/src/lib/components/edra/extensions/url-embed/UrlEmbed.ts b/src/lib/components/edra/extensions/url-embed/UrlEmbed.ts
new file mode 100644
index 0000000..e52b7dd
--- /dev/null
+++ b/src/lib/components/edra/extensions/url-embed/UrlEmbed.ts
@@ -0,0 +1,254 @@
+import { Node, mergeAttributes } from '@tiptap/core'
+import { Plugin, PluginKey } from '@tiptap/pm/state'
+import { Decoration, DecorationSet } from '@tiptap/pm/view'
+
+export interface UrlEmbedOptions {
+ HTMLAttributes: Record
+ onShowDropdown?: (pos: number, url: string) => void
+}
+
+declare module '@tiptap/core' {
+ interface Commands {
+ urlEmbed: {
+ /**
+ * Set a URL embed
+ */
+ setUrlEmbed: (options: {
+ url: string
+ title?: string
+ description?: string
+ image?: string
+ favicon?: string
+ siteName?: string
+ }) => ReturnType
+ /**
+ * Insert a URL embed placeholder
+ */
+ insertUrlEmbedPlaceholder: () => ReturnType
+ /**
+ * Convert a link at position to URL embed
+ */
+ convertLinkToEmbed: (pos: number) => ReturnType
+ }
+ }
+}
+
+export const UrlEmbed = Node.create({
+ name: 'urlEmbed',
+
+ group: 'block',
+
+ atom: true,
+
+ addOptions() {
+ return {
+ HTMLAttributes: {}
+ }
+ },
+
+ addAttributes() {
+ return {
+ url: {
+ default: null
+ },
+ title: {
+ default: null
+ },
+ description: {
+ default: null
+ },
+ image: {
+ default: null
+ },
+ favicon: {
+ default: null
+ },
+ siteName: {
+ default: null
+ }
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'div[data-url-embed]'
+ }
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['div', mergeAttributes({ 'data-url-embed': '' }, this.options.HTMLAttributes, HTMLAttributes)]
+ },
+
+ addCommands() {
+ return {
+ setUrlEmbed:
+ (options) =>
+ ({ commands }) => {
+ return commands.insertContent({
+ type: this.name,
+ attrs: options
+ })
+ },
+ insertUrlEmbedPlaceholder:
+ () =>
+ ({ commands }) => {
+ return commands.insertContent({
+ type: 'urlEmbedPlaceholder'
+ })
+ },
+ convertLinkToEmbed:
+ (pos) =>
+ ({ state, commands, chain }) => {
+ const { doc } = state
+
+ // Find the link mark at the given position
+ const $pos = doc.resolve(pos)
+ const marks = $pos.marks()
+ const linkMark = marks.find(mark => mark.type.name === 'link')
+
+ if (!linkMark) return false
+
+ const url = linkMark.attrs.href
+ if (!url) return false
+
+ // Find the complete range of text with this link mark
+ let from = pos
+ let to = pos
+
+ // Walk backwards to find the start
+ doc.nodesBetween(Math.max(0, pos - 300), pos, (node, nodePos) => {
+ if (node.isText && node.marks.some(m => m.type.name === 'link' && m.attrs.href === url)) {
+ from = nodePos
+ }
+ })
+
+ // Walk forwards to find the end
+ doc.nodesBetween(pos, Math.min(doc.content.size, pos + 300), (node, nodePos) => {
+ if (node.isText && node.marks.some(m => m.type.name === 'link' && m.attrs.href === url)) {
+ to = nodePos + node.nodeSize
+ }
+ })
+
+ // Use Tiptap's chain commands to replace content
+ return chain()
+ .focus()
+ .deleteRange({ from, to })
+ .insertContent([
+ {
+ type: 'urlEmbedPlaceholder',
+ attrs: { url }
+ },
+ {
+ type: 'paragraph'
+ }
+ ])
+ .run()
+ }
+ }
+ },
+
+ addProseMirrorPlugins() {
+ const options = this.options
+ return [
+ new Plugin({
+ key: new PluginKey('urlEmbedPaste'),
+ state: {
+ init: () => ({ lastPastedUrl: null, lastPastedPos: null }),
+ apply: (tr, value) => {
+ // Clear state if document changed significantly
+ if (tr.docChanged && tr.steps.length > 0) {
+ const meta = tr.getMeta('urlEmbedPaste')
+ if (meta) {
+ return meta
+ }
+ return { lastPastedUrl: null, lastPastedPos: null }
+ }
+ return value
+ }
+ },
+ props: {
+ handlePaste: (view, event) => {
+ const { clipboardData } = event
+ if (!clipboardData) return false
+
+ const text = clipboardData.getData('text/plain')
+ const html = clipboardData.getData('text/html')
+
+ // Check if it's a plain text paste
+ if (text && !html) {
+ // Simple URL regex check
+ const urlRegex = /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/
+
+ if (urlRegex.test(text.trim())) {
+ // It's a URL, let it paste as a link naturally (don't prevent default)
+ // But track it so we can show dropdown after
+ const pastedUrl = text.trim()
+
+ // Get the position before paste
+ const beforePos = view.state.selection.from
+
+ setTimeout(() => {
+ const { state } = view
+ const { doc } = state
+
+ // Find the link that was just inserted
+ // Start from where we were before paste
+ let linkStart = -1
+ let linkEnd = -1
+
+ // Search for the link in a reasonable range
+ for (let pos = beforePos; pos < Math.min(doc.content.size, beforePos + pastedUrl.length + 10); pos++) {
+ try {
+ const $pos = doc.resolve(pos)
+ const marks = $pos.marks()
+ const linkMark = marks.find(m => m.type.name === 'link' && m.attrs.href === pastedUrl)
+
+ if (linkMark) {
+ // Found the link, now find its boundaries
+ linkStart = pos
+
+ // Find the end of the link
+ for (let endPos = pos; endPos < Math.min(doc.content.size, pos + pastedUrl.length + 5); endPos++) {
+ const $endPos = doc.resolve(endPos)
+ const hasLink = $endPos.marks().some(m => m.type.name === 'link' && m.attrs.href === pastedUrl)
+ if (hasLink) {
+ linkEnd = endPos + 1
+ } else {
+ break
+ }
+ }
+ break
+ }
+ } catch (e) {
+ // Position might be invalid, continue
+ }
+ }
+
+ if (linkStart !== -1) {
+ // Store the pasted URL info with correct position
+ const tr = state.tr.setMeta('urlEmbedPaste', {
+ lastPastedUrl: pastedUrl,
+ lastPastedPos: linkStart
+ })
+ view.dispatch(tr)
+
+ // Notify the editor to show dropdown
+ if (options.onShowDropdown) {
+ options.onShowDropdown(linkStart, pastedUrl)
+ // Ensure editor maintains focus
+ view.focus()
+ }
+ }
+ }, 100) // Small delay to let the link paste naturally
+ }
+ }
+
+ return false
+ }
+ }
+ })
+ ]
+ }
+})
\ No newline at end of file
diff --git a/src/lib/components/edra/extensions/url-embed/UrlEmbedExtended.ts b/src/lib/components/edra/extensions/url-embed/UrlEmbedExtended.ts
new file mode 100644
index 0000000..9edec3f
--- /dev/null
+++ b/src/lib/components/edra/extensions/url-embed/UrlEmbedExtended.ts
@@ -0,0 +1,52 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+import { SvelteNodeViewRenderer } from 'svelte-tiptap'
+
+export const UrlEmbedExtended = (component: any) =>
+ Node.create({
+ name: 'urlEmbed',
+
+ group: 'block',
+
+ atom: true,
+
+ draggable: true,
+
+ addAttributes() {
+ return {
+ url: {
+ default: null
+ },
+ title: {
+ default: null
+ },
+ description: {
+ default: null
+ },
+ image: {
+ default: null
+ },
+ favicon: {
+ default: null
+ },
+ siteName: {
+ default: null
+ }
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'div[data-url-embed]'
+ }
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['div', mergeAttributes({ 'data-url-embed': '' }, HTMLAttributes)]
+ },
+
+ addNodeView() {
+ return SvelteNodeViewRenderer(component)
+ }
+ })
\ No newline at end of file
diff --git a/src/lib/components/edra/extensions/url-embed/UrlEmbedPlaceholder.ts b/src/lib/components/edra/extensions/url-embed/UrlEmbedPlaceholder.ts
new file mode 100644
index 0000000..8ce71f0
--- /dev/null
+++ b/src/lib/components/edra/extensions/url-embed/UrlEmbedPlaceholder.ts
@@ -0,0 +1,35 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+import { SvelteNodeViewRenderer } from 'svelte-tiptap'
+
+export const UrlEmbedPlaceholder = (component: any) =>
+ Node.create({
+ name: 'urlEmbedPlaceholder',
+
+ group: 'block',
+
+ atom: true,
+
+ addAttributes() {
+ return {
+ url: {
+ default: null
+ }
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'div[data-url-embed-placeholder]'
+ }
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['div', mergeAttributes({ 'data-url-embed-placeholder': '' }, HTMLAttributes)]
+ },
+
+ addNodeView() {
+ return SvelteNodeViewRenderer(component)
+ }
+ })
\ No newline at end of file
diff --git a/src/lib/components/edra/headless/components/EmbedContextMenu.svelte b/src/lib/components/edra/headless/components/EmbedContextMenu.svelte
new file mode 100644
index 0000000..d76bc5c
--- /dev/null
+++ b/src/lib/components/edra/headless/components/EmbedContextMenu.svelte
@@ -0,0 +1,133 @@
+
+
+
+
+
\ 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 @@
+
+
+
+
+ Convert to card
+
+
+
+
\ 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..d43cce3
--- /dev/null
+++ b/src/lib/components/edra/headless/components/UrlEmbedExtended.svelte
@@ -0,0 +1,557 @@
+
+
+
+ {#if isYouTube}
+ {@const videoId = getYouTubeVideoId(node.attrs.url || '')}
+ (showActions = true)}
+ onmouseleave={() => (showActions = false)}
+ onkeydown={handleKeydown}
+ oncontextmenu={handleContextMenu}
+ tabindex="0"
+ role="article"
+ >
+ {#if showActions && editor.isEditable}
+
+ {
+ e.stopPropagation()
+ const rect = e.currentTarget.getBoundingClientRect()
+ contextMenuPosition = {
+ x: rect.left,
+ y: rect.bottom + 4
+ }
+ showContextMenu = true
+ }}
+ class="edra-youtube-embed-action-button"
+ title="More options"
+ >
+
+
+
+ {/if}
+
+ {#if videoId}
+
+ VIDEO
+
+ {:else}
+
+ {/if}
+
+ {:else}
+ (showActions = true)}
+ onmouseleave={() => (showActions = false)}
+ onkeydown={handleKeydown}
+ oncontextmenu={handleContextMenu}
+ tabindex="0"
+ role="article"
+ >
+ {#if showActions && editor.isEditable}
+
+ {
+ e.stopPropagation()
+ const rect = e.currentTarget.getBoundingClientRect()
+ contextMenuPosition = {
+ x: rect.left,
+ y: rect.bottom + 4
+ }
+ showContextMenu = true
+ }}
+ class="edra-url-embed-action-button edra-url-embed-menu-button"
+ title="More options"
+ >
+
+
+
+ {/if}
+
+
+ {#if node.attrs.image}
+
+
+
+ {/if}
+
+
+ {#if node.attrs.title}
+
{decodeHtmlEntities(node.attrs.title)}
+ {/if}
+ {#if node.attrs.description}
+
{decodeHtmlEntities(node.attrs.description)}
+ {/if}
+
+
+
+ {/if}
+
+
+{#if showContextMenu}
+ {
+ convertToLink()
+ showContextMenu = false
+ }}
+ onCopyLink={copyLink}
+ onRefresh={() => {
+ refreshMetadata()
+ showContextMenu = false
+ }}
+ onOpenLink={() => {
+ openLink()
+ showContextMenu = false
+ }}
+ onRemove={() => {
+ deleteNode()
+ showContextMenu = false
+ }}
+ onDismiss={dismissContextMenu}
+ />
+{/if}
+
+
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..b1aed7b
--- /dev/null
+++ b/src/lib/components/edra/headless/components/UrlEmbedPlaceholder.svelte
@@ -0,0 +1,277 @@
+
+
+
+ {#if showInput && !node.attrs.url}
+
+
+
+ Embed
+
+
+ {:else if loading}
+
+
+ Loading preview...
+
+ {:else if error}
+
+
+
+ {errorMessage}
+ { showInput = true; error = false; }} class="retry-button">
+ Try another URL
+
+
+
+ {: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..90f2f7f 100644
--- a/src/lib/utils/content.ts
+++ b/src/lib/utils/content.ts
@@ -157,6 +157,89 @@ 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 ''
+ }
+ }
+
+ // 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 += `VIDEO '
+ embedHtml += '
'
+ embedHtml += '
'
+ return embedHtml
+ }
+
+ // Regular URL embed for non-YouTube links
+ let embedHtml = ''
+
+ return embedHtml
+ }
+
default: {
// For any unknown block types, try to render their content
if (node.content) {
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
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 @@
-
-
-
-
-
-
-
-
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:
-
- Copy an image to your clipboard
- Click in the editor below and paste (Cmd+V)
- Or click the image placeholder to browse files
- Or drag and drop an image onto the placeholder
-
-
-
-
-
-
Editor with Image Upload
-
-
-
- {#if uploadedImages.length > 0}
-
-
Uploaded Images
-
- {#each uploadedImages as image}
-
-
-
- {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..b0e12b9 100644
--- a/src/routes/api/og-metadata/+server.ts
+++ b/src/routes/api/og-metadata/+server.ts
@@ -1,14 +1,67 @@
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}`)
+ }
+
+ // For YouTube URLs, we can construct metadata without fetching
+ const isYouTube = /(?:youtube\.com|youtu\.be)/.test(targetUrl)
+ if (isYouTube) {
+ // Extract video ID
+ const patterns = [
+ /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
+ /youtube\.com\/watch\?.*v=([^&\n?#]+)/
+ ]
+
+ let videoId = null
+ for (const pattern of patterns) {
+ const match = targetUrl.match(pattern)
+ if (match && match[1]) {
+ videoId = match[1]
+ break
+ }
+ }
+
+ if (videoId) {
+ // Return YouTube-specific metadata
+ const ogData = {
+ url: targetUrl,
+ title: 'YouTube Video',
+ description: 'Watch this video on YouTube',
+ image: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
+ favicon: 'https://www.youtube.com/favicon.ico',
+ siteName: 'YouTube'
+ }
+
+ // Cache for 24 hours (86400 seconds)
+ await redis.set(cacheKey, JSON.stringify(ogData), 'EX', 86400)
+ console.log(`Cached YouTube metadata for ${targetUrl}`)
+
+ return json(ogData)
+ }
+ }
+
// Fetch the HTML content
const response = await fetch(targetUrl, {
headers: {
@@ -33,6 +86,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 +180,73 @@ 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 || ''
+ }
+ }
+ })
+ }
+
+ // For YouTube URLs, we can construct metadata without fetching
+ const isYouTube = /(?:youtube\.com|youtu\.be)/.test(targetUrl)
+ if (isYouTube) {
+ // Extract video ID
+ const patterns = [
+ /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
+ /youtube\.com\/watch\?.*v=([^&\n?#]+)/
+ ]
+
+ let videoId = null
+ for (const pattern of patterns) {
+ const match = targetUrl.match(pattern)
+ if (match && match[1]) {
+ videoId = match[1]
+ break
+ }
+ }
+
+ if (videoId) {
+ // Return YouTube-specific metadata
+ const ogData = {
+ url: targetUrl,
+ title: 'YouTube Video',
+ description: 'Watch this video on YouTube',
+ image: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
+ siteName: 'YouTube',
+ favicon: 'https://www.youtube.com/favicon.ico'
+ }
+
+ // Cache for 24 hours (86400 seconds)
+ await redis.set(cacheKey, JSON.stringify(ogData), 'EX', 86400)
+ console.log(`Cached YouTube metadata for ${targetUrl} (POST)`)
+
+ 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 +260,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,