Skip to content

Commit 8f952e9

Browse files
Add link preview cards (og:image, favicon, title, description)
URLs pasted into notes are scraped server-side with open-graph-scraper and displayed as unfurl cards at the bottom of each NoteCard. Previews populate automatically after save via a background fetch and socket push. Cards cap at 3; collapse to 1 when the note already has images/sketches. Border uses --border-transparent so cards look natural on all note colors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d92e6c9 commit 8f952e9

17 files changed

Lines changed: 697 additions & 22 deletions
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import React from 'react';
2+
import styled from 'styled-components';
3+
import { getServerUrl } from '../services/api';
4+
import { useUIPreferences } from '../contexts/UIPreferencesContext';
5+
6+
const Card = styled.a`
7+
/* Flex column so the image can shrink when the note card is tight on space,
8+
letting several previews compress to fit instead of overflowing. */
9+
display: flex;
10+
flex-direction: column;
11+
flex: 0 1 auto;
12+
min-height: 52px;
13+
text-decoration: none;
14+
color: inherit;
15+
border: 1px solid var(--border-transparent);
16+
border-radius: 8px;
17+
overflow: hidden;
18+
cursor: pointer;
19+
transition: opacity 0.15s ease;
20+
21+
&:hover {
22+
opacity: 0.8;
23+
}
24+
`;
25+
26+
const CardImage = styled.div`
27+
width: 100%;
28+
/* Basis of 130px but allowed to shrink to 0 (never grows), so the image is the
29+
part that gives when previews need to fit a bounded area. */
30+
flex: 0 1 130px;
31+
min-height: 0;
32+
background-image: url(${props => props.$src});
33+
background-size: cover;
34+
background-position: center;
35+
background-color: ${props => props.theme === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)'};
36+
`;
37+
38+
const CardBody = styled.div`
39+
padding: 7px 10px 8px;
40+
flex-shrink: 0; /* keep the text (favicon/domain/title) legible; only the image shrinks */
41+
`;
42+
43+
const CardMeta = styled.div`
44+
display: flex;
45+
align-items: center;
46+
gap: 5px;
47+
margin-bottom: 3px;
48+
`;
49+
50+
const Favicon = styled.img`
51+
width: 13px;
52+
height: 13px;
53+
object-fit: contain;
54+
flex-shrink: 0;
55+
`;
56+
57+
const Domain = styled.span`
58+
font-size: 11px;
59+
color: ${props => props.theme === 'dark' ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.45)'};
60+
overflow: hidden;
61+
text-overflow: ellipsis;
62+
white-space: nowrap;
63+
`;
64+
65+
const CardTitle = styled.div`
66+
font-size: 13px;
67+
font-weight: 600;
68+
line-height: 1.35;
69+
display: -webkit-box;
70+
-webkit-line-clamp: ${props => props.$oneLine ? 1 : 2};
71+
-webkit-box-orient: vertical;
72+
overflow: hidden;
73+
color: var(--text-color);
74+
`;
75+
76+
const CardDesc = styled.div`
77+
font-size: 12px;
78+
color: ${props => props.theme === 'dark' ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'};
79+
margin-top: 2px;
80+
line-height: 1.4;
81+
display: -webkit-box;
82+
-webkit-line-clamp: ${props => props.$oneLine ? 1 : 2};
83+
-webkit-box-orient: vertical;
84+
overflow: hidden;
85+
`;
86+
87+
function LinkPreviewCard({ link, isDark, showImage = true, oneLine = false }) {
88+
const { showLinkPreviews, showLinkPreviewImages } = useUIPreferences();
89+
const theme = isDark ? 'dark' : 'light';
90+
91+
// Previews disabled entirely — render nothing (the data is still fetched/stored
92+
// server-side, so re-enabling shows them instantly without a refetch).
93+
if (!showLinkPreviews) return null;
94+
95+
let domain = '';
96+
try {
97+
domain = new URL(link.url).hostname.replace(/^www\./, '');
98+
} catch {
99+
domain = link.url;
100+
}
101+
102+
// Image shown only when the setting is on AND the caller allows it (hidden once
103+
// there are 3 cards, to keep them compact).
104+
const imageUrl = (showImage && showLinkPreviewImages) ? getServerUrl(link.image_url) : null;
105+
const faviconUrl = getServerUrl(link.favicon_url);
106+
107+
return (
108+
<Card
109+
href={link.url}
110+
target="_blank"
111+
rel="noopener noreferrer"
112+
onClick={e => e.stopPropagation()}
113+
>
114+
{imageUrl && <CardImage $src={imageUrl} theme={theme} />}
115+
<CardBody>
116+
<CardMeta>
117+
{faviconUrl && (
118+
<Favicon
119+
src={faviconUrl}
120+
alt=""
121+
onError={e => { e.target.style.display = 'none'; }}
122+
/>
123+
)}
124+
<Domain theme={theme}>{domain}</Domain>
125+
</CardMeta>
126+
{link.title && <CardTitle $oneLine={oneLine}>{link.title}</CardTitle>}
127+
{link.description && <CardDesc theme={theme} $oneLine={oneLine}>{link.description}</CardDesc>}
128+
</CardBody>
129+
</Card>
130+
);
131+
}
132+
133+
export default LinkPreviewCard;

client/src/components/NoteCard.jsx

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { useState, useEffect, memo, useCallback, useRef, useMemo, useSync
22
import styled, { keyframes, css } from 'styled-components';
33
import { useNoteActions } from '../contexts/NoteActionsContext'; // Use stable actions context
44
import { selectionModeRef } from '../contexts/NoteSelectionContext';
5-
import { tagsApi, getServerUrl } from '../services/api';
5+
import { tagsApi } from '../services/api';
66
import NoteTagPicker, { TagPicker } from './NoteTagPicker';
77
import { ColorPicker as SharedColorPicker } from './ColorPicker'; // Import the new ColorPicker
88
import Icon from './Icons';
@@ -11,6 +11,7 @@ import ImageGallery from './ImageGallery';
1111
import ProgressFlyout from './ProgressFlyout';
1212
import CompactBookCard from './CompactBookCard';
1313
import CompactImdbCard from './CompactImdbCard';
14+
import LinkPreviewCard from './LinkPreviewCard';
1415
import { loadNoteImages } from '../services/imageManager';
1516
import { useInlineImageResolution } from '../hooks/useInlineImageResolution';
1617

@@ -93,7 +94,7 @@ const Card = styled.div`
9394
padding: 16px;
9495
padding-bottom: 8px;
9596
height: fit-content; /* Key for masonry layout: use fit-content instead of min-height */
96-
max-height: ${props => props.$hasImages ? '620px' : '460px'};
97+
max-height: ${props => props.$hasImages ? '620px' : props.$hasLinks ? '600px' : '460px'};
9798
display: flex;
9899
flex-direction: column;
99100
transition: box-shadow 0.2s ease, transform 0.2s ease, opacity 0.3s ease;
@@ -121,7 +122,7 @@ const Card = styled.div`
121122
@media (max-width: 600px) {
122123
padding: 12px;
123124
font-size: 0.9em;
124-
max-height: ${props => props.$hasImages ? '440px' : props.$hasTags ? '380px' : '250px'};
125+
max-height: ${props => props.$hasImages ? '440px' : props.$hasLinks ? '440px' : props.$hasTags ? '380px' : '250px'};
125126
/* Use CSS variable for mobile background */
126127
background-color: var(--card-bg-color, var(--mobile-background-color));
127128
border: ${props => props.$isDirectIdMatch
@@ -526,6 +527,30 @@ const TagText = styled.span`
526527
min-width: 0;
527528
`;
528529

530+
// Holds the stack of link preview cards. A flex column with a bounded height: when
531+
// several (image-heavy) previews would overflow, the cards themselves shrink to fit
532+
// (their images compress) rather than the card spilling or the note text getting
533+
// starved. flex-shrink:999 + min-height:0 makes THIS section yield first under
534+
// pressure. Collapses to no margin when it renders empty (previews toggled off).
535+
const LinkPreviewList = styled.div`
536+
display: flex;
537+
flex-direction: column;
538+
gap: 6px;
539+
min-height: 0;
540+
flex-shrink: 999;
541+
max-height: 300px;
542+
margin: 8px 0 10px;
543+
544+
&:empty {
545+
margin: 0;
546+
}
547+
548+
/* When the note already has images/sketches, only the first preview is shown */
549+
${props => props.$compact && `
550+
> *:nth-child(n+2) { display: none; }
551+
`}
552+
`;
553+
529554
const UrlLink = styled(Tag)`
530555
531556
`;
@@ -670,7 +695,6 @@ const extractNoteReferences = (text, urlsToExclude = []) => {
670695
};
671696

672697
const NoteCard = memo(function NoteCard({ note, searchQuery, layoutView, onPickerOpen, isSelected, onToggleSelect }) {
673-
console.log('[NoteCard] Rendering note:', note.id);
674698
const [justEdited, setJustEdited] = useState(false);
675699
const [showActions, setShowActions] = useState(false);
676700
const [showColorPicker, setShowColorPicker] = useState(false);
@@ -1040,6 +1064,17 @@ const NoteCard = memo(function NoteCard({ note, searchQuery, layoutView, onPicke
10401064
return noteTags.some(tag => tag.visible === false);
10411065
}, [noteTags]);
10421066

1067+
// Renderable link previews (have a fetched title/image), capped at 3.
1068+
const previewLinks = useMemo(
1069+
() => (note.links || []).filter(l => l.title || l.image_url).slice(0, 3),
1070+
[note.links]
1071+
);
1072+
const hasLinkPreviews = previewLinks.length > 0;
1073+
// Images get cramped once there are three cards, so only show them for 1-2.
1074+
const showLinkImages = previewLinks.length <= 2;
1075+
// With more than one card, clamp each title/description to a single line.
1076+
const oneLinePreviewText = previewLinks.length > 1;
1077+
10431078
// Compute inline style for color CSS variables - avoids styled-components class regeneration
10441079
const cardColorStyle = useMemo(() => {
10451080
const color = note.color || 'default';
@@ -1060,6 +1095,7 @@ const NoteCard = memo(function NoteCard({ note, searchQuery, layoutView, onPicke
10601095
style={cardColorStyle}
10611096
$isPinned={note.is_pinned}
10621097
$hasImages={images.length > 0}
1098+
$hasLinks={hasLinkPreviews}
10631099
$hasTags={visibleTags.length > 0} // Use visibleTags for layout check
10641100
$justEdited={justEdited}
10651101
$isSelected={isSelected}
@@ -1113,6 +1149,15 @@ const NoteCard = memo(function NoteCard({ note, searchQuery, layoutView, onPicke
11131149
</div>
11141150
)}
11151151

1152+
{/* Link preview cards (max 3; images hidden once there are 3) */}
1153+
{hasLinkPreviews && (
1154+
<LinkPreviewList $compact={images.length > 0 || sketches.length > 0}>
1155+
{previewLinks.map(link => (
1156+
<LinkPreviewCard key={link.id} link={link} isDark={isDarkTheme} showImage={showLinkImages} oneLine={oneLinePreviewText} />
1157+
))}
1158+
</LinkPreviewList>
1159+
)}
1160+
11161161
{/* Display tags, URLs, book references, note references, and objects if any */}
11171162
{(noteTags.length > 0 || noteUrls.length > 0 || bookReferences.length > 0 || noteReferences.length > 0 || noteObjects.length > 0) && (
11181163
<TagsContainer style={{
@@ -1443,6 +1488,18 @@ const NoteCard = memo(function NoteCard({ note, searchQuery, layoutView, onPicke
14431488
}
14441489
}
14451490

1491+
// Check for link preview updates. Preview fetches (title/image/favicon) update
1492+
// the note_links table WITHOUT bumping note.updated_at, so this must be compared
1493+
// explicitly — otherwise a card whose preview just arrived over the socket won't
1494+
// re-render until a full refresh.
1495+
const prevLinks = prevProps.note.links || [];
1496+
const nextLinks = nextProps.note.links || [];
1497+
if (prevLinks.length !== nextLinks.length) return false;
1498+
for (let i = 0; i < prevLinks.length; i++) {
1499+
if (prevLinks[i].id !== nextLinks[i].id) return false;
1500+
if (prevLinks[i].fetched_at !== nextLinks[i].fetched_at) return false;
1501+
}
1502+
14461503
// For large content, only check length to avoid expensive comparison
14471504
const prevContent = prevProps.note.content || '';
14481505
const nextContent = nextProps.note.content || '';

client/src/components/settings/AppearanceTab.jsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,15 @@ const AppearanceTab = ({ settings, commit, isDarkTheme, toggleTheme }) => {
8585
showQuickAccess,
8686
showMonthMarkers,
8787
showNoteTabs,
88+
showLinkPreviews,
89+
showLinkPreviewImages,
8890
fullscreenNoteForm,
8991
layoutView,
9092
toggleQuickAccess,
9193
toggleMonthMarkers,
9294
toggleNoteTabs,
95+
toggleLinkPreviews,
96+
toggleLinkPreviewImages,
9397
toggleFullscreenNoteForm,
9498
changeLayoutView,
9599
colorLabels,
@@ -317,6 +321,31 @@ const AppearanceTab = ({ settings, commit, isDarkTheme, toggleTheme }) => {
317321
</p>
318322
</SectionContainer>
319323

324+
<SectionContainer>
325+
<SectionTitle>Link Previews</SectionTitle>
326+
<OptionRow>
327+
<Label style={{ marginBottom: 0 }}>Show Link Previews</Label>
328+
<Switch
329+
id="link-previews-toggle"
330+
checked={showLinkPreviews}
331+
onChange={toggleLinkPreviews}
332+
/>
333+
</OptionRow>
334+
{showLinkPreviews && (
335+
<OptionRow>
336+
<Label style={{ marginBottom: 0 }}>Show Preview Images</Label>
337+
<Switch
338+
id="link-preview-images-toggle"
339+
checked={showLinkPreviewImages}
340+
onChange={toggleLinkPreviewImages}
341+
/>
342+
</OptionRow>
343+
)}
344+
<p style={{ fontSize: '14px', color: 'var(--text-secondary-color)', margin: 0 }}>
345+
Preview cards for links that sit alone on their own line.
346+
</p>
347+
</SectionContainer>
348+
320349
<SectionContainer>
321350
<SectionTitle>Custom Color Labels</SectionTitle>
322351
<p style={{ fontSize: '14px', color: 'var(--text-secondary-color)' }}>

client/src/contexts/NotesContext.jsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ const notesArrayEqual = (a, b) => {
5151
) {
5252
return false;
5353
}
54+
const xl = x.links, yl = y.links;
55+
const xll = xl ? xl.length : 0;
56+
const yll = yl ? yl.length : 0;
57+
if (xll !== yll) return false;
58+
for (let j = 0; j < xll; j++) {
59+
if (xl[j].fetched_at !== yl[j].fetched_at) return false;
60+
}
5461
}
5562
return true;
5663
};

0 commit comments

Comments
 (0)