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 { CalloutExtension, insertCalloutBlock } from './tiptap-callout-extension'
|
||||||
import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension'
|
import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension'
|
||||||
import { FindReplaceBar, FindReplaceExtension } from './editor-find-replace-bar'
|
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 { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
|
||||||
import { ClipArticleExtension } from './tiptap-clip-article-extension'
|
import { ClipArticleExtension } from './tiptap-clip-article-extension'
|
||||||
import { BlockPicker, type BlockSuggestion } from './block-picker'
|
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',
|
title: 'Outline', description: 'Table of contents from headings', icon: ListTree, category: 'Basic blocks', shortcut: '/toc',
|
||||||
command: (e) => { insertOutlineBlock(e) },
|
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> {
|
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 [isMobile, setIsMobile] = useState(false)
|
||||||
const [actionSheetOpen, setActionSheetOpen] = useState(false)
|
const [actionSheetOpen, setActionSheetOpen] = useState(false)
|
||||||
const [showFindReplace, setShowFindReplace] = 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(() => {
|
useEffect(() => {
|
||||||
const handleFindShortcut = (e: KeyboardEvent) => {
|
const handleFindShortcut = (e: KeyboardEvent) => {
|
||||||
@@ -458,6 +472,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
|||||||
CalloutExtension,
|
CalloutExtension,
|
||||||
OutlineExtension,
|
OutlineExtension,
|
||||||
FindReplaceExtension,
|
FindReplaceExtension,
|
||||||
|
LinkPreviewExtension,
|
||||||
ClipArticleExtension,
|
ClipArticleExtension,
|
||||||
RtlPreserveExtension,
|
RtlPreserveExtension,
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
@@ -1118,6 +1133,50 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
|||||||
<FindReplaceBar editor={editor} onClose={() => setShowFindReplace(false)} />
|
<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 && (
|
{editor && blockMenuState && (
|
||||||
<BlockActionMenu
|
<BlockActionMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
@@ -1150,6 +1209,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
|||||||
isImage={smartPasteExtended.isImage}
|
isImage={smartPasteExtended.isImage}
|
||||||
isVideo={smartPasteExtended.isVideo}
|
isVideo={smartPasteExtended.isVideo}
|
||||||
onLink={() => handlePasteUrlLink(smartPasteExtended.text)}
|
onLink={() => handlePasteUrlLink(smartPasteExtended.text)}
|
||||||
|
onLinkPreview={() => { insertLinkPreview(editor, smartPasteExtended.text); setSmartPasteExtended(null) }}
|
||||||
onImage={() => handlePasteUrlImage(smartPasteExtended.text)}
|
onImage={() => handlePasteUrlImage(smartPasteExtended.text)}
|
||||||
onVideo={() => handlePasteUrlVideo(smartPasteExtended.text)}
|
onVideo={() => handlePasteUrlVideo(smartPasteExtended.text)}
|
||||||
onCodeBlock={() => handlePasteCodeBlock(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[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[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[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'),
|
title: t('richTextEditor.slashNoteLink'),
|
||||||
description: t('richTextEditor.slashNoteLinkDesc'),
|
description: t('richTextEditor.slashNoteLinkDesc'),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
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 = {
|
export type SmartPasteExtendedMenuProps = {
|
||||||
type: 'url' | 'code'
|
type: 'url' | 'code'
|
||||||
@@ -12,6 +12,7 @@ export type SmartPasteExtendedMenuProps = {
|
|||||||
isImage?: boolean
|
isImage?: boolean
|
||||||
isVideo?: boolean
|
isVideo?: boolean
|
||||||
onLink?: () => void
|
onLink?: () => void
|
||||||
|
onLinkPreview?: () => void
|
||||||
onImage?: () => void
|
onImage?: () => void
|
||||||
onVideo?: () => void
|
onVideo?: () => void
|
||||||
onCodeBlock?: () => void
|
onCodeBlock?: () => void
|
||||||
@@ -26,6 +27,7 @@ export function SmartPasteExtendedMenu({
|
|||||||
isImage,
|
isImage,
|
||||||
isVideo,
|
isVideo,
|
||||||
onLink,
|
onLink,
|
||||||
|
onLinkPreview,
|
||||||
onImage,
|
onImage,
|
||||||
onVideo,
|
onVideo,
|
||||||
onCodeBlock,
|
onCodeBlock,
|
||||||
@@ -90,6 +92,12 @@ export function SmartPasteExtendedMenu({
|
|||||||
<Link2 size={15} className="text-blue-500" />
|
<Link2 size={15} className="text-blue-500" />
|
||||||
<span>{t('richTextEditor.smartPasteUrlLink') || 'Coller comme lien hypertexte'}</span>
|
<span>{t('richTextEditor.smartPasteUrlLink') || 'Coller comme lien hypertexte'}</span>
|
||||||
</button>
|
</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 && (
|
{isImage && onImage && (
|
||||||
<button type="button" className="block-action-item" onClick={onImage}>
|
<button type="button" className="block-action-item" onClick={onImage}>
|
||||||
<ImageIcon size={15} className="text-emerald-500" />
|
<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 { NotesStructuredTable } from './notes-structured-table'
|
||||||
import { NotesKanbanView } from './notes-kanban-view'
|
import { NotesKanbanView } from './notes-kanban-view'
|
||||||
import { NotesGalleryView } from './notes-gallery-view'
|
import { NotesGalleryView } from './notes-gallery-view'
|
||||||
|
import { NotesCalendarView } from './notes-calendar-view'
|
||||||
|
|
||||||
type StructuredViewsContainerProps = {
|
type StructuredViewsContainerProps = {
|
||||||
mode: StructuredViewMode
|
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
|
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 const MAX_PROPERTIES_PER_NOTEBOOK = 15
|
||||||
|
|
||||||
export type StructuredViewMode = 'list' | 'table' | 'kanban' | 'gallery'
|
export type StructuredViewMode = 'list' | 'table' | 'kanban' | 'gallery' | 'calendar'
|
||||||
|
|
||||||
export type NotebookViewSettings = {
|
export type NotebookViewSettings = {
|
||||||
kanbanGroupPropertyId?: string | null
|
kanbanGroupPropertyId?: string | null
|
||||||
|
|||||||
@@ -2427,6 +2427,14 @@
|
|||||||
"findWholeWord": "Whole word",
|
"findWholeWord": "Whole word",
|
||||||
"findRegex": "Regular expression",
|
"findRegex": "Regular expression",
|
||||||
"findReplaceToggle": "Show/Hide replace",
|
"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",
|
"calloutDelete": "Delete callout",
|
||||||
"calloutUnwrap": "Disable callout",
|
"calloutUnwrap": "Disable callout",
|
||||||
"calloutInfo": "Information",
|
"calloutInfo": "Information",
|
||||||
@@ -2611,6 +2619,8 @@
|
|||||||
"viewKanban": "Kanban",
|
"viewKanban": "Kanban",
|
||||||
"viewGallery": "Gallery",
|
"viewGallery": "Gallery",
|
||||||
"viewKanbanHint": "Columns — like Trello to track your notes",
|
"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",
|
"viewGalleryHint": "Visual cards — browse your notes at a glance",
|
||||||
"intro": {
|
"intro": {
|
||||||
"databaseTitle": "Organized notebook",
|
"databaseTitle": "Organized notebook",
|
||||||
|
|||||||
@@ -2431,6 +2431,14 @@
|
|||||||
"findWholeWord": "Mot entier",
|
"findWholeWord": "Mot entier",
|
||||||
"findRegex": "Expression régulière",
|
"findRegex": "Expression régulière",
|
||||||
"findReplaceToggle": "Afficher/Masquer le remplacement",
|
"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é",
|
"calloutDelete": "Supprimer l'encadré",
|
||||||
"calloutUnwrap": "Désactiver l'encadré",
|
"calloutUnwrap": "Désactiver l'encadré",
|
||||||
"calloutInfo": "Information",
|
"calloutInfo": "Information",
|
||||||
@@ -2615,6 +2623,8 @@
|
|||||||
"viewKanban": "Kanban",
|
"viewKanban": "Kanban",
|
||||||
"viewGallery": "Galerie",
|
"viewGallery": "Galerie",
|
||||||
"viewKanbanHint": "Colonnes — comme un tableau Trello pour faire avancer vos notes",
|
"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",
|
"viewGalleryHint": "Cartes visuelles — parcourir vos notes en un coup d'œil",
|
||||||
"intro": {
|
"intro": {
|
||||||
"databaseTitle": "Base organisable",
|
"databaseTitle": "Base organisable",
|
||||||
|
|||||||
Reference in New Issue
Block a user