Compare commits

...

26 Commits

Author SHA1 Message Date
Gigi
a2c4bed0f5 chore: bump version to 0.6.22 2025-10-16 16:01:19 +02:00
Gigi
9bad49fe5f feat(vercel): add rewrite rule for article OG endpoint
Route /a/:naddr requests to /api/article-og for dynamic social preview tags.
2025-10-16 16:00:36 +02:00
Gigi
2aa6536496 Merge pull request #17 from dergigi/social-preview
Add dynamic social preview for article deep-links
2025-10-16 15:58:52 +02:00
Gigi
bd6d8a0342 chore(api): remove debug logging from article-og endpoint 2025-10-16 15:50:00 +02:00
Gigi
dc8e86bc57 fix(api): use history.replaceState before redirecting to SPA
Set the browser history to /a/{naddr} before redirecting to /, so when the SPA loads it sees the correct URL path.
2025-10-16 15:41:22 +02:00
Gigi
32b843908e debug: add logging and debug endpoint to article-og
Add console logging for debugging and ?debug=1 query param to see request details in browser.
2025-10-16 15:34:50 +02:00
Gigi
5a71480459 fix(api): add base tag for proper asset loading
Use named parameter syntax in Vercel rewrite and add <base href="/"> tag to ensure assets load correctly from root when serving index.html through the API.
2025-10-16 15:27:13 +02:00
Gigi
17455aa47b fix(api): serve index.html to browsers with preserved URL
Instead of redirecting, serve the static index.html file directly. The Vercel rewrite preserves the /a/{naddr} URL, allowing client-side SPA routing to work correctly.
2025-10-16 15:20:10 +02:00
Gigi
4cc32c27de fix(api): detect crawlers and redirect browsers to SPA
Browsers get 302 redirect to / where the SPA handles routing client-side with the original /a/{naddr} URL preserved. Crawlers/bots get the full HTML with OG meta tags.
2025-10-16 14:43:29 +02:00
Gigi
99bfe209a5 fix(api): use meta refresh instead of SPA boot in OG endpoint
Browsers will immediately redirect to / and load the SPA client-side, while crawlers/bots ignore meta refresh and only see the OG meta tags.
2025-10-16 14:38:17 +02:00
Gigi
0a28bfbd50 fix(api): replace any type with Filter from nostr-tools 2025-10-16 14:32:35 +02:00
Gigi
ba9fb109f6 refactor(api): DRY improvements for article OG endpoint
- Extract fetchEventsFromRelays helper to eliminate duplication
- Add setCacheHeaders helper for consistent header setting
- Parallelize article and profile fetching for faster response
- Move relayPool.close() to finally block to prevent leaks
- Remove redundant cacheKey variable and sorting
2025-10-16 14:31:39 +02:00
Gigi
ec9d2fcb49 chore(meta): add social preview image to homepage OG tags 2025-10-16 14:23:44 +02:00
Gigi
f841043e03 chore(assets): add default social preview image (1200x630) 2025-10-16 14:22:04 +02:00
Gigi
94dc95e1f0 feat(api): dynamic OG HTML for /a/{naddr} using relay metadata 2025-10-16 14:21:49 +02:00
Gigi
32a5145d8f chore(vercel): route /a/* to article OG handler 2025-10-16 14:20:58 +02:00
Gigi
a856e8ca26 docs: update CHANGELOG.md for v0.6.21 2025-10-16 09:57:13 +02:00
Gigi
d54306cf92 chore: bump version to 0.6.21 2025-10-16 09:56:06 +02:00
Gigi
9fdb96b64e Merge pull request #16 from dergigi/reading-progress-filters-part-two
feat: add reading progress filters and reads/links tabs
2025-10-16 09:55:32 +02:00
Gigi
c50aa3a243 fix: resolve TypeScript errors from merge
- Remove unused readingPositions and markedAsReadIds from useBookmarksData
- Remove eventStore parameter from useBookmarksData call
- Add reads and links fields to MeCache interface
2025-10-16 09:53:20 +02:00
Gigi
adef1a922c chore: remove completed plan file 2025-10-16 09:49:43 +02:00
Gigi
99df4d6761 chore: merge master into reading-progress-filters-part-two
Resolved conflicts by keeping feature branch changes:
- Kept /me/reads and /me/links routes (not /me/archive)
- Kept ReadingProgressFilters component and readingProgressUtils
- Kept readsService, linksService, and readingDataProcessor
- Restored files that were renamed/deleted in master
2025-10-16 09:49:13 +02:00
Gigi
cf2098a723 Merge pull request #15 from dergigi/revert-14-reading-progress-filters
Revert "Add reading progress filters and split Reads/Links tabs"
2025-10-16 08:06:06 +02:00
Gigi
5568437663 Revert "Add reading progress filters and split Reads/Links tabs" 2025-10-16 08:05:20 +02:00
Gigi
7bfd7fdf6c Merge pull request #14 from dergigi/reading-progress-filters
Add reading progress filters and split Reads/Links tabs
2025-10-16 01:46:32 +02:00
Gigi
85649ae283 Merge pull request #13 from dergigi/sync-reading-position
Add reading position sync and archive enhancements
2025-10-15 22:45:13 +02:00
20 changed files with 476 additions and 593 deletions

View File

@@ -1,136 +0,0 @@
<!-- 658dc3b5-4b0b-4d30-8cfa-a9326f1d467e f1d78d5b-786d-4658-ae4b-56278aba318e -->
# Lazy Load Me Component Tabs
## Overview
Currently, the Me component loads all data for all tabs upfront, causing 30+ second load times even when viewing a single tab. This plan implements lazy loading where only the active tab's data is fetched on demand.
## Implementation Strategy
Based on user requirements:
- Load only the active tab's data (pure lazy loading)
- No background prefetching
- Show cached data immediately, refresh in background when revisiting tabs
- Works for both `/me` (own profile) and `/p/` (other profiles) using the same code
## Key Insight
The Me component already handles both own profile and other profiles via the `isOwnProfile` flag. The lazy loading will naturally work for both cases:
- Own profile (`/me`): Loads all tabs including private data (bookmarks, reads)
- Other profiles (`/p/npub...`): Only loads public tabs (highlights, writings)
## Changes Required
### 1. Update Me.tsx Loading Logic
**Current behavior**: Single `useEffect` loads all data (highlights, writings, bookmarks, reads) regardless of active tab.
**New behavior**:
- Create separate loading functions per tab
- Load only active tab's data on mount and tab switches
- Show cached data immediately if available
- Refresh cached data in background when tab is revisited
**Key changes**:
- Remove the monolithic `loadData()` function
- Add `loadedTabs` state to track which tabs have been fetched
- Create tab-specific loaders: `loadHighlights()`, `loadWritings()`, `loadBookmarks()`, `loadReads()`
- Add `useEffect` that watches `activeTab` and loads data for current tab only
- Check cache first, display cached data, then refresh in background
**Code location**: Lines 64-123 in `src/components/Me.tsx`
### 2. Per-Tab Loading State
Add tab-specific loading tracking:
```typescript
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
```
This prevents unnecessary reloads and allows showing cached data instantly.
### 3. Tab-Specific Load Functions
Create individual functions:
- `loadHighlightsTab()` - fetch highlights
- `loadWritingsTab()` - fetch writings
- `loadReadingListTab()` - fetch bookmarks
- `loadReadsTab()` - fetch bookmarks first, then reads
Each function:
1. Checks cache, displays if available
2. Sets loading state
3. Fetches fresh data
4. Updates state and cache
5. Marks tab as loaded
### 4. Tab Switch Effect
Replace the current useEffect with:
```typescript
useEffect(() => {
if (!activeTab || !viewingPubkey) return
// Check if we have cached data
const cached = getCachedMeData(viewingPubkey)
if (cached) {
// Show cached data immediately
setHighlights(cached.highlights)
setBookmarks(cached.bookmarks)
setReads(cached.reads)
// Continue to refresh in background
}
// Load data for active tab
switch (activeTab) {
case 'highlights':
loadHighlightsTab()
break
case 'writings':
loadWritingsTab()
break
case 'reading-list':
loadReadingListTab()
break
case 'reads':
loadReadsTab()
break
}
}, [activeTab, viewingPubkey, refreshTrigger])
```
### 5. Handle Pull-to-Refresh
Update pull-to-refresh logic to only reload the active tab instead of all tabs.
## Benefits
- Initial load: ~2-5s instead of 30+ seconds (only loads one tab)
- Tab switching: Instant with cached data, refreshes in background
- Network efficiency: Only fetches what the user views
- Better UX: Users see content immediately from cache
## Testing Checklist
- Verify each tab loads independently
- Confirm cached data shows immediately on tab switch
- Ensure background refresh works without flickering
- Test pull-to-refresh only reloads active tab
- Verify loading states per tab work correctly
### To-dos
- [ ] Create src/services/readsService.ts with fetchAllReads function
- [ ] Update Me.tsx to use reads instead of archive
- [ ] Update routes from /me/archive to /me/reads
- [ ] Update meCache.ts to use reads field
- [ ] Update filter logic to handle actual reading progress
- [ ] Test all 5 filters and data sources work correctly

View File

@@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.6.21] - 2025-10-16
### Added
- Reading position sync across devices using Nostr Kind 30078 (NIP-78)
- Automatically saves and syncs reading position as you scroll
- Visual reading progress indicator on article cards
- Reading progress shown in Explore and Bookmarks sidebar
- Auto-scroll to last reading position setting (configurable in Settings)
- Reading position displayed as colored progress bar on cards
- Reading progress filters for organizing articles
- Filter by reading state: Unopened, Started (0-10%), Reading (11-94%), Completed (95-100% or marked as read)
- Filter icons colored when active (blue for most, green for completed)
- URL routing support for reading progress filters
- Reading progress filters available in Archive tab and bookmarks sidebar
- Reads and Links tabs on `/me` page
- Reads tab shows nostr-native articles with reading progress
- Links tab shows external URLs with reading progress
- Both tabs populate instantly from bookmarks for fast loading
- Lazy loading for improved performance
- Auto-mark as read at 100% reading progress
- Articles automatically marked as read when scrolled to end
- Marked-as-read articles treated as 100% progress
- Fancy checkmark animation on Mark as Read button
- Click-to-open article navigation on highlights
- Clicking highlights in Explore and Me pages opens the source article
- Automatically scrolls to highlighted text position
### Changed
- Renamed Archive to Reads with expanded functionality
- Merged 'Completed' and 'Marked as Read' filters into one unified filter
- Simplified filter icon colors to blue (except green for completed)
- Started reading progress state (0-10%) uses neutral text color
- Replace spinners with skeleton placeholders during refresh in Archive/Reads/Links tabs
- Removed unused IEventStore import in ContentPanel
### Fixed
- Reading position calculation now accurately reaches 100%
- Reading position filters work correctly in bookmarks sidebar
- Filter out reads without timestamps or 'Untitled' items
- Show skeleton placeholders correctly during initial tab load
- External URLs in Reads tab only shown if they have reading progress
- Reading progress merges even when timestamp is older than bookmark
- Resolved all linter errors and TypeScript type issues
### Refactored
- Renamed ArchiveFilters component to ReadingProgressFilters
- Extracted shared utilities from readsFromBookmarks for DRY code
- Use setState callback pattern for background enrichment
- Use naddr format for article IDs to match reading positions
- Extract article titles, images, summaries from bookmark tags using applesauce helpers
## [0.6.20] - 2025-10-15
### Added
@@ -1641,7 +1696,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.20...HEAD
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.21...HEAD
[0.6.21]: https://github.com/dergigi/boris/compare/v0.6.20...v0.6.21
[0.6.20]: https://github.com/dergigi/boris/compare/v0.6.19...v0.6.20
[0.6.19]: https://github.com/dergigi/boris/compare/v0.6.18...v0.6.19
[0.6.18]: https://github.com/dergigi/boris/compare/v0.6.17...v0.6.18

282
api/article-og.ts Normal file
View File

@@ -0,0 +1,282 @@
import type { VercelRequest, VercelResponse } from '@vercel/node'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent, Filter } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers
// Relay configuration (from src/config/relays.ts)
const RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://relay.dergigi.com',
'wss://wot.dergigi.com',
'wss://relay.snort.social',
'wss://relay.current.fyi',
'wss://nostr-pub.wellorder.net',
'wss://purplepag.es',
'wss://relay.primal.net'
]
type CacheEntry = {
html: string
expires: number
}
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
const memoryCache = new Map<string, CacheEntry>()
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void {
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
res.setHeader('Content-Type', 'text/html; charset=utf-8')
}
interface ArticleMetadata {
title: string
summary: string
image: string
author: string
published?: number
}
async function fetchEventsFromRelays(
relayPool: RelayPool,
relayUrls: string[],
filter: Filter,
timeoutMs: number
): Promise<NostrEvent[]> {
const events: NostrEvent[] = []
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => resolve(), timeoutMs)
relayPool.req(relayUrls, filter).subscribe({
next: (msg) => {
if (msg.type === 'EVENT') {
events.push(msg.event)
}
},
error: () => resolve(),
complete: () => {
clearTimeout(timeout)
resolve()
}
})
})
// Sort by created_at and return most recent first
return events.sort((a, b) => b.created_at - a.created_at)
}
async function fetchArticleMetadata(naddr: string): Promise<ArticleMetadata | null> {
const relayPool = new RelayPool()
try {
// Decode naddr
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') {
return null
}
const pointer = decoded.data as AddressPointer
// Connect to relays
const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS
relayUrls.forEach(url => relayPool.open(url))
// Fetch article and profile in parallel
const [articleEvents, profileEvents] = await Promise.all([
fetchEventsFromRelays(relayPool, relayUrls, {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier || '']
}, 5000),
fetchEventsFromRelays(relayPool, relayUrls, {
kinds: [0],
authors: [pointer.pubkey]
}, 3000)
])
if (articleEvents.length === 0) {
return null
}
const article = articleEvents[0]
// Extract article metadata
const title = getArticleTitle(article) || 'Untitled Article'
const summary = getArticleSummary(article) || 'Read this article on Boris'
const image = getArticleImage(article) || '/boris-social-1200.png'
// Extract author name from profile
let authorName = pointer.pubkey.slice(0, 8) + '...'
if (profileEvents.length > 0) {
try {
const profileData = JSON.parse(profileEvents[0].content)
authorName = profileData.display_name || profileData.name || authorName
} catch {
// Use fallback
}
}
return {
title,
summary,
image,
author: authorName,
published: article.created_at
}
} catch (err) {
console.error('Failed to fetch article metadata:', err)
return null
} finally {
relayPool.close()
}
}
function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
const baseUrl = 'https://read.withboris.com'
const articleUrl = `${baseUrl}/a/${naddr}`
const title = meta?.title || 'Boris Nostr Bookmarks'
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
const author = meta?.author || 'Boris'
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f172a" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>${escapeHtml(title)}</title>
<meta name="description" content="${escapeHtml(description)}" />
<link rel="canonical" href="${articleUrl}" />
<!-- Open Graph / Social Media -->
<meta property="og:type" content="article" />
<meta property="og:url" content="${articleUrl}" />
<meta property="og:title" content="${escapeHtml(title)}" />
<meta property="og:description" content="${escapeHtml(description)}" />
<meta property="og:image" content="${escapeHtml(image)}" />
<meta property="og:site_name" content="Boris" />
${meta?.published ? `<meta property="article:published_time" content="${new Date(meta.published * 1000).toISOString()}" />` : ''}
<meta property="article:author" content="${escapeHtml(author)}" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="${articleUrl}" />
<meta name="twitter:title" content="${escapeHtml(title)}" />
<meta name="twitter:description" content="${escapeHtml(description)}" />
<meta name="twitter:image" content="${escapeHtml(image)}" />
</head>
<body>
<noscript>
<p>Redirecting to <a href="/">Boris</a>...</p>
</noscript>
</body>
</html>`
}
function isCrawler(userAgent: string | undefined): boolean {
if (!userAgent) return false
const crawlers = [
'bot', 'crawl', 'spider', 'slurp', 'facebook', 'twitter', 'linkedin',
'whatsapp', 'telegram', 'slack', 'discord', 'preview'
]
const ua = userAgent.toLowerCase()
return crawlers.some(crawler => ua.includes(crawler))
}
export default async function handler(req: VercelRequest, res: VercelResponse) {
const naddr = (req.query.naddr as string | undefined)?.trim()
if (!naddr) {
return res.status(400).json({ error: 'Missing naddr parameter' })
}
const userAgent = req.headers['user-agent'] as string | undefined
const isCrawlerRequest = isCrawler(userAgent)
// If it's a regular browser (not a bot), serve HTML that loads SPA
// Use history.replaceState to set the URL before the SPA boots
if (!isCrawlerRequest) {
const articlePath = `/a/${naddr}`
// Serve a minimal HTML that sets up the URL and loads the SPA
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Boris - Loading Article...</title>
<script>
// Set the URL to the article path before SPA loads
if (window.location.pathname !== '${articlePath}') {
history.replaceState(null, '', '${articlePath}');
}
</script>
<script>
// Redirect to index.html which will load the SPA
// The history state is already set, so SPA will see the correct URL
window.location.replace('/');
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>`
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
return res.status(200).send(html)
}
// Check cache for bots/crawlers
const now = Date.now()
const cached = memoryCache.get(naddr)
if (cached && cached.expires > now) {
setCacheHeaders(res)
return res.status(200).send(cached.html)
}
try {
// Fetch metadata
const meta = await fetchArticleMetadata(naddr)
// Generate HTML
const html = generateHtml(naddr, meta)
// Cache the result
memoryCache.set(naddr, { html, expires: now + WEEK_MS })
// Send response
setCacheHeaders(res)
return res.status(200).send(html)
} catch (err) {
console.error('Error generating article OG HTML:', err)
// Fallback to basic HTML with SPA boot
const html = generateHtml(naddr, null)
setCacheHeaders(res, 3600)
return res.status(200).send(html)
}
}

View File

@@ -18,6 +18,7 @@
<meta property="og:url" content="https://read.withboris.com/" />
<meta property="og:title" content="Boris - Nostr Bookmarks" />
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<meta property="og:image" content="https://read.withboris.com/boris-social-1200.png" />
<meta property="og:site_name" content="Boris" />
<!-- Twitter Card -->
@@ -25,6 +26,7 @@
<meta name="twitter:url" content="https://read.withboris.com/" />
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
<!-- Default to system theme until settings load from Nostr -->
<script>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

View File

@@ -0,0 +1,47 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookOpen, faBookmark, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
import { faBooks } from '../icons/customIcons'
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
interface ArchiveFiltersProps {
selectedFilter: ArchiveFilterType
onFilterChange: (filter: ArchiveFilterType) => void
}
const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilterChange }) => {
const filters = [
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
{ type: 'marked' as const, icon: faBooks, label: 'Marked as Read' }
]
return (
<div className="bookmark-filters">
{filters.map(filter => {
const isActive = selectedFilter === filter.type
// Only "completed" gets green color, everything else uses default blue
const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined
return (
<button
key={filter.type}
onClick={() => onFilterChange(filter.type)}
className={`filter-btn ${isActive ? 'active' : ''}`}
title={filter.label}
aria-label={`Filter by ${filter.label}`}
style={activeStyle}
>
<FontAwesomeIcon icon={filter.icon} />
</button>
)
})}
</div>
)
}
export default ArchiveFilters

View File

@@ -19,10 +19,9 @@ interface BookmarkItemProps {
index: number
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
viewMode?: ViewMode
readingProgress?: number // 0-1 reading progress (optional)
}
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', readingProgress }) => {
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
const [ogImage, setOgImage] = useState<string | null>(null)
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
@@ -151,7 +150,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
if (viewMode === 'large') {
const previewImage = articleImage || instantPreview || ogImage
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} readingProgress={readingProgress} />
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
}
return <CardView {...sharedProps} articleImage={articleImage} />

View File

@@ -21,7 +21,6 @@ import { RELAYS } from '../config/relays'
import { Hooks } from 'applesauce-react'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
interface BookmarkListProps {
bookmarks: Bookmark[]
@@ -40,8 +39,6 @@ interface BookmarkListProps {
relayPool: RelayPool | null
isMobile?: boolean
settings?: UserSettings
readingPositions?: Map<string, number>
markedAsReadIds?: Set<string>
}
export const BookmarkList: React.FC<BookmarkListProps> = ({
@@ -60,16 +57,13 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
loading = false,
relayPool,
isMobile = false,
settings,
readingPositions,
markedAsReadIds
settings
}) => {
const navigate = useNavigate()
const bookmarksListRef = useRef<HTMLDivElement>(null)
const friendsColor = settings?.highlightColorFriends || '#f97316'
const [showAddModal, setShowAddModal] = useState(false)
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>('all')
const activeAccount = Hooks.useActiveAccount()
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
@@ -96,42 +90,8 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
// Apply type filter
const typeFilteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
// Apply reading progress filter (only affects kind:30023 articles)
const filteredBookmarks = typeFilteredBookmarks.filter(bookmark => {
// Only apply reading progress filter to kind:30023 articles
if (bookmark.kind !== 30023) return true
// If reading progress filter is 'all', show all articles
if (readingProgressFilter === 'all') return true
const isMarkedAsRead = markedAsReadIds?.has(bookmark.id)
const position = readingPositions?.get(bookmark.id)
// Marked-as-read articles are always treated as 100% complete
if (isMarkedAsRead) {
return readingProgressFilter === 'completed'
}
switch (readingProgressFilter) {
case 'unopened':
// No reading progress - never opened
return !position || position === 0
case 'started':
// 0-10% reading progress - opened but not read far
return position !== undefined && position > 0 && position <= 0.10
case 'reading':
// Has some progress but not completed (11% - 94%)
return position !== undefined && position > 0.10 && position <= 0.94
case 'completed':
// 95% or more read
return position !== undefined && position >= 0.95
default:
return true
}
})
// Apply filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
// Separate bookmarks with setName (kind 30003) from regular bookmarks
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
@@ -244,7 +204,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
index={index}
onSelectUrl={onSelectUrl}
viewMode={viewMode}
readingProgress={markedAsReadIds?.has(individualBookmark.id) ? 1.0 : readingPositions?.get(individualBookmark.id)}
/>
))}
</div>
@@ -252,17 +211,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
))}
</div>
)}
{/* Reading progress filters - only show if there are kind:30023 articles */}
{typeFilteredBookmarks.some(b => b.kind === 30023) && (
<div className="reading-progress-filters-wrapper">
<ReadingProgressFilters
selectedFilter={readingProgressFilter}
onFilterChange={setReadingProgressFilter}
/>
</div>
)}
<div className="view-mode-controls">
<div className="view-mode-left">
<IconButton

View File

@@ -162,9 +162,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
isRefreshing,
lastFetchTime,
handleFetchHighlights,
handleRefreshAll,
readingPositions,
markedAsReadIds
handleRefreshAll
} = useBookmarksData({
relayPool,
activeAccount,
@@ -173,8 +171,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
externalUrl,
currentArticleCoordinate,
currentArticleEventId,
settings,
eventStore
settings
})
const {
@@ -316,8 +313,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
highlightButtonRef={highlightButtonRef}
onCreateHighlight={handleCreateHighlight}
hasActiveAccount={!!(activeAccount && relayPool)}
readingPositions={readingPositions}
markedAsReadIds={markedAsReadIds}
explore={showExplore ? (
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
) : undefined}

View File

@@ -187,77 +187,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
enabled: isTextContent,
syncEnabled: settings?.syncReadingPosition,
onSave: handleSavePosition
onSave: handleSavePosition,
onReadingComplete: () => {
// Optional: Auto-mark as read when reading is complete
if (activeAccount && !isMarkedAsRead) {
// Could trigger auto-mark as read here if desired
}
}
})
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
// Define handleMarkAsRead with useCallback to use in auto-mark effect
const handleMarkAsRead = useCallback(() => {
if (!activeAccount || !relayPool || isMarkedAsRead) {
return
}
// Instantly update UI with checkmark animation
setIsMarkedAsRead(true)
setShowCheckAnimation(true)
// Reset animation after it completes (2.5s for full fancy animation)
setTimeout(() => {
setShowCheckAnimation(false)
}, 2500)
// Fire-and-forget: publish in background without blocking UI
;(async () => {
try {
if (isNostrArticle && currentArticle) {
await createEventReaction(
currentArticle.id,
currentArticle.pubkey,
currentArticle.kind,
activeAccount,
relayPool
)
console.log('✅ Marked nostr article as read')
} else if (selectedUrl) {
await createWebsiteReaction(
selectedUrl,
activeAccount,
relayPool
)
console.log('✅ Marked website as read')
}
} catch (error) {
console.error('Failed to mark as read:', error)
// Revert UI state on error
setIsMarkedAsRead(false)
}
})()
}, [activeAccount, relayPool, isMarkedAsRead, isNostrArticle, currentArticle, selectedUrl])
// Auto-mark as read when reaching 100% for 2 seconds
useEffect(() => {
if (!settings?.autoMarkAsReadAt100 || isMarkedAsRead || !activeAccount || !relayPool) {
return
}
// Only trigger when progress is exactly 100%
if (progressPercentage === 100) {
console.log('📍 [ContentPanel] Progress at 100%, starting 2-second timer for auto-mark')
const timer = setTimeout(() => {
console.log('✅ [ContentPanel] Auto-marking as read after 2 seconds at 100%')
handleMarkAsRead()
}, 2000)
return () => {
console.log('⏹️ [ContentPanel] Canceling auto-mark timer (progress changed or unmounting)')
clearTimeout(timer)
}
}
}, [progressPercentage, settings?.autoMarkAsReadAt100, isMarkedAsRead, activeAccount, relayPool, handleMarkAsRead])
// Load saved reading position when article loads
useEffect(() => {
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
@@ -288,25 +226,19 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
console.log('🎯 [ContentPanel] Restoring position:', Math.round(savedPosition.position * 100) + '%')
// Only auto-scroll if the setting is enabled (default: true)
if (settings?.autoScrollToPosition !== false) {
// Wait for content to be fully rendered before scrolling
setTimeout(() => {
const documentHeight = document.documentElement.scrollHeight
const windowHeight = window.innerHeight
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
window.scrollTo({
top: scrollTop,
behavior: 'smooth'
})
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
}, 500) // Give content time to render
} else {
console.log('⏭️ [ContentPanel] Auto-scroll disabled in settings')
}
// Wait for content to be fully rendered before scrolling
setTimeout(() => {
const documentHeight = document.documentElement.scrollHeight
const windowHeight = window.innerHeight
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
window.scrollTo({
top: scrollTop,
behavior: 'smooth'
})
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
}, 500) // Give content time to render
} else if (savedPosition) {
if (savedPosition.position === 1) {
console.log('✅ [ContentPanel] Article completed (100%), starting from top')
@@ -320,7 +252,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}
loadPosition()
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, settings?.autoScrollToPosition, selectedUrl])
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
// Save position before unmounting or changing article
useEffect(() => {
@@ -392,6 +324,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const hasHighlights = relevantHighlights.length > 0
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type)
// Track external video duration (in seconds) for display in header
@@ -660,6 +594,48 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
checkReadStatus()
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
const handleMarkAsRead = () => {
if (!activeAccount || !relayPool || isMarkedAsRead) {
return
}
// Instantly update UI with checkmark animation
setIsMarkedAsRead(true)
setShowCheckAnimation(true)
// Reset animation after it completes
setTimeout(() => {
setShowCheckAnimation(false)
}, 600)
// Fire-and-forget: publish in background without blocking UI
;(async () => {
try {
if (isNostrArticle && currentArticle) {
await createEventReaction(
currentArticle.id,
currentArticle.pubkey,
currentArticle.kind,
activeAccount,
relayPool
)
console.log('✅ Marked nostr article as read')
} else if (selectedUrl) {
await createWebsiteReaction(
selectedUrl,
activeAccount,
relayPool
)
console.log('✅ Marked website as read')
}
} catch (error) {
console.error('Failed to mark as read:', error)
// Revert UI state on error
setIsMarkedAsRead(false)
}
})()
}
if (!selectedUrl) {
return (

View File

@@ -22,8 +22,6 @@ import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { classifyHighlights } from '../utils/highlightClassification'
import { HighlightVisibility } from './HighlightsPanel'
import { loadReadingPosition, generateArticleIdentifier } from '../services/readingPositionService'
import { fetchReadArticles } from '../services/libraryService'
interface ExploreProps {
relayPool: RelayPool
@@ -43,8 +41,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [refreshTrigger, setRefreshTrigger] = useState(0)
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
const [markedAsReadIds, setMarkedAsReadIds] = useState<Set<string>>(new Set())
// Visibility filters (defaults from settings, or friends only)
const [visibility, setVisibility] = useState<HighlightVisibility>({
@@ -217,88 +213,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
loadData()
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
// Fetch marked-as-read articles
useEffect(() => {
const loadMarkedAsRead = async () => {
if (!activeAccount || !eventStore) {
return
}
try {
const readArticles = await fetchReadArticles(relayPool, activeAccount.pubkey)
// Create a set of article IDs that are marked as read
const markedArticleIds = new Set<string>()
// For each read article, add both event ID and coordinate format
for (const readArticle of readArticles) {
// Add the event ID directly
markedArticleIds.add(readArticle.id)
// For nostr-native articles (kind:7 reactions), also add the coordinate format
if (readArticle.eventId && readArticle.eventAuthor && readArticle.eventKind) {
// Try to get the event from the eventStore to find the 'd' tag
const event = eventStore.getEvent(readArticle.eventId)
if (event) {
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
markedArticleIds.add(coordinate)
}
}
}
setMarkedAsReadIds(markedArticleIds)
} catch (error) {
console.warn('⚠️ [Explore] Failed to load marked-as-read articles:', error)
}
}
loadMarkedAsRead()
}, [relayPool, activeAccount, eventStore])
// Load reading positions for blog posts
useEffect(() => {
const loadPositions = async () => {
if (!activeAccount || !eventStore || blogPosts.length === 0 || !settings?.syncReadingPosition) {
return
}
const positions = new Map<string, number>()
await Promise.all(
blogPosts.map(async (post) => {
try {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: post.author,
identifier: dTag
})
const articleUrl = `nostr:${naddr}`
const identifier = generateArticleIdentifier(articleUrl)
const savedPosition = await loadReadingPosition(
relayPool,
eventStore,
activeAccount.pubkey,
identifier
)
if (savedPosition && savedPosition.position > 0) {
positions.set(post.event.id, savedPosition.position)
}
} catch (error) {
console.warn('⚠️ [Explore] Failed to load reading position for post:', error)
}
})
)
setReadingPositions(positions)
}
loadPositions()
}, [blogPosts, activeAccount, relayPool, eventStore, settings?.syncReadingPosition])
// Pull-to-refresh
const { isRefreshing, pullPosition } = usePullToRefresh({
onRefresh: () => {
@@ -388,7 +302,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
post={post}
href={getPostUrl(post)}
level={post.level}
readingProgress={markedAsReadIds.has(post.event.id) ? 1.0 : readingPositions.get(post.event.id)}
/>
))}
</div>

View File

@@ -117,32 +117,6 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
<span>Sync reading position across devices</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="autoScrollToPosition" className="checkbox-label">
<input
id="autoScrollToPosition"
type="checkbox"
checked={settings.autoScrollToPosition !== false}
onChange={(e) => onUpdate({ autoScrollToPosition: e.target.checked })}
className="setting-checkbox"
/>
<span>Auto-scroll to last reading position</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="autoMarkAsReadAt100" className="checkbox-label">
<input
id="autoMarkAsReadAt100"
type="checkbox"
checked={settings.autoMarkAsReadAt100 ?? false}
onChange={(e) => onUpdate({ autoMarkAsReadAt100: e.target.checked })}
className="setting-checkbox"
/>
<span>Automatically mark as read when reading progress is 100%</span>
</label>
</div>
</div>
)
}

View File

@@ -47,8 +47,6 @@ interface ThreePaneLayoutProps {
onRefresh: () => void
relayPool: RelayPool | null
eventStore: IEventStore | null
readingPositions?: Map<string, number>
markedAsReadIds?: Set<string>
// Content pane
readerLoading: boolean
@@ -326,8 +324,6 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
loading={props.bookmarksLoading}
relayPool={props.relayPool}
isMobile={isMobile}
readingPositions={props.readingPositions}
markedAsReadIds={props.markedAsReadIds}
settings={props.settings}
/>
</div>

View File

@@ -1,16 +1,12 @@
import { useState, useEffect, useCallback } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IAccount, AccountManager } from 'applesauce-accounts'
import { IEventStore } from 'applesauce-core'
import { Bookmark } from '../types/bookmarks'
import { Highlight } from '../types/highlights'
import { fetchBookmarks } from '../services/bookmarkService'
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
import { fetchContacts } from '../services/contactService'
import { UserSettings } from '../services/settingsService'
import { loadReadingPosition, generateArticleIdentifier } from '../services/readingPositionService'
import { fetchReadArticles } from '../services/libraryService'
import { nip19 } from 'nostr-tools'
interface UseBookmarksDataParams {
relayPool: RelayPool | null
@@ -21,7 +17,6 @@ interface UseBookmarksDataParams {
currentArticleCoordinate?: string
currentArticleEventId?: string
settings?: UserSettings
eventStore?: IEventStore
}
export const useBookmarksData = ({
@@ -32,8 +27,7 @@ export const useBookmarksData = ({
externalUrl,
currentArticleCoordinate,
currentArticleEventId,
settings,
eventStore
settings
}: UseBookmarksDataParams) => {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [bookmarksLoading, setBookmarksLoading] = useState(true)
@@ -42,8 +36,6 @@ export const useBookmarksData = ({
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [isRefreshing, setIsRefreshing] = useState(false)
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
const [markedAsReadIds, setMarkedAsReadIds] = useState<Set<string>>(new Set())
const handleFetchContacts = useCallback(async () => {
if (!relayPool || !activeAccount) return
@@ -133,93 +125,6 @@ export const useBookmarksData = ({
handleFetchContacts()
}, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts])
// Fetch marked-as-read articles
useEffect(() => {
const loadMarkedAsRead = async () => {
if (!activeAccount || !relayPool || !eventStore || bookmarks.length === 0) {
return
}
try {
const readArticles = await fetchReadArticles(relayPool, activeAccount.pubkey)
// Create a set of bookmark IDs that are marked as read
const markedBookmarkIds = new Set<string>()
// For each read article, we need to match it to bookmark IDs
for (const readArticle of readArticles) {
// Add the event ID directly (for web bookmarks and legacy compatibility)
markedBookmarkIds.add(readArticle.id)
// For nostr-native articles (kind:7 reactions), also add the coordinate format
if (readArticle.eventId && readArticle.eventAuthor && readArticle.eventKind) {
// Try to get the event from the eventStore to find the 'd' tag
const event = eventStore.getEvent(readArticle.eventId)
if (event) {
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
markedBookmarkIds.add(coordinate)
}
}
}
setMarkedAsReadIds(markedBookmarkIds)
} catch (error) {
console.warn('⚠️ [Bookmarks] Failed to load marked-as-read articles:', error)
}
}
loadMarkedAsRead()
}, [relayPool, activeAccount, eventStore, bookmarks])
// Load reading positions for bookmarked articles (kind:30023)
useEffect(() => {
const loadPositions = async () => {
if (!activeAccount || !relayPool || !eventStore || bookmarks.length === 0 || !settings?.syncReadingPosition) {
return
}
const positions = new Map<string, number>()
// Extract all kind:30023 articles from bookmarks
const articles = bookmarks.flatMap(bookmark =>
(bookmark.individualBookmarks || []).filter(item => item.kind === 30023)
)
await Promise.all(
articles.map(async (article) => {
try {
const dTag = article.tags.find(t => t[0] === 'd')?.[1] || ''
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: article.pubkey,
identifier: dTag
})
const articleUrl = `nostr:${naddr}`
const identifier = generateArticleIdentifier(articleUrl)
const savedPosition = await loadReadingPosition(
relayPool,
eventStore,
activeAccount.pubkey,
identifier
)
if (savedPosition && savedPosition.position > 0) {
positions.set(article.id, savedPosition.position)
}
} catch (error) {
console.warn('⚠️ [Bookmarks] Failed to load reading position for article:', error)
}
})
)
setReadingPositions(positions)
}
loadPositions()
}, [bookmarks, activeAccount, relayPool, eventStore, settings?.syncReadingPosition])
return {
bookmarks,
bookmarksLoading,
@@ -232,9 +137,7 @@ export const useBookmarksData = ({
lastFetchTime,
handleFetchBookmarks,
handleFetchHighlights,
handleRefreshAll,
readingPositions,
markedAsReadIds
handleRefreshAll
}
}

View File

@@ -1,12 +1,14 @@
import { Highlight } from '../types/highlights'
import { Bookmark } from '../types/bookmarks'
import { BlogPostPreview } from './exploreService'
import { ReadItem } from './readsService'
export interface MeCache {
highlights: Highlight[]
bookmarks: Bookmark[]
reads: ReadItem[]
links: ReadItem[]
readArticles: BlogPostPreview[]
reads?: ReadItem[]
links?: ReadItem[]
timestamp: number
}
@@ -22,14 +24,12 @@ export function setCachedMeData(
pubkey: string,
highlights: Highlight[],
bookmarks: Bookmark[],
reads: ReadItem[],
links: ReadItem[] = []
readArticles: BlogPostPreview[]
): void {
meCache.set(pubkey, {
highlights,
bookmarks,
reads,
links,
readArticles,
timestamp: Date.now()
})
}
@@ -48,10 +48,10 @@ export function updateCachedBookmarks(pubkey: string, bookmarks: Bookmark[]): vo
}
}
export function updateCachedReads(pubkey: string, reads: ReadItem[]): void {
export function updateCachedReadArticles(pubkey: string, readArticles: BlogPostPreview[]): void {
const existing = meCache.get(pubkey)
if (existing) {
meCache.set(pubkey, { ...existing, reads, timestamp: Date.now() })
meCache.set(pubkey, { ...existing, readArticles, timestamp: Date.now() })
}
}

View File

@@ -56,8 +56,6 @@ export interface UserSettings {
paragraphAlignment?: 'left' | 'justify' // default: justify
// Reading position sync
syncReadingPosition?: boolean // default: false (opt-in)
autoScrollToPosition?: boolean // default: true (auto-scroll to last reading position)
autoMarkAsReadAt100?: boolean // default: false (auto-mark as read when reaching 100% for 2 seconds)
}
export async function loadSettings(

View File

@@ -216,72 +216,7 @@
.mark-as-read-btn:hover:not(:disabled) { background: var(--color-border); border-color: var(--color-text-muted); transform: translateY(-1px); }
.mark-as-read-btn:active:not(:disabled) { transform: translateY(0); }
.mark-as-read-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.mark-as-read-btn svg { font-size: 1.1rem; transition: transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); }
/* Fancy Mark as Read animation */
@keyframes markAsReadSuccess {
0% {
background: var(--color-bg-elevated);
border-color: var(--color-border-subtle);
transform: scale(1);
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
10% {
transform: scale(1.05);
box-shadow: 0 0 0 8px rgba(16, 185, 129, 0.3);
}
25% {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border-color: #10b981;
color: white;
transform: scale(1.02);
box-shadow: 0 4px 20px rgba(16, 185, 129, 0.4);
}
65% {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border-color: #10b981;
color: white;
transform: scale(1.02);
box-shadow: 0 4px 20px rgba(16, 185, 129, 0.4);
}
100% {
background: #6b7280;
border-color: #6b7280;
color: white;
transform: scale(1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
}
@keyframes iconSpin {
0% {
transform: rotate(0deg) scale(1);
}
15% {
transform: rotate(0deg) scale(1.2);
}
50% {
transform: rotate(360deg) scale(1.2);
}
100% {
transform: rotate(360deg) scale(1);
}
}
.mark-as-read-btn.animating {
animation: markAsReadSuccess 2.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
pointer-events: none;
}
.mark-as-read-btn.animating svg {
animation: iconSpin 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.mark-as-read-btn.marked {
background: #6b7280;
border-color: #6b7280;
color: white;
}
.mark-as-read-btn svg { font-size: 1.1rem; }
@media (max-width: 768px) {
.reader {
max-width: 100%;

View File

@@ -211,12 +211,3 @@
background: transparent;
}
/* Reading progress filters in bookmarks sidebar - add top border, remove bottom border to avoid double border with view-mode-controls */
.reading-progress-filters-wrapper {
border-top: 1px solid var(--color-border);
}
.reading-progress-filters-wrapper .bookmark-filters {
border-bottom: none;
}

View File

@@ -1,5 +1,9 @@
{
"rewrites": [
{
"source": "/a/:naddr",
"destination": "/api/article-og?naddr=:naddr"
},
{
"source": "/(.*)",
"destination": "/index.html"