Compare commits

...

9 Commits

Author SHA1 Message Date
Gigi
7464a8b505 chore: bump version to 0.6.3 2025-10-14 01:27:22 +02:00
Gigi
938d79663b fix: remove unused isRefreshing parameter from PullToRefreshIndicator
- Keep prop in interface for backward compatibility
- Don't destructure unused parameter to satisfy linter
- All lint checks and type checks now pass
2025-10-14 01:13:02 +02:00
Gigi
cc0ad69275 chore: remove COLOR_SYSTEM.md documentation file 2025-10-14 01:11:32 +02:00
Gigi
810ff060f8 feat: make relay status indicator circular FAB on mobile
- Default to collapsed (icon only) on mobile
- Expand to show details when tapped on mobile
- Circular 56px FAB when collapsed, matching highlight button style
- Desktop always shows expanded with details
- Hide on scroll via showOnMobile prop (matches sidepanel buttons)
2025-10-14 01:11:14 +02:00
Gigi
5e03ef70a6 style: improve relay status indicator text layout
- Make subtitle text smaller (0.75rem) with reduced opacity
- Display text in column layout with proper line spacing
- Subtitle now appears on second line below title
- Apply consistent styling to offline and flight mode subtitles
2025-10-14 01:08:28 +02:00
Gigi
f05fb29c7b refactor: remove refresh spinner and text from pull-to-refresh
- Remove 'Refreshing...' text from indicator
- Remove spinner from pull-to-refresh (button already spins)
- Only show indicator when actively pulling, not when refreshing
- Simplify logic and improve UX consistency
2025-10-14 01:05:22 +02:00
Gigi
e737b1f7f0 fix: position relay status indicator in bottom-left corner
- Add fixed positioning (bottom-left) to match highlight button (bottom-right)
- Add modern styling with semi-transparent background, blur, and shadow
- Ensure proper visibility on mobile with smooth transitions
- Maintain responsive behavior for expanded/collapsed states
2025-10-14 01:04:18 +02:00
Gigi
21a7be2f98 fix: unify highlight visibility button styling across app
- Remove blue primary variant from highlight filter buttons
- Use opacity (1.0 active, 0.4 inactive) instead of variant change
- Update settings to use IconButton like sidebar (DRY)
- Consistent styling: ghost variant + opacity + custom colors
- Settings buttons now match sidebar buttons exactly
2025-10-14 01:02:46 +02:00
Gigi
4c720aa049 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
2025-10-14 01:01:10 +02:00
12 changed files with 260 additions and 215 deletions

View File

@@ -1,98 +0,0 @@
# Boris Color System
All colors now use Tailwind CSS color palette for consistency and maintainability.
## Semantic Color Aliases (Tailwind Config)
```javascript
'app-bg': '#18181b', // zinc-900 - Main backgrounds
'app-bg-elevated': '#27272a', // zinc-800 - Elevated surfaces (cards, modals)
'app-bg-subtle': '#1e1e1e', // Custom ~zinc-850 - Subtle backgrounds
'app-border': '#3f3f46', // zinc-700 - Primary borders
'app-border-subtle': '#52525b', // zinc-600 - Subtle borders
'app-text': '#e4e4e7', // zinc-200 - Primary text
'app-text-secondary': '#a1a1aa', // zinc-400 - Secondary text
'app-text-muted': '#71717a', // zinc-500 - Muted text
'primary': '#6366f1', // indigo-500 - Primary accent
'primary-hover': '#4f46e5', // indigo-600 - Primary hover state
'highlight-mine': '#fde047', // yellow-300 - User highlights
'highlight-friends': '#f97316', // orange-500 - Friends highlights
'highlight-nostrverse': '#9333ea', // purple-600 - Nostrverse highlights
```
## Highlight Colors (User-Settable)
Default colors in the color picker:
- **Yellow** (default): `#fde047` - yellow-300
- **Orange**: `#f97316` - orange-500
- **Pink**: `#ec4899` - pink-500
- **Green**: `#22c55e` - green-500
- **Blue**: `#3b82f6` - blue-500
- **Purple**: `#9333ea` - purple-600
## Common Color Mappings
| Old Hex | Tailwind Color | Usage |
|-----------|----------------|-------|
| `#18181b` | zinc-900 | Main app background |
| `#1a1a1a` | zinc-900 | Component backgrounds |
| `#1e1e1e` | ~zinc-850 | Code blocks, subtle surfaces |
| `#252525` | zinc-800 | Hover states |
| `#27272a` | zinc-800 | Elevated surfaces |
| `#2a2a2a` | zinc-800 | Buttons, inputs |
| `#333` | zinc-700 | Primary borders |
| `#3f3f46` | zinc-700 | Component borders |
| `#444` | zinc-600 | Subtle borders |
| `#52525b` | zinc-600 | Input borders |
| `#555` | zinc-500 | Hover borders |
| `#666` | zinc-500 | Muted text |
| `#71717a` | zinc-500 | Secondary text |
| `#888` | zinc-400 | Secondary text |
| `#999` | zinc-400 | Muted labels |
| `#a1a1aa` | zinc-400 | Placeholder text |
| `#aaa` | zinc-300 | Light text |
| `#ccc` | zinc-300 | Primary text on dark |
| `#ddd` | zinc-200 | Bright text |
| `#e4e4e7` | zinc-200 | Primary text |
| `#646cff` | indigo-500 | Primary accent |
| `#535bf2` | indigo-600 | Primary hover |
| `#fde047` | yellow-300 | Default highlight (brighter) |
| `#f97316` | orange-500 | Friends highlights |
| `#9333ea` | purple-600 | Nostrverse highlights |
## Usage Guidelines
### In CSS
Use Tailwind utilities whenever possible:
```css
.example {
background: rgb(24 24 27); /* zinc-900 */
border: 1px solid rgb(63 63 70); /* zinc-700 */
color: rgb(228 228 231); /* zinc-200 */
}
```
### In TSX
Use Tailwind classes:
```tsx
<div className="bg-zinc-900 border border-zinc-700 text-zinc-200">
```
Or semantic aliases:
```tsx
<div className="bg-app-bg border border-app-border text-app-text">
```
### CSS Variables (User-Settable)
For colors that users can customize:
```css
background: var(--highlight-color-mine, #fde047);
```
## Notes
- All hex colors are now Tailwind palette colors
- CSS variables remain for user-customizable colors
- Semantic aliases provide easier maintenance
- RGB format in CSS allows for opacity control

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.6.2",
"version": "0.6.3",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",

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 />} />
</Routes>
)

View File

@@ -1,14 +1,18 @@
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUserCircle } from '@fortawesome/free-solid-svg-icons'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
interface AuthorCardProps {
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 getAuthorName = () => {
@@ -20,8 +24,19 @@ const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey }) => {
const authorImage = profile?.picture || profile?.image
const authorBio = profile?.about
const handleClick = () => {
if (clickable) {
const npub = nip19.npubEncode(authorPubkey)
navigate(`/p/${npub}`)
}
}
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">
{authorImage ? (
<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 { useEventStore } from 'applesauce-react/hooks'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { useSettings } from '../hooks/useSettings'
import { useArticleLoader } from '../hooks/useArticleLoader'
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
@@ -25,7 +26,7 @@ interface BookmarksProps {
}
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const { naddr } = useParams<{ naddr?: string }>()
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
const location = useLocation()
const navigate = useNavigate()
const previousLocationRef = useRef<string>()
@@ -37,6 +38,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const showSettings = location.pathname === '/settings'
const showExplore = location.pathname === '/explore'
const showMe = location.pathname.startsWith('/me')
const showProfile = location.pathname.startsWith('/p/')
// Extract tab from me routes
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/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(() => {
if (!showSettings && !showMe && !showExplore) {
if (!showSettings && !showMe && !showExplore && !showProfile) {
previousLocationRef.current = location.pathname
}
}, [location.pathname, showSettings, showMe, showExplore])
}, [location.pathname, showSettings, showMe, showExplore, showProfile])
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
@@ -212,6 +230,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
showSettings={showSettings}
showExplore={showExplore}
showMe={showMe}
showProfile={showProfile}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
viewMode={viewMode}
@@ -272,6 +291,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
me={showMe ? (
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
) : undefined}
profile={showProfile && profilePubkey ? (
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null
) : undefined}
toastMessage={toastMessage ?? undefined}
toastType={toastType}
onClearToast={clearToast}

View File

@@ -40,8 +40,11 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
})}
title="Toggle nostrverse highlights"
ariaLabel="Toggle nostrverse highlights"
variant={highlightVisibility.nostrverse ? 'primary' : 'ghost'}
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
variant="ghost"
style={{
color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
opacity: highlightVisibility.nostrverse ? 1 : 0.4
}}
/>
<IconButton
icon={faUserGroup}
@@ -51,9 +54,12 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
})}
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
ariaLabel="Toggle friends highlights"
variant={highlightVisibility.friends ? 'primary' : 'ghost'}
variant="ghost"
disabled={!currentUserPubkey}
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
style={{
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
opacity: highlightVisibility.friends ? 1 : 0.4
}}
/>
<IconButton
icon={faUser}
@@ -63,9 +69,12 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
})}
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
ariaLabel="Toggle my highlights"
variant={highlightVisibility.mine ? 'primary' : 'ghost'}
variant="ghost"
disabled={!currentUserPubkey}
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
style={{
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
opacity: highlightVisibility.mine ? 1 : 0.4
}}
/>
</div>
)}

View File

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

View File

@@ -1,6 +1,6 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faArrowDown, faSpinner } from '@fortawesome/free-solid-svg-icons'
import { faArrowDown } from '@fortawesome/free-solid-svg-icons'
interface PullToRefreshIndicatorProps {
isPulling: boolean
@@ -14,11 +14,10 @@ const PullToRefreshIndicator: React.FC<PullToRefreshIndicatorProps> = ({
isPulling,
pullDistance,
canRefresh,
isRefreshing,
threshold = 80
}) => {
// Don't show if not pulling and not refreshing
if (!isPulling && !isRefreshing) return null
// Only show when actively pulling, not when refreshing
if (!isPulling) return null
const opacity = Math.min(pullDistance / threshold, 1)
const rotation = (pullDistance / threshold) * 180
@@ -27,31 +26,23 @@ const PullToRefreshIndicator: React.FC<PullToRefreshIndicatorProps> = ({
<div
className="pull-to-refresh-indicator"
style={{
opacity: isRefreshing ? 1 : opacity,
transform: `translateY(${isRefreshing ? 0 : -20 + pullDistance / 2}px)`
opacity,
transform: `translateY(${-20 + pullDistance / 2}px)`
}}
>
<div
className="pull-to-refresh-icon"
style={{
transform: isRefreshing ? 'none' : `rotate(${rotation}deg)`
transform: `rotate(${rotation}deg)`
}}
>
{isRefreshing ? (
<FontAwesomeIcon icon={faSpinner} spin />
) : (
<FontAwesomeIcon
icon={faArrowDown}
style={{ color: canRefresh ? 'var(--accent-color, #3b82f6)' : 'var(--text-secondary)' }}
/>
)}
<FontAwesomeIcon
icon={faArrowDown}
style={{ color: canRefresh ? 'var(--accent-color, #3b82f6)' : 'var(--text-secondary)' }}
/>
</div>
<div className="pull-to-refresh-text">
{isRefreshing
? 'Refreshing...'
: canRefresh
? 'Release to refresh'
: 'Pull to refresh'}
{canRefresh ? 'Release to refresh' : 'Pull to refresh'}
</div>
</div>
)

View File

@@ -70,8 +70,12 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
}
}
// On mobile, default to collapsed (icon only). On desktop, always show details.
const showDetails = !isMobile || isExpanded
// On mobile when collapsed, make it circular like the highlight button
const isCollapsedOnMobile = isMobile && !isExpanded
return (
<div
className={`relay-status-indicator ${isConnecting ? 'connecting' : ''} ${isMobile ? 'mobile' : ''} ${isExpanded ? 'expanded' : ''} ${isMobile && !showOnMobile ? 'hidden' : 'visible'}`}
@@ -85,25 +89,75 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
) : undefined
}
onClick={handleClick}
style={{ cursor: isMobile ? 'pointer' : 'default' }}
style={{
position: 'fixed',
bottom: '32px',
left: '32px',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
gap: showDetails ? '0.5rem' : '0',
padding: isCollapsedOnMobile ? '0.875rem' : (showDetails ? '0.75rem 1rem' : '0.75rem'),
width: isCollapsedOnMobile ? '56px' : 'auto',
height: isCollapsedOnMobile ? '56px' : 'auto',
backgroundColor: 'rgba(39, 39, 42, 0.9)',
backdropFilter: 'blur(8px)',
border: '1px solid rgb(82, 82, 91)',
borderRadius: isCollapsedOnMobile ? '50%' : '12px',
color: 'rgb(228, 228, 231)',
fontSize: '0.875rem',
fontWeight: 500,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
cursor: isMobile ? 'pointer' : 'default',
opacity: isMobile && !showOnMobile ? 0 : 1,
visibility: isMobile && !showOnMobile ? 'hidden' : 'visible',
transition: 'all 0.3s ease',
userSelect: 'none',
justifyContent: isCollapsedOnMobile ? 'center' : 'flex-start'
}}
>
<div className="relay-status-icon">
<FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
</div>
{showDetails && (
<>
<div className="relay-status-text">
<div
className="relay-status-text"
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.125rem'
}}
>
{isConnecting ? (
<span className="relay-status-title">Connecting</span>
) : offlineMode ? (
<>
<span className="relay-status-title">Offline</span>
<span className="relay-status-subtitle">No relays connected</span>
<span
className="relay-status-subtitle"
style={{
fontSize: '0.75rem',
opacity: 0.7,
fontWeight: 400
}}
>
No relays connected
</span>
</>
) : (
<>
<span className="relay-status-title">Flight Mode</span>
<span className="relay-status-subtitle">{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}</span>
<span
className="relay-status-subtitle"
style={{
fontSize: '0.75rem',
opacity: 0.7,
fontWeight: 400
}}
>
{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}
</span>
</>
)}
</div>

View File

@@ -1,5 +1,4 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton'
@@ -102,33 +101,39 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
<div className="setting-group setting-inline">
<label>Default Highlight Visibility</label>
<div className="highlight-level-toggles">
<button
<IconButton
icon={faNetworkWired}
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityNostrverse !== false) ? 'active' : ''}`}
title="Nostrverse highlights"
aria-label="Toggle nostrverse highlights by default"
style={{ color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
>
<FontAwesomeIcon icon={faNetworkWired} />
</button>
<button
ariaLabel="Toggle nostrverse highlights by default"
variant="ghost"
style={{
color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
opacity: (settings.defaultHighlightVisibilityNostrverse !== false) ? 1 : 0.4
}}
/>
<IconButton
icon={faUserGroup}
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityFriends !== false) ? 'active' : ''}`}
title="Friends highlights"
aria-label="Toggle friends highlights by default"
style={{ color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined }}
>
<FontAwesomeIcon icon={faUserGroup} />
</button>
<button
ariaLabel="Toggle friends highlights by default"
variant="ghost"
style={{
color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined,
opacity: (settings.defaultHighlightVisibilityFriends !== false) ? 1 : 0.4
}}
/>
<IconButton
icon={faUser}
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityMine !== false) ? 'active' : ''}`}
title="My highlights"
aria-label="Toggle my highlights by default"
style={{ color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined }}
>
<FontAwesomeIcon icon={faUser} />
</button>
ariaLabel="Toggle my highlights by default"
variant="ghost"
style={{
color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined,
opacity: (settings.defaultHighlightVisibilityMine !== false) ? 1 : 0.4
}}
/>
</div>
</div>

View File

@@ -31,6 +31,7 @@ interface ThreePaneLayoutProps {
showSettings: boolean
showExplore?: boolean
showMe?: boolean
showProfile?: boolean
// Bookmarks pane
bookmarks: Bookmark[]
@@ -89,6 +90,9 @@ interface ThreePaneLayoutProps {
// Optional Me content
me?: React.ReactNode
// Optional Profile content
profile?: React.ReactNode
}
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
@@ -221,8 +225,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
return (
<>
{/* Mobile bookmark button - only show when viewing article (not on settings/explore/me) */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && (
{/* 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 && !props.showProfile && (
<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 ${
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
@@ -241,8 +245,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
</button>
)}
{/* Mobile highlights button - only show when viewing article (not on settings/explore/me) */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && (
{/* 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 && !props.showProfile && (
<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 ${
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
@@ -320,6 +324,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
<>
{props.me}
</>
) : props.showProfile && props.profile ? (
// Render Profile inside the main pane to keep side panels
<>
{props.profile}
</>
) : (
<ContentPanel
loading={props.readerLoading}

View File

@@ -1,6 +1,8 @@
/* Profile UI fragments */
.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 img { width: 100%; height: 100%; object-fit: cover; }
.author-card-avatar svg { font-size: 2.5rem; }