feat: add public profile pages at /p/:npub

- Make AuthorCard clickable to navigate to user profiles
- Add /p/:npub and /p/:npub/writings routes
- Reuse Me component for public profiles with pubkey prop
- Show highlights and writings tabs for any user
- Hide private tabs (reading-list, archive) on public profiles
- Public profiles show only public data (highlights, writings)
- Private data (bookmarks, read articles) only visible on own profile
- Add clickable author card hover styles with indigo border
- Decode npub to pubkey for profile viewing
- DRY: Single Me component serves both /me and /p/:npub routes
This commit is contained in:
Gigi
2025-10-14 01:01:10 +02:00
parent 6b240b01ec
commit 4c720aa049
6 changed files with 148 additions and 64 deletions

View File

@@ -110,6 +110,24 @@ function AppRoutes({
/> />
} }
/> />
<Route
path="/p/:npub"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/p/:npub/writings"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} /> <Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
</Routes> </Routes>
) )

View File

@@ -1,14 +1,18 @@
import React from 'react' import React from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUserCircle } from '@fortawesome/free-solid-svg-icons' import { faUserCircle } from '@fortawesome/free-solid-svg-icons'
import { useEventModel } from 'applesauce-react/hooks' import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core' import { Models } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
interface AuthorCardProps { interface AuthorCardProps {
authorPubkey: string authorPubkey: string
clickable?: boolean
} }
const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey }) => { const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey, clickable = true }) => {
const navigate = useNavigate()
const profile = useEventModel(Models.ProfileModel, [authorPubkey]) const profile = useEventModel(Models.ProfileModel, [authorPubkey])
const getAuthorName = () => { const getAuthorName = () => {
@@ -20,8 +24,19 @@ const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey }) => {
const authorImage = profile?.picture || profile?.image const authorImage = profile?.picture || profile?.image
const authorBio = profile?.about const authorBio = profile?.about
const handleClick = () => {
if (clickable) {
const npub = nip19.npubEncode(authorPubkey)
navigate(`/p/${npub}`)
}
}
return ( return (
<div className="author-card"> <div
className={`author-card ${clickable ? 'author-card-clickable' : ''}`}
onClick={handleClick}
style={clickable ? { cursor: 'pointer' } : undefined}
>
<div className="author-card-avatar"> <div className="author-card-avatar">
{authorImage ? ( {authorImage ? (
<img src={authorImage} alt={getAuthorName()} /> <img src={authorImage} alt={getAuthorName()} />

View File

@@ -3,6 +3,7 @@ import { useParams, useLocation, useNavigate } from 'react-router-dom'
import { Hooks } from 'applesauce-react' import { Hooks } from 'applesauce-react'
import { useEventStore } from 'applesauce-react/hooks' import { useEventStore } from 'applesauce-react/hooks'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { useSettings } from '../hooks/useSettings' import { useSettings } from '../hooks/useSettings'
import { useArticleLoader } from '../hooks/useArticleLoader' import { useArticleLoader } from '../hooks/useArticleLoader'
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader' import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
@@ -25,7 +26,7 @@ interface BookmarksProps {
} }
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => { const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const { naddr } = useParams<{ naddr?: string }>() const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const previousLocationRef = useRef<string>() const previousLocationRef = useRef<string>()
@@ -37,6 +38,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const showSettings = location.pathname === '/settings' const showSettings = location.pathname === '/settings'
const showExplore = location.pathname === '/explore' const showExplore = location.pathname === '/explore'
const showMe = location.pathname.startsWith('/me') const showMe = location.pathname.startsWith('/me')
const showProfile = location.pathname.startsWith('/p/')
// Extract tab from me routes // Extract tab from me routes
const meTab = location.pathname === '/me' ? 'highlights' : const meTab = location.pathname === '/me' ? 'highlights' :
@@ -45,12 +47,28 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
location.pathname === '/me/archive' ? 'archive' : location.pathname === '/me/archive' ? 'archive' :
location.pathname === '/me/writings' ? 'writings' : 'highlights' location.pathname === '/me/writings' ? 'writings' : 'highlights'
// Track previous location for going back from settings/me/explore // Extract tab from profile routes
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
// Decode npub to pubkey for profile view
let profilePubkey: string | undefined
if (npub && showProfile) {
try {
const decoded = nip19.decode(npub)
if (decoded.type === 'npub') {
profilePubkey = decoded.data
}
} catch (err) {
console.error('Failed to decode npub:', err)
}
}
// Track previous location for going back from settings/me/explore/profile
useEffect(() => { useEffect(() => {
if (!showSettings && !showMe && !showExplore) { if (!showSettings && !showMe && !showExplore && !showProfile) {
previousLocationRef.current = location.pathname previousLocationRef.current = location.pathname
} }
}, [location.pathname, showSettings, showMe, showExplore]) }, [location.pathname, showSettings, showMe, showExplore, showProfile])
const activeAccount = Hooks.useActiveAccount() const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager() const accountManager = Hooks.useAccountManager()
@@ -212,6 +230,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
showSettings={showSettings} showSettings={showSettings}
showExplore={showExplore} showExplore={showExplore}
showMe={showMe} showMe={showMe}
showProfile={showProfile}
bookmarks={bookmarks} bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading} bookmarksLoading={bookmarksLoading}
viewMode={viewMode} viewMode={viewMode}
@@ -272,6 +291,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
me={showMe ? ( me={showMe ? (
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
) : undefined} ) : undefined}
profile={showProfile && profilePubkey ? (
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null
) : undefined}
toastMessage={toastMessage ?? undefined} toastMessage={toastMessage ?? undefined}
toastType={toastType} toastType={toastType}
onClearToast={clearToast} onClearToast={clearToast}

View File

@@ -27,14 +27,19 @@ import PullToRefreshIndicator from './PullToRefreshIndicator'
interface MeProps { interface MeProps {
relayPool: RelayPool relayPool: RelayPool
activeTab?: TabType activeTab?: TabType
pubkey?: string // Optional pubkey for viewing other users' profiles
} }
type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings' type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => { const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
const activeAccount = Hooks.useActiveAccount() const activeAccount = Hooks.useActiveAccount()
const navigate = useNavigate() const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights') const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
// Use provided pubkey or fall back to active account
const viewingPubkey = propPubkey || activeAccount?.pubkey
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
const [highlights, setHighlights] = useState<Highlight[]>([]) const [highlights, setHighlights] = useState<Highlight[]>([])
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]) const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([]) const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
@@ -54,8 +59,8 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
if (!activeAccount) { if (!viewingPubkey) {
setError('Please log in to view your data') setError(isOwnProfile ? 'Please log in to view your data' : 'Invalid profile')
setLoading(false) setLoading(false)
return return
} }
@@ -64,39 +69,48 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
setLoading(true) setLoading(true)
setError(null) setError(null)
// Seed from cache if available to avoid empty flash // Seed from cache if available to avoid empty flash (own profile only)
const cached = getCachedMeData(activeAccount.pubkey) if (isOwnProfile) {
if (cached) { const cached = getCachedMeData(viewingPubkey)
setHighlights(cached.highlights) if (cached) {
setBookmarks(cached.bookmarks) setHighlights(cached.highlights)
setReadArticles(cached.readArticles) setBookmarks(cached.bookmarks)
setReadArticles(cached.readArticles)
}
} }
// Fetch highlights, read articles, and writings // Fetch highlights and writings (public data)
const [userHighlights, userReadArticles, userWritings] = await Promise.all([ const [userHighlights, userWritings] = await Promise.all([
fetchHighlights(relayPool, activeAccount.pubkey), fetchHighlights(relayPool, viewingPubkey),
fetchReadArticlesWithData(relayPool, activeAccount.pubkey), fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
fetchBlogPostsFromAuthors(relayPool, [activeAccount.pubkey], RELAYS)
]) ])
setHighlights(userHighlights) setHighlights(userHighlights)
setReadArticles(userReadArticles)
setWritings(userWritings) setWritings(userWritings)
// Fetch bookmarks using callback pattern // Only fetch private data for own profile
let fetchedBookmarks: Bookmark[] = [] if (isOwnProfile && activeAccount) {
try { const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey)
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => { setReadArticles(userReadArticles)
fetchedBookmarks = newBookmarks
setBookmarks(newBookmarks)
})
} catch (err) {
console.warn('Failed to load bookmarks:', err)
setBookmarks([])
}
// Update cache with all fetched data // Fetch bookmarks using callback pattern
setCachedMeData(activeAccount.pubkey, userHighlights, fetchedBookmarks, userReadArticles) let fetchedBookmarks: Bookmark[] = []
try {
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
fetchedBookmarks = newBookmarks
setBookmarks(newBookmarks)
})
} catch (err) {
console.warn('Failed to load bookmarks:', err)
setBookmarks([])
}
// Update cache with all fetched data
setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles)
} else {
setBookmarks([])
setReadArticles([])
}
} catch (err) { } catch (err) {
console.error('Failed to load data:', err) console.error('Failed to load data:', err)
setError('Failed to load data. Please try again.') setError('Failed to load data. Please try again.')
@@ -106,7 +120,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
} }
loadData() loadData()
}, [relayPool, activeAccount, refreshTrigger]) }, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
// Pull-to-refresh // Pull-to-refresh
const pullToRefreshState = usePullToRefresh(meContainerRef, { const pullToRefreshState = usePullToRefresh(meContainerRef, {
@@ -119,9 +133,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
const handleHighlightDelete = (highlightId: string) => { const handleHighlightDelete = (highlightId: string) => {
setHighlights(prev => { setHighlights(prev => {
const updated = prev.filter(h => h.id !== highlightId) const updated = prev.filter(h => h.id !== highlightId)
// Update cache when highlight is deleted // Update cache when highlight is deleted (own profile only)
if (activeAccount) { if (isOwnProfile && viewingPubkey) {
updateCachedHighlights(activeAccount.pubkey, updated) updateCachedHighlights(viewingPubkey, updated)
} }
return updated return updated
}) })
@@ -324,7 +338,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
isRefreshing={loading && pullToRefreshState.canRefresh} isRefreshing={loading && pullToRefreshState.canRefresh}
/> />
<div className="explore-header"> <div className="explore-header">
{activeAccount && <AuthorCard authorPubkey={activeAccount.pubkey} />} {viewingPubkey && <AuthorCard authorPubkey={viewingPubkey} clickable={false} />}
{loading && hasData && ( {loading && hasData && (
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}> <div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
@@ -336,34 +350,38 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
<button <button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`} className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
data-tab="highlights" data-tab="highlights"
onClick={() => navigate('/me/highlights')} onClick={() => navigate(isOwnProfile ? '/me/highlights' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}`)}
> >
<FontAwesomeIcon icon={faHighlighter} /> <FontAwesomeIcon icon={faHighlighter} />
<span className="tab-label">Highlights</span> <span className="tab-label">Highlights</span>
<span className="tab-count">({highlights.length})</span> <span className="tab-count">({highlights.length})</span>
</button> </button>
<button {isOwnProfile && (
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`} <>
data-tab="reading-list" <button
onClick={() => navigate('/me/reading-list')} className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
> data-tab="reading-list"
<FontAwesomeIcon icon={faBookmark} /> onClick={() => navigate('/me/reading-list')}
<span className="tab-label">Reading List</span> >
<span className="tab-count">({allIndividualBookmarks.length})</span> <FontAwesomeIcon icon={faBookmark} />
</button> <span className="tab-label">Reading List</span>
<button <span className="tab-count">({allIndividualBookmarks.length})</span>
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`} </button>
data-tab="archive" <button
onClick={() => navigate('/me/archive')} className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
> data-tab="archive"
<FontAwesomeIcon icon={faBooks} /> onClick={() => navigate('/me/archive')}
<span className="tab-label">Archive</span> >
<span className="tab-count">({readArticles.length})</span> <FontAwesomeIcon icon={faBooks} />
</button> <span className="tab-label">Archive</span>
<span className="tab-count">({readArticles.length})</span>
</button>
</>
)}
<button <button
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`} className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
data-tab="writings" data-tab="writings"
onClick={() => navigate('/me/writings')} onClick={() => navigate(isOwnProfile ? '/me/writings' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}/writings`)}
> >
<FontAwesomeIcon icon={faPenToSquare} /> <FontAwesomeIcon icon={faPenToSquare} />
<span className="tab-label">Writings</span> <span className="tab-label">Writings</span>

View File

@@ -31,6 +31,7 @@ interface ThreePaneLayoutProps {
showSettings: boolean showSettings: boolean
showExplore?: boolean showExplore?: boolean
showMe?: boolean showMe?: boolean
showProfile?: boolean
// Bookmarks pane // Bookmarks pane
bookmarks: Bookmark[] bookmarks: Bookmark[]
@@ -89,6 +90,9 @@ interface ThreePaneLayoutProps {
// Optional Me content // Optional Me content
me?: React.ReactNode me?: React.ReactNode
// Optional Profile content
profile?: React.ReactNode
} }
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => { const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
@@ -221,8 +225,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
return ( return (
<> <>
{/* Mobile bookmark button - only show when viewing article (not on settings/explore/me) */} {/* Mobile bookmark button - only show when viewing article (not on settings/explore/me/profile) */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && ( {isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && (
<button <button
className={`fixed z-[900] bg-zinc-800/70 border border-zinc-600/40 rounded-lg text-zinc-200 flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${ className={`fixed z-[900] bg-zinc-800/70 border border-zinc-600/40 rounded-lg text-zinc-200 flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none' showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
@@ -241,8 +245,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
</button> </button>
)} )}
{/* Mobile highlights button - only show when viewing article (not on settings/explore/me) */} {/* Mobile highlights button - only show when viewing article (not on settings/explore/me/profile) */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && ( {isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && (
<button <button
className={`fixed z-[900] border border-zinc-600/40 rounded-lg flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${ className={`fixed z-[900] border border-zinc-600/40 rounded-lg flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none' showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
@@ -320,6 +324,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
<> <>
{props.me} {props.me}
</> </>
) : props.showProfile && props.profile ? (
// Render Profile inside the main pane to keep side panels
<>
{props.profile}
</>
) : ( ) : (
<ContentPanel <ContentPanel
loading={props.readerLoading} loading={props.readerLoading}

View File

@@ -1,6 +1,8 @@
/* Profile UI fragments */ /* Profile UI fragments */
.author-card-container { display: flex; justify-content: center; padding: 2rem 1rem; } .author-card-container { display: flex; justify-content: center; padding: 2rem 1rem; }
.author-card { display: flex; gap: 1rem; padding: 1.5rem; background: rgb(24 24 27); /* zinc-900 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ border-radius: 12px; max-width: 600px; width: 100%; } .author-card { display: flex; gap: 1rem; padding: 1.5rem; background: rgb(24 24 27); /* zinc-900 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ border-radius: 12px; max-width: 600px; width: 100%; transition: all 0.2s ease; }
.author-card-clickable:hover { border-color: rgb(99 102 241); /* indigo-500 */ background: rgb(30 30 33); /* slightly lighter */ transform: translateY(-1px); }
.author-card-clickable:active { transform: translateY(0); }
.author-card-avatar { flex-shrink: 0; width: 60px; height: 60px; border-radius: 50%; overflow: hidden; background: rgb(39 39 42); /* zinc-800 */ display: flex; align-items: center; justify-content: center; color: rgb(113 113 122); /* zinc-500 */ } .author-card-avatar { flex-shrink: 0; width: 60px; height: 60px; border-radius: 50%; overflow: hidden; background: rgb(39 39 42); /* zinc-800 */ display: flex; align-items: center; justify-content: center; color: rgb(113 113 122); /* zinc-500 */ }
.author-card-avatar img { width: 100%; height: 100%; object-fit: cover; } .author-card-avatar img { width: 100%; height: 100%; object-fit: cover; }
.author-card-avatar svg { font-size: 2.5rem; } .author-card-avatar svg { font-size: 2.5rem; }