From ba3ab3422a76d555e659fcfb73b77566034e8130 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Sun, 14 Jun 2026 17:43:53 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Link=20Preview=20block=20(carte=20aper?= =?UTF-8?q?=C3=A7u=20URL)=20+=20proxy=20images?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- memento-note/app/api/image-proxy/route.ts | 43 ++++ memento-note/app/api/link-preview/route.ts | 101 +++++++++ memento-note/components/rich-text-editor.tsx | 61 ++++++ .../components/smart-paste-extended-menu.tsx | 10 +- .../structured-views/notes-calendar-view.tsx | 202 +++++++++++++++++ .../structured-views-container.tsx | 14 ++ .../tiptap-link-preview-extension.tsx | 206 ++++++++++++++++++ memento-note/lib/structured-views/types.ts | 2 +- memento-note/locales/en.json | 10 + memento-note/locales/fr.json | 10 + 10 files changed, 657 insertions(+), 2 deletions(-) create mode 100644 memento-note/app/api/image-proxy/route.ts create mode 100644 memento-note/app/api/link-preview/route.ts create mode 100644 memento-note/components/structured-views/notes-calendar-view.tsx create mode 100644 memento-note/components/tiptap-link-preview-extension.tsx 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 && ( + + + + + +
+ {weekdays.map(wd => ( +
+ {wd} +
+ ))} + + {calendarDays.map((day, i) => { + const dayNotes = day.date ? (notesByDate.get(day.date) || []) : [] + + return ( +
+ {day.day && ( + + {day.day} + + )} + {dayNotes.slice(0, 3).map(note => ( + + ))} + {dayNotes.length > 3 && ( + + +{dayNotes.length - 3} + + )} +
+ ) + })} +
+ + ) +} diff --git a/memento-note/components/structured-views/structured-views-container.tsx b/memento-note/components/structured-views/structured-views-container.tsx index 76210ae..a44b50b 100644 --- a/memento-note/components/structured-views/structured-views-container.tsx +++ b/memento-note/components/structured-views/structured-views-container.tsx @@ -6,6 +6,7 @@ import type { NotebookSchemaPayload, NotePropertyValues, StructuredViewMode } fr import { NotesStructuredTable } from './notes-structured-table' import { NotesKanbanView } from './notes-kanban-view' import { NotesGalleryView } from './notes-gallery-view' +import { NotesCalendarView } from './notes-calendar-view' type StructuredViewsContainerProps = { mode: StructuredViewMode @@ -101,5 +102,18 @@ export function StructuredViewsContainer({ ) } + if (mode === 'calendar') { + return ( + + ) + } + return null } diff --git a/memento-note/components/tiptap-link-preview-extension.tsx b/memento-note/components/tiptap-link-preview-extension.tsx new file mode 100644 index 0000000..82de9a8 --- /dev/null +++ b/memento-note/components/tiptap-link-preview-extension.tsx @@ -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(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 ( + +
+
+ {cached?.image && ( +
+ { (e.target as HTMLImageElement).parentElement!.style.display = 'none' }} + /> +
+ )} +
+ {loading ? ( +
+ + {t('richTextEditor.linkPreviewLoading')} +
+ ) : error ? ( + + ) : cached ? ( + <> + + {cached.description && ( +

+ {cached.description} +

+ )} +
+ {cached.favicon && ( + { (e.target as HTMLImageElement).style.display = 'none' }} /> + )} + {cached.siteName || domain} +
+ + ) : null} +
+
+ +
+ + +
+
+
+ ) +} + +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()) +} diff --git a/memento-note/lib/structured-views/types.ts b/memento-note/lib/structured-views/types.ts index a33ad70..de2255d 100644 --- a/memento-note/lib/structured-views/types.ts +++ b/memento-note/lib/structured-views/types.ts @@ -11,7 +11,7 @@ export type PropertyType = (typeof PROPERTY_TYPES)[number] export const MAX_PROPERTIES_PER_NOTEBOOK = 15 -export type StructuredViewMode = 'list' | 'table' | 'kanban' | 'gallery' +export type StructuredViewMode = 'list' | 'table' | 'kanban' | 'gallery' | 'calendar' export type NotebookViewSettings = { kanbanGroupPropertyId?: string | null diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index 5c27820..710d917 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -2427,6 +2427,14 @@ "findWholeWord": "Whole word", "findRegex": "Regular expression", "findReplaceToggle": "Show/Hide replace", + "slashLinkPreview": "Link Preview", + "slashLinkPreviewDesc": "Turn a URL into a visual card", + "linkPreviewLoading": "Loading preview...", + "linkPreviewModalTitle": "Paste a link", + "linkPreviewModalInsert": "Create preview", + "linkPreviewUnwrap": "Revert to simple link", + "linkPreviewDelete": "Delete preview", + "smartPasteUrlPreview": "Paste as preview card", "calloutDelete": "Delete callout", "calloutUnwrap": "Disable callout", "calloutInfo": "Information", @@ -2611,6 +2619,8 @@ "viewKanban": "Kanban", "viewGallery": "Gallery", "viewKanbanHint": "Columns — like Trello to track your notes", + "viewCalendarHint": "Calendar — your notes organized by date", + "calendarNoDateProperty": "Add a date property to use the calendar view", "viewGalleryHint": "Visual cards — browse your notes at a glance", "intro": { "databaseTitle": "Organized notebook", diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index 6afd039..fe2d463 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -2431,6 +2431,14 @@ "findWholeWord": "Mot entier", "findRegex": "Expression régulière", "findReplaceToggle": "Afficher/Masquer le remplacement", + "slashLinkPreview": "Aperçu de lien", + "slashLinkPreviewDesc": "Transformer une URL en carte visuelle", + "linkPreviewLoading": "Chargement de l'aperçu...", + "linkPreviewModalTitle": "Coller un lien", + "linkPreviewModalInsert": "Créer l'aperçu", + "linkPreviewUnwrap": "Revenir au lien simple", + "linkPreviewDelete": "Supprimer l'aperçu", + "smartPasteUrlPreview": "Coller comme carte aperçu", "calloutDelete": "Supprimer l'encadré", "calloutUnwrap": "Désactiver l'encadré", "calloutInfo": "Information", @@ -2615,6 +2623,8 @@ "viewKanban": "Kanban", "viewGallery": "Galerie", "viewKanbanHint": "Colonnes — comme un tableau Trello pour faire avancer vos notes", + "viewCalendarHint": "Calendrier — vos notes organisées par date", + "calendarNoDateProperty": "Ajoutez une propriété de type date pour utiliser la vue calendrier", "viewGalleryHint": "Cartes visuelles — parcourir vos notes en un coup d'œil", "intro": { "databaseTitle": "Base organisable",