feat(workspace): link previews above bullets with a web address (M5)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s

- GET /link-preview?url= fetches the page on the server (https only,
  SSRF-guarded like webhooks), reads Open Graph/Twitter tags, title,
  description and icon from the head, loads picture and icon (no SVG) and
  keeps them in link_previews (migration 6) for 7 days; readers' browsers
  never contact the page. Off in the demo; 300 fetches per address in
  15 minutes.
- Compact card above the bullet (icon, site, title, one line, small
  picture); the duplicate link badge is left out.
- ✕ turns the address into [title](url), so hiding syncs with the note;
  "Link-Vorschauen anzeigen" under appearance switches all off per device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 00:06:05 +01:00
1 parent 83408d120d
commit 4c26eb2960
16 files changed
+688 -6

No files matched your search

@@ -30,6 +30,8 @@ import { LegalLinks } from '../../components/LegalLinks';
import { AboutModal } from '../../components/AboutModal';
import { ImportCard } from './ImportCard';
import { SessionsPanel } from './SessionsPanel';
import { ToggleSwitch } from '../../components/ToggleSwitch';
import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
import { FormSheet } from '../../components/FormSheet';
interface UserSettingsViewProps {
@@ -99,6 +101,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const [showAbout, setShowAbout] = useState(false);
// The public demo sends nothing and keeps the shared accounts intact (FLAW-33)
const [demoMode, setDemoMode] = useState(false);
const linkPreviewsOn = useLinkPreviewsEnabled();
useEffect(() => {
api
.getAuthStatus()
@@ -774,6 +777,18 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</p>
</div>
<div className="p-3.5 rounded-card border border-border bg-canvas flex items-center justify-between gap-4">
<div className="text-xs">
<p className="font-semibold text-main">Link-Vorschauen anzeigen</p>
<p className="text-[11px] text-faint">
Karte mit Titel, Beschreibung und kleinem Bild über Punkten mit einer Web-Adresse. Die Seite wird
dafür vom MindSift-Server abgerufen, nicht von deinem Browser. Gilt für dieses Gerät; einzelne
Vorschauen lassen sich mit ✕ ausblenden.
</p>
</div>
<ToggleSwitch checked={linkPreviewsOn} onChange={setLinkPreviewsEnabled} title="Link-Vorschauen anzeigen" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{themes.map((t) => {
const isSelected = activeTheme === t.id;
@@ -0,0 +1,84 @@
import React, { useEffect, useState } from 'react';
import { Globe, X } from 'lucide-react';
import { LinkPreview, loadLinkPreview } from '../../lib/linkPreview';
import { loadAuthorized } from '../../lib/media';
import { safeHref } from '../../lib/safeUrl';
interface LinkPreviewCardProps {
url: string;
canEdit: boolean;
onHide: (title: string) => void;
}
function usePicture(preview: LinkPreview | null, kind: 'image' | 'icon'): string | null {
const [src, setSrc] = useState<string | null>(null);
const has = preview ? (kind === 'image' ? preview.hasImage : preview.hasIcon) : false;
const key = preview?.key;
useEffect(() => {
if (!has || !key) return;
let alive = true;
loadAuthorized(`/api/v1/link-preview/${key}/${kind}`)
.then((p) => alive && setSrc(p.url))
.catch(() => {});
return () => {
alive = false;
};
}, [has, key, kind]);
return src;
}
// Compact card above a bullet with a web address: icon and site, title, one line of description and a
// small picture. Nothing is shown when the page offers nothing usable.
export const LinkPreviewCard: React.FC<LinkPreviewCardProps> = ({ url, canEdit, onHide }) => {
const [preview, setPreview] = useState<LinkPreview | null>(null);
const image = usePicture(preview, 'image');
const icon = usePicture(preview, 'icon');
const href = safeHref(url);
useEffect(() => {
let alive = true;
setPreview(null);
void loadLinkPreview(url).then((p) => alive && setPreview(p));
return () => {
alive = false;
};
}, [url]);
if (!preview || !href) return null;
return (
<div className="relative mb-1 max-w-xl">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="flex items-stretch gap-3 rounded-card border border-border bg-panel hover:bg-panel-elevated transition-colors overflow-hidden no-underline"
>
<div className="min-w-0 flex-1 py-2 pl-3 pr-11 sm:pr-8 space-y-0.5">
<div className="flex items-center gap-1.5 text-[11px] text-faint min-w-0">
{icon ? (
<img src={icon} alt="" className="w-3.5 h-3.5 rounded-sm shrink-0" />
) : (
<Globe className="w-3.5 h-3.5 shrink-0" />
)}
<span className="truncate">{preview.siteName}</span>
</div>
{preview.title && <div className="text-sm font-semibold text-main line-clamp-2 leading-snug">{preview.title}</div>}
{preview.description && <div className="text-xs text-muted truncate">{preview.description}</div>}
</div>
{image && <img src={image} alt="" className="w-16 sm:w-20 object-cover shrink-0 border-l border-border" />}
</a>
{canEdit && (
<button
type="button"
onClick={() => onHide(preview.title)}
title="Vorschau für diesen Link ausblenden"
aria-label="Vorschau ausblenden"
className="absolute top-0 right-0 sm:top-1 sm:right-1 w-11 h-11 sm:w-7 sm:h-7 flex items-center justify-center rounded-full bg-panel/70 text-muted hover:text-main"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
);
};
@@ -33,6 +33,8 @@ import { AudioRecorderPicker } from './AudioRecorderPicker';
import { AudioBlock } from './AudioBlock';
import { DictationBlock } from './DictationBlock';
import { currentVoiceConfig } from '../../lib/voice';
import { LinkPreviewCard } from './LinkPreviewCard';
import { firstBareUrl, hidePreviewContent, useLinkPreviewsEnabled } from '../../lib/linkPreview';
import { mediaContent, parseMediaContent } from '../../lib/media';
import { renderInlineMarkdown } from '../../lib/markdown';
import { api } from '../../lib/api';
@@ -153,6 +155,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const [slashSearch, setSlashSearch] = useState<string | null>(null);
// Inline picker an empty bullet turns into after /image (local only, nothing synced until done)
const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null);
// Card above bullets with a plain web address (lib/linkPreview.ts)
const previewsOn = useLinkPreviewsEnabled();
const previewUrl =
previewsOn && !mediaPicker && ['bullet', 'text', 'paragraph', 'todo', 'h1', 'h2', 'h3'].includes(block.type || 'bullet')
? firstBareUrl(localContent)
: null;
const [slashIndex, setSlashIndex] = useState(0);
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
@@ -749,7 +757,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
(t) =>
(t.startsWith('[[') && t.endsWith(']]')) ||
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
/^https?:\/\//.test(t)
(/^https?:\/\//.test(t) && t !== previewUrl)
);
return (
@@ -851,6 +859,18 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{/* Main Block Content */}
<div className="flex-1 min-w-0 pr-1 relative min-h-[1.5rem]">
{previewUrl && (
<LinkPreviewCard
url={previewUrl}
canEdit={canEdit}
onHide={(title) => {
const content = hidePreviewContent(localContent, previewUrl, title);
setLocalContent(content);
localContentRef.current = content;
flushDebounce(content);
}}
/>
)}
{mediaPicker === 'image' ? (
<ImagePicker
docId={docId}
@@ -1193,7 +1213,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</a>
);
}
if (/^https?:\/\//.test(part) && safeHref(part)) {
// The preview card above already shows this address
if (/^https?:\/\//.test(part) && safeHref(part) && part !== previewUrl) {
return (
<a
key={idx}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { firstBareUrl, hidePreviewContent } from '../linkPreview';
describe('link preview helpers', () => {
it('finds the first plain https address', () => {
expect(firstBareUrl('https://example.com/a?b=1')).toBe('https://example.com/a?b=1');
expect(firstBareUrl('Rezept: https://koch.de/spaetzle, lecker!')).toBe('https://koch.de/spaetzle');
expect(firstBareUrl('siehe (https://x.de/p).')).toBeNull(); // inside brackets: not a plain address
expect(firstBareUrl('[Kochseite](https://koch.de)')).toBeNull();
expect(firstBareUrl('http://unsicher.de')).toBeNull();
expect(firstBareUrl('nur Text')).toBeNull();
});
it('hides a preview by turning the address into a named link', () => {
expect(hidePreviewContent('Lesen: https://koch.de/x', 'https://koch.de/x', 'Käsespätzle [Rezept]')).toBe(
'Lesen: [Käsespätzle Rezept](https://koch.de/x)'
);
expect(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', '')).toBe('[koch.de](https://koch.de/x)');
expect(firstBareUrl(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', 'T'))).toBeNull();
});
});
+85
View File
@@ -0,0 +1,85 @@
// Link previews in notes: a card above a bullet that contains a plain web address. The server fetches
// the page (https only) and keeps title, description, picture and icon; the browser only talks to
// MindSift. Hiding the card for one link turns the address into a named link [Titel](url), so the
// choice syncs with the note; a global switch (this device) turns all previews off.
import { useEffect, useState } from 'react';
import { getToken } from './api';
export interface LinkPreview {
key: string;
url: string;
title: string;
description: string;
siteName: string;
hasImage: boolean;
hasIcon: boolean;
ok: boolean;
}
// First plain https address in a text (not inside [text](url), not right after "(" or "<")
const BARE_URL_RE = /(^|[\s])(https:\/\/[^\s<>()\]]+[^\s<>()\].,;:!?'"])/;
export function firstBareUrl(content: string): string | null {
const m = BARE_URL_RE.exec(content || '');
return m ? m[2] : null;
}
/** Replaces the address by a named link, which shows no preview. */
export function hidePreviewContent(content: string, url: string, title: string): string {
const label = (title || new URL(url).hostname).replace(/[[\]]/g, '').trim() || url;
return content.replace(url, `[${label}](${url})`);
}
const cache = new Map<string, Promise<LinkPreview | null>>();
export function loadLinkPreview(url: string): Promise<LinkPreview | null> {
let p = cache.get(url);
if (!p) {
p = (async () => {
const token = getToken();
const res = await fetch(`/api/v1/link-preview?url=${encodeURIComponent(url)}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) return null; // demo, http link, offline: just no card
const data = (await res.json()) as LinkPreview;
return data.ok ? data : null;
})().catch(() => null);
cache.set(url, p);
}
return p;
}
const SETTING_KEY = 'mindsift_link_previews';
const SETTING_EVENT = 'mindsift:link-previews';
export function linkPreviewsEnabled(): boolean {
try {
return localStorage.getItem(SETTING_KEY) !== 'off';
} catch {
return true;
}
}
export function setLinkPreviewsEnabled(on: boolean): void {
try {
localStorage.setItem(SETTING_KEY, on ? 'on' : 'off');
} catch {
// private mode: only for this page
}
window.dispatchEvent(new CustomEvent(SETTING_EVENT));
}
/** The global switch, kept in sync across all open notes and tabs. */
export function useLinkPreviewsEnabled(): boolean {
const [on, setOn] = useState(linkPreviewsEnabled);
useEffect(() => {
const update = () => setOn(linkPreviewsEnabled());
window.addEventListener(SETTING_EVENT, update);
window.addEventListener('storage', update);
return () => {
window.removeEventListener(SETTING_EVENT, update);
window.removeEventListener('storage', update);
};
}, []);
return on;
}
+9 -4
View File
@@ -27,19 +27,24 @@ export function mediaContent(kind: 'image' | 'audio', id: string, caption = ''):
const loaded = new Map<string, Promise<{ blob: Blob; url: string }>>();
function loadMedia(id: string): Promise<{ blob: Blob; url: string }> {
let p = loaded.get(id);
return loadAuthorized(`/api/v1/media/${encodeURIComponent(id)}`);
}
/** Any picture of the API (needs the login, so no plain <img src>), as Blob plus blob: URL. */
export function loadAuthorized(path: string): Promise<{ blob: Blob; url: string }> {
let p = loaded.get(path);
if (!p) {
p = (async () => {
const token = getToken();
const res = await fetch(`/api/v1/media/${encodeURIComponent(id)}`, {
const res = await fetch(path, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(res.status === 404 ? 'Datei nicht gefunden' : `HTTP ${res.status}`);
const blob = await res.blob();
return { blob, url: URL.createObjectURL(blob) };
})();
p.catch(() => loaded.delete(id)); // try again next time (e.g. was offline)
loaded.set(id, p);
p.catch(() => loaded.delete(path)); // try again next time (e.g. was offline)
loaded.set(path, p);
}
return p;
}