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 { RelayPool } from 'applesauce-relay'
|
||||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||||
import Bookmarks from './components/Bookmarks'
|
import Bookmarks from './components/Bookmarks'
|
||||||
|
import Explore from './components/Explore'
|
||||||
import Toast from './components/Toast'
|
import Toast from './components/Toast'
|
||||||
import { useToast } from './hooks/useToast'
|
import { useToast } from './hooks/useToast'
|
||||||
import { RELAYS } from './config/relays'
|
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 />} />
|
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||||
</Routes>
|
</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 {
|
.three-pane.sidebar-collapsed .relay-status-indicator {
|
||||||
left: calc(var(--sidebar-collapsed-width) + 1.5rem);
|
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