Youtube embeds too

This commit is contained in:
Justin Edmund 2025-06-13 18:13:43 -04:00
parent 1f7b388a6c
commit fe30f9e9b2
6 changed files with 393 additions and 84 deletions

View file

@ -398,6 +398,30 @@
overflow: hidden; 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 // Mobile styles for URL embeds
@media (max-width: 640px) { @media (max-width: 640px) {
:global(.url-embed-link) { :global(.url-embed-link) {

View file

@ -100,8 +100,8 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
}, },
convertLinkToEmbed: convertLinkToEmbed:
(pos) => (pos) =>
({ state, dispatch }) => { ({ state, commands, chain }) => {
const { doc, schema, tr } = state const { doc } = state
// Find the link mark at the given position // Find the link mark at the given position
const $pos = doc.resolve(pos) const $pos = doc.resolve(pos)
@ -131,15 +131,20 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
} }
}) })
// Create the embed node // Use Tiptap's chain commands to replace content
const node = schema.nodes.urlEmbedPlaceholder.create({ url }) return chain()
.focus()
// Replace the range with the embed .deleteRange({ from, to })
if (dispatch) { .insertContent([
dispatch(tr.replaceRangeWith(from, to, node)) {
type: 'urlEmbedPlaceholder',
attrs: { url }
},
{
type: 'paragraph'
} }
])
return true .run()
} }
} }
}, },

View file

@ -11,6 +11,25 @@
let showContextMenu = $state(false) let showContextMenu = $state(false)
let contextMenuPosition = $state({ x: 0, y: 0 }) 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) => { const getDomain = (url: string) => {
try { try {
const urlObj = new URL(url) const urlObj = new URL(url)
@ -32,7 +51,9 @@
loading = true loading = true
try { 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) { if (!response.ok) {
throw new Error('Failed to fetch metadata') throw new Error('Failed to fetch metadata')
} }
@ -136,10 +157,58 @@
contenteditable={false} contenteditable={false}
data-drag-handle data-drag-handle
> >
{#if isYouTube}
{@const videoId = getYouTubeVideoId(node.attrs.url || '')}
<div
class="edra-youtube-embed-card"
onmouseenter={() => (showActions = true)}
onmouseleave={() => (showActions = false)}
onkeydown={handleKeydown}
oncontextmenu={handleContextMenu}
tabindex="0"
role="article"
>
{#if showActions && editor.isEditable}
<div class="edra-youtube-embed-actions">
<button
onclick={(e) => {
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"
>
<MoreHorizontal />
</button>
</div>
{/if}
{#if videoId}
<div class="edra-youtube-embed-player">
<iframe
src="https://www.youtube.com/embed/{videoId}"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
title="YouTube video player"
></iframe>
</div>
{:else}
<div class="edra-youtube-embed-error">
<p>Invalid YouTube URL</p>
</div>
{/if}
</div>
{:else}
<div <div
class="edra-url-embed-card" class="edra-url-embed-card"
onmouseenter={() => showActions = true} onmouseenter={() => (showActions = true)}
onmouseleave={() => showActions = false} onmouseleave={() => (showActions = false)}
onkeydown={handleKeydown} onkeydown={handleKeydown}
oncontextmenu={handleContextMenu} oncontextmenu={handleContextMenu}
tabindex="0" tabindex="0"
@ -176,7 +245,11 @@
{#if node.attrs.favicon} {#if node.attrs.favicon}
<img src={node.attrs.favicon} alt="" class="edra-url-embed-favicon" /> <img src={node.attrs.favicon} alt="" class="edra-url-embed-favicon" />
{/if} {/if}
<span class="edra-url-embed-domain">{node.attrs.siteName ? decodeHtmlEntities(node.attrs.siteName) : getDomain(node.attrs.url)}</span> <span class="edra-url-embed-domain"
>{node.attrs.siteName
? decodeHtmlEntities(node.attrs.siteName)
: getDomain(node.attrs.url)}</span
>
</div> </div>
{#if node.attrs.title} {#if node.attrs.title}
<h3 class="edra-url-embed-title">{decodeHtmlEntities(node.attrs.title)}</h3> <h3 class="edra-url-embed-title">{decodeHtmlEntities(node.attrs.title)}</h3>
@ -187,6 +260,7 @@
</div> </div>
</button> </button>
</div> </div>
{/if}
</NodeViewWrapper> </NodeViewWrapper>
{#if showContextMenu} {#if showContextMenu}
@ -387,6 +461,88 @@
animation: spin 1s linear infinite; 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 */ /* Mobile styles */
@media (max-width: 640px) { @media (max-width: 640px) {
.edra-url-embed-content { .edra-url-embed-content {

View file

@ -35,6 +35,7 @@
.focus() .focus()
.insertContentAt( .insertContentAt(
{ from: pos, to: pos + node.nodeSize }, { from: pos, to: pos + node.nodeSize },
[
{ {
type: 'urlEmbed', type: 'urlEmbed',
attrs: { attrs: {
@ -45,7 +46,11 @@
favicon: metadata.favicon, favicon: metadata.favicon,
siteName: metadata.siteName siteName: metadata.siteName
} }
},
{
type: 'paragraph'
} }
]
) )
.run() .run()
} }

View file

@ -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 = '<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 += 'allowfullscreen>'
embedHtml += '</iframe>'
embedHtml += '</div>'
embedHtml += '</div>'
return embedHtml
}
// Regular URL embed for non-YouTube links
let embedHtml = '<div class="url-embed-rendered">' let embedHtml = '<div class="url-embed-rendered">'
embedHtml += `<a href="${url}" target="_blank" rel="noopener noreferrer" class="url-embed-link">` embedHtml += `<a href="${url}" target="_blank" rel="noopener noreferrer" class="url-embed-link">`

View file

@ -25,6 +25,43 @@ export const GET: RequestHandler = async ({ url }) => {
console.log(`Force refresh requested for ${targetUrl}`) 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 // Fetch the HTML content
const response = await fetch(targetUrl, { const response = await fetch(targetUrl, {
headers: { headers: {
@ -163,6 +200,53 @@ export const POST: RequestHandler = async ({ request }) => {
}) })
} }
// 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 // Fetch the HTML content
const response = await fetch(targetUrl, { const response = await fetch(targetUrl, {
headers: { headers: {