mirror of
https://github.com/dergigi/boris.git
synced 2025-12-18 15:14:20 +01:00
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:
@@ -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
4
dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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> = ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface ReadableContent {
|
||||
title?: string
|
||||
html?: string
|
||||
markdown?: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
interface CachedContent {
|
||||
|
||||
Reference in New Issue
Block a user