This commit is contained in:
Justin Edmund 2025-06-13 21:22:49 -04:00
parent b3979008ae
commit cc6eba7df1
16 changed files with 260 additions and 237 deletions

View file

@ -1,3 +1,3 @@
/* Global styles for the entire application */
@import './assets/styles/reset.css';
@import './assets/styles/globals.scss';
@import './assets/styles/globals.scss';

View file

@ -3,4 +3,4 @@
@import './variables.scss';
@import './fonts.scss';
@import './themes.scss';
@import './themes.scss';

View file

@ -119,9 +119,7 @@
onmouseenter={() => (hoveredIndex = index)}
onmouseleave={() => (hoveredIndex = null)}
>
<item.icon
class="nav-icon {hoveredIndex === index ? 'animate' : ''}"
/>
<item.icon class="nav-icon {hoveredIndex === index ? 'animate' : ''}" />
<span>{item.text}</span>
</a>
{/each}

View file

@ -83,18 +83,18 @@
let mediaDropdownTriggerRef = $state<HTMLElement>()
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<number | null>(null)
// Link context menu state
let showLinkContextMenu = $state(false)
let linkContextMenuPosition = $state({ x: 0, y: 0 })
let linkContextUrl = $state<string | null>(null)
let linkContextPos = $state<number | null>(null)
// Link edit dialog state
let showLinkEditDialog = $state(false)
let linkEditDialogPosition = $state({ x: 0, y: 0 })
@ -239,85 +239,89 @@
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 }) => {
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
@ -325,7 +329,13 @@
}
$effect(() => {
if (showTextStyleDropdown || showMediaDropdown || showUrlConvertDropdown || showLinkContextMenu || showLinkEditDialog) {
if (
showTextStyleDropdown ||
showMediaDropdown ||
showUrlConvertDropdown ||
showLinkContextMenu ||
showLinkEditDialog
) {
document.addEventListener('click', handleClickOutside)
return () => {
document.removeEventListener('click', handleClickOutside)
@ -484,16 +494,16 @@
// 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'
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 })

View file

@ -2,7 +2,7 @@ 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
onShowContextMenu?: (pos: number, url: string, coords: { x: number; y: number }) => void
}
export const LinkContextMenu = Extension.create<LinkContextMenuOptions>({
@ -16,7 +16,7 @@ export const LinkContextMenu = Extension.create<LinkContextMenuOptions>({
addProseMirrorPlugins() {
const options = this.options
return [
new Plugin({
key: new PluginKey('linkContextMenu'),
@ -25,26 +25,26 @@ export const LinkContextMenu = Extension.create<LinkContextMenuOptions>({
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')
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
}
}
@ -52,4 +52,4 @@ export const LinkContextMenu = Extension.create<LinkContextMenuOptions>({
})
]
}
})
})

View file

@ -78,7 +78,10 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes({ 'data-url-embed': '' }, this.options.HTMLAttributes, HTMLAttributes)]
return [
'div',
mergeAttributes({ 'data-url-embed': '' }, this.options.HTMLAttributes, HTMLAttributes)
]
},
addCommands() {
@ -102,35 +105,41 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
(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')
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)) {
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)) {
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()
@ -179,40 +188,53 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
// 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()@:%_\+.~#?&//=]*)$/
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++) {
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)
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++) {
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)
const hasLink = $endPos
.marks()
.some((m) => m.type.name === 'link' && m.attrs.href === pastedUrl)
if (hasLink) {
linkEnd = endPos + 1
} else {
@ -225,7 +247,7 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
// Position might be invalid, continue
}
}
if (linkStart !== -1) {
// Store the pasted URL info with correct position
const tr = state.tr.setMeta('urlEmbedPaste', {
@ -233,7 +255,7 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
lastPastedPos: linkStart
})
view.dispatch(tr)
// Notify the editor to show dropdown
if (options.onShowDropdown) {
options.onShowDropdown(linkStart, pastedUrl)
@ -251,4 +273,4 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
})
]
}
})
})

View file

@ -49,4 +49,4 @@ export const UrlEmbedExtended = (component: any) =>
addNodeView() {
return SvelteNodeViewRenderer(component)
}
})
})

View file

@ -32,4 +32,4 @@ export const UrlEmbedPlaceholder = (component: any) =>
addNodeView() {
return SvelteNodeViewRenderer(component)
}
})
})

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { fly } from 'svelte/transition'
interface Props {
x: number
y: number
@ -13,29 +13,39 @@
onRemove: () => void
onDismiss: () => void
}
let { x, y, url, onConvertToLink, onCopyLink, onRefresh, onOpenLink, onRemove, onDismiss }: Props = $props()
let {
x,
y,
url,
onConvertToLink,
onCopyLink,
onRefresh,
onOpenLink,
onRemove,
onDismiss
}: Props = $props()
let dropdown: HTMLDivElement
function handleClickOutside(event: MouseEvent) {
if (dropdown && !dropdown.contains(event.target as Node)) {
onDismiss()
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onDismiss()
}
}
onMount(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleKeydown)
dropdown?.focus()
})
onDestroy(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeydown)
@ -51,28 +61,18 @@
>
<div class="menu-url">{url}</div>
<div class="menu-divider"></div>
<button class="menu-item" onclick={onOpenLink}>
Open link
</button>
<button class="menu-item" onclick={onCopyLink}>
Copy link
</button>
<button class="menu-item" onclick={onRefresh}>
Refresh preview
</button>
<button class="menu-item" onclick={onConvertToLink}>
Convert to link
</button>
<button class="menu-item" onclick={onOpenLink}> Open link </button>
<button class="menu-item" onclick={onCopyLink}> Copy link </button>
<button class="menu-item" onclick={onRefresh}> Refresh preview </button>
<button class="menu-item" onclick={onConvertToLink}> Convert to link </button>
<div class="menu-divider"></div>
<button class="menu-item danger" onclick={onRemove}>
Remove card
</button>
<button class="menu-item danger" onclick={onRemove}> Remove card </button>
</div>
<style lang="scss">
@ -88,7 +88,7 @@
min-width: 200px;
max-width: 300px;
}
.menu-url {
padding: $unit $unit-2x;
font-size: 0.75rem;
@ -97,13 +97,13 @@
overflow: hidden;
text-overflow: ellipsis;
}
.menu-divider {
height: 1px;
background-color: $grey-90;
margin: 4px 0;
}
.menu-item {
display: block;
width: 100%;
@ -116,18 +116,18 @@
color: $grey-20;
text-align: left;
transition: background-color 0.2s;
&:hover {
background-color: $grey-95;
}
&:focus {
outline: 2px solid $red-60;
outline-offset: -2px;
}
&.danger {
color: $red-60;
}
}
</style>
</style>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { fly } from 'svelte/transition'
interface Props {
x: number
y: number
@ -13,29 +13,39 @@
onOpenLink: () => void
onDismiss: () => void
}
let { x, y, url, onConvertToCard, onEditLink, onCopyLink, onRemoveLink, onOpenLink, onDismiss }: Props = $props()
let {
x,
y,
url,
onConvertToCard,
onEditLink,
onCopyLink,
onRemoveLink,
onOpenLink,
onDismiss
}: Props = $props()
let dropdown: HTMLDivElement
function handleClickOutside(event: MouseEvent) {
if (dropdown && !dropdown.contains(event.target as Node)) {
onDismiss()
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onDismiss()
}
}
onMount(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleKeydown)
dropdown?.focus()
})
onDestroy(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeydown)
@ -51,28 +61,18 @@
>
<div class="menu-url">{url}</div>
<div class="menu-divider"></div>
<button class="menu-item" onclick={onOpenLink}>
Open link
</button>
<button class="menu-item" onclick={onEditLink}>
Edit link
</button>
<button class="menu-item" onclick={onCopyLink}>
Copy link
</button>
<button class="menu-item" onclick={onConvertToCard}>
Convert to card
</button>
<button class="menu-item" onclick={onOpenLink}> Open link </button>
<button class="menu-item" onclick={onEditLink}> Edit link </button>
<button class="menu-item" onclick={onCopyLink}> Copy link </button>
<button class="menu-item" onclick={onConvertToCard}> Convert to card </button>
<div class="menu-divider"></div>
<button class="menu-item danger" onclick={onRemoveLink}>
Remove link
</button>
<button class="menu-item danger" onclick={onRemoveLink}> Remove link </button>
</div>
<style lang="scss">
@ -88,7 +88,7 @@
min-width: 200px;
max-width: 300px;
}
.menu-url {
padding: $unit $unit-2x;
font-size: 0.75rem;
@ -97,13 +97,13 @@
overflow: hidden;
text-overflow: ellipsis;
}
.menu-divider {
height: 1px;
background-color: $grey-90;
margin: 4px 0;
}
.menu-item {
display: block;
width: 100%;
@ -116,18 +116,18 @@
color: $grey-20;
text-align: left;
transition: background-color 0.2s;
&:hover {
background-color: $grey-95;
}
&:focus {
outline: 2px solid $red-60;
outline-offset: -2px;
}
&.danger {
color: $red-60;
}
}
</style>
</style>

View file

@ -3,7 +3,7 @@
import { fly } from 'svelte/transition'
import Check from 'lucide-svelte/icons/check'
import X from 'lucide-svelte/icons/x'
interface Props {
x: number
y: number
@ -11,13 +11,13 @@
onSave: (url: string) => void
onCancel: () => void
}
let { x, y, currentUrl, onSave, onCancel }: Props = $props()
let urlInput = $state(currentUrl)
let inputElement: HTMLInputElement
let dialogElement: HTMLDivElement
const isValid = $derived(() => {
if (!urlInput.trim()) return false
try {
@ -33,19 +33,19 @@
}
}
})
function handleSave() {
if (!isValid) return
let finalUrl = urlInput.trim()
// Add https:// if no protocol
if (!finalUrl.match(/^https?:\/\//)) {
finalUrl = 'https://' + finalUrl
}
onSave(finalUrl)
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && isValid) {
event.preventDefault()
@ -55,7 +55,7 @@
onCancel()
}
}
onMount(() => {
inputElement?.focus()
inputElement?.select()
@ -79,19 +79,10 @@
class:invalid={urlInput && !isValid}
/>
<div class="dialog-actions">
<button
class="action-button save"
onclick={handleSave}
disabled={!isValid}
title="Save"
>
<button class="action-button save" onclick={handleSave} disabled={!isValid} title="Save">
<Check />
</button>
<button
class="action-button cancel"
onclick={onCancel}
title="Cancel"
>
<button class="action-button cancel" onclick={onCancel} title="Cancel">
<X />
</button>
</div>
@ -110,13 +101,13 @@
outline: none;
min-width: 300px;
}
.dialog-content {
display: flex;
gap: $unit;
align-items: center;
}
.url-input {
flex: 1;
padding: $unit $unit-2x;
@ -126,22 +117,22 @@
color: $grey-20;
background: white;
transition: border-color 0.2s;
&:focus {
outline: none;
border-color: $red-60;
}
&.invalid {
border-color: $red-60;
}
}
.dialog-actions {
display: flex;
gap: 4px;
}
.action-button {
display: flex;
align-items: center;
@ -155,35 +146,35 @@
cursor: pointer;
transition: all 0.2s;
color: $grey-40;
svg {
width: 16px;
height: 16px;
}
&:hover:not(:disabled) {
background-color: $grey-95;
color: $grey-20;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.save:not(:disabled) {
color: $red-60;
border-color: $red-60;
&:hover {
background-color: $red-60;
color: white;
}
}
&.cancel:hover {
color: $red-60;
border-color: $red-60;
}
}
</style>
</style>

View file

@ -1,28 +1,28 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { fly } from 'svelte/transition'
interface Props {
x: number
y: number
onConvert: () => void
onDismiss: () => void
}
let { x, y, onConvert, onDismiss }: Props = $props()
let dropdown: HTMLDivElement
function handleConvert() {
onConvert()
}
function handleClickOutside(event: MouseEvent) {
if (dropdown && !dropdown.contains(event.target as Node)) {
onDismiss()
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onDismiss()
@ -30,16 +30,16 @@
handleConvert()
}
}
onMount(() => {
// Add event listeners
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleKeydown)
// Don't focus the dropdown - this steals focus from the editor
// dropdown?.focus()
})
onDestroy(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeydown)
@ -53,9 +53,7 @@
transition:fly={{ y: -10, duration: 200 }}
tabindex="-1"
>
<button class="convert-button" onclick={handleConvert}>
Convert to card
</button>
<button class="convert-button" onclick={handleConvert}> Convert to card </button>
</div>
<style lang="scss">
@ -70,7 +68,7 @@
outline: none;
min-width: 160px;
}
.convert-button {
display: block;
width: 100%;
@ -84,14 +82,14 @@
white-space: nowrap;
transition: background-color 0.2s;
text-align: left;
&:hover {
background-color: $grey-95;
}
&:focus {
outline: 2px solid $red-60;
outline-offset: -2px;
}
}
</style>
</style>

View file

@ -7,7 +7,7 @@
import { onMount } from 'svelte'
const { editor, node, deleteNode, getPos }: NodeViewProps = $props()
let loading = $state(true)
let error = $state(false)
let errorMessage = $state('')
@ -26,32 +26,29 @@
}
const metadata = await response.json()
// Replace this placeholder with the actual URL embed
const pos = getPos()
if (typeof pos === 'number') {
editor
.chain()
.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: 'paragraph'
.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: 'paragraph'
}
])
.run()
}
} catch (err) {
@ -64,7 +61,7 @@
function handleSubmit() {
if (!inputUrl.trim()) return
// Basic URL validation
try {
new URL(inputUrl)
@ -88,7 +85,7 @@
function handleClick(e: MouseEvent) {
if (!editor.isEditable) return
e.preventDefault()
if (!showInput) {
showInput = true
}
@ -126,7 +123,13 @@
<AlertCircle class="placeholder-icon" />
<div class="error-content">
<span class="placeholder-text">{errorMessage}</span>
<button onclick={() => { showInput = true; error = false; }} class="retry-button">
<button
onclick={() => {
showInput = true
error = false
}}
class="retry-button"
>
Try another URL
</button>
</div>
@ -167,7 +170,7 @@
border-radius: 6px;
font-size: 0.875rem;
background: white;
&:focus {
outline: none;
border-color: var(--primary-color, #3b82f6);
@ -274,4 +277,4 @@
.animate-spin {
animation: spin 1s linear infinite;
}
</style>
</style>

View file

@ -164,7 +164,7 @@ function renderTiptapContent(doc: any): string {
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 {
@ -174,14 +174,14 @@ function renderTiptapContent(doc: any): string {
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]) {
@ -190,33 +190,34 @@ function renderTiptapContent(doc: any): string {
}
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 = '<div class="url-embed-rendered url-embed-youtube">'
embedHtml += '<div class="youtube-embed-wrapper">'
embedHtml += `<iframe src="https://www.youtube.com/embed/${videoId}" `
embedHtml += 'frameborder="0" '
embedHtml += 'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" '
embedHtml +=
'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" '
embedHtml += 'allowfullscreen>'
embedHtml += '</iframe>'
embedHtml += '</div>'
embedHtml += '</div>'
return embedHtml
}
// Regular URL embed for non-YouTube links
let embedHtml = '<div class="url-embed-rendered">'
embedHtml += `<a href="${url}" target="_blank" rel="noopener noreferrer" class="url-embed-link">`
if (image) {
embedHtml += `<div class="url-embed-image"><img src="${image}" alt="${title || 'Link preview'}" /></div>`
}
embedHtml += '<div class="url-embed-text">'
embedHtml += '<div class="url-embed-meta">'
if (favicon) {
@ -224,19 +225,19 @@ function renderTiptapContent(doc: any): string {
}
embedHtml += `<span class="url-embed-domain">${siteName || getDomain(url)}</span>`
embedHtml += '</div>'
if (title) {
embedHtml += `<h3 class="url-embed-title">${title}</h3>`
}
if (description) {
embedHtml += `<p class="url-embed-description">${description}</p>`
}
embedHtml += '</div>'
embedHtml += '</a>'
embedHtml += '</div>'
return embedHtml
}

View file

@ -21,7 +21,7 @@ export function extractEmbeds(content: any): ExtractedEmbed[] {
/(?: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]) {
@ -36,7 +36,7 @@ export function extractEmbeds(content: any): ExtractedEmbed[] {
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) {
@ -76,4 +76,4 @@ export function extractEmbeds(content: any): ExtractedEmbed[] {
}
return embeds
}
}

View file

@ -13,10 +13,10 @@ export const GET: RequestHandler = async ({ url }) => {
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))
@ -33,7 +33,7 @@ export const GET: RequestHandler = async ({ url }) => {
/(?: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)
@ -183,7 +183,7 @@ export const POST: RequestHandler = async ({ request }) => {
// 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)
@ -208,7 +208,7 @@ export const POST: RequestHandler = async ({ request }) => {
/(?: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)