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:
43
memento-note/app/api/image-proxy/route.ts
Normal file
43
memento-note/app/api/image-proxy/route.ts
Normal file
@@ -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 })
|
||||
}
|
||||
}
|
||||
101
memento-note/app/api/link-preview/route.ts
Normal file
101
memento-note/app/api/link-preview/route.ts
Normal file
@@ -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(`<meta[^>]+(?:property|name)=["']${prop}["'][^>]+content=["']([^"']+)["']`, 'i'))
|
||||
|| getMeta(new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["']${prop}["']`, 'i'))
|
||||
}
|
||||
|
||||
const title =
|
||||
getMetaProperty('og:title') ||
|
||||
getMeta(new RegExp('<title[^>]*>([^<]+)</title>', '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, ' ')
|
||||
}
|
||||
@@ -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<string> {
|
||||
@@ -308,6 +315,13 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [actionSheetOpen, setActionSheetOpen] = useState(false)
|
||||
const [showFindReplace, setShowFindReplace] = useState(false)
|
||||
const [linkPreviewUrl, setLinkPreviewUrl] = useState<string | null>(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<RichTextEditorHandle, RichTextEditorPro
|
||||
CalloutExtension,
|
||||
OutlineExtension,
|
||||
FindReplaceExtension,
|
||||
LinkPreviewExtension,
|
||||
ClipArticleExtension,
|
||||
RtlPreserveExtension,
|
||||
Placeholder.configure({
|
||||
@@ -1118,6 +1133,50 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
<FindReplaceBar editor={editor} onClose={() => setShowFindReplace(false)} />
|
||||
)}
|
||||
|
||||
{editor && linkPreviewUrl !== null && (
|
||||
<div className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/30" onClick={() => setLinkPreviewUrl(null)}>
|
||||
<div className="bg-popover rounded-xl border border-border shadow-lg w-96 max-w-[90vw] p-4" onClick={(e) => e.stopPropagation()} dir="auto">
|
||||
<div className="text-sm font-medium mb-3">{t('richTextEditor.linkPreviewModalTitle')}</div>
|
||||
<input
|
||||
type="url"
|
||||
autoFocus
|
||||
value={linkPreviewUrl}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="flex justify-end gap-2 mt-3">
|
||||
<button onClick={() => setLinkPreviewUrl(null)} className="px-3 py-1.5 text-sm rounded-md hover:bg-muted transition-colors">
|
||||
{t('richTextEditor.imageModalCancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (linkPreviewUrl.trim()) {
|
||||
insertLinkPreview(editor, linkPreviewUrl.trim())
|
||||
setLinkPreviewUrl(null)
|
||||
}
|
||||
}}
|
||||
disabled={!linkPreviewUrl.trim()}
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{t('richTextEditor.linkPreviewModalInsert')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editor && blockMenuState && (
|
||||
<BlockActionMenu
|
||||
editor={editor}
|
||||
@@ -1150,6 +1209,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
isImage={smartPasteExtended.isImage}
|
||||
isVideo={smartPasteExtended.isVideo}
|
||||
onLink={() => 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'),
|
||||
|
||||
@@ -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({
|
||||
<Link2 size={15} className="text-blue-500" />
|
||||
<span>{t('richTextEditor.smartPasteUrlLink') || 'Coller comme lien hypertexte'}</span>
|
||||
</button>
|
||||
{onLinkPreview && (
|
||||
<button type="button" className="block-action-item" onClick={onLinkPreview}>
|
||||
<Layout size={15} className="text-indigo-500" />
|
||||
<span>{t('richTextEditor.smartPasteUrlPreview') || 'Coller comme carte aperçu'}</span>
|
||||
</button>
|
||||
)}
|
||||
{isImage && onImage && (
|
||||
<button type="button" className="block-action-item" onClick={onImage}>
|
||||
<ImageIcon size={15} className="text-emerald-500" />
|
||||
|
||||
202
memento-note/components/structured-views/notes-calendar-view.tsx
Normal file
202
memento-note/components/structured-views/notes-calendar-view.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Note } from '@/lib/types'
|
||||
import type { NotebookSchemaPayload, NotePropertyValues } from '@/lib/structured-views/types'
|
||||
|
||||
type NotesCalendarViewProps = {
|
||||
notes: Note[]
|
||||
schema: NotebookSchemaPayload
|
||||
noteValues: Record<string, NotePropertyValues>
|
||||
notebookColor?: string | null
|
||||
onOpen: (note: Note) => void
|
||||
onPropertyChange: (noteId: string, propertyId: string, value: unknown) => void
|
||||
}
|
||||
|
||||
const WEEKDAYS_FR = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
|
||||
const WEEKDAYS_EN = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
const MONTHS_FR = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
|
||||
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
||||
|
||||
function getDateString(value: unknown): string | null {
|
||||
if (!value || typeof value !== 'string') return null
|
||||
const d = new Date(value)
|
||||
if (isNaN(d.getTime())) return null
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function NotesCalendarView({
|
||||
notes,
|
||||
schema,
|
||||
noteValues,
|
||||
notebookColor,
|
||||
onOpen,
|
||||
onPropertyChange,
|
||||
}: NotesCalendarViewProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const isFR = language === 'fr'
|
||||
const weekdays = isFR ? WEEKDAYS_FR : WEEKDAYS_EN
|
||||
const months = isFR ? MONTHS_FR : MONTHS_EN
|
||||
|
||||
const [currentMonth, setCurrentMonth] = useState(() => {
|
||||
const now = new Date()
|
||||
return new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
})
|
||||
|
||||
const dateProperty = useMemo(() => {
|
||||
return schema.properties.find(p => p.type === 'date')
|
||||
}, [schema.properties])
|
||||
|
||||
const notesByDate = useMemo(() => {
|
||||
const map = new Map<string, Note[]>()
|
||||
if (!dateProperty) return map
|
||||
|
||||
for (const note of notes) {
|
||||
const vals = noteValues[note.id]
|
||||
if (!vals) continue
|
||||
const raw = vals[dateProperty.id]
|
||||
const dateStr = getDateString(raw)
|
||||
if (!dateStr) continue
|
||||
const existing = map.get(dateStr) || []
|
||||
existing.push(note)
|
||||
map.set(dateStr, existing)
|
||||
}
|
||||
return map
|
||||
}, [notes, noteValues, dateProperty])
|
||||
|
||||
const calendarDays = useMemo(() => {
|
||||
const year = currentMonth.getFullYear()
|
||||
const month = currentMonth.getMonth()
|
||||
const firstDay = new Date(year, month, 1)
|
||||
const lastDay = new Date(year, month + 1, 0)
|
||||
|
||||
const startWeekday = (firstDay.getDay() + 6) % 7
|
||||
const daysInMonth = lastDay.getDate()
|
||||
|
||||
const days: Array<{ date: string | null; day: number | null; isToday: boolean }> = []
|
||||
|
||||
for (let i = 0; i < startWeekday; i++) {
|
||||
days.push({ date: null, day: null, isToday: false })
|
||||
}
|
||||
|
||||
const todayStr = new Date().toISOString().slice(0, 10)
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = new Date(year, month, d).toISOString().slice(0, 10)
|
||||
days.push({ date: dateStr, day: d, isToday: dateStr === todayStr })
|
||||
}
|
||||
|
||||
const remaining = (7 - (days.length % 7)) % 7
|
||||
for (let i = 0; i < remaining; i++) {
|
||||
days.push({ date: null, day: null, isToday: false })
|
||||
}
|
||||
|
||||
return days
|
||||
}, [currentMonth])
|
||||
|
||||
const prevMonth = useCallback(() => {
|
||||
setCurrentMonth(m => new Date(m.getFullYear(), m.getMonth() - 1, 1))
|
||||
}, [])
|
||||
|
||||
const nextMonth = useCallback(() => {
|
||||
setCurrentMonth(m => new Date(m.getFullYear(), m.getMonth() + 1, 1))
|
||||
}, [])
|
||||
|
||||
const todayMonth = useCallback(() => {
|
||||
const now = new Date()
|
||||
setCurrentMonth(new Date(now.getFullYear(), now.getMonth(), 1))
|
||||
}, [])
|
||||
|
||||
if (!dateProperty) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center" dir="auto">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('structuredViews.calendarNoDateProperty')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full" dir="auto">
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold">
|
||||
{months[currentMonth.getMonth()]} {currentMonth.getFullYear()}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={todayMonth}
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
{isFR ? "Aujourd'hui" : 'Today'}
|
||||
</button>
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
className="p-1 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
className="p-1 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-px bg-border/30 rounded-lg overflow-hidden border border-border/30">
|
||||
{weekdays.map(wd => (
|
||||
<div key={wd} className="bg-muted/40 px-2 py-1.5 text-center text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{wd}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{calendarDays.map((day, i) => {
|
||||
const dayNotes = day.date ? (notesByDate.get(day.date) || []) : []
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'min-h-[80px] bg-background p-1 flex flex-col gap-0.5',
|
||||
!day.date && 'bg-muted/20',
|
||||
)}
|
||||
>
|
||||
{day.day && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-medium w-6 h-6 flex items-center justify-center rounded-full',
|
||||
day.isToday
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{day.day}
|
||||
</span>
|
||||
)}
|
||||
{dayNotes.slice(0, 3).map(note => (
|
||||
<button
|
||||
key={note.id}
|
||||
onClick={() => onOpen(note)}
|
||||
className="text-left text-[11px] leading-tight px-1.5 py-0.5 rounded bg-foreground/[0.04] hover:bg-primary/10 text-foreground/70 hover:text-primary transition-colors truncate"
|
||||
>
|
||||
{note.title || (isFR ? 'Sans titre' : 'Untitled')}
|
||||
</button>
|
||||
))}
|
||||
{dayNotes.length > 3 && (
|
||||
<span className="text-[10px] text-muted-foreground px-1.5">
|
||||
+{dayNotes.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<NotesCalendarView
|
||||
notes={notes}
|
||||
schema={schema}
|
||||
noteValues={noteValues}
|
||||
notebookColor={notebookColor}
|
||||
onOpen={onOpen}
|
||||
onPropertyChange={saveProperty}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user