mirror of
https://github.com/dergigi/boris.git
synced 2025-12-18 15:14:20 +01:00
feat: add /explore route to discover blog posts from friends
- Create exploreService to fetch kind:30023 events from followed users - Add BlogPostCard component for displaying blog post previews - Add Explore page component with grid layout - Add /explore route to App.tsx (not linked in navigation yet) - Add responsive CSS styles for explore page and blog post cards - Clicking blog post cards navigates to article view
This commit is contained in:
@@ -9,6 +9,7 @@ import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import Bookmarks from './components/Bookmarks'
|
||||
import Explore from './components/Explore'
|
||||
import Toast from './components/Toast'
|
||||
import { useToast } from './hooks/useToast'
|
||||
import { RELAYS } from './config/relays'
|
||||
@@ -61,6 +62,12 @@ function AppRoutes({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/explore"
|
||||
element={
|
||||
<Explore relayPool={relayPool} />
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
||||
59
src/components/BlogPostCard.tsx
Normal file
59
src/components/BlogPostCard.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faCalendar, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { formatDistance } from 'date-fns'
|
||||
import { BlogPostPreview } from '../services/exploreService'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
|
||||
interface BlogPostCardProps {
|
||||
post: BlogPostPreview
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, onClick }) => {
|
||||
const profile = Hooks.useProfile(post.author)
|
||||
const displayName = profile?.name || profile?.display_name ||
|
||||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
||||
|
||||
const publishedDate = post.published || post.event.created_at
|
||||
const formattedDate = formatDistance(new Date(publishedDate * 1000), new Date(), {
|
||||
addSuffix: true
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className="blog-post-card"
|
||||
onClick={onClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{post.image && (
|
||||
<div className="blog-post-card-image">
|
||||
<img
|
||||
src={post.image}
|
||||
alt={post.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="blog-post-card-content">
|
||||
<h3 className="blog-post-card-title">{post.title}</h3>
|
||||
{post.summary && (
|
||||
<p className="blog-post-card-summary">{post.summary}</p>
|
||||
)}
|
||||
<div className="blog-post-card-meta">
|
||||
<span className="blog-post-card-author">
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="blog-post-card-date">
|
||||
<FontAwesomeIcon icon={faCalendar} />
|
||||
{formattedDate}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BlogPostCard
|
||||
|
||||
132
src/components/Explore.tsx
Normal file
132
src/components/Explore.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faCompass } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { fetchContacts } from '../services/contactService'
|
||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
|
||||
interface ExploreProps {
|
||||
relayPool: RelayPool
|
||||
}
|
||||
|
||||
const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
const navigate = useNavigate()
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const loadBlogPosts = async () => {
|
||||
if (!activeAccount) {
|
||||
setError('Please log in to explore content from your friends')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Fetch the user's contacts (friends)
|
||||
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
|
||||
|
||||
if (contacts.size === 0) {
|
||||
setError('You are not following anyone yet. Follow some people to see their blog posts!')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Get relay URLs from pool
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
|
||||
// Fetch blog posts from friends
|
||||
const posts = await fetchBlogPostsFromAuthors(
|
||||
relayPool,
|
||||
Array.from(contacts),
|
||||
relayUrls
|
||||
)
|
||||
|
||||
if (posts.length === 0) {
|
||||
setError('No blog posts found from your friends yet')
|
||||
}
|
||||
|
||||
setBlogPosts(posts)
|
||||
} catch (err) {
|
||||
console.error('Failed to load blog posts:', err)
|
||||
setError('Failed to load blog posts. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadBlogPosts()
|
||||
}, [relayPool, activeAccount])
|
||||
|
||||
const handlePostClick = (post: BlogPostPreview) => {
|
||||
// Get the d-tag identifier
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
|
||||
// Create naddr
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: post.author,
|
||||
identifier: dTag
|
||||
})
|
||||
|
||||
// Navigate to article view
|
||||
navigate(`/a/${naddr}`)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-loading">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
<p>Loading blog posts from your friends...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-error">
|
||||
<FontAwesomeIcon icon={faExclamationCircle} size="2x" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-header">
|
||||
<h1>
|
||||
<FontAwesomeIcon icon={faCompass} />
|
||||
Explore
|
||||
</h1>
|
||||
<p className="explore-subtitle">
|
||||
Discover blog posts from your friends on Nostr
|
||||
</p>
|
||||
</div>
|
||||
<div className="explore-grid">
|
||||
{blogPosts.map((post) => (
|
||||
<BlogPostCard
|
||||
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
|
||||
post={post}
|
||||
onClick={() => handlePostClick(post)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Explore
|
||||
|
||||
160
src/index.css
160
src/index.css
@@ -2555,3 +2555,163 @@ body {
|
||||
.three-pane.sidebar-collapsed .relay-status-indicator {
|
||||
left: calc(var(--sidebar-collapsed-width) + 1.5rem);
|
||||
}
|
||||
|
||||
/* Explore Page Styles */
|
||||
.explore-container {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.explore-header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.explore-header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin: 0 0 1rem 0;
|
||||
color: #646cff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.explore-subtitle {
|
||||
font-size: 1.125rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.explore-loading,
|
||||
.explore-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
min-height: 50vh;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.explore-error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.explore-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.blog-post-card {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.blog-post-card:hover {
|
||||
border-color: #646cff;
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(100, 108, 255, 0.15);
|
||||
}
|
||||
|
||||
.blog-post-card-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
background: #0f0f0f;
|
||||
}
|
||||
|
||||
.blog-post-card-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.blog-post-card:hover .blog-post-card-image img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.blog-post-card-content {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.blog-post-card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.blog-post-card-summary {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.blog-post-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #333;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.blog-post-card-author,
|
||||
.blog-post-card-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.blog-post-card-author svg,
|
||||
.blog-post-card-date svg {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.explore-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.explore-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.explore-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
87
src/services/exploreService.ts
Normal file
87
src/services/exploreService.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
export interface BlogPostPreview {
|
||||
event: NostrEvent
|
||||
title: string
|
||||
summary?: string
|
||||
image?: string
|
||||
published?: number
|
||||
author: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches blog posts (kind:30023) from a list of pubkeys (friends)
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param pubkeys - Array of pubkeys to fetch posts from
|
||||
* @param relayUrls - Array of relay URLs to query
|
||||
* @returns Array of blog post previews
|
||||
*/
|
||||
export const fetchBlogPostsFromAuthors = async (
|
||||
relayPool: RelayPool,
|
||||
pubkeys: string[],
|
||||
relayUrls: string[]
|
||||
): Promise<BlogPostPreview[]> => {
|
||||
try {
|
||||
if (pubkeys.length === 0) {
|
||||
console.log('⚠️ No pubkeys to fetch blog posts from')
|
||||
return []
|
||||
}
|
||||
|
||||
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
|
||||
|
||||
const events = await lastValueFrom(
|
||||
relayPool
|
||||
.req(relayUrls, {
|
||||
kinds: [30023],
|
||||
authors: pubkeys,
|
||||
limit: 100 // Fetch up to 100 recent posts
|
||||
})
|
||||
.pipe(completeOnEose(), takeUntil(timer(15000)), toArray())
|
||||
)
|
||||
|
||||
console.log('📊 Blog post events fetched:', events.length)
|
||||
|
||||
// Deduplicate replaceable events by keeping the most recent version
|
||||
// Group by author + d-tag identifier
|
||||
const uniqueEvents = new Map<string, NostrEvent>()
|
||||
|
||||
for (const event of events) {
|
||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${event.pubkey}:${dTag}`
|
||||
|
||||
const existing = uniqueEvents.get(key)
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
uniqueEvents.set(key, event)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to blog post previews and sort by published date (most recent first)
|
||||
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
||||
.map(event => ({
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA // Most recent first
|
||||
})
|
||||
|
||||
console.log('📰 Processed', blogPosts.length, 'unique blog posts')
|
||||
|
||||
return blogPosts
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch blog posts:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user