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" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Boris - Nostr Bookmarks</title> <title>Boris - Nostr Bookmarks</title>
<script type="module" crossorigin src="/assets/index-B-AkjDEr.js"></script> <script type="module" crossorigin src="/assets/index-D4L_70u1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ChEoItgK.css"> <link rel="stylesheet" crossorigin href="/assets/index-Dljx1pJR.css">
</head> </head>
<body> <body>
<div id="root"></div> <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 firstUrl = hasUrls ? extractedUrls[0] : null
const firstUrlClassification = firstUrl ? classifyUrl(firstUrl) : 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) // Fetch OG image for large view (hook must be at top level)
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassification?.type || '') : null const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassification?.type || '') : null
React.useEffect(() => { React.useEffect(() => {
if (viewMode === 'large' && firstUrl && !instantPreview && !ogImage) { if (viewMode === 'large' && firstUrl && !instantPreview && !ogImage && !articleImage) {
fetchOgImage(firstUrl).then(setOgImage) fetchOgImage(firstUrl).then(setOgImage)
} }
}, [viewMode, firstUrl, instantPreview, ogImage]) }, [viewMode, firstUrl, instantPreview, ogImage, articleImage])
// Resolve author profile using applesauce // Resolve author profile using applesauce
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey]) const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
@@ -99,7 +103,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
authorNpub, authorNpub,
eventNevent, eventNevent,
getAuthorDisplayName, getAuthorDisplayName,
handleReadNow handleReadNow,
articleImage
} }
if (viewMode === 'compact') { if (viewMode === 'compact') {
@@ -107,9 +112,9 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
} }
if (viewMode === 'large') { if (viewMode === 'large') {
const previewImage = instantPreview || ogImage const previewImage = articleImage || instantPreview || ogImage
return <LargeView {...sharedProps} previewImage={previewImage} /> return <LargeView {...sharedProps} previewImage={previewImage} />
} }
return <CardView {...sharedProps} /> return <CardView {...sharedProps} articleImage={articleImage} />
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1023,6 +1023,48 @@ body {
background: #218838; 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 Styles */
.private-bookmark { .private-bookmark {
background: #2a2a2a; background: #2a2a2a;

View File

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