feat: display article hero images in bookmark views and reader

- Add image prop to ContentPanel to display hero images
- Extract image tag from kind:30023 bookmark tags
- Display article images in Card, Large, and Compact views
- Show hero image at top of article reader view
- Add CSS styling for article-hero-image and reader-hero-image
- Article images clickable to open article in reader
- Per NIP-23: image tag contains header/preview image URL
This commit is contained in:
Gigi
2025-10-05 08:22:46 +01:00
parent 3d304dab15
commit e8f44986da
10 changed files with 94 additions and 12 deletions

View File

@@ -1,3 +1,11 @@
---
alwaysApply: true
description: anything that has to do with kind:30023 aka nostr blog posts aka nostr-native long-form content
alwaysApply: false
---
Always stick to NIPs. Do everything with applesauce (getArticleTitle, getArticleSummary, getHashtags, getMentions).
- https://github.com/hzrd149/applesauce/blob/17c9dbb0f2c263e2ebd01729ea2fa138eca12bd1/packages/docs/tutorial/02-helpers.md
- https://github.com/nostr-protocol/nips/blob/master/19.md
- https://github.com/nostr-protocol/nips/blob/master/23.md
- https://nostrbook.dev/kinds/30023

4
dist/index.html vendored
View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Boris - Nostr Bookmarks</title>
<script type="module" crossorigin src="/assets/index-B-AkjDEr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ChEoItgK.css">
<script type="module" crossorigin src="/assets/index-D4L_70u1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dljx1pJR.css">
</head>
<body>
<div id="root"></div>

View File

@@ -30,13 +30,17 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
const firstUrl = hasUrls ? extractedUrls[0] : null
const firstUrlClassification = firstUrl ? classifyUrl(firstUrl) : null
// For kind:30023 articles, get the image from tags
const isArticle = bookmark.kind === 30023
const articleImage = isArticle ? bookmark.tags.find(t => t[0] === 'image')?.[1] : undefined
// Fetch OG image for large view (hook must be at top level)
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassification?.type || '') : null
React.useEffect(() => {
if (viewMode === 'large' && firstUrl && !instantPreview && !ogImage) {
if (viewMode === 'large' && firstUrl && !instantPreview && !ogImage && !articleImage) {
fetchOgImage(firstUrl).then(setOgImage)
}
}, [viewMode, firstUrl, instantPreview, ogImage])
}, [viewMode, firstUrl, instantPreview, ogImage, articleImage])
// Resolve author profile using applesauce
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
@@ -99,7 +103,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow
handleReadNow,
articleImage
}
if (viewMode === 'compact') {
@@ -107,9 +112,9 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
}
if (viewMode === 'large') {
const previewImage = instantPreview || ogImage
const previewImage = articleImage || instantPreview || ogImage
return <LargeView {...sharedProps} previewImage={previewImage} />
}
return <CardView {...sharedProps} />
return <CardView {...sharedProps} articleImage={articleImage} />
}

View File

@@ -20,6 +20,7 @@ interface CardViewProps {
eventNevent?: string
getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleImage?: string
}
export const CardView: React.FC<CardViewProps> = ({
@@ -33,15 +34,24 @@ export const CardView: React.FC<CardViewProps> = ({
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow
handleReadNow,
articleImage
}) => {
const [expanded, setExpanded] = useState(false)
const [urlsExpanded, setUrlsExpanded] = useState(false)
const contentLength = (bookmark.content || '').length
const shouldTruncate = !expanded && contentLength > 210
const isArticle = bookmark.kind === 30023
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
{isArticle && articleImage && (
<div
className="article-hero-image"
style={{ backgroundImage: `url(${articleImage})` }}
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
/>
)}
<div className="bookmark-header">
<span className="bookmark-type">
{bookmark.isPrivate ? (

View File

@@ -14,6 +14,7 @@ interface CompactViewProps {
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
getIconForUrlType: IconGetter
firstUrlClassification: { buttonText: string } | null
articleImage?: string
}
export const CompactView: React.FC<CompactViewProps> = ({

View File

@@ -38,13 +38,19 @@ export const LargeView: React.FC<LargeViewProps> = ({
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
{hasUrls && (
{(hasUrls || (isArticle && previewImage)) && (
<div
className="large-preview-image"
onClick={() => onSelectUrl?.(extractedUrls[0])}
onClick={() => {
if (isArticle) {
handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
} else {
onSelectUrl?.(extractedUrls[0])
}
}}
style={previewImage ? { backgroundImage: `url(${previewImage})` } : undefined}
>
{!previewImage && (
{!previewImage && hasUrls && (
<div className="preview-placeholder">
<FontAwesomeIcon icon={getIconForUrlType(extractedUrls[0])} />
</div>

View File

@@ -140,6 +140,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setReaderContent({
title: article.title,
markdown: article.markdown,
image: article.image,
url: `nostr:${naddr}`
})
} else {
@@ -190,6 +191,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
title={readerContent?.title}
html={readerContent?.html}
markdown={readerContent?.markdown}
image={readerContent?.image}
selectedUrl={selectedUrl}
highlights={highlights}
showUnderlines={showUnderlines}

View File

@@ -15,6 +15,7 @@ interface ContentPanelProps {
html?: string
markdown?: string
selectedUrl?: string
image?: string
highlights?: Highlight[]
showUnderlines?: boolean
highlightStyle?: 'marker' | 'underline'
@@ -29,6 +30,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
html,
markdown,
selectedUrl,
image,
highlights = [],
showUnderlines = true,
highlightStyle = 'marker',
@@ -158,6 +160,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
return (
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
{image && (
<div className="reader-hero-image">
<img src={image} alt={title || 'Article image'} />
</div>
)}
{title && (
<div className="reader-header">
<h2 className="reader-title">{title}</h2>

View File

@@ -1023,6 +1023,48 @@ body {
background: #218838;
}
/* Article hero image in card view */
.article-hero-image {
width: 100%;
height: 200px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 8px 8px 0 0;
position: relative;
}
.article-hero-image:hover {
opacity: 0.9;
}
.article-hero-image::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.4) 100%);
pointer-events: none;
border-radius: 8px 8px 0 0;
}
/* Hero image in reader view */
.reader-hero-image {
width: 100%;
margin: 0 0 2rem 0;
border-radius: 8px;
overflow: hidden;
}
.reader-hero-image img {
width: 100%;
height: auto;
max-height: 500px;
object-fit: cover;
display: block;
}
/* Private Bookmark Styles */
.private-bookmark {
background: #2a2a2a;

View File

@@ -6,6 +6,7 @@ export interface ReadableContent {
title?: string
html?: string
markdown?: string
image?: string
}
interface CachedContent {