Merge pull request #7 from jedmund/universe/embed-cards

Add link cards for URLs and Youtube
This commit is contained in:
Justin Edmund 2025-06-13 15:37:35 -07:00 committed by GitHub
commit f3119885bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 2952 additions and 493 deletions

View file

@ -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

View file

@ -305,5 +305,124 @@
border-radius: $unit;
}
}
// URL Embed styles
:global(.url-embed-rendered) {
margin: $unit-2x 0;
width: 100%;
&:first-child {
margin-top: 0;
}
}
:global(.url-embed-link) {
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);
text-decoration: none;
box-shadow: 0 0px 8px rgba(0, 0, 0, 0.08);
}
}
:global(.url-embed-image) {
width: 100%;
aspect-ratio: 2 / 1;
overflow: hidden;
background: $grey-90;
}
:global(.url-embed-image img) {
width: 100%;
height: 100%;
object-fit: cover;
}
:global(.url-embed-text) {
flex: 1;
padding: $unit-2x $unit-3x $unit-3x;
display: flex;
flex-direction: column;
gap: $unit;
min-width: 0;
}
:global(.url-embed-meta) {
display: flex;
align-items: center;
gap: $unit-half;
font-size: 0.8125rem;
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;
text-transform: lowercase;
}
:global(.url-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;
}
:global(.url-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;
}
// 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;
}
}
</style>

View file

@ -1,10 +1,15 @@
<script lang="ts">
import UniverseCard from './UniverseCard.svelte'
import { getContentExcerpt } from '$lib/utils/content'
import { extractEmbeds } from '$lib/utils/extractEmbeds'
import type { UniverseItem } from '../../routes/api/universe/+server'
let { post }: { post: UniverseItem } = $props()
// Extract embeds from content
const embeds = $derived(post.content ? extractEmbeds(post.content) : [])
const firstEmbed = $derived(embeds[0])
// Check if content is truncated
const isContentTruncated = $derived(() => {
if (post.content) {
@ -14,6 +19,16 @@
}
return false
})
// Helper to get domain from URL
const getDomain = (url: string) => {
try {
const urlObj = new URL(url)
return urlObj.hostname.replace('www.', '')
} catch {
return ''
}
}
</script>
<UniverseCard item={post} type="post">
@ -29,6 +44,46 @@
</div>
{/if}
{#if firstEmbed}
<div class="embed-preview">
{#if firstEmbed.type === 'youtube' && firstEmbed.videoId}
<div class="youtube-embed-preview">
<div class="youtube-player">
<iframe
src="https://www.youtube.com/embed/{firstEmbed.videoId}"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
title="YouTube video player"
></iframe>
</div>
</div>
{:else}
<a href="/universe/{post.slug}" class="url-embed-preview" tabindex="-1">
{#if firstEmbed.image}
<div class="embed-image">
<img src={firstEmbed.image} alt={firstEmbed.title || 'Link preview'} />
</div>
{/if}
<div class="embed-text">
<div class="embed-meta">
{#if firstEmbed.favicon}
<img src={firstEmbed.favicon} alt="" class="embed-favicon" />
{/if}
<span class="embed-domain">{firstEmbed.siteName || getDomain(firstEmbed.url)}</span>
</div>
{#if firstEmbed.title}
<h3 class="embed-title">{firstEmbed.title}</h3>
{/if}
{#if firstEmbed.description}
<p class="embed-description">{firstEmbed.description}</p>
{/if}
</div>
</a>
{/if}
</div>
{/if}
{#if post.postType === 'essay' && isContentTruncated}
<p>
<a href="/universe/{post.slug}" class="read-more" tabindex="-1">Continue reading</a>
@ -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;
}
}
</style>

View file

@ -297,7 +297,7 @@
.dropdown-divider {
height: 1px;
background-color: $grey-90;
background-color: $grey-80;
margin: $unit-half 0;
}
</style>

View file

@ -38,6 +38,10 @@
export function getContent() {
return editorRef?.getContent()
}
export function clear() {
editorRef?.clear()
}
</script>
<div class={`case-study-editor-wrapper ${mode} ${className}`}>

View file

@ -123,7 +123,7 @@
.dropdown-divider {
height: 1px;
background-color: $grey-90;
background-color: $grey-80;
margin: $unit-half 0;
}
</style>

View file

@ -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;

View file

@ -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'
@ -75,6 +84,23 @@
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 })
let linkEditUrl = $state<string>('')
let linkEditPos = $state<number | null>(null)
// Filter out unwanted commands
const getFilteredCommands = () => {
const filtered = { ...commands }
@ -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 @@
</div>
{/if}
{#if editor}
{#if showLinkBubbleMenu}
{#if false && showLinkBubbleMenu}
<LinkMenu {editor} />
{/if}
{#if showTableBubbleMenu}
@ -528,28 +673,7 @@
showMediaDropdown = false
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
<rect
x="3"
y="5"
width="14"
height="10"
stroke="currentColor"
stroke-width="2"
fill="none"
rx="1"
/>
<circle cx="7" cy="9" r="1.5" stroke="currentColor" stroke-width="2" fill="none" />
<path
d="M3 12L7 8L10 11L13 8L17 12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>
<span>Image</span>
Image
</button>
<button
class="dropdown-item"
@ -558,38 +682,7 @@
showMediaDropdown = false
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
<rect
x="2"
y="4"
width="12"
height="9"
stroke="currentColor"
stroke-width="2"
fill="none"
rx="1"
/>
<rect
x="6"
y="7"
width="12"
height="9"
stroke="currentColor"
stroke-width="2"
fill="none"
rx="1"
/>
<circle cx="6.5" cy="9.5" r="1" stroke="currentColor" stroke-width="1.5" fill="none" />
<path
d="M6 12L8 10L10 12L12 10L15 13"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>
<span>Gallery</span>
Gallery
</button>
<button
class="dropdown-item"
@ -598,20 +691,7 @@
showMediaDropdown = false
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
<rect
x="3"
y="4"
width="14"
height="12"
stroke="currentColor"
stroke-width="2"
fill="none"
rx="2"
/>
<path d="M8 8.5L12 10L8 11.5V8.5Z" fill="currentColor" />
</svg>
<span>Video</span>
Video
</button>
<button
class="dropdown-item"
@ -620,15 +700,17 @@
showMediaDropdown = false
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
<path
d="M10 4L10 16M6 8L6 12M14 8L14 12M2 6L2 14M18 6L18 14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
<span>Audio</span>
Audio
</button>
<div class="dropdown-separator"></div>
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().insertUrlEmbedPlaceholder().run()
showMediaDropdown = false
}}
>
Link
</button>
</div>
</div>
@ -729,6 +811,53 @@
</div>
{/if}
<!-- URL Convert Dropdown -->
{#if showUrlConvertDropdown}
<UrlConvertDropdown
x={urlConvertDropdownPosition.x}
y={urlConvertDropdownPosition.y}
onConvert={handleConvertToEmbed}
onDismiss={() => {
showUrlConvertDropdown = false
urlConvertPos = null
}}
/>
{/if}
<!-- Link Context Menu -->
{#if showLinkContextMenu && linkContextUrl}
<LinkContextMenuComponent
x={linkContextMenuPosition.x}
y={linkContextMenuPosition.y}
url={linkContextUrl}
onConvertToCard={handleConvertLinkToEmbed}
onEditLink={handleEditLink}
onCopyLink={handleCopyLink}
onRemoveLink={handleRemoveLink}
onOpenLink={handleOpenLink}
onDismiss={() => {
showLinkContextMenu = false
linkContextPos = null
linkContextUrl = null
}}
/>
{/if}
<!-- Link Edit Dialog -->
{#if showLinkEditDialog}
<LinkEditDialog
x={linkEditDialogPosition.x}
y={linkEditDialogPosition.y}
currentUrl={linkEditUrl}
onSave={handleSaveLink}
onCancel={() => {
showLinkEditDialog = false
linkEditPos = null
linkEditUrl = ''
}}
/>
{/if}
<style lang="scss">
.edra {
width: 100%;
@ -821,9 +950,7 @@
}
.dropdown-item {
display: flex;
align-items: center;
gap: 8px;
display: block;
width: 100%;
padding: 8px 16px;
text-align: left;
@ -840,12 +967,6 @@
background-color: #f5f5f5;
}
.dropdown-icon {
flex-shrink: 0;
width: 20px;
height: 20px;
}
.dropdown-separator {
height: 1px;
background-color: #e0e0e0;

View file

@ -306,7 +306,7 @@
.dropdown-divider {
height: 1px;
background-color: $grey-90;
background-color: $grey-80;
margin: $unit-half 0;
}

View file

@ -256,7 +256,7 @@
.dropdown-divider {
height: 1px;
background-color: $grey-90;
background-color: $grey-80;
margin: $unit-half 0;
}
</style>

View file

@ -134,7 +134,7 @@
.dropdown-divider {
height: 1px;
background-color: $grey-90;
background-color: $grey-80;
margin: $unit-half 0;
}

View file

@ -29,7 +29,7 @@
content: [{ type: 'paragraph' }]
}
let characterCount = 0
let editorInstance: Editor
let editorInstance: CaseStudyEditor
// Essay metadata
let essayTitle = ''
@ -457,31 +457,33 @@
</div>
{:else}
<div class="inline-composer">
<Button
variant="ghost"
iconOnly
buttonSize="icon"
onclick={switchToEssay}
title="Switch to essay mode"
class="floating-expand-button"
>
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M10 6L14 2M14 2H10M14 2V6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M6 10L2 14M2 14H6M2 14V10"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
{#if hasContent()}
<Button
variant="ghost"
iconOnly
buttonSize="icon"
onclick={switchToEssay}
title="Switch to essay mode"
class="floating-expand-button"
>
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M10 6L14 2M14 2H10M14 2V6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M6 10L2 14M2 14H6M2 14V10"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
{/if}
<div class="composer-body">
<CaseStudyEditor
bind:this={editorInstance}

View file

@ -349,5 +349,95 @@ export const commands: Record<string, EdraCommandGroup> = {
}
}
]
},
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()
}
}
]
}
}

View file

@ -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<LinkContextMenuOptions>({
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
}
}
}
})
]
}
})

View file

@ -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<string, any>
onShowDropdown?: (pos: number, url: string) => void
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
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<UrlEmbedOptions>({
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
}
}
})
]
}
})

View file

@ -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)
}
})

View file

@ -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)
}
})

View file

@ -0,0 +1,133 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { fly } from 'svelte/transition'
interface Props {
x: number
y: number
url: string
onConvertToLink: () => void
onCopyLink: () => void
onRefresh: () => void
onOpenLink: () => void
onRemove: () => void
onDismiss: () => void
}
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)
})
</script>
<div
bind:this={dropdown}
class="embed-context-menu"
style="left: {x}px; top: {y}px;"
transition:fly={{ y: -10, duration: 200 }}
tabindex="-1"
>
<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>
<div class="menu-divider"></div>
<button class="menu-item danger" onclick={onRemove}>
Remove card
</button>
</div>
<style lang="scss">
.embed-context-menu {
position: fixed;
z-index: 1050;
background: white;
border: 1px solid $grey-85;
border-radius: $unit;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
padding: 4px;
outline: none;
min-width: 200px;
max-width: 300px;
}
.menu-url {
padding: $unit $unit-2x;
font-size: 0.75rem;
color: $grey-40;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-divider {
height: 1px;
background-color: $grey-90;
margin: 4px 0;
}
.menu-item {
display: block;
width: 100%;
padding: $unit $unit-2x;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
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>

View file

@ -0,0 +1,133 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { fly } from 'svelte/transition'
interface Props {
x: number
y: number
url: string
onConvertToCard: () => void
onEditLink: () => void
onCopyLink: () => void
onRemoveLink: () => void
onOpenLink: () => void
onDismiss: () => void
}
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)
})
</script>
<div
bind:this={dropdown}
class="link-context-menu"
style="left: {x}px; top: {y}px;"
transition:fly={{ y: -10, duration: 200 }}
tabindex="-1"
>
<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>
<div class="menu-divider"></div>
<button class="menu-item danger" onclick={onRemoveLink}>
Remove link
</button>
</div>
<style lang="scss">
.link-context-menu {
position: fixed;
z-index: 1050;
background: white;
border: 1px solid $grey-85;
border-radius: $unit;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
padding: 4px;
outline: none;
min-width: 200px;
max-width: 300px;
}
.menu-url {
padding: $unit $unit-2x;
font-size: 0.75rem;
color: $grey-40;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-divider {
height: 1px;
background-color: $grey-90;
margin: 4px 0;
}
.menu-item {
display: block;
width: 100%;
padding: $unit $unit-2x;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
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>

View file

@ -0,0 +1,189 @@
<script lang="ts">
import { onMount } from 'svelte'
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
currentUrl: string
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 {
new URL(urlInput)
return true
} catch {
// Try with https:// prefix
try {
new URL('https://' + urlInput)
return true
} catch {
return false
}
}
})
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()
handleSave()
} else if (event.key === 'Escape') {
event.preventDefault()
onCancel()
}
}
onMount(() => {
inputElement?.focus()
inputElement?.select()
})
</script>
<div
bind:this={dialogElement}
class="link-edit-dialog"
style="left: {x}px; top: {y}px;"
transition:fly={{ y: -10, duration: 200 }}
onkeydown={handleKeydown}
>
<div class="dialog-content">
<input
bind:this={inputElement}
type="text"
bind:value={urlInput}
placeholder="Enter URL"
class="url-input"
class:invalid={urlInput && !isValid}
/>
<div class="dialog-actions">
<button
class="action-button save"
onclick={handleSave}
disabled={!isValid}
title="Save"
>
<Check />
</button>
<button
class="action-button cancel"
onclick={onCancel}
title="Cancel"
>
<X />
</button>
</div>
</div>
</div>
<style lang="scss">
.link-edit-dialog {
position: fixed;
z-index: 1051;
background: white;
border: 1px solid $grey-85;
border-radius: $unit;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
padding: $unit-2x;
outline: none;
min-width: 300px;
}
.dialog-content {
display: flex;
gap: $unit;
align-items: center;
}
.url-input {
flex: 1;
padding: $unit $unit-2x;
border: 1px solid $grey-85;
border-radius: 4px;
font-size: 0.875rem;
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;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: 1px solid $grey-85;
border-radius: 4px;
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>

View file

@ -0,0 +1,97 @@
<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()
} else if (event.key === 'Enter') {
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)
})
</script>
<div
bind:this={dropdown}
class="url-convert-dropdown"
style="left: {x}px; top: {y}px;"
transition:fly={{ y: -10, duration: 200 }}
tabindex="-1"
>
<button class="convert-button" onclick={handleConvert}>
Convert to card
</button>
</div>
<style lang="scss">
.url-convert-dropdown {
position: fixed;
z-index: 1050;
background: white;
border: 1px solid $grey-85;
border-radius: $unit;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
padding: 4px;
outline: none;
min-width: 160px;
}
.convert-button {
display: block;
width: 100%;
padding: $unit-2x $unit-3x;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
color: $grey-20;
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>

View file

@ -0,0 +1,557 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core'
import { NodeViewWrapper } from 'svelte-tiptap'
import MoreHorizontal from 'lucide-svelte/icons/more-horizontal'
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)
return urlObj.hostname.replace('www.', '')
} catch {
return ''
}
}
const decodeHtmlEntities = (text: string) => {
if (!text) return ''
const textarea = document.createElement('textarea')
textarea.innerHTML = text
return textarea.value
}
async function refreshMetadata() {
if (!node.attrs.url) return
loading = true
try {
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') {
editor
.chain()
.focus()
.updateAttributes('urlEmbed', {
title: metadata.title,
description: metadata.description,
image: metadata.image,
favicon: metadata.favicon,
siteName: metadata.siteName
})
.run()
}
} catch (err) {
console.error('Error refreshing metadata:', err)
} finally {
loading = false
}
}
function openLink() {
if (node.attrs.url) {
window.open(node.attrs.url, '_blank', 'noopener,noreferrer')
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Delete' || e.key === 'Backspace') {
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()
.focus()
.deleteRange({ from: pos, to: pos + node.nodeSize })
.insertContent({
type: 'text',
text: text,
marks: [
{
type: 'link',
attrs: {
href: url,
target: '_blank',
rel: 'noopener noreferrer'
}
}
]
})
.run()
}
function handleContextMenu(event: MouseEvent) {
if (!editor.isEditable) return
event.preventDefault()
contextMenuPosition = {
x: event.clientX,
y: event.clientY
}
showContextMenu = true
}
function copyLink() {
if (node.attrs.url) {
navigator.clipboard.writeText(node.attrs.url)
}
showContextMenu = false
}
function dismissContextMenu() {
showContextMenu = false
}
</script>
<NodeViewWrapper
class="edra-url-embed-wrapper {selected ? 'selected' : ''}"
contenteditable={false}
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
class="edra-url-embed-card"
onmouseenter={() => (showActions = true)}
onmouseleave={() => (showActions = false)}
onkeydown={handleKeydown}
oncontextmenu={handleContextMenu}
tabindex="0"
role="article"
>
{#if showActions && editor.isEditable}
<div class="edra-url-embed-actions">
<button
onclick={(e) => {
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"
>
<MoreHorizontal />
</button>
</div>
{/if}
<button class="edra-url-embed-content" onclick={openLink}>
{#if node.attrs.image}
<div class="edra-url-embed-image">
<img src={node.attrs.image} alt={node.attrs.title || 'Link preview'} />
</div>
{/if}
<div class="edra-url-embed-text">
<div class="edra-url-embed-meta">
{#if node.attrs.favicon}
<img src={node.attrs.favicon} alt="" class="edra-url-embed-favicon" />
{/if}
<span class="edra-url-embed-domain"
>{node.attrs.siteName
? decodeHtmlEntities(node.attrs.siteName)
: getDomain(node.attrs.url)}</span
>
</div>
{#if node.attrs.title}
<h3 class="edra-url-embed-title">{decodeHtmlEntities(node.attrs.title)}</h3>
{/if}
{#if node.attrs.description}
<p class="edra-url-embed-description">{decodeHtmlEntities(node.attrs.description)}</p>
{/if}
</div>
</button>
</div>
{/if}
</NodeViewWrapper>
{#if showContextMenu}
<EmbedContextMenu
x={contextMenuPosition.x}
y={contextMenuPosition.y}
url={node.attrs.url || ''}
onConvertToLink={() => {
convertToLink()
showContextMenu = false
}}
onCopyLink={copyLink}
onRefresh={() => {
refreshMetadata()
showContextMenu = false
}}
onOpenLink={() => {
openLink()
showContextMenu = false
}}
onRemove={() => {
deleteNode()
showContextMenu = false
}}
onDismiss={dismissContextMenu}
/>
{/if}
<style lang="scss">
.edra-url-embed-wrapper {
margin: 1.5rem 0;
position: relative;
}
.edra-url-embed-card {
position: relative;
width: 100%;
max-width: 600px;
border: 1px solid transparent;
border-radius: $corner-radius;
transition: all 0.2s ease;
}
.edra-url-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-url-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:not(:disabled) {
background: $grey-95;
color: $grey-20;
}
&.delete:hover {
background: #fee;
color: $red-60;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
width: 16px;
height: 16px;
}
}
.edra-url-embed-content {
display: flex;
width: 100%;
background: $grey-95;
border-radius: $corner-radius;
overflow: hidden;
border: 1px solid $grey-85;
padding: 0;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
/* Reset button styles that might be inherited */
font-family: inherit;
font-size: inherit;
line-height: inherit;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
&:hover {
border-color: $grey-60;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&:focus {
outline: none;
}
}
.edra-url-embed-image {
flex-shrink: 0;
width: 200px;
height: 150px;
overflow: hidden;
background: $grey-80;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.edra-url-embed-text {
flex: 1;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.edra-url-embed-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: $grey-40;
}
.edra-url-embed-favicon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.edra-url-embed-domain {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.edra-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;
}
.edra-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;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
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 {
flex-direction: column;
}
.edra-url-embed-image {
width: 100%;
height: 200px;
}
}
</style>

View file

@ -0,0 +1,277 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core'
import Link from 'lucide-svelte/icons/link'
import LoaderCircle from 'lucide-svelte/icons/loader-circle'
import AlertCircle from 'lucide-svelte/icons/alert-circle'
import { NodeViewWrapper } from 'svelte-tiptap'
import { onMount } from 'svelte'
const { editor, node, deleteNode, getPos }: NodeViewProps = $props()
let loading = $state(true)
let error = $state(false)
let errorMessage = $state('')
let inputUrl = $state(node.attrs.url || '')
let showInput = $state(!node.attrs.url)
async function fetchMetadata(url: string) {
loading = true
error = false
errorMessage = ''
try {
const response = await fetch(`/api/og-metadata?url=${encodeURIComponent(url)}`)
if (!response.ok) {
throw new Error('Failed to fetch metadata')
}
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'
}
]
)
.run()
}
} catch (err) {
console.error('Error fetching URL metadata:', err)
error = true
errorMessage = 'Failed to load preview. Please check the URL and try again.'
loading = false
}
}
function handleSubmit() {
if (!inputUrl.trim()) return
// Basic URL validation
try {
new URL(inputUrl)
fetchMetadata(inputUrl)
} catch {
error = true
errorMessage = 'Please enter a valid URL'
loading = false
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
} else if (e.key === 'Escape') {
deleteNode()
}
}
function handleClick(e: MouseEvent) {
if (!editor.isEditable) return
e.preventDefault()
if (!showInput) {
showInput = true
}
}
onMount(() => {
// If we have a URL from paste, fetch metadata immediately
if (node.attrs.url) {
fetchMetadata(node.attrs.url)
}
})
</script>
<NodeViewWrapper class="edra-url-embed-placeholder-wrapper" contenteditable={false}>
{#if showInput && !node.attrs.url}
<div class="url-input-container">
<input
bind:value={inputUrl}
onkeydown={handleKeydown}
placeholder="Paste or type a URL..."
class="url-input"
autofocus
/>
<button onclick={handleSubmit} class="submit-button" disabled={!inputUrl.trim()}>
Embed
</button>
</div>
{:else if loading}
<div class="placeholder-content loading">
<LoaderCircle class="animate-spin placeholder-icon" />
<span class="placeholder-text">Loading preview...</span>
</div>
{:else if error}
<div class="placeholder-content error">
<AlertCircle class="placeholder-icon" />
<div class="error-content">
<span class="placeholder-text">{errorMessage}</span>
<button onclick={() => { showInput = true; error = false; }} class="retry-button">
Try another URL
</button>
</div>
</div>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<span
class="placeholder-content"
onclick={handleClick}
tabindex="0"
role="button"
aria-label="Insert URL embed"
>
<Link class="placeholder-icon" />
<span class="placeholder-text">Embed a link</span>
</span>
{/if}
</NodeViewWrapper>
<style lang="scss">
.edra-url-embed-placeholder-wrapper {
margin: 1rem 0;
}
.url-input-container {
display: flex;
gap: 0.5rem;
padding: 1rem;
background: var(--grey-95, #f8f9fa);
border: 2px solid var(--grey-85, #e9ecef);
border-radius: 8px;
}
.url-input {
flex: 1;
padding: 0.5rem 1rem;
border: 1px solid var(--grey-80, #dee2e6);
border-radius: 6px;
font-size: 0.875rem;
background: white;
&:focus {
outline: none;
border-color: var(--primary-color, #3b82f6);
}
}
.submit-button {
padding: 0.5rem 1rem;
background: var(--primary-color, #3b82f6);
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
&:hover:not(:disabled) {
background: var(--primary-hover, #2563eb);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.placeholder-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
border: 2px dashed var(--grey-80, #dee2e6);
border-radius: 8px;
background: var(--grey-95, #f8f9fa);
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(.loading):not(.error) {
border-color: var(--grey-60, #adb5bd);
background: var(--grey-90, #e9ecef);
}
&.loading {
cursor: default;
}
&.error {
border-color: var(--red-60, #dc3545);
background: #fee;
cursor: default;
}
}
.placeholder-icon {
width: 32px;
height: 32px;
color: var(--grey-50, #6c757d);
}
.error .placeholder-icon {
color: var(--red-60, #dc3545);
}
.placeholder-text {
font-size: 0.875rem;
color: var(--grey-30, #495057);
}
.error-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.retry-button {
padding: 0.25rem 0.75rem;
background: transparent;
color: var(--red-60, #dc3545);
border: 1px solid var(--red-60, #dc3545);
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--red-60, #dc3545);
color: white;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>

View file

@ -157,6 +157,89 @@ function renderTiptapContent(doc: any): string {
return '<br>'
}
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 = '<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">'
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) {
embedHtml += `<img src="${favicon}" alt="" class="url-embed-favicon" />`
}
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
}
default: {
// For any unknown block types, try to render their content
if (node.content) {

View file

@ -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
}

View file

@ -1,371 +0,0 @@
<script lang="ts">
import AdminPage from '$lib/components/admin/AdminPage.svelte'
import Editor from '$lib/components/admin/Editor.svelte'
import type { JSONContent } from '@tiptap/core'
let testContent = $state<JSONContent>({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Test the image upload functionality:'
}
]
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: '1. Try pasting an image from clipboard (Cmd+V)'
}
]
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: '2. Click the image placeholder below to upload'
}
]
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: '3. Drag and drop an image onto the placeholder'
}
]
},
{
type: 'image-placeholder'
}
]
})
let uploadedImages = $state<Array<{ url: string; timestamp: string }>>([])
function handleEditorChange(content: JSONContent) {
testContent = content
// Extract images from content
const images: Array<{ url: string; timestamp: string }> = []
function extractImages(node: any) {
if (node.type === 'image' && node.attrs?.src) {
images.push({
url: node.attrs.src,
timestamp: new Date().toLocaleTimeString()
})
}
if (node.content) {
node.content.forEach(extractImages)
}
}
if (content.content) {
content.content.forEach(extractImages)
}
uploadedImages = images
}
// Check local uploads directory
let localUploadsExist = $state(false)
async function checkLocalUploads() {
try {
const auth = localStorage.getItem('admin_auth')
if (!auth) return
const response = await fetch('/api/media?limit=10', {
headers: { Authorization: `Basic ${auth}` }
})
if (response.ok) {
const data = await response.json()
localUploadsExist = data.media.some((m: any) => m.url.includes('/local-uploads/'))
}
} catch (error) {
console.error('Failed to check uploads:', error)
}
}
// Check on mount and when images change
$effect(() => {
checkLocalUploads()
})
// Re-check when new images are uploaded
$effect(() => {
if (uploadedImages.length > 0) {
setTimeout(checkLocalUploads, 500) // Small delay to ensure DB is updated
}
})
</script>
<AdminPage>
<header slot="header">
<h1>Upload Test</h1>
<a href="/admin/projects" class="back-link">← Back to Projects</a>
</header>
<div class="test-container">
<div class="info-section">
<h2>Image Upload Test</h2>
<p>This page helps you test that image uploads are working correctly.</p>
{#if localUploadsExist}
<div class="status success">✅ Local uploads directory is configured</div>
{:else}
<div class="status warning">⚠️ No local uploads found yet</div>
{/if}
<div class="instructions">
<h3>How to test:</h3>
<ol>
<li>Copy an image to your clipboard</li>
<li>Click in the editor below and paste (Cmd+V)</li>
<li>Or click the image placeholder to browse files</li>
<li>Or drag and drop an image onto the placeholder</li>
</ol>
</div>
</div>
<div class="editor-section">
<h3>Editor with Image Upload</h3>
<Editor
bind:data={testContent}
onChange={handleEditorChange}
placeholder="Start typing or paste an image..."
minHeight={400}
/>
</div>
{#if uploadedImages.length > 0}
<div class="results-section">
<h3>Uploaded Images</h3>
<div class="image-grid">
{#each uploadedImages as image}
<div class="uploaded-image">
<img src={image.url} alt="Uploaded" />
<div class="image-info">
<span class="timestamp">{image.timestamp}</span>
<code class="url">{image.url}</code>
{#if image.url.includes('/local-uploads/')}
<span class="badge local">Local</span>
{:else if image.url.includes('cloudinary')}
<span class="badge cloud">Cloudinary</span>
{:else}
<span class="badge">Unknown</span>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
<div class="json-section">
<h3>Editor Content (JSON)</h3>
<pre>{JSON.stringify(testContent, null, 2)}</pre>
</div>
</div>
</AdminPage>
<style lang="scss">
header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
h1 {
font-size: 1.75rem;
font-weight: 700;
margin: 0;
color: $grey-10;
}
.back-link {
color: $grey-40;
text-decoration: none;
&:hover {
color: $grey-20;
}
}
}
.test-container {
max-width: 800px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.info-section {
background: white;
padding: $unit-4x;
border-radius: $unit-2x;
margin-bottom: $unit-4x;
box-sizing: border-box;
overflow: hidden;
h2 {
margin: 0 0 $unit-2x;
}
p {
color: $grey-30;
margin-bottom: $unit-3x;
}
}
.status {
padding: $unit-2x $unit-3x;
border-radius: $unit;
margin-bottom: $unit-3x;
&.success {
background: #e6f7e6;
color: #2d662d;
}
&.warning {
background: #fff4e6;
color: #996600;
}
}
.instructions {
h3 {
font-size: 1rem;
margin-bottom: $unit-2x;
}
ol {
margin: 0;
padding-left: $unit-4x;
li {
margin-bottom: $unit;
color: $grey-30;
}
}
}
.editor-section {
background: white;
padding: $unit-4x;
border-radius: $unit-2x;
margin-bottom: $unit-4x;
box-sizing: border-box;
overflow: hidden;
h3 {
margin: 0 0 $unit-3x;
}
}
.results-section {
background: white;
padding: $unit-4x;
border-radius: $unit-2x;
margin-bottom: $unit-4x;
box-sizing: border-box;
overflow: hidden;
h3 {
margin: 0 0 $unit-3x;
}
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: $unit-3x;
}
.uploaded-image {
border: 1px solid $grey-80;
border-radius: $unit;
overflow: hidden;
img {
width: 100%;
height: 150px;
object-fit: cover;
}
.image-info {
padding: $unit-2x;
background: $grey-95;
.timestamp {
display: block;
font-size: 0.875rem;
color: $grey-40;
margin-bottom: $unit;
}
.url {
display: block;
font-size: 0.75rem;
word-break: break-all;
color: $grey-30;
margin-bottom: $unit;
overflow-wrap: break-word;
max-width: 100%;
}
.badge {
display: inline-block;
padding: 2px 8px;
font-size: 0.75rem;
border-radius: 4px;
&.local {
background: #e6f0ff;
color: #0066cc;
}
&.cloud {
background: #f0e6ff;
color: #6600cc;
}
}
}
}
.json-section {
background: white;
padding: $unit-4x;
border-radius: $unit-2x;
box-sizing: border-box;
overflow: hidden;
h3 {
margin: 0 0 $unit-3x;
}
pre {
background: $grey-95;
padding: $unit-3x;
border-radius: $unit;
overflow-x: auto;
overflow-y: auto;
max-height: 400px;
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
color: $grey-10;
white-space: pre-wrap;
word-wrap: break-word;
}
}
</style>

View file

@ -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,