mirror of
https://github.com/dergigi/boris.git
synced 2026-02-23 16:04:29 +01:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1619e328da | ||
|
|
b852dad243 | ||
|
|
1552a5f106 | ||
|
|
0feaffb21b | ||
|
|
9b3a4e20de | ||
|
|
c83b972a68 | ||
|
|
2e96f93d81 | ||
|
|
1e8182d984 | ||
|
|
b20a67d4d0 | ||
|
|
60975b449d | ||
|
|
704fce4d80 | ||
|
|
4d1eb0f9fd | ||
|
|
ceafe277d3 | ||
|
|
8f2ecd5fe1 | ||
|
|
d6be6f364b | ||
|
|
035d4d3bd0 | ||
|
|
43d5554c0c | ||
|
|
724a3e5cfa | ||
|
|
0c49988d36 | ||
|
|
70de68848b | ||
|
|
8a12ae72cb | ||
|
|
f8d5d19a9f | ||
|
|
dbd20e676f | ||
|
|
bbdf47fb94 | ||
|
|
1b754e02dc | ||
|
|
a2e410252a | ||
|
|
c9a14d151d | ||
|
|
b286562e86 | ||
|
|
507288f51c | ||
|
|
e08bc54f15 | ||
|
|
4306069191 | ||
|
|
56e56af8ec |
47
CHANGELOG.md
47
CHANGELOG.md
@@ -5,6 +5,52 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.3.0] - 2025-10-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Flight Mode with offline highlight creation and local relay support
|
||||||
|
- Automatic offline sync - rebroadcast local events when back online
|
||||||
|
- Relay indicator icon on highlight items showing sync status
|
||||||
|
- Click-to-rebroadcast functionality for highlights
|
||||||
|
- Flight mode indicator (plane icon) on offline-created highlights
|
||||||
|
- Relay rebroadcast settings for caching and propagation
|
||||||
|
- Local relay status indicator for local-only/offline mode
|
||||||
|
- Second local relay support (localhost:4869)
|
||||||
|
- Relay connection status tracking and display
|
||||||
|
- 6th font size option for better UI scaling
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Highlight creation resilient to offline/flight mode
|
||||||
|
- TypeScript type errors in offline sync
|
||||||
|
- Relay indicator tooltip accuracy and reliability
|
||||||
|
- Always show relay indicator icon on highlights
|
||||||
|
- Show remote relay list for fetched highlights
|
||||||
|
- Publish highlights to all connected relays instead of just one
|
||||||
|
- Keep all relay connections alive, not just local ones
|
||||||
|
- Check actual relay connection status instead of pool membership
|
||||||
|
- Skip rebroadcasting when in flight mode
|
||||||
|
- Update relay info after automatic sync completes
|
||||||
|
- Only show successfully reachable relays in flight mode
|
||||||
|
- Include local relays in relay indicator tooltip
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Rename 'Offline Mode' to 'Flight Mode' throughout UI
|
||||||
|
- Move publication date to top-right corner with subtle border styling
|
||||||
|
- Consolidate relay/status indicators into single unified icon
|
||||||
|
- Simplify relay indicator tooltip to show relay list
|
||||||
|
- Move rebroadcast settings to dedicated Flight Mode section
|
||||||
|
- Place Reading Font and Font Size settings side-by-side
|
||||||
|
- Improve font size scale and default value
|
||||||
|
- Use wifi icon for disconnected remote relays
|
||||||
|
- Use airplane icons for local relay indicators
|
||||||
|
- Make Relays heading same level as Flight Mode in settings
|
||||||
|
- Simplify rebroadcast settings UI with consistent checkbox style
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Make highlight creation instant with non-blocking relay publish
|
||||||
|
- Reduce relay status polling interval to 20 seconds
|
||||||
|
- Show sync progress and hide indicator after successful sync
|
||||||
|
|
||||||
## [0.2.10] - 2025-10-09
|
## [0.2.10] - 2025-10-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -426,6 +472,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Optimize relay usage following applesauce-relay best practices
|
- Optimize relay usage following applesauce-relay best practices
|
||||||
- Use applesauce-react event models for better profile handling
|
- Use applesauce-react event models for better profile handling
|
||||||
|
|
||||||
|
[0.3.0]: https://github.com/dergigi/boris/compare/v0.2.10...v0.3.0
|
||||||
[0.2.10]: https://github.com/dergigi/boris/compare/v0.2.9...v0.2.10
|
[0.2.10]: https://github.com/dergigi/boris/compare/v0.2.9...v0.2.10
|
||||||
[0.2.9]: https://github.com/dergigi/boris/compare/v0.2.8...v0.2.9
|
[0.2.9]: https://github.com/dergigi/boris/compare/v0.2.8...v0.2.9
|
||||||
[0.2.8]: https://github.com/dergigi/boris/compare/v0.2.7...v0.2.8
|
[0.2.8]: https://github.com/dergigi/boris/compare/v0.2.7...v0.2.8
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.3.0",
|
"version": "0.3.3",
|
||||||
"description": "A minimal nostr client for bookmark management",
|
"description": "A minimal nostr client for bookmark management",
|
||||||
"homepage": "https://read.withboris.com/",
|
"homepage": "https://read.withboris.com/",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
56
public/sw.js
Normal file
56
public/sw.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Service Worker for Boris - handles offline image caching
|
||||||
|
const CACHE_NAME = 'boris-image-cache-v1'
|
||||||
|
|
||||||
|
// Install event - activate immediately
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
console.log('[SW] Installing service worker...')
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Activate event - take control immediately
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
console.log('[SW] Activating service worker...')
|
||||||
|
event.waitUntil(self.clients.claim())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch event - intercept image requests
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const url = new URL(event.request.url)
|
||||||
|
|
||||||
|
// Only intercept image requests
|
||||||
|
const isImage = event.request.destination === 'image' ||
|
||||||
|
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
|
||||||
|
|
||||||
|
if (!isImage) {
|
||||||
|
return // Let other requests pass through
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.open(CACHE_NAME).then(cache => {
|
||||||
|
return cache.match(event.request).then(cachedResponse => {
|
||||||
|
if (cachedResponse) {
|
||||||
|
console.log('[SW] Serving cached image:', url.pathname)
|
||||||
|
return cachedResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not in cache, try to fetch
|
||||||
|
return fetch(event.request)
|
||||||
|
.then(response => {
|
||||||
|
// Only cache successful responses
|
||||||
|
if (response && response.status === 200) {
|
||||||
|
// Clone the response before caching
|
||||||
|
cache.put(event.request, response.clone())
|
||||||
|
console.log('[SW] Cached new image:', url.pathname)
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('[SW] Fetch failed for:', url.pathname, error)
|
||||||
|
// Return a fallback or let it fail
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||||
import Bookmarks from './components/Bookmarks'
|
import Bookmarks from './components/Bookmarks'
|
||||||
|
import Explore from './components/Explore'
|
||||||
import Toast from './components/Toast'
|
import Toast from './components/Toast'
|
||||||
import { useToast } from './hooks/useToast'
|
import { useToast } from './hooks/useToast'
|
||||||
import { RELAYS } from './config/relays'
|
import { RELAYS } from './config/relays'
|
||||||
@@ -61,6 +62,12 @@ function AppRoutes({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/explore"
|
||||||
|
element={
|
||||||
|
<Explore relayPool={relayPool} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
61
src/components/BlogPostCard.tsx
Normal file
61
src/components/BlogPostCard.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faCalendar, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { formatDistance } from 'date-fns'
|
||||||
|
import { BlogPostPreview } from '../services/exploreService'
|
||||||
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
|
import { Models } from 'applesauce-core'
|
||||||
|
|
||||||
|
interface BlogPostCardProps {
|
||||||
|
post: BlogPostPreview
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href }) => {
|
||||||
|
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||||
|
const displayName = profile?.name || profile?.display_name ||
|
||||||
|
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
||||||
|
|
||||||
|
const publishedDate = post.published || post.event.created_at
|
||||||
|
const formattedDate = formatDistance(new Date(publishedDate * 1000), new Date(), {
|
||||||
|
addSuffix: true
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={href}
|
||||||
|
className="blog-post-card"
|
||||||
|
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||||
|
>
|
||||||
|
{post.image && (
|
||||||
|
<div className="blog-post-card-image">
|
||||||
|
<img
|
||||||
|
src={post.image}
|
||||||
|
alt={post.title}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="blog-post-card-content">
|
||||||
|
<h3 className="blog-post-card-title">{post.title}</h3>
|
||||||
|
{post.summary && (
|
||||||
|
<p className="blog-post-card-summary">{post.summary}</p>
|
||||||
|
)}
|
||||||
|
<div className="blog-post-card-meta">
|
||||||
|
<span className="blog-post-card-author">
|
||||||
|
<FontAwesomeIcon icon={faUser} />
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
<span className="blog-post-card-date">
|
||||||
|
<FontAwesomeIcon icon={faCalendar} />
|
||||||
|
{formattedDate}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BlogPostCard
|
||||||
|
|
||||||
@@ -11,15 +11,17 @@ import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
|
|||||||
import { CompactView } from './BookmarkViews/CompactView'
|
import { CompactView } from './BookmarkViews/CompactView'
|
||||||
import { LargeView } from './BookmarkViews/LargeView'
|
import { LargeView } from './BookmarkViews/LargeView'
|
||||||
import { CardView } from './BookmarkViews/CardView'
|
import { CardView } from './BookmarkViews/CardView'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
|
||||||
interface BookmarkItemProps {
|
interface BookmarkItemProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
index: number
|
index: number
|
||||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||||
viewMode?: ViewMode
|
viewMode?: ViewMode
|
||||||
|
settings?: UserSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
|
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', settings }) => {
|
||||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||||
|
|
||||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||||
@@ -115,7 +117,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleImage,
|
articleImage,
|
||||||
articleSummary
|
articleSummary,
|
||||||
|
settings
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewMode === 'compact') {
|
if (viewMode === 'compact') {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
import { BookmarkItem } from './BookmarkItem'
|
import { BookmarkItem } from './BookmarkItem'
|
||||||
@@ -8,6 +9,7 @@ import SidebarHeader from './SidebarHeader'
|
|||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import { ViewMode } from './Bookmarks'
|
import { ViewMode } from './Bookmarks'
|
||||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
|
||||||
interface BookmarkListProps {
|
interface BookmarkListProps {
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
@@ -21,8 +23,10 @@ interface BookmarkListProps {
|
|||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
isRefreshing?: boolean
|
isRefreshing?: boolean
|
||||||
|
lastFetchTime?: number | null
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
|
settings?: UserSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||||
@@ -37,8 +41,10 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
|
lastFetchTime,
|
||||||
loading = false,
|
loading = false,
|
||||||
relayPool
|
relayPool,
|
||||||
|
settings
|
||||||
}) => {
|
}) => {
|
||||||
// Helper to check if a bookmark has either content or a URL
|
// Helper to check if a bookmark has either content or a URL
|
||||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
||||||
@@ -99,8 +105,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
onRefresh={onRefresh}
|
|
||||||
isRefreshing={isRefreshing}
|
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -123,9 +127,38 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
index={index}
|
index={index}
|
||||||
onSelectUrl={onSelectUrl}
|
onSelectUrl={onSelectUrl}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
|
settings={settings}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{onRefresh && (
|
||||||
|
<div className="refresh-section" style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '1rem',
|
||||||
|
marginTop: '1rem',
|
||||||
|
borderTop: '1px solid var(--border-color)',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: 'var(--text-secondary)'
|
||||||
|
}}>
|
||||||
|
<IconButton
|
||||||
|
icon={faRotate}
|
||||||
|
onClick={onRefresh}
|
||||||
|
title="Refresh bookmarks"
|
||||||
|
ariaLabel="Refresh bookmarks"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={isRefreshing}
|
||||||
|
spin={isRefreshing}
|
||||||
|
/>
|
||||||
|
{lastFetchTime && (
|
||||||
|
<span>
|
||||||
|
Updated {formatDistanceToNow(lastFetchTime, { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="view-mode-controls">
|
<div className="view-mode-controls">
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
|||||||
import IconButton from '../IconButton'
|
import IconButton from '../IconButton'
|
||||||
import { classifyUrl } from '../../utils/helpers'
|
import { classifyUrl } from '../../utils/helpers'
|
||||||
import { IconGetter } from './shared'
|
import { IconGetter } from './shared'
|
||||||
|
import { useImageCache } from '../../hooks/useImageCache'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
|
||||||
interface CardViewProps {
|
interface CardViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -22,6 +24,7 @@ interface CardViewProps {
|
|||||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
articleImage?: string
|
articleImage?: string
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
|
settings?: UserSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CardView: React.FC<CardViewProps> = ({
|
export const CardView: React.FC<CardViewProps> = ({
|
||||||
@@ -37,8 +40,10 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleImage,
|
articleImage,
|
||||||
articleSummary
|
articleSummary,
|
||||||
|
settings
|
||||||
}) => {
|
}) => {
|
||||||
|
const cachedImage = useImageCache(articleImage, settings)
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const [urlsExpanded, setUrlsExpanded] = useState(false)
|
const [urlsExpanded, setUrlsExpanded] = useState(false)
|
||||||
const contentLength = (bookmark.content || '').length
|
const contentLength = (bookmark.content || '').length
|
||||||
@@ -48,10 +53,10 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||||
{isArticle && articleImage && (
|
{isArticle && cachedImage && (
|
||||||
<div
|
<div
|
||||||
className="article-hero-image"
|
className="article-hero-image"
|
||||||
style={{ backgroundImage: `url(${articleImage})` }}
|
style={{ backgroundImage: `url(${cachedImage})` }}
|
||||||
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
|
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { IndividualBookmark } from '../../types/bookmarks'
|
|||||||
import { formatDate } from '../../utils/bookmarkUtils'
|
import { formatDate } from '../../utils/bookmarkUtils'
|
||||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||||
import { IconGetter } from './shared'
|
import { IconGetter } from './shared'
|
||||||
|
import { useImageCache } from '../../hooks/useImageCache'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
|
||||||
interface LargeViewProps {
|
interface LargeViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -19,6 +21,7 @@ interface LargeViewProps {
|
|||||||
getAuthorDisplayName: () => string
|
getAuthorDisplayName: () => string
|
||||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
|
settings?: UserSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LargeView: React.FC<LargeViewProps> = ({
|
export const LargeView: React.FC<LargeViewProps> = ({
|
||||||
@@ -34,13 +37,15 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
eventNevent,
|
eventNevent,
|
||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleSummary
|
articleSummary,
|
||||||
|
settings
|
||||||
}) => {
|
}) => {
|
||||||
|
const cachedImage = useImageCache(previewImage || undefined, settings)
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||||
{(hasUrls || (isArticle && previewImage)) && (
|
{(hasUrls || (isArticle && cachedImage)) && (
|
||||||
<div
|
<div
|
||||||
className="large-preview-image"
|
className="large-preview-image"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -50,7 +55,7 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
onSelectUrl?.(extractedUrls[0])
|
onSelectUrl?.(extractedUrls[0])
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={previewImage ? { backgroundImage: `url(${previewImage})` } : undefined}
|
style={cachedImage ? { backgroundImage: `url(${cachedImage})` } : undefined}
|
||||||
>
|
>
|
||||||
{!previewImage && hasUrls && (
|
{!previewImage && hasUrls && (
|
||||||
<div className="preview-placeholder">
|
<div className="preview-placeholder">
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
setHighlightsLoading,
|
setHighlightsLoading,
|
||||||
followedPubkeys,
|
followedPubkeys,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
|
lastFetchTime,
|
||||||
handleFetchHighlights,
|
handleFetchHighlights,
|
||||||
handleRefreshAll
|
handleRefreshAll
|
||||||
} = useBookmarksData({
|
} = useBookmarksData({
|
||||||
@@ -182,6 +183,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
bookmarksLoading={bookmarksLoading}
|
bookmarksLoading={bookmarksLoading}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
|
lastFetchTime={lastFetchTime}
|
||||||
onToggleSidebar={() => setIsCollapsed(!isCollapsed)}
|
onToggleSidebar={() => setIsCollapsed(!isCollapsed)}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { HighlightVisibility } from './HighlightsPanel'
|
|||||||
import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
|
import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
|
||||||
import { useHighlightedContent } from '../hooks/useHighlightedContent'
|
import { useHighlightedContent } from '../hooks/useHighlightedContent'
|
||||||
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
|
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
|
||||||
interface ContentPanelProps {
|
interface ContentPanelProps {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -30,6 +31,7 @@ interface ContentPanelProps {
|
|||||||
highlightVisibility?: HighlightVisibility
|
highlightVisibility?: HighlightVisibility
|
||||||
currentUserPubkey?: string
|
currentUserPubkey?: string
|
||||||
followedPubkeys?: Set<string>
|
followedPubkeys?: Set<string>
|
||||||
|
settings?: UserSettings
|
||||||
// For highlight creation
|
// For highlight creation
|
||||||
onTextSelection?: (text: string) => void
|
onTextSelection?: (text: string) => void
|
||||||
onClearSelection?: () => void
|
onClearSelection?: () => void
|
||||||
@@ -48,6 +50,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
showHighlights = true,
|
showHighlights = true,
|
||||||
highlightStyle = 'marker',
|
highlightStyle = 'marker',
|
||||||
highlightColor = '#ffff00',
|
highlightColor = '#ffff00',
|
||||||
|
settings,
|
||||||
onHighlightClick,
|
onHighlightClick,
|
||||||
selectedHighlightId,
|
selectedHighlightId,
|
||||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||||
@@ -126,6 +129,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
readingTimeText={readingStats ? readingStats.text : null}
|
readingTimeText={readingStats ? readingStats.text : null}
|
||||||
hasHighlights={hasHighlights}
|
hasHighlights={hasHighlights}
|
||||||
highlightCount={relevantHighlights.length}
|
highlightCount={relevantHighlights.length}
|
||||||
|
settings={settings}
|
||||||
/>
|
/>
|
||||||
{markdown || html ? (
|
{markdown || html ? (
|
||||||
markdown ? (
|
markdown ? (
|
||||||
|
|||||||
129
src/components/Explore.tsx
Normal file
129
src/components/Explore.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faSpinner, faExclamationCircle, faCompass } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { fetchContacts } from '../services/contactService'
|
||||||
|
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||||
|
import BlogPostCard from './BlogPostCard'
|
||||||
|
|
||||||
|
interface ExploreProps {
|
||||||
|
relayPool: RelayPool
|
||||||
|
}
|
||||||
|
|
||||||
|
const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadBlogPosts = async () => {
|
||||||
|
if (!activeAccount) {
|
||||||
|
setError('Please log in to explore content from your friends')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
// Fetch the user's contacts (friends)
|
||||||
|
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
|
||||||
|
|
||||||
|
if (contacts.size === 0) {
|
||||||
|
setError('You are not following anyone yet. Follow some people to see their blog posts!')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relay URLs from pool
|
||||||
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
|
|
||||||
|
// Fetch blog posts from friends
|
||||||
|
const posts = await fetchBlogPostsFromAuthors(
|
||||||
|
relayPool,
|
||||||
|
Array.from(contacts),
|
||||||
|
relayUrls
|
||||||
|
)
|
||||||
|
|
||||||
|
if (posts.length === 0) {
|
||||||
|
setError('No blog posts found from your friends yet')
|
||||||
|
}
|
||||||
|
|
||||||
|
setBlogPosts(posts)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load blog posts:', err)
|
||||||
|
setError('Failed to load blog posts. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadBlogPosts()
|
||||||
|
}, [relayPool, activeAccount])
|
||||||
|
|
||||||
|
const getPostUrl = (post: BlogPostPreview) => {
|
||||||
|
// Get the d-tag identifier
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
|
||||||
|
// Create naddr
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: post.author,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
|
||||||
|
return `/a/${naddr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="explore-container">
|
||||||
|
<div className="explore-loading">
|
||||||
|
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||||
|
<p>Loading blog posts from your friends...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="explore-container">
|
||||||
|
<div className="explore-error">
|
||||||
|
<FontAwesomeIcon icon={faExclamationCircle} size="2x" />
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="explore-container">
|
||||||
|
<div className="explore-header">
|
||||||
|
<h1>
|
||||||
|
<FontAwesomeIcon icon={faCompass} />
|
||||||
|
Explore
|
||||||
|
</h1>
|
||||||
|
<p className="explore-subtitle">
|
||||||
|
Discover blog posts from your friends on Nostr
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="explore-grid">
|
||||||
|
{blogPosts.map((post) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
|
||||||
|
post={post}
|
||||||
|
href={getPostUrl(post)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Explore
|
||||||
|
|
||||||
@@ -2,6 +2,8 @@ import React from 'react'
|
|||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
|
import { useImageCache } from '../hooks/useImageCache'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
|
||||||
interface ReaderHeaderProps {
|
interface ReaderHeaderProps {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -11,6 +13,7 @@ interface ReaderHeaderProps {
|
|||||||
readingTimeText?: string | null
|
readingTimeText?: string | null
|
||||||
hasHighlights: boolean
|
hasHighlights: boolean
|
||||||
highlightCount: number
|
highlightCount: number
|
||||||
|
settings?: UserSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||||
@@ -20,13 +23,15 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
published,
|
published,
|
||||||
readingTimeText,
|
readingTimeText,
|
||||||
hasHighlights,
|
hasHighlights,
|
||||||
highlightCount
|
highlightCount,
|
||||||
|
settings
|
||||||
}) => {
|
}) => {
|
||||||
|
const cachedImage = useImageCache(image, settings)
|
||||||
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
||||||
if (image) {
|
if (cachedImage) {
|
||||||
return (
|
return (
|
||||||
<div className="reader-hero-image">
|
<div className="reader-hero-image">
|
||||||
<img src={image} alt={title || 'Article image'} />
|
<img src={cachedImage} alt={title || 'Article image'} />
|
||||||
{formattedDate && (
|
{formattedDate && (
|
||||||
<div className="publish-date-topright">
|
<div className="publish-date-topright">
|
||||||
{formattedDate}
|
{formattedDate}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faPlane, faGlobe, faCircle } from '@fortawesome/free-solid-svg-icons'
|
import { faPlane, faGlobe, faCircle, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
import { isLocalRelay } from '../utils/helpers'
|
import { isLocalRelay } from '../utils/helpers'
|
||||||
@@ -10,8 +10,9 @@ interface RelayStatusIndicatorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ relayPool }) => {
|
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ relayPool }) => {
|
||||||
// Poll for relay status updates
|
// Poll frequently for responsive offline indicator (5s instead of default 20s)
|
||||||
const relayStatuses = useRelayStatus({ relayPool })
|
const relayStatuses = useRelayStatus({ relayPool, pollingInterval: 5000 })
|
||||||
|
const [isConnecting, setIsConnecting] = useState(true)
|
||||||
|
|
||||||
if (!relayPool) return null
|
if (!relayPool) return null
|
||||||
|
|
||||||
@@ -25,32 +26,55 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
|
|||||||
const localOnlyMode = hasLocalRelay && !hasRemoteRelay
|
const localOnlyMode = hasLocalRelay && !hasRemoteRelay
|
||||||
const offlineMode = connectedUrls.length === 0
|
const offlineMode = connectedUrls.length === 0
|
||||||
|
|
||||||
// Debug logging
|
// Show "Connecting" for first few seconds or until relays connect
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (localOnlyMode || offlineMode) {
|
if (connectedUrls.length > 0) {
|
||||||
console.log('✈️ Relay Status Indicator:', {
|
// Connected! Stop showing connecting state
|
||||||
mode: offlineMode ? 'OFFLINE' : 'LOCAL_ONLY',
|
setIsConnecting(false)
|
||||||
connectedUrls,
|
} else {
|
||||||
hasLocalRelay,
|
// No connections yet - show connecting for 4 seconds
|
||||||
hasRemoteRelay
|
setIsConnecting(true)
|
||||||
})
|
const timeout = setTimeout(() => {
|
||||||
|
setIsConnecting(false)
|
||||||
|
}, 4000)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
}
|
}
|
||||||
}, [localOnlyMode, offlineMode, connectedUrls.length])
|
}, [connectedUrls.length])
|
||||||
|
|
||||||
// Don't show indicator when fully connected
|
// Debug logging
|
||||||
if (!localOnlyMode && !offlineMode) return null
|
useEffect(() => {
|
||||||
|
console.log('🔌 Relay Status Indicator:', {
|
||||||
|
mode: isConnecting ? 'CONNECTING' : offlineMode ? 'OFFLINE' : localOnlyMode ? 'LOCAL_ONLY' : 'ONLINE',
|
||||||
|
totalStatuses: relayStatuses.length,
|
||||||
|
connectedCount: connectedUrls.length,
|
||||||
|
connectedUrls: connectedUrls.map(u => u.replace(/^wss?:\/\//, '')),
|
||||||
|
hasLocalRelay,
|
||||||
|
hasRemoteRelay,
|
||||||
|
isConnecting
|
||||||
|
})
|
||||||
|
}, [offlineMode, localOnlyMode, connectedUrls.length, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
|
||||||
|
|
||||||
|
// Don't show indicator when fully connected (but show when connecting)
|
||||||
|
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relay-status-indicator" title={
|
<div className="relay-status-indicator" title={
|
||||||
offlineMode
|
isConnecting
|
||||||
? 'Offline - No relays connected'
|
? 'Connecting to relays...'
|
||||||
: 'Local Relays Only - Highlights will be marked as local'
|
: offlineMode
|
||||||
|
? 'Offline - No relays connected'
|
||||||
|
: 'Local Relays Only - Highlights will be marked as local'
|
||||||
}>
|
}>
|
||||||
<div className="relay-status-icon">
|
<div className="relay-status-icon">
|
||||||
<FontAwesomeIcon icon={offlineMode ? faCircle : faPlane} />
|
<FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
|
||||||
</div>
|
</div>
|
||||||
<div className="relay-status-text">
|
<div className="relay-status-text">
|
||||||
{offlineMode ? (
|
{isConnecting ? (
|
||||||
|
<>
|
||||||
|
<span className="relay-status-title">Connecting</span>
|
||||||
|
<span className="relay-status-subtitle">Establishing connections...</span>
|
||||||
|
</>
|
||||||
|
) : offlineMode ? (
|
||||||
<>
|
<>
|
||||||
<span className="relay-status-title">Offline</span>
|
<span className="relay-status-title">Offline</span>
|
||||||
<span className="relay-status-subtitle">No relays connected</span>
|
<span className="relay-status-subtitle">No relays connected</span>
|
||||||
@@ -62,7 +86,7 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!offlineMode && (
|
{!offlineMode && !isConnecting && (
|
||||||
<div className="relay-status-pulse">
|
<div className="relay-status-pulse">
|
||||||
<FontAwesomeIcon icon={faGlobe} className="pulse-icon" />
|
<FontAwesomeIcon icon={faGlobe} className="pulse-icon" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { faTrash } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { UserSettings } from '../../services/settingsService'
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
import { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService'
|
||||||
|
import IconButton from '../IconButton'
|
||||||
|
|
||||||
interface OfflineModeSettingsProps {
|
interface OfflineModeSettingsProps {
|
||||||
settings: UserSettings
|
settings: UserSettings
|
||||||
@@ -10,16 +13,95 @@ interface OfflineModeSettingsProps {
|
|||||||
|
|
||||||
const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => {
|
const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [cacheStats, setCacheStats] = useState<{
|
||||||
|
totalSizeMB: number
|
||||||
|
itemCount: number
|
||||||
|
items: Array<{ url: string, sizeMB: number }>
|
||||||
|
}>({ totalSizeMB: 0, itemCount: 0, items: [] })
|
||||||
|
|
||||||
const handleLinkClick = (url: string) => {
|
const handleLinkClick = (url: string) => {
|
||||||
if (onClose) onClose()
|
if (onClose) onClose()
|
||||||
navigate(`/r/${encodeURIComponent(url)}`)
|
navigate(`/r/${encodeURIComponent(url)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleClearCache = async () => {
|
||||||
|
if (confirm('Are you sure you want to clear all cached images?')) {
|
||||||
|
await clearImageCache()
|
||||||
|
const stats = await getImageCacheStatsAsync()
|
||||||
|
setCacheStats(stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache stats periodically
|
||||||
|
useEffect(() => {
|
||||||
|
const updateStats = async () => {
|
||||||
|
const stats = await getImageCacheStatsAsync()
|
||||||
|
setCacheStats(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStats() // Initial load
|
||||||
|
const interval = setInterval(updateStats, 3000) // Update every 3 seconds
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h3 className="section-title">Flight Mode</h3>
|
<h3 className="section-title">Flight Mode</h3>
|
||||||
|
|
||||||
|
<div className="setting-group" style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
<label htmlFor="enableImageCache" className="checkbox-label" style={{ marginBottom: 0 }}>
|
||||||
|
<input
|
||||||
|
id="enableImageCache"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.enableImageCache ?? true}
|
||||||
|
onChange={(e) => onUpdate({ enableImageCache: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Use local image cache</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{(settings.enableImageCache ?? true) && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem'
|
||||||
|
}}>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||||
|
( {cacheStats.totalSizeMB.toFixed(1)} MB /
|
||||||
|
<input
|
||||||
|
id="imageCacheSizeMB"
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
max="500"
|
||||||
|
value={settings.imageCacheSizeMB ?? 210}
|
||||||
|
onChange={(e) => onUpdate({ imageCacheSizeMB: parseInt(e.target.value) || 210 })}
|
||||||
|
style={{
|
||||||
|
width: '50px',
|
||||||
|
padding: '0.15rem 0.35rem',
|
||||||
|
background: 'var(--surface-secondary)',
|
||||||
|
border: '1px solid var(--border-color, #333)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: 'inherit',
|
||||||
|
fontSize: 'inherit',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
MB used )
|
||||||
|
</span>
|
||||||
|
<IconButton
|
||||||
|
icon={faTrash}
|
||||||
|
onClick={handleClearCache}
|
||||||
|
title="Clear cache"
|
||||||
|
variant="ghost"
|
||||||
|
size={28}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="setting-group">
|
<div className="setting-group">
|
||||||
<label htmlFor="useLocalRelayAsCache" className="checkbox-label">
|
<label htmlFor="useLocalRelayAsCache" className="checkbox-label">
|
||||||
<input
|
<input
|
||||||
@@ -33,19 +115,6 @@ const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onU
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="setting-group">
|
|
||||||
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
|
|
||||||
<input
|
|
||||||
id="rebroadcastToAllRelays"
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.rebroadcastToAllRelays ?? false}
|
|
||||||
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
|
|
||||||
className="setting-checkbox"
|
|
||||||
/>
|
|
||||||
<span>Rebroadcast events while browsing</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
marginTop: '1.5rem',
|
marginTop: '1.5rem',
|
||||||
padding: '1rem',
|
padding: '1rem',
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface StartupPreferencesSettingsProps {
|
|||||||
const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({ settings, onUpdate }) => {
|
const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({ settings, onUpdate }) => {
|
||||||
return (
|
return (
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h3 className="section-title">Startup Preferences</h3>
|
<h3 className="section-title">Startup & Behavior</h3>
|
||||||
|
|
||||||
<div className="setting-group">
|
<div className="setting-group">
|
||||||
<label htmlFor="sidebarCollapsed" className="checkbox-label">
|
<label htmlFor="sidebarCollapsed" className="checkbox-label">
|
||||||
@@ -36,6 +36,19 @@ const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({
|
|||||||
<span>Start with highlights panel collapsed</span>
|
<span>Start with highlights panel collapsed</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="rebroadcastToAllRelays"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.rebroadcastToAllRelays ?? false}
|
||||||
|
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Rebroadcast events while browsing</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faRotate, faHome, faPlus } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
@@ -16,12 +16,10 @@ interface SidebarHeaderProps {
|
|||||||
onToggleCollapse: () => void
|
onToggleCollapse: () => void
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
onRefresh?: () => void
|
|
||||||
isRefreshing?: boolean
|
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, onRefresh, isRefreshing, relayPool }) => {
|
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool }) => {
|
||||||
const [isConnecting, setIsConnecting] = useState(false)
|
const [isConnecting, setIsConnecting] = useState(false)
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -61,11 +59,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
}
|
}
|
||||||
|
|
||||||
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
||||||
|
|
||||||
// Refresh bookmarks after creating
|
|
||||||
if (onRefresh) {
|
|
||||||
onRefresh()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const profileImage = getProfileImage()
|
const profileImage = getProfileImage()
|
||||||
@@ -101,6 +94,13 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
ariaLabel="Home"
|
ariaLabel="Home"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faNewspaper}
|
||||||
|
onClick={() => navigate('/explore')}
|
||||||
|
title="Explore"
|
||||||
|
ariaLabel="Explore"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faGear}
|
icon={faGear}
|
||||||
onClick={onOpenSettings}
|
onClick={onOpenSettings}
|
||||||
@@ -108,17 +108,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
ariaLabel="Settings"
|
ariaLabel="Settings"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
{onRefresh && (
|
|
||||||
<IconButton
|
|
||||||
icon={faRotate}
|
|
||||||
onClick={onRefresh}
|
|
||||||
title="Refresh bookmarks"
|
|
||||||
ariaLabel="Refresh bookmarks"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={isRefreshing}
|
|
||||||
spin={isRefreshing}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activeAccount && (
|
{activeAccount && (
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faPlus}
|
icon={faPlus}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ interface ThreePaneLayoutProps {
|
|||||||
bookmarksLoading: boolean
|
bookmarksLoading: boolean
|
||||||
viewMode: ViewMode
|
viewMode: ViewMode
|
||||||
isRefreshing: boolean
|
isRefreshing: boolean
|
||||||
|
lastFetchTime?: number | null
|
||||||
onToggleSidebar: () => void
|
onToggleSidebar: () => void
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
onViewModeChange: (mode: ViewMode) => void
|
onViewModeChange: (mode: ViewMode) => void
|
||||||
@@ -90,8 +91,10 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
onOpenSettings={props.onOpenSettings}
|
onOpenSettings={props.onOpenSettings}
|
||||||
onRefresh={props.onRefresh}
|
onRefresh={props.onRefresh}
|
||||||
isRefreshing={props.isRefreshing}
|
isRefreshing={props.isRefreshing}
|
||||||
|
lastFetchTime={props.lastFetchTime}
|
||||||
loading={props.bookmarksLoading}
|
loading={props.bookmarksLoading}
|
||||||
relayPool={props.relayPool}
|
relayPool={props.relayPool}
|
||||||
|
settings={props.settings}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="pane main">
|
<div className="pane main">
|
||||||
@@ -123,6 +126,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
onClearSelection={props.onClearSelection}
|
onClearSelection={props.onClearSelection}
|
||||||
currentUserPubkey={props.currentUserPubkey}
|
currentUserPubkey={props.currentUserPubkey}
|
||||||
followedPubkeys={props.followedPubkeys}
|
followedPubkeys={props.followedPubkeys}
|
||||||
|
settings={props.settings}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const useBookmarksData = ({
|
|||||||
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
||||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
|
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
|
||||||
|
|
||||||
const handleFetchContacts = useCallback(async () => {
|
const handleFetchContacts = useCallback(async () => {
|
||||||
if (!relayPool || !activeAccount) return
|
if (!relayPool || !activeAccount) return
|
||||||
@@ -93,6 +94,7 @@ export const useBookmarksData = ({
|
|||||||
await handleFetchBookmarks()
|
await handleFetchBookmarks()
|
||||||
await handleFetchHighlights()
|
await handleFetchHighlights()
|
||||||
await handleFetchContacts()
|
await handleFetchContacts()
|
||||||
|
setLastFetchTime(Date.now())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to refresh data:', err)
|
console.error('Failed to refresh data:', err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -119,6 +121,7 @@ export const useBookmarksData = ({
|
|||||||
setHighlightsLoading,
|
setHighlightsLoading,
|
||||||
followedPubkeys,
|
followedPubkeys,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
|
lastFetchTime,
|
||||||
handleFetchBookmarks,
|
handleFetchBookmarks,
|
||||||
handleFetchHighlights,
|
handleFetchHighlights,
|
||||||
handleRefreshAll
|
handleRefreshAll
|
||||||
|
|||||||
34
src/hooks/useImageCache.ts
Normal file
34
src/hooks/useImageCache.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to return image URL for display
|
||||||
|
* Service Worker handles all caching transparently
|
||||||
|
* Images are cached on first load and available offline automatically
|
||||||
|
*
|
||||||
|
* @param imageUrl - The URL of the image to display
|
||||||
|
* @returns The image URL (Service Worker handles caching)
|
||||||
|
*/
|
||||||
|
export function useImageCache(
|
||||||
|
imageUrl: string | undefined,
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
_settings?: UserSettings
|
||||||
|
): string | undefined {
|
||||||
|
// Service Worker handles everything - just return the URL as-is
|
||||||
|
return imageUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-load image to ensure it's cached by Service Worker
|
||||||
|
* Triggers a fetch so the SW can cache it even if not visible yet
|
||||||
|
*/
|
||||||
|
export function useCacheImageOnLoad(
|
||||||
|
imageUrl: string | undefined,
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
_settings?: UserSettings
|
||||||
|
): void {
|
||||||
|
// Service Worker will cache on first fetch
|
||||||
|
// This hook is now a no-op, kept for API compatibility
|
||||||
|
// The browser will automatically fetch and cache images when they're used in <img> tags
|
||||||
|
void imageUrl
|
||||||
|
}
|
||||||
|
|
||||||
166
src/index.css
166
src/index.css
@@ -1095,9 +1095,9 @@ body {
|
|||||||
|
|
||||||
/* Hero image in reader view */
|
/* Hero image in reader view */
|
||||||
.reader-hero-image {
|
.reader-hero-image {
|
||||||
width: 100%;
|
width: calc(100% + 1.5rem);
|
||||||
margin: 0 0 2rem 0;
|
margin: -0.75rem -0.75rem 2rem -0.75rem;
|
||||||
border-radius: 8px;
|
border-radius: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
@@ -2555,3 +2555,163 @@ body {
|
|||||||
.three-pane.sidebar-collapsed .relay-status-indicator {
|
.three-pane.sidebar-collapsed .relay-status-indicator {
|
||||||
left: calc(var(--sidebar-collapsed-width) + 1.5rem);
|
left: calc(var(--sidebar-collapsed-width) + 1.5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Explore Page Styles */
|
||||||
|
.explore-container {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explore-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explore-header h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: #646cff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explore-subtitle {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explore-loading,
|
||||||
|
.explore-error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
min-height: 50vh;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.explore-error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explore-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-post-card {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-post-card:hover {
|
||||||
|
border-color: #646cff;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 24px rgba(100, 108, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-post-card-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0f0f0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-post-card-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-post-card:hover .blog-post-card-image img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-post-card-content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-post-card-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-post-card-summary {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-post-card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-post-card-author,
|
||||||
|
.blog-post-card-date {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-post-card-author svg,
|
||||||
|
.blog-post-card-date svg {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.explore-container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explore-header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explore-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
26
src/main.tsx
26
src/main.tsx
@@ -3,6 +3,32 @@ import ReactDOM from 'react-dom/client'
|
|||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
|
// Register Service Worker for offline image caching
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register('/sw.js')
|
||||||
|
.then(registration => {
|
||||||
|
console.log('✅ Service Worker registered:', registration.scope)
|
||||||
|
|
||||||
|
// Update service worker when a new version is available
|
||||||
|
registration.addEventListener('updatefound', () => {
|
||||||
|
const newWorker = registration.installing
|
||||||
|
if (newWorker) {
|
||||||
|
newWorker.addEventListener('statechange', () => {
|
||||||
|
if (newWorker.state === 'activated') {
|
||||||
|
console.log('🔄 Service Worker updated, page may need reload')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('❌ Service Worker registration failed:', error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ export async function fetchArticleByNaddr(
|
|||||||
// Save to cache before returning
|
// Save to cache before returning
|
||||||
saveToCache(naddr, content)
|
saveToCache(naddr, content)
|
||||||
|
|
||||||
|
// Image caching is handled automatically by Service Worker
|
||||||
|
|
||||||
return content
|
return content
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch article:', err)
|
console.error('Failed to fetch article:', err)
|
||||||
|
|||||||
87
src/services/exploreService.ts
Normal file
87
src/services/exploreService.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||||
|
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { Helpers } from 'applesauce-core'
|
||||||
|
|
||||||
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
|
export interface BlogPostPreview {
|
||||||
|
event: NostrEvent
|
||||||
|
title: string
|
||||||
|
summary?: string
|
||||||
|
image?: string
|
||||||
|
published?: number
|
||||||
|
author: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches blog posts (kind:30023) from a list of pubkeys (friends)
|
||||||
|
* @param relayPool - The relay pool to query
|
||||||
|
* @param pubkeys - Array of pubkeys to fetch posts from
|
||||||
|
* @param relayUrls - Array of relay URLs to query
|
||||||
|
* @returns Array of blog post previews
|
||||||
|
*/
|
||||||
|
export const fetchBlogPostsFromAuthors = async (
|
||||||
|
relayPool: RelayPool,
|
||||||
|
pubkeys: string[],
|
||||||
|
relayUrls: string[]
|
||||||
|
): Promise<BlogPostPreview[]> => {
|
||||||
|
try {
|
||||||
|
if (pubkeys.length === 0) {
|
||||||
|
console.log('⚠️ No pubkeys to fetch blog posts from')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
|
||||||
|
|
||||||
|
const events = await lastValueFrom(
|
||||||
|
relayPool
|
||||||
|
.req(relayUrls, {
|
||||||
|
kinds: [30023],
|
||||||
|
authors: pubkeys,
|
||||||
|
limit: 100 // Fetch up to 100 recent posts
|
||||||
|
})
|
||||||
|
.pipe(completeOnEose(), takeUntil(timer(15000)), toArray())
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('📊 Blog post events fetched:', events.length)
|
||||||
|
|
||||||
|
// Deduplicate replaceable events by keeping the most recent version
|
||||||
|
// Group by author + d-tag identifier
|
||||||
|
const uniqueEvents = new Map<string, NostrEvent>()
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${event.pubkey}:${dTag}`
|
||||||
|
|
||||||
|
const existing = uniqueEvents.get(key)
|
||||||
|
if (!existing || event.created_at > existing.created_at) {
|
||||||
|
uniqueEvents.set(key, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to blog post previews and sort by published date (most recent first)
|
||||||
|
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
||||||
|
.map(event => ({
|
||||||
|
event,
|
||||||
|
title: getArticleTitle(event) || 'Untitled',
|
||||||
|
summary: getArticleSummary(event),
|
||||||
|
image: getArticleImage(event),
|
||||||
|
published: getArticlePublished(event),
|
||||||
|
author: event.pubkey
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA // Most recent first
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('📰 Processed', blogPosts.length, 'unique blog posts')
|
||||||
|
|
||||||
|
return blogPosts
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch blog posts:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
74
src/services/imageCacheService.ts
Normal file
74
src/services/imageCacheService.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Image Cache Service
|
||||||
|
*
|
||||||
|
* Utility functions for managing the Service Worker's image cache
|
||||||
|
* Service Worker automatically caches images on fetch
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CACHE_NAME = 'boris-image-cache-v1'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached images
|
||||||
|
*/
|
||||||
|
export async function clearImageCache(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await caches.delete(CACHE_NAME)
|
||||||
|
console.log('🗑️ Cleared all cached images')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to clear image cache:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache statistics by inspecting Cache API directly
|
||||||
|
*/
|
||||||
|
export async function getImageCacheStatsAsync(): Promise<{
|
||||||
|
totalSizeMB: number
|
||||||
|
itemCount: number
|
||||||
|
items: Array<{ url: string, sizeMB: number }>
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(CACHE_NAME)
|
||||||
|
const requests = await cache.keys()
|
||||||
|
|
||||||
|
let totalSize = 0
|
||||||
|
const items: Array<{ url: string, sizeMB: number }> = []
|
||||||
|
|
||||||
|
for (const request of requests) {
|
||||||
|
const response = await cache.match(request)
|
||||||
|
if (response) {
|
||||||
|
const blob = await response.blob()
|
||||||
|
const sizeMB = blob.size / (1024 * 1024)
|
||||||
|
totalSize += blob.size
|
||||||
|
items.push({ url: request.url, sizeMB })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSizeMB: totalSize / (1024 * 1024),
|
||||||
|
itemCount: requests.length,
|
||||||
|
items
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get cache stats:', err)
|
||||||
|
return { totalSizeMB: 0, itemCount: 0, items: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous wrapper for cache stats (returns approximate values)
|
||||||
|
* For real-time stats, use getImageCacheStatsAsync
|
||||||
|
*/
|
||||||
|
export function getImageCacheStats(): {
|
||||||
|
totalSizeMB: number
|
||||||
|
itemCount: number
|
||||||
|
items: Array<{ url: string, sizeMB: number, lastAccessed: Date }>
|
||||||
|
} {
|
||||||
|
// Return placeholder - actual stats require async Cache API access
|
||||||
|
// Component should use getImageCacheStatsAsync for real values
|
||||||
|
return {
|
||||||
|
totalSizeMB: 0,
|
||||||
|
itemCount: 0,
|
||||||
|
items: []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,9 @@ export interface UserSettings {
|
|||||||
// Relay rebroadcast settings
|
// Relay rebroadcast settings
|
||||||
useLocalRelayAsCache?: boolean // Rebroadcast events to local relays
|
useLocalRelayAsCache?: boolean // Rebroadcast events to local relays
|
||||||
rebroadcastToAllRelays?: boolean // Rebroadcast events to all relays
|
rebroadcastToAllRelays?: boolean // Rebroadcast events to all relays
|
||||||
|
// Image cache settings
|
||||||
|
enableImageCache?: boolean // Enable caching images in localStorage
|
||||||
|
imageCacheSizeMB?: number // Maximum cache size in megabytes (default: 210MB)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSettings(
|
export async function loadSettings(
|
||||||
|
|||||||
Reference in New Issue
Block a user