mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcc329cc7c | ||
|
|
c9544e0fd2 | ||
|
|
d7906cfb95 | ||
|
|
13cd6aeb11 | ||
|
|
d4821d18fb | ||
|
|
b86bf48382 | ||
|
|
c595f94567 | ||
|
|
82058c0ef4 | ||
|
|
a1f3424b38 | ||
|
|
14ab749ef1 | ||
|
|
61dd4b2089 | ||
|
|
fb2fe1cc63 | ||
|
|
720f12ce1c | ||
|
|
423ebb403f | ||
|
|
c90fb66bb8 | ||
|
|
188de7ab1d | ||
|
|
0b1cf267a7 | ||
|
|
19f68612a5 | ||
|
|
1b1600d6f2 | ||
|
|
ce67c19ece | ||
|
|
f754ce3cfe | ||
|
|
19a86525cb | ||
|
|
29213ceb1c | ||
|
|
d25a9b1735 | ||
|
|
0f03706166 | ||
|
|
b1f79e3844 | ||
|
|
243d9b17ef | ||
|
|
50a6cf6499 | ||
|
|
8f7991e971 | ||
|
|
0aba54bd23 | ||
|
|
23833b2cff |
63
CHANGELOG.md
63
CHANGELOG.md
@@ -7,6 +7,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.4] - 2025-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- Color theme variants for light and dark modes
|
||||
- Sepia, Classic (white/black), Rose, Sky, Mint, and Lavender themes
|
||||
- Color swatches shown in theme selector instead of text labels
|
||||
- CSS variable tokens and theme classes for consistent theming
|
||||
- Playful empty state message for other users' profiles
|
||||
- Profile links now open within app instead of external portals
|
||||
|
||||
### Changed
|
||||
|
||||
- Default light theme changed to sepia for better readability
|
||||
- Theme setting labels renamed from 'Colors' to 'Theme'
|
||||
- Highlight text now aligns properly with footer icons
|
||||
- Increased spacing between highlight cards for better visual separation
|
||||
- Increased bottom padding in highlight cards
|
||||
- Simplified Me page tab labels for cleaner UI
|
||||
- Highlight marker style applied to active Highlights tab
|
||||
- All profile links open internally instead of via external Nostr portals
|
||||
- Match highlight comment color to highlight level color
|
||||
|
||||
### Fixed
|
||||
|
||||
- Consistent yellow-300 highlight color across all themes
|
||||
- Highlight contrast improved in light themes
|
||||
- Text contrast improved in dark color themes
|
||||
- Darker background for app body in dark themes
|
||||
- Reading progress indicator now uses theme colors
|
||||
- Highlights tab readability improved in light mode with proper background
|
||||
- Empty state text color changed from red to gray for better aesthetics
|
||||
- Replaced 'any' types with proper type definitions for better type safety
|
||||
|
||||
### Refactored
|
||||
|
||||
- Migrated entire codebase to semantic token system
|
||||
- Pull-to-refresh components updated to use semantic tokens
|
||||
- Cards, forms, and layout components migrated to semantic tokens
|
||||
- All remaining components converted to semantic token usage
|
||||
- Removed localStorage for theme persistence, using only Nostr (NIP-78)
|
||||
- Theme colors applied to body element for consistent theming
|
||||
|
||||
## [0.6.3] - 2025-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- Ants link to empty writings state for other users
|
||||
|
||||
### Changed
|
||||
|
||||
- Empty state text color from red to gray
|
||||
|
||||
### Fixed
|
||||
|
||||
- Match highlight comment color to highlight level color
|
||||
- Open all profile links within app instead of external portals
|
||||
- Playful empty state message for other users' profiles
|
||||
|
||||
## [0.6.2] - 2025-01-27
|
||||
|
||||
### Added
|
||||
@@ -1096,7 +1155,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Optimize relay usage following applesauce-relay best practices
|
||||
- Use applesauce-react event models for better profile handling
|
||||
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.2...HEAD
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.4...HEAD
|
||||
[0.6.4]: https://github.com/dergigi/boris/compare/v0.6.3...v0.6.4
|
||||
[0.6.3]: https://github.com/dergigi/boris/compare/v0.6.2...v0.6.3
|
||||
[0.6.2]: https://github.com/dergigi/boris/compare/v0.6.1...v0.6.2
|
||||
[0.6.1]: https://github.com/dergigi/boris/compare/v0.6.0...v0.6.1
|
||||
[0.6.0]: https://github.com/dergigi/boris/compare/v0.5.7...v0.6.0
|
||||
|
||||
17
package-lock.json
generated
17
package-lock.json
generated
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.5.7",
|
||||
"version": "0.6.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "boris",
|
||||
"version": "0.5.7",
|
||||
"version": "0.6.5",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||
"@treeee/youtube-caption-extractor": "^1.5.5",
|
||||
@@ -2263,6 +2264,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/free-regular-svg-icons": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-7.1.0.tgz",
|
||||
"integrity": "sha512-0e2fdEyB4AR+e6kU4yxwA/MonnYcw/CsMEP9lH82ORFi9svA6/RhDyhxIv5mlJaldmaHLLYVTb+3iEr+PDSZuQ==",
|
||||
"license": "(CC-BY-4.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/free-solid-svg-icons": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.1.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.6.4",
|
||||
"version": "0.6.5",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||
"@treeee/youtube-caption-extractor": "^1.5.5",
|
||||
|
||||
@@ -70,6 +70,15 @@ function AppRoutes({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/explore/writings"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me"
|
||||
element={<Navigate to="/me/highlights" replace />}
|
||||
|
||||
@@ -31,15 +31,21 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const navigate = useNavigate()
|
||||
const previousLocationRef = useRef<string>()
|
||||
|
||||
// Check for highlight navigation state
|
||||
const navigationState = location.state as { highlightId?: string; openHighlights?: boolean } | null
|
||||
|
||||
const externalUrl = location.pathname.startsWith('/r/')
|
||||
? decodeURIComponent(location.pathname.slice(3))
|
||||
: undefined
|
||||
|
||||
const showSettings = location.pathname === '/settings'
|
||||
const showExplore = location.pathname === '/explore'
|
||||
const showExplore = location.pathname.startsWith('/explore')
|
||||
const showMe = location.pathname.startsWith('/me')
|
||||
const showProfile = location.pathname.startsWith('/p/')
|
||||
|
||||
// Extract tab from explore routes
|
||||
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
||||
|
||||
// Extract tab from me routes
|
||||
const meTab = location.pathname === '/me' ? 'highlights' :
|
||||
location.pathname === '/me/highlights' ? 'highlights' :
|
||||
@@ -125,6 +131,19 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.pathname])
|
||||
|
||||
// Handle highlight navigation from explore page
|
||||
useEffect(() => {
|
||||
if (navigationState?.highlightId && navigationState?.openHighlights) {
|
||||
// Open the highlights sidebar
|
||||
setIsHighlightsCollapsed(false)
|
||||
// Select the highlight (scroll happens automatically in useHighlightInteractions)
|
||||
setSelectedHighlightId(navigationState.highlightId)
|
||||
|
||||
// Clear the state after handling to avoid re-triggering
|
||||
navigate(location.pathname, { replace: true, state: {} })
|
||||
}
|
||||
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
|
||||
|
||||
const {
|
||||
bookmarks,
|
||||
bookmarksLoading,
|
||||
@@ -286,7 +305,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
onCreateHighlight={handleCreateHighlight}
|
||||
hasActiveAccount={!!(activeAccount && relayPool)}
|
||||
explore={showExplore ? (
|
||||
relayPool ? <Explore relayPool={relayPool} /> : null
|
||||
relayPool ? <Explore relayPool={relayPool} activeTab={exploreTab} /> : null
|
||||
) : undefined}
|
||||
me={showMe ? (
|
||||
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
|
||||
|
||||
@@ -1,30 +1,49 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faSpinner, faExclamationCircle, faNewspaper, faPenToSquare, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { fetchContacts } from '../services/contactService'
|
||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
import { getCachedPosts, upsertCachedPost, setCachedPosts } from '../services/exploreCache'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
import { classifyHighlights } from '../utils/highlightClassification'
|
||||
|
||||
interface ExploreProps {
|
||||
relayPool: RelayPool
|
||||
activeTab?: TabType
|
||||
}
|
||||
|
||||
const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
type TabType = 'writings' | 'highlights'
|
||||
|
||||
const Explore: React.FC<ExploreProps> = ({ relayPool, activeTab: propActiveTab }) => {
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const exploreContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
|
||||
// Update local state when prop changes
|
||||
useEffect(() => {
|
||||
const loadBlogPosts = async () => {
|
||||
if (propActiveTab) {
|
||||
setActiveTab(propActiveTab)
|
||||
}
|
||||
}, [propActiveTab])
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!activeAccount) {
|
||||
setError('Please log in to explore content from your friends')
|
||||
setLoading(false)
|
||||
@@ -32,14 +51,18 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// show spinner but keep existing posts
|
||||
// show spinner but keep existing data
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Seed from in-memory cache if available to avoid empty flash
|
||||
const cached = getCachedPosts(activeAccount.pubkey)
|
||||
if (cached && cached.length > 0 && blogPosts.length === 0) {
|
||||
setBlogPosts(cached)
|
||||
const cachedPosts = getCachedPosts(activeAccount.pubkey)
|
||||
if (cachedPosts && cachedPosts.length > 0 && blogPosts.length === 0) {
|
||||
setBlogPosts(cachedPosts)
|
||||
}
|
||||
const cachedHighlights = getCachedHighlights(activeAccount.pubkey)
|
||||
if (cachedHighlights && cachedHighlights.length > 0 && highlights.length === 0) {
|
||||
setHighlights(cachedHighlights)
|
||||
}
|
||||
|
||||
// Fetch the user's contacts (friends)
|
||||
@@ -47,15 +70,19 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
relayPool,
|
||||
activeAccount.pubkey,
|
||||
(partial) => {
|
||||
// When local contacts are available, kick off early posts fetch
|
||||
// Store followed pubkeys for highlight classification
|
||||
setFollowedPubkeys(partial)
|
||||
// When local contacts are available, kick off early fetch
|
||||
if (partial.size > 0) {
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const partialArray = Array.from(partial)
|
||||
|
||||
// Fetch blog posts
|
||||
fetchBlogPostsFromAuthors(
|
||||
relayPool,
|
||||
Array.from(partial),
|
||||
partialArray,
|
||||
relayUrls,
|
||||
(post) => {
|
||||
// merge into UI and cache as we stream
|
||||
setBlogPosts((prev) => {
|
||||
const exists = prev.some(p => p.event.id === post.event.id)
|
||||
if (exists) return prev
|
||||
@@ -69,7 +96,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
|
||||
}
|
||||
).then((all) => {
|
||||
// Ensure union of streamed + final is displayed
|
||||
setBlogPosts((prev) => {
|
||||
const byId = new Map(prev.map(p => [p.event.id, p]))
|
||||
for (const post of all) byId.set(post.event.id, post)
|
||||
@@ -82,22 +108,52 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
return merged
|
||||
})
|
||||
})
|
||||
|
||||
// Fetch highlights
|
||||
fetchHighlightsFromAuthors(
|
||||
relayPool,
|
||||
partialArray,
|
||||
(highlight) => {
|
||||
setHighlights((prev) => {
|
||||
const exists = prev.some(h => h.id === highlight.id)
|
||||
if (exists) return prev
|
||||
const next = [...prev, highlight]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
|
||||
}
|
||||
).then((all) => {
|
||||
setHighlights((prev) => {
|
||||
const byId = new Map(prev.map(h => [h.id, h]))
|
||||
for (const highlight of all) byId.set(highlight.id, highlight)
|
||||
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
||||
setCachedHighlights(activeAccount.pubkey, merged)
|
||||
return merged
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (contacts.size === 0) {
|
||||
setError('You are not following anyone yet. Follow some people to see their blog posts!')
|
||||
setError('You are not following anyone yet. Follow some people to see their content!')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Store final followed pubkeys
|
||||
setFollowedPubkeys(contacts)
|
||||
|
||||
// After full contacts, do a final pass for completeness
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const posts = await fetchBlogPostsFromAuthors(relayPool, Array.from(contacts), relayUrls)
|
||||
const contactsArray = Array.from(contacts)
|
||||
const [posts, userHighlights] = await Promise.all([
|
||||
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
|
||||
fetchHighlightsFromAuthors(relayPool, contactsArray)
|
||||
])
|
||||
|
||||
if (posts.length === 0) {
|
||||
setError('No blog posts found from your friends yet')
|
||||
if (posts.length === 0 && userHighlights.length === 0) {
|
||||
setError('No content found from your friends yet')
|
||||
}
|
||||
|
||||
setBlogPosts((prev) => {
|
||||
@@ -111,16 +167,24 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
setCachedPosts(activeAccount.pubkey, merged)
|
||||
return merged
|
||||
})
|
||||
|
||||
setHighlights((prev) => {
|
||||
const byId = new Map(prev.map(h => [h.id, h]))
|
||||
for (const highlight of userHighlights) byId.set(highlight.id, highlight)
|
||||
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
||||
setCachedHighlights(activeAccount.pubkey, merged)
|
||||
return merged
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to load blog posts:', err)
|
||||
setError('Failed to load blog posts. Please try again.')
|
||||
console.error('Failed to load data:', err)
|
||||
setError('Failed to load content. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadBlogPosts()
|
||||
}, [relayPool, activeAccount, blogPosts.length, refreshTrigger])
|
||||
loadData()
|
||||
}, [relayPool, activeAccount, blogPosts.length, highlights.length, refreshTrigger])
|
||||
|
||||
// Pull-to-refresh
|
||||
const pullToRefreshState = usePullToRefresh(exploreContainerRef, {
|
||||
@@ -144,6 +208,96 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
|
||||
const handleHighlightClick = (highlightId: string) => {
|
||||
const highlight = highlights.find(h => h.id === highlightId)
|
||||
if (!highlight) return
|
||||
|
||||
// For nostr-native articles
|
||||
if (highlight.eventReference) {
|
||||
// Convert eventReference to naddr
|
||||
if (highlight.eventReference.includes(':')) {
|
||||
const parts = highlight.eventReference.split(':')
|
||||
const kind = parseInt(parts[0])
|
||||
const pubkey = parts[1]
|
||||
const identifier = parts[2] || ''
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind,
|
||||
pubkey,
|
||||
identifier
|
||||
})
|
||||
navigate(`/a/${naddr}`, { state: { highlightId, openHighlights: true } })
|
||||
} else {
|
||||
// Already an naddr
|
||||
navigate(`/a/${highlight.eventReference}`, { state: { highlightId, openHighlights: true } })
|
||||
}
|
||||
}
|
||||
// For web URLs
|
||||
else if (highlight.urlReference) {
|
||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, { state: { highlightId, openHighlights: true } })
|
||||
}
|
||||
}
|
||||
|
||||
// Classify highlights with levels based on user context
|
||||
const classifiedHighlights = useMemo(() => {
|
||||
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
||||
}, [highlights, activeAccount?.pubkey, followedPubkeys])
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'writings':
|
||||
return blogPosts.length === 0 ? (
|
||||
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p>No blog posts found yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{blogPosts.map((post) => (
|
||||
<BlogPostCard
|
||||
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'highlights':
|
||||
return classifiedHighlights.length === 0 ? (
|
||||
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p>No highlights yet. Your friends should start highlighting content!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{classifiedHighlights.map((highlight) => (
|
||||
<HighlightItem
|
||||
key={highlight.id}
|
||||
highlight={highlight}
|
||||
relayPool={relayPool}
|
||||
onHighlightClick={handleHighlightClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Only show full loading screen if we don't have any data yet
|
||||
const hasData = highlights.length > 0 || blogPosts.length > 0
|
||||
|
||||
if (loading && !hasData) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-loading">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
@@ -172,28 +326,36 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
Explore
|
||||
</h1>
|
||||
<p className="explore-subtitle">
|
||||
Discover blog posts from your friends on Nostr
|
||||
Discover highlights and blog posts from your friends and others
|
||||
</p>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
)}
|
||||
<div className="explore-grid">
|
||||
{blogPosts.map((post) => (
|
||||
<BlogPostCard
|
||||
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
/>
|
||||
))}
|
||||
{!loading && blogPosts.length === 0 && (
|
||||
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p>No blog posts found yet.</p>
|
||||
|
||||
{loading && hasData && (
|
||||
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="me-tabs">
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||
data-tab="highlights"
|
||||
onClick={() => navigate('/explore')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span className="tab-label">Highlights</span>
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
||||
data-tab="writings"
|
||||
onClick={() => navigate('/explore/writings')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
<span className="tab-label">Writings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
104
src/components/HighlightCitation.tsx
Normal file
104
src/components/HighlightCitation.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { fetchArticleTitle } from '../services/articleTitleResolver'
|
||||
import { Highlight } from '../types/highlights'
|
||||
|
||||
interface HighlightCitationProps {
|
||||
highlight: Highlight
|
||||
relayPool?: RelayPool | null
|
||||
}
|
||||
|
||||
export const HighlightCitation: React.FC<HighlightCitationProps> = ({
|
||||
highlight,
|
||||
relayPool
|
||||
}) => {
|
||||
const [articleTitle, setArticleTitle] = useState<string>()
|
||||
|
||||
// Extract author pubkey from p tag directly
|
||||
const authorPubkey = useMemo(() => {
|
||||
// First try the extracted author from highlight.author
|
||||
if (highlight.author) {
|
||||
return highlight.author
|
||||
}
|
||||
|
||||
// Fallback: extract directly from p tag
|
||||
const pTag = highlight.tags.find(t => t[0] === 'p')
|
||||
if (pTag && pTag[1]) {
|
||||
console.log('📝 Found author from p tag:', pTag[1])
|
||||
return pTag[1]
|
||||
}
|
||||
|
||||
return undefined
|
||||
}, [highlight.author, highlight.tags])
|
||||
|
||||
const authorProfile = useEventModel(Models.ProfileModel, authorPubkey ? [authorPubkey] : null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlight.eventReference || !relayPool) {
|
||||
return
|
||||
}
|
||||
|
||||
const loadTitle = async () => {
|
||||
try {
|
||||
if (!highlight.eventReference) return
|
||||
|
||||
// Convert eventReference to naddr if needed
|
||||
let naddr: string
|
||||
if (highlight.eventReference.includes(':')) {
|
||||
const parts = highlight.eventReference.split(':')
|
||||
const kind = parseInt(parts[0])
|
||||
const pubkey = parts[1]
|
||||
const identifier = parts[2] || ''
|
||||
|
||||
naddr = nip19.naddrEncode({
|
||||
kind,
|
||||
pubkey,
|
||||
identifier
|
||||
})
|
||||
} else {
|
||||
naddr = highlight.eventReference
|
||||
}
|
||||
|
||||
const title = await fetchArticleTitle(relayPool, naddr)
|
||||
if (title) {
|
||||
setArticleTitle(title)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load article title:', error)
|
||||
}
|
||||
}
|
||||
|
||||
loadTitle()
|
||||
}, [highlight.eventReference, relayPool])
|
||||
|
||||
const authorName = authorProfile?.name || authorProfile?.display_name
|
||||
|
||||
// For nostr-native content with article reference
|
||||
if (highlight.eventReference && (authorName || articleTitle)) {
|
||||
return (
|
||||
<div className="highlight-citation">
|
||||
— {authorName || 'Unknown'}{articleTitle ? `, ${articleTitle}` : ''}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// For web URLs
|
||||
if (highlight.urlReference) {
|
||||
try {
|
||||
const url = new URL(highlight.urlReference)
|
||||
return (
|
||||
<div className="highlight-citation">
|
||||
— {url.hostname}
|
||||
</div>
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faHighlighter, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faComments } from '@fortawesome/free-regular-svg-icons'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models, IEventStore } from 'applesauce-core'
|
||||
@@ -15,6 +16,7 @@ import { createDeletionRequest } from '../services/deletionService'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
import { getNostrUrl } from '../config/nostrGateways'
|
||||
import CompactButton from './CompactButton'
|
||||
import { HighlightCitation } from './HighlightCitation'
|
||||
|
||||
interface HighlightWithLevel extends Highlight {
|
||||
level?: 'mine' | 'friends' | 'nostrverse'
|
||||
@@ -208,13 +210,13 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
// Always show relay list, use plane icon for local-only
|
||||
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
|
||||
|
||||
// Show server icon with relay info if available
|
||||
// Show highlighter icon with relay info if available
|
||||
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
|
||||
const relayNames = highlight.publishedRelays.map(url =>
|
||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||
)
|
||||
return {
|
||||
icon: isLocalOrOffline ? faPlane : faServer,
|
||||
icon: isLocalOrOffline ? faPlane : faHighlighter,
|
||||
tooltip: relayNames.join('\n'),
|
||||
spin: false
|
||||
}
|
||||
@@ -225,7 +227,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||
)
|
||||
return {
|
||||
icon: faServer,
|
||||
icon: faHighlighter,
|
||||
tooltip: relayNames.join('\n'),
|
||||
spin: false
|
||||
}
|
||||
@@ -236,7 +238,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||
)
|
||||
return {
|
||||
icon: faServer,
|
||||
icon: faHighlighter,
|
||||
tooltip: relayNames.join('\n'),
|
||||
spin: false
|
||||
}
|
||||
@@ -318,7 +320,10 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
<CompactButton
|
||||
className="highlight-timestamp"
|
||||
title={new Date(highlight.created_at * 1000).toLocaleString()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.location.href = highlightLinks.native
|
||||
}}
|
||||
>
|
||||
{formatDateCompact(highlight.created_at)}
|
||||
</CompactButton>
|
||||
@@ -338,8 +343,14 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
{highlight.content}
|
||||
</blockquote>
|
||||
|
||||
<HighlightCitation
|
||||
highlight={highlight}
|
||||
relayPool={relayPool}
|
||||
/>
|
||||
|
||||
{highlight.comment && (
|
||||
<div className="highlight-comment">
|
||||
<FontAwesomeIcon icon={faComments} flip="horizontal" className="highlight-comment-icon" />
|
||||
{highlight.comment}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -38,7 +38,7 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
const isLongSummary = summary && summary.length > 150
|
||||
|
||||
// Determine the dominant highlight color based on visibility and priority
|
||||
const highlightIndicatorStyles = useMemo(() => {
|
||||
const getHighlightIndicatorStyles = useMemo(() => (isOverlay: boolean) => {
|
||||
if (!highlights.length) return undefined
|
||||
|
||||
// Count highlights by level that are visible
|
||||
@@ -65,7 +65,8 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
return {
|
||||
backgroundColor: `rgba(${rgb}, 0.1)`,
|
||||
borderColor: `rgba(${rgb}, 0.3)`,
|
||||
color: '#fff'
|
||||
// Only force white color in overlay context, otherwise let CSS handle it
|
||||
...(isOverlay && { color: '#fff' })
|
||||
}
|
||||
}, [highlights, highlightVisibility, settings])
|
||||
|
||||
@@ -93,7 +94,7 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
{hasHighlights && (
|
||||
<div
|
||||
className="highlight-indicator"
|
||||
style={highlightIndicatorStyles}
|
||||
style={getHighlightIndicatorStyles(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||
@@ -133,7 +134,7 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
{hasHighlights && (
|
||||
<div
|
||||
className="highlight-indicator"
|
||||
style={highlightIndicatorStyles}
|
||||
style={getHighlightIndicatorStyles(false)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||
|
||||
@@ -58,12 +58,15 @@ export const useHighlightInteractions = ({
|
||||
}
|
||||
}, [onHighlightClick, contentVersion])
|
||||
|
||||
// Scroll to selected highlight
|
||||
// Scroll to selected highlight with retry mechanism
|
||||
useEffect(() => {
|
||||
if (!selectedHighlightId || !contentRef.current) return
|
||||
|
||||
// Use a small delay to ensure DOM is updated
|
||||
const timeoutId = setTimeout(() => {
|
||||
let attempts = 0
|
||||
const maxAttempts = 20 // Try for up to 2 seconds
|
||||
const retryDelay = 100
|
||||
|
||||
const tryScroll = () => {
|
||||
if (!contentRef.current) return
|
||||
|
||||
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
|
||||
@@ -76,10 +79,16 @@ export const useHighlightInteractions = ({
|
||||
htmlElement.classList.add('highlight-pulse')
|
||||
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
|
||||
}, 500)
|
||||
} else if (attempts < maxAttempts) {
|
||||
attempts++
|
||||
setTimeout(tryScroll, retryDelay)
|
||||
} else {
|
||||
console.warn('Could not find mark element for highlight:', selectedHighlightId)
|
||||
console.warn('Could not find mark element for highlight after', maxAttempts, 'attempts:', selectedHighlightId)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// Start trying after a small initial delay
|
||||
const timeoutId = setTimeout(tryScroll, 100)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [selectedHighlightId, contentVersion])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Highlight } from '../types/highlights'
|
||||
|
||||
export interface CachedBlogPostPreview {
|
||||
event: NostrEvent
|
||||
@@ -11,6 +12,7 @@ export interface CachedBlogPostPreview {
|
||||
|
||||
type CacheValue = {
|
||||
posts: CachedBlogPostPreview[]
|
||||
highlights: Highlight[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
@@ -22,8 +24,28 @@ export function getCachedPosts(pubkey: string): CachedBlogPostPreview[] | null {
|
||||
return entry.posts
|
||||
}
|
||||
|
||||
export function getCachedHighlights(pubkey: string): Highlight[] | null {
|
||||
const entry = exploreCache.get(pubkey)
|
||||
if (!entry) return null
|
||||
return entry.highlights
|
||||
}
|
||||
|
||||
export function setCachedPosts(pubkey: string, posts: CachedBlogPostPreview[]): void {
|
||||
exploreCache.set(pubkey, { posts, timestamp: Date.now() })
|
||||
const current = exploreCache.get(pubkey)
|
||||
exploreCache.set(pubkey, {
|
||||
posts,
|
||||
highlights: current?.highlights || [],
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
export function setCachedHighlights(pubkey: string, highlights: Highlight[]): void {
|
||||
const current = exploreCache.get(pubkey)
|
||||
exploreCache.set(pubkey, {
|
||||
posts: current?.posts || [],
|
||||
highlights,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
export function upsertCachedPost(pubkey: string, post: CachedBlogPostPreview): CachedBlogPostPreview[] {
|
||||
@@ -39,4 +61,13 @@ export function upsertCachedPost(pubkey: string, post: CachedBlogPostPreview): C
|
||||
return merged
|
||||
}
|
||||
|
||||
export function upsertCachedHighlight(pubkey: string, highlight: Highlight): Highlight[] {
|
||||
const current = exploreCache.get(pubkey)?.highlights || []
|
||||
const byId = new Map(current.map(h => [h.id, h]))
|
||||
byId.set(highlight.id, highlight)
|
||||
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
||||
setCachedHighlights(pubkey, merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export * from './highlights/fetchForArticle'
|
||||
export * from './highlights/fetchForUrl'
|
||||
export * from './highlights/fetchByAuthor'
|
||||
|
||||
export * from './highlights/fetchFromAuthors'
|
||||
|
||||
|
||||
79
src/services/highlights/fetchFromAuthors.ts
Normal file
79
src/services/highlights/fetchFromAuthors.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Highlight } from '../../types/highlights'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||
|
||||
/**
|
||||
* Fetches highlights (kind:9802) from a list of pubkeys (friends)
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param pubkeys - Array of pubkeys to fetch highlights from
|
||||
* @param onHighlight - Optional callback for streaming highlights as they arrive
|
||||
* @returns Array of highlights
|
||||
*/
|
||||
export const fetchHighlightsFromAuthors = async (
|
||||
relayPool: RelayPool,
|
||||
pubkeys: string[],
|
||||
onHighlight?: (highlight: Highlight) => void
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
if (pubkeys.length === 0) {
|
||||
console.log('⚠️ No pubkeys to fetch highlights from')
|
||||
return []
|
||||
}
|
||||
|
||||
console.log('💡 Fetching highlights (kind 9802) from', pubkeys.length, 'authors')
|
||||
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const prioritized = prioritizeLocalRelays(relayUrls)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [9802], authors: pubkeys, limit: 200 })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id)
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [9802], authors: pubkeys, limit: 200 })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id)
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
const highlights = uniqueEvents.map(eventToHighlight)
|
||||
|
||||
console.log('💡 Processed', highlights.length, 'unique highlights')
|
||||
|
||||
return sortHighlights(highlights)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch highlights from authors:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
.explore-header { text-align: center; margin-bottom: 3rem; }
|
||||
.explore-header h1 { font-size: 2.5rem; margin: 0 0 1rem 0; color: var(--color-primary); display: flex; align-items: center; justify-content: center; gap: 1rem; }
|
||||
.explore-subtitle { font-size: 1.125rem; color: var(--color-text-secondary); margin: 0; }
|
||||
.explore-header .me-tabs { text-align: left; margin-top: 2rem; width: 100%; max-width: 100%; justify-content: flex-start; }
|
||||
.explore-loading, .explore-error, .explore-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; color: var(--color-text-secondary); }
|
||||
.explore-loading { min-height: 0; padding: 0.25rem 0; }
|
||||
.explore-error { color: rgb(239 68 68); /* red-500 */ }
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
.publish-date-topright { position: absolute; top: 1rem; right: 1rem; font-size: 0.813rem; color: var(--color-text); padding: 0.4rem 0.75rem; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); z-index: 10; }
|
||||
.reading-time { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border); border-radius: 6px; font-size: 0.875rem; color: var(--color-text-secondary); }
|
||||
.reading-time svg { font-size: 0.875rem; }
|
||||
.highlight-indicator { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.3); border-radius: 6px; font-size: 0.875rem; color: var(--color-primary); }
|
||||
.highlight-indicator { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.3); border-radius: 6px; font-size: 0.875rem; color: var(--color-text); }
|
||||
.highlight-indicator svg { font-size: 0.875rem; }
|
||||
.reader-html { color: var(--color-text); line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-family: var(--reading-font); font-size: var(--reading-font-size); }
|
||||
.reader-markdown { color: var(--color-text); line-height: 1.7; font-family: var(--reading-font); font-size: var(--reading-font-size); }
|
||||
|
||||
@@ -128,12 +128,14 @@
|
||||
|
||||
.highlight-content { flex: 1; display: flex; flex-direction: column; gap: 0.5rem; padding: 2.25rem 0.75rem 2.5rem; }
|
||||
.highlight-text { margin: 0; padding: 0 0 0 1.25rem; font-style: italic; color: var(--color-text); line-height: 1.6; border-left: none; font-size: 0.95rem; }
|
||||
.highlight-comment { margin-top: 0.5rem; margin-left: 1.25rem; padding: 0.75rem; border-left: 3px solid; border-radius: 4px; font-size: 0.875rem; color: var(--color-text); line-height: 1.5; }
|
||||
.highlight-citation { margin-left: 1.25rem; font-size: 0.8rem; color: var(--color-text-secondary); font-style: normal; padding-top: 0.25rem; }
|
||||
.highlight-comment { margin-top: 0.5rem; padding: 0.75rem; border-radius: 4px; font-size: 0.875rem; color: var(--color-text); line-height: 1.5; display: flex; gap: 0.5rem; align-items: flex-start; }
|
||||
.highlight-comment-icon { flex-shrink: 0; margin-top: 0.125rem; }
|
||||
|
||||
/* Level-colored comments */
|
||||
.highlight-item.level-mine .highlight-comment { background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 10%, transparent); border-left-color: var(--highlight-color-mine, #ffff00); }
|
||||
.highlight-item.level-friends .highlight-comment { background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 10%, transparent); border-left-color: var(--highlight-color-friends, #f97316); }
|
||||
.highlight-item.level-nostrverse .highlight-comment { background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 10%, transparent); border-left-color: var(--highlight-color-nostrverse, #9333ea); }
|
||||
/* Level-colored comment icons */
|
||||
.highlight-item.level-mine .highlight-comment-icon { color: var(--highlight-color-mine, #ffff00); }
|
||||
.highlight-item.level-friends .highlight-comment-icon { color: var(--highlight-color-friends, #f97316); }
|
||||
.highlight-item.level-nostrverse .highlight-comment-icon { color: var(--highlight-color-nostrverse, #9333ea); }
|
||||
|
||||
.highlight-footer { position: absolute; bottom: 0; left: 0; right: 0; display: flex; align-items: center; justify-content: space-between; padding: 0.25rem 0.5rem; font-size: 0.8rem; color: var(--color-text-secondary); border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; transition: border-color 0.2s ease; }
|
||||
.highlight-footer-left { display: flex; align-items: center; gap: 0.4rem; min-width: 0; }
|
||||
|
||||
Reference in New Issue
Block a user