@@ -273,6 +297,7 @@ const MemoEditor = observer((props: Props) => {
isUploading={isUploadingAttachment}
location={location}
onLocationChange={setLocation}
+ onLinkPreviewAdd={handleAddLinkPreview}
onToggleFocusMode={toggleFocusMode}
/>
diff --git a/web/src/components/MemoEditor/utils/linkPreviewSerializer.ts b/web/src/components/MemoEditor/utils/linkPreviewSerializer.ts
new file mode 100644
index 000000000..f4421cb07
--- /dev/null
+++ b/web/src/components/MemoEditor/utils/linkPreviewSerializer.ts
@@ -0,0 +1,68 @@
+import type { LinkPreview } from "@/components/memo-metadata";
+
+const PREVIEW_REGEX = /
]*data-memo-link-preview=["']true["'][^>]*><\/div>/gi;
+
+export function serializeLinkPreviews(previews: LinkPreview[]): string {
+ return previews
+ .map((preview) => {
+ const attrs = [
+ `data-memo-link-preview="true"`,
+ `data-id="${escapeAttribute(preview.id)}"`,
+ `data-url="${escapeAttribute(preview.url)}"`,
+ `data-title="${escapeAttribute(preview.title)}"`,
+ `data-description="${escapeAttribute(preview.description)}"`,
+ `data-image="${escapeAttribute(preview.imageUrl)}"`,
+ `data-site="${escapeAttribute(preview.siteName || "")}"`,
+ ];
+ return `
`;
+ })
+ .join("\n\n");
+}
+
+export function appendLinkPreviewsToContent(content: string, previews: LinkPreview[]): string {
+ if (previews.length === 0) return content;
+ const serialized = serializeLinkPreviews(previews);
+ const trimmedContent = content.trimEnd();
+ if (!trimmedContent.trim()) return serialized;
+ return `${trimmedContent}\n\n${serialized}`;
+}
+
+export function extractLinkPreviewsFromContent(content: string): { cleanedContent: string; previews: LinkPreview[] } {
+ const matches = content.match(PREVIEW_REGEX) || [];
+ const previews: LinkPreview[] = matches.map((snippet) => parsePreviewSnippet(snippet)).filter(Boolean) as LinkPreview[];
+ const cleanedContent = content.replace(PREVIEW_REGEX, "").trimEnd();
+ return { cleanedContent, previews };
+}
+
+function parsePreviewSnippet(snippet: string): LinkPreview | null {
+ if (typeof document === "undefined") return null;
+ const container = document.createElement("div");
+ container.innerHTML = snippet;
+ const el = container.firstElementChild as HTMLElement | null;
+ if (!el) return null;
+
+ return {
+ id: el.getAttribute("data-id") || cryptoId(),
+ url: unescapeAttribute(el.getAttribute("data-url") || ""),
+ title: unescapeAttribute(el.getAttribute("data-title") || "Link preview"),
+ description: unescapeAttribute(el.getAttribute("data-description") || ""),
+ imageUrl: unescapeAttribute(el.getAttribute("data-image") || ""),
+ siteName: unescapeAttribute(el.getAttribute("data-site") || ""),
+ };
+}
+
+function escapeAttribute(value: string): string {
+ return value.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
+}
+
+function unescapeAttribute(value: string): string {
+ return value
+ .replace(/"/g, '"')
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/&/g, "&");
+}
+
+function cryptoId(): string {
+ return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}`;
+}
diff --git a/web/src/components/memo-metadata/LinkPreviewCard.tsx b/web/src/components/memo-metadata/LinkPreviewCard.tsx
new file mode 100644
index 000000000..4cf6b91dd
--- /dev/null
+++ b/web/src/components/memo-metadata/LinkPreviewCard.tsx
@@ -0,0 +1,81 @@
+import { Globe2Icon, SparklesIcon, XIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+import type { DisplayMode, LinkPreview } from "./types";
+
+interface LinkPreviewCardProps {
+ preview: LinkPreview;
+ mode: DisplayMode;
+ onRemove?: () => void;
+ className?: string;
+}
+
+const LinkPreviewCard = ({ preview, mode, onRemove, className }: LinkPreviewCardProps) => {
+ const hostname = getHostname(preview.url);
+
+ const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) =>
+ preview.url ? (
+
+ {children}
+
+ ) : (
+
{children}
+ );
+
+ return (
+
+
+
+

+ {hostname && (
+
+ {preview.siteName || hostname}
+
+ )}
+
+
+
+
+
+
{preview.title}
+
{preview.description}
+
+ {mode === "edit" && onRemove && (
+
+ )}
+
+
+
+
+ {preview.url}
+
+
+
+
+ );
+};
+
+function getHostname(url: string): string {
+ try {
+ return new URL(url).hostname.replace(/^www\./, "");
+ } catch (_error) {
+ return "";
+ }
+}
+
+export default LinkPreviewCard;
diff --git a/web/src/components/memo-metadata/LinkPreviewList.tsx b/web/src/components/memo-metadata/LinkPreviewList.tsx
new file mode 100644
index 000000000..c69664b78
--- /dev/null
+++ b/web/src/components/memo-metadata/LinkPreviewList.tsx
@@ -0,0 +1,33 @@
+import { SparklesIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { useTranslate } from "@/utils/i18n";
+import LinkPreviewCard from "./LinkPreviewCard";
+import type { BaseMetadataProps, LinkPreview } from "./types";
+
+interface LinkPreviewListProps extends BaseMetadataProps {
+ previews: LinkPreview[];
+ onRemove?: (id: string) => void;
+}
+
+const LinkPreviewList = ({ previews, mode, onRemove, className }: LinkPreviewListProps) => {
+ const t = useTranslate();
+
+ if (previews.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+ {t("editor.link-preview")}
+
+
+ {previews.map((preview) => (
+
onRemove(preview.id) : undefined} />
+ ))}
+
+ );
+};
+
+export default LinkPreviewList;
diff --git a/web/src/components/memo-metadata/index.ts b/web/src/components/memo-metadata/index.ts
index 525d4411f..b43d5a849 100644
--- a/web/src/components/memo-metadata/index.ts
+++ b/web/src/components/memo-metadata/index.ts
@@ -1,5 +1,7 @@
export { default as AttachmentCard } from "./AttachmentCard";
export { default as AttachmentList } from "./AttachmentList";
+export { default as LinkPreviewCard } from "./LinkPreviewCard";
+export { default as LinkPreviewList } from "./LinkPreviewList";
export { default as LocationDisplay } from "./LocationDisplay";
// Base components (can be used for other metadata types)
@@ -8,5 +10,5 @@ export { default as RelationCard } from "./RelationCard";
export { default as RelationList } from "./RelationList";
// Types
-export type { AttachmentItem, BaseMetadataProps, DisplayMode, FileCategory, LocalFile } from "./types";
+export type { AttachmentItem, BaseMetadataProps, DisplayMode, FileCategory, LinkPreview, LocalFile } from "./types";
export { attachmentToItem, fileToItem, filterByCategory, separateMediaAndDocs, toAttachmentItems } from "./types";
diff --git a/web/src/components/memo-metadata/types.ts b/web/src/components/memo-metadata/types.ts
index 1e7a6bf90..b14bd3728 100644
--- a/web/src/components/memo-metadata/types.ts
+++ b/web/src/components/memo-metadata/types.ts
@@ -62,6 +62,15 @@ export interface LocalFile {
readonly previewUrl: string;
}
+export interface LinkPreview {
+ id: string;
+ url: string;
+ title: string;
+ description: string;
+ imageUrl: string;
+ siteName?: string;
+}
+
export function toAttachmentItems(attachments: Attachment[], localFiles: LocalFile[] = []): AttachmentItem[] {
return [...attachments.map(attachmentToItem), ...localFiles.map(({ file, previewUrl }) => fileToItem(file, previewUrl))];
}
diff --git a/web/src/locales/en.json b/web/src/locales/en.json
index 2f494e882..309ec3c13 100644
--- a/web/src/locales/en.json
+++ b/web/src/locales/en.json
@@ -122,7 +122,14 @@
"save": "Save",
"no-changes-detected": "No changes detected",
"focus-mode": "Focus Mode",
- "exit-focus-mode": "Exit Focus Mode"
+ "exit-focus-mode": "Exit Focus Mode",
+ "link-preview": "Link preview",
+ "scrape-link": "Scrape link preview",
+ "scrape-link-description": "Paste a URL to fetch its title, description, and cover image.",
+ "link-preview-empty": "Paste a link to generate a preview card.",
+ "link-preview-invalid": "Please enter a valid URL.",
+ "link-preview-fetch-error": "Failed to fetch preview. Please try again.",
+ "link-preview-timeout": "Request timed out. Please retry."
},
"filters": {
"has-code": "hasCode",
diff --git a/web/src/locales/id.json b/web/src/locales/id.json
index 5d03058cc..3fb98cf98 100644
--- a/web/src/locales/id.json
+++ b/web/src/locales/id.json
@@ -114,7 +114,14 @@
"add-your-comment-here": "Tambahkan komentar Anda di sini...",
"any-thoughts": "Punya pemikiran...",
"save": "Simpan",
- "no-changes-detected": "Tidak ada perubahan yang terdeteksi"
+ "no-changes-detected": "Tidak ada perubahan yang terdeteksi",
+ "link-preview": "Pratinjau tautan",
+ "scrape-link": "Ambil pratinjau tautan",
+ "scrape-link-description": "Tempel URL untuk mengambil judul, deskripsi, dan gambar sampul.",
+ "link-preview-empty": "Tempel tautan untuk membuat kartu pratinjau.",
+ "link-preview-invalid": "Masukkan URL yang valid.",
+ "link-preview-fetch-error": "Gagal mengambil pratinjau. Silakan coba lagi.",
+ "link-preview-timeout": "Permintaan habis waktu. Silakan coba lagi."
},
"filters": {
"has-code": "Memiliki kode",