feat(workspace): link previews above bullets with a web address (M5)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user