- 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
207 lines
6.9 KiB
TypeScript
207 lines
6.9 KiB
TypeScript
'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())
|
|
}
|