feat: Link Preview block (carte aperçu URL) + proxy images
- Bloc Link Preview : colle une URL → carte avec titre, description, image, favicon - API /api/link-preview : extraction OpenGraph + meta tags - API /api/image-proxy : contourne le hotlinking (Referer spoofing) - Métadonnées persistées en HTML (data-preview JSON) — pas de refetch au reload - Texte indexable : titre + description + URL inclus pour recherche/embeddings - Modal propre pour saisir l'URL (plus de prompt()) - Slash menu + smart paste 'Coller comme carte aperçu' - i18n FR/EN complet - Fix: bouton calendrier retiré du sélecteur de vue
This commit is contained in:
206
memento-note/components/tiptap-link-preview-extension.tsx
Normal file
206
memento-note/components/tiptap-link-preview-extension.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client'
|
||||
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { Link2, Trash2, X, ExternalLink, Loader2 } from 'lucide-react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface PreviewData {
|
||||
title: string
|
||||
description: string
|
||||
image: string | null
|
||||
favicon: string | null
|
||||
siteName: string | null
|
||||
}
|
||||
|
||||
const LinkPreviewView = ({ node, updateAttributes, deleteNode, selected }: any) => {
|
||||
const { t } = useLanguage()
|
||||
const url = node.attrs.url as string
|
||||
const cached = node.attrs.preview as PreviewData | null
|
||||
const [loading, setLoading] = useState(!cached && !!url)
|
||||
const [error, setError] = useState(false)
|
||||
const fetchedRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!url || cached || fetchedRef.current === url) return
|
||||
fetchedRef.current = url
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
|
||||
fetch(`/api/link-preview?url=${encodeURIComponent(url)}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data) {
|
||||
updateAttributes({ preview: data })
|
||||
} else {
|
||||
setError(true)
|
||||
}
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false))
|
||||
}, [url, cached, updateAttributes])
|
||||
|
||||
const unwrap = () => {
|
||||
updateAttributes({ url: '', preview: null })
|
||||
}
|
||||
|
||||
const domain = (() => {
|
||||
try { return new URL(url).hostname.replace('www.', '') } catch { return url }
|
||||
})()
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="link-preview-block my-2" dir="auto" data-selected={selected}>
|
||||
<div className={cn(
|
||||
'group relative rounded-xl border overflow-hidden transition-all',
|
||||
'border-border bg-card hover:border-primary/40 hover:shadow-md',
|
||||
selected && 'ring-2 ring-primary/30'
|
||||
)}>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
{cached?.image && (
|
||||
<div className="sm:w-48 h-32 sm:h-auto flex-shrink-0 bg-muted overflow-hidden">
|
||||
<img
|
||||
src={`/api/image-proxy?url=${encodeURIComponent(cached.image)}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).parentElement!.style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 p-3.5">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>{t('richTextEditor.linkPreviewLoading')}</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link2 className="h-4 w-4 flex-shrink-0" />
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="truncate hover:underline text-primary">
|
||||
{url}
|
||||
</a>
|
||||
</div>
|
||||
) : cached ? (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-sm leading-tight hover:text-primary transition-colors line-clamp-2"
|
||||
>
|
||||
{cached.title || domain}
|
||||
</a>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||
</div>
|
||||
{cached.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mb-2 leading-relaxed">
|
||||
{cached.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
{cached.favicon && (
|
||||
<img src={`/api/image-proxy?url=${encodeURIComponent(cached.favicon)}`} alt="" className="w-3.5 h-3.5 rounded-sm" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
|
||||
)}
|
||||
<span className="truncate">{cached.siteName || domain}</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-1.5 right-1.5 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={unwrap}
|
||||
contentEditable={false}
|
||||
className="p-1 rounded-md bg-background/80 backdrop-blur hover:bg-muted text-muted-foreground"
|
||||
title={t('richTextEditor.linkPreviewUnwrap')}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={deleteNode}
|
||||
contentEditable={false}
|
||||
className="p-1 rounded-md bg-background/80 backdrop-blur hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
title={t('richTextEditor.linkPreviewDelete')}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const LinkPreviewExtension = Node.create({
|
||||
name: 'linkPreviewBlock',
|
||||
|
||||
group: 'block',
|
||||
|
||||
atom: true,
|
||||
|
||||
defining: true,
|
||||
|
||||
isolating: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
url: {
|
||||
default: '',
|
||||
parseHTML: (el) => el.getAttribute('data-url') || '',
|
||||
renderHTML: (attrs) => ({ 'data-url': attrs.url }),
|
||||
},
|
||||
preview: {
|
||||
default: null,
|
||||
parseHTML: (el) => {
|
||||
const raw = el.getAttribute('data-preview')
|
||||
if (!raw) return null
|
||||
try { return JSON.parse(raw) } catch { return null }
|
||||
},
|
||||
renderHTML: (attrs) => {
|
||||
if (!attrs.preview) return {}
|
||||
return { 'data-preview': JSON.stringify(attrs.preview) }
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'div[data-type="link-preview-block"]' }]
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const url = node.attrs.url || ''
|
||||
const preview = node.attrs.preview as PreviewData | null
|
||||
const searchText = preview
|
||||
? `${preview.title || ''} ${preview.description || ''} ${url}`.trim()
|
||||
: url
|
||||
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-type': 'link-preview-block',
|
||||
'data-url': url,
|
||||
class: 'link-preview-block',
|
||||
}),
|
||||
['div', { class: 'link-preview-searchable', style: 'display:none' }, searchText],
|
||||
]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(LinkPreviewView)
|
||||
},
|
||||
})
|
||||
|
||||
export function insertLinkPreview(editor: any, url: string) {
|
||||
if (!url?.trim()) return
|
||||
editor.chain().focus().insertContent({
|
||||
type: 'linkPreviewBlock',
|
||||
attrs: { url: url.trim(), preview: null },
|
||||
}).run()
|
||||
}
|
||||
|
||||
export function isUrl(text: string): boolean {
|
||||
return /^https?:\/\/[^\s]+$/i.test(text.trim())
|
||||
}
|
||||
Reference in New Issue
Block a user