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

@@ -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}