diff --git a/memento-note/app/api/image-proxy/route.ts b/memento-note/app/api/image-proxy/route.ts
new file mode 100644
index 0000000..e8193a7
--- /dev/null
+++ b/memento-note/app/api/image-proxy/route.ts
@@ -0,0 +1,43 @@
+import { NextRequest, NextResponse } from 'next/server'
+
+export async function GET(req: NextRequest) {
+ const url = req.nextUrl.searchParams.get('url')
+ if (!url) return NextResponse.json({ error: 'Missing url' }, { status: 400 })
+
+ try {
+ const parsed = new URL(url)
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
+ return NextResponse.json({ error: 'Invalid protocol' }, { status: 400 })
+ }
+
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), 5000)
+
+ const res = await fetch(url, {
+ signal: controller.signal,
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (compatible; MementoBot/1.0)',
+ 'Accept': 'image/*',
+ 'Referer': parsed.origin,
+ },
+ })
+ clearTimeout(timeout)
+
+ if (!res.ok) return NextResponse.json({ error: 'Fetch failed' }, { status: 502 })
+
+ const contentType = res.headers.get('content-type') || 'image/jpeg'
+ if (!contentType.startsWith('image/')) {
+ return NextResponse.json({ error: 'Not an image' }, { status: 400 })
+ }
+
+ const buffer = await res.arrayBuffer()
+ return new NextResponse(buffer, {
+ headers: {
+ 'Content-Type': contentType,
+ 'Cache-Control': 'public, s-maxage=604800',
+ },
+ })
+ } catch {
+ return NextResponse.json({ error: 'Failed' }, { status: 502 })
+ }
+}
diff --git a/memento-note/app/api/link-preview/route.ts b/memento-note/app/api/link-preview/route.ts
new file mode 100644
index 0000000..b009da6
--- /dev/null
+++ b/memento-note/app/api/link-preview/route.ts
@@ -0,0 +1,101 @@
+import { NextRequest, NextResponse } from 'next/server'
+
+export async function GET(req: NextRequest) {
+ const url = req.nextUrl.searchParams.get('url')
+ if (!url) {
+ return NextResponse.json({ error: 'Missing url' }, { status: 400 })
+ }
+
+ try {
+ const parsed = new URL(url)
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
+ return NextResponse.json({ error: 'Invalid protocol' }, { status: 400 })
+ }
+
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), 8000)
+
+ const res = await fetch(url, {
+ signal: controller.signal,
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (compatible; MementoBot/1.0)',
+ 'Accept': 'text/html',
+ },
+ redirect: 'follow',
+ })
+
+ clearTimeout(timeout)
+
+ if (!res.ok) {
+ return NextResponse.json({ error: 'Fetch failed' }, { status: 502 })
+ }
+
+ const html = await res.text()
+ const data = extractMetadata(html, parsed)
+
+ return NextResponse.json(data, {
+ headers: { 'Cache-Control': 'public, s-maxage=86400' },
+ })
+ } catch {
+ return NextResponse.json({ error: 'Failed to fetch' }, { status: 502 })
+ }
+}
+
+function extractMetadata(html: string, url: URL) {
+ const getMeta = (pattern: RegExp): string | null => {
+ const m = html.match(pattern)
+ return m?.[1]?.trim() || null
+ }
+
+ const getMetaProperty = (prop: string): string | null => {
+ return getMeta(new RegExp(`]+(?:property|name)=["']${prop}["'][^>]+content=["']([^"']+)["']`, 'i'))
+ || getMeta(new RegExp(`]+content=["']([^"']+)["'][^>]+(?:property|name)=["']${prop}["']`, 'i'))
+ }
+
+ const title =
+ getMetaProperty('og:title') ||
+ getMeta(new RegExp('
]*>([^<]+)', 'i')) ||
+ null
+
+ const description =
+ getMetaProperty('og:description') ||
+ getMetaProperty('description') ||
+ getMetaProperty('twitter:description') ||
+ null
+
+ const image =
+ getMetaProperty('og:image') ||
+ getMetaProperty('twitter:image') ||
+ null
+
+ const siteName =
+ getMetaProperty('og:site_name') ||
+ null
+
+ const favicon = image
+ ? null
+ : `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32`
+
+ const finalImage = image
+ ? (image.startsWith('http') ? image : new URL(image, url.origin).href)
+ : null
+
+ return {
+ title: title ? decodeEntities(title) : null,
+ description: description ? decodeEntities(description).slice(0, 200) : null,
+ image: finalImage,
+ favicon,
+ siteName: siteName || url.hostname.replace('www.', ''),
+ }
+}
+
+function decodeEntities(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/'/g, "'")
+ .replace(/ /g, ' ')
+}
diff --git a/memento-note/components/rich-text-editor.tsx b/memento-note/components/rich-text-editor.tsx
index 15c146f..d32a542 100644
--- a/memento-note/components/rich-text-editor.tsx
+++ b/memento-note/components/rich-text-editor.tsx
@@ -30,6 +30,7 @@ import { ToggleExtension, insertToggleBlock } from './tiptap-toggle-extension'
import { CalloutExtension, insertCalloutBlock } from './tiptap-callout-extension'
import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension'
import { FindReplaceBar, FindReplaceExtension } from './editor-find-replace-bar'
+import { LinkPreviewExtension, insertLinkPreview, isUrl } from './tiptap-link-preview-extension'
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
import { ClipArticleExtension } from './tiptap-clip-article-extension'
import { BlockPicker, type BlockSuggestion } from './block-picker'
@@ -219,6 +220,12 @@ const slashCommands: SlashItem[] = [
title: 'Outline', description: 'Table of contents from headings', icon: ListTree, category: 'Basic blocks', shortcut: '/toc',
command: (e) => { insertOutlineBlock(e) },
},
+ {
+ title: 'Link Preview', description: 'Embed a URL as a rich card', icon: Link2, category: 'Basic blocks', shortcut: '/link',
+ command: (e) => {
+ window.dispatchEvent(new CustomEvent('memento-open-link-preview'))
+ },
+ },
]
async function aiReformulate(text: string, option: string, t: any, language?: string): Promise {
@@ -308,6 +315,13 @@ export const RichTextEditor = forwardRef(null)
+
+ useEffect(() => {
+ const handler = () => setLinkPreviewUrl('')
+ window.addEventListener('memento-open-link-preview', handler)
+ return () => window.removeEventListener('memento-open-link-preview', handler)
+ }, [])
useEffect(() => {
const handleFindShortcut = (e: KeyboardEvent) => {
@@ -458,6 +472,7 @@ export const RichTextEditor = forwardRef setShowFindReplace(false)} />
)}
+ {editor && linkPreviewUrl !== null && (
+ setLinkPreviewUrl(null)}>
+
e.stopPropagation()} dir="auto">
+
{t('richTextEditor.linkPreviewModalTitle')}
+
setLinkPreviewUrl(e.target.value)}
+ onKeyDown={(e) => {
+ e.stopPropagation()
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ if (linkPreviewUrl.trim()) {
+ insertLinkPreview(editor, linkPreviewUrl.trim())
+ setLinkPreviewUrl(null)
+ }
+ }
+ if (e.key === 'Escape') setLinkPreviewUrl(null)
+ }}
+ placeholder="https://..."
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30"
+ />
+
+
+
+
+
+
+ )}
+
{editor && blockMenuState && (
handlePasteUrlLink(smartPasteExtended.text)}
+ onLinkPreview={() => { insertLinkPreview(editor, smartPasteExtended.text); setSmartPasteExtended(null) }}
onImage={() => handlePasteUrlImage(smartPasteExtended.text)}
onVideo={() => handlePasteUrlVideo(smartPasteExtended.text)}
onCodeBlock={() => handlePasteCodeBlock(smartPasteExtended.text)}
@@ -1634,6 +1694,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
{ ...slashCommands[31], title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] },
{ ...slashCommands[32], title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] },
{ ...slashCommands[33], title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'table', 'matieres', 'matières', 'plan'] },
+ { ...slashCommands[34], title: t('richTextEditor.slashLinkPreview'), description: t('richTextEditor.slashLinkPreviewDesc'), categoryId: 'embed', slashKeywords: ['link', 'lien', 'url', 'preview', 'apercu', 'aperçu', 'embed', 'card', 'carte'] },
{
title: t('richTextEditor.slashNoteLink'),
description: t('richTextEditor.slashNoteLinkDesc'),
diff --git a/memento-note/components/smart-paste-extended-menu.tsx b/memento-note/components/smart-paste-extended-menu.tsx
index 2775502..e39486e 100644
--- a/memento-note/components/smart-paste-extended-menu.tsx
+++ b/memento-note/components/smart-paste-extended-menu.tsx
@@ -3,7 +3,7 @@
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useLanguage } from '@/lib/i18n'
-import { Link2, ImageIcon, Video, Code, FileText } from 'lucide-react'
+import { Link2, ImageIcon, Video, Code, FileText, Layout } from 'lucide-react'
export type SmartPasteExtendedMenuProps = {
type: 'url' | 'code'
@@ -12,6 +12,7 @@ export type SmartPasteExtendedMenuProps = {
isImage?: boolean
isVideo?: boolean
onLink?: () => void
+ onLinkPreview?: () => void
onImage?: () => void
onVideo?: () => void
onCodeBlock?: () => void
@@ -26,6 +27,7 @@ export function SmartPasteExtendedMenu({
isImage,
isVideo,
onLink,
+ onLinkPreview,
onImage,
onVideo,
onCodeBlock,
@@ -90,6 +92,12 @@ export function SmartPasteExtendedMenu({
{t('richTextEditor.smartPasteUrlLink') || 'Coller comme lien hypertexte'}
+ {onLinkPreview && (
+
+ )}
{isImage && onImage && (