mirror of
https://github.com/dergigi/boris.git
synced 2026-01-24 09:14:39 +01:00
- Add 'unopened' filter (no progress, 0%) - uses fa-envelope icon - Add 'started' filter (0-10% progress) - uses fa-envelope-open icon - Remove 'to-read' filter - Use classic/regular variant for envelope icons - Update filter logic in BookmarkList and Me components - New filter ranges: - Unopened: 0% (never opened) - Started: 0-10% (opened but not read far) - Reading: 11-94% - Completed: 95-100%
321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
import React, { useRef, useState } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus } from '@fortawesome/free-solid-svg-icons'
|
|
import { formatDistanceToNow } from 'date-fns'
|
|
import { RelayPool } from 'applesauce-relay'
|
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
|
import { BookmarkItem } from './BookmarkItem'
|
|
import SidebarHeader from './SidebarHeader'
|
|
import IconButton from './IconButton'
|
|
import CompactButton from './CompactButton'
|
|
import { ViewMode } from './Bookmarks'
|
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
|
import RefreshIndicator from './RefreshIndicator'
|
|
import { BookmarkSkeleton } from './Skeletons'
|
|
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet } from '../utils/bookmarkUtils'
|
|
import { UserSettings } from '../services/settingsService'
|
|
import AddBookmarkModal from './AddBookmarkModal'
|
|
import { createWebBookmark } from '../services/webBookmarkService'
|
|
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[]
|
|
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
|
isCollapsed: boolean
|
|
onToggleCollapse: () => void
|
|
onLogout: () => void
|
|
viewMode: ViewMode
|
|
onViewModeChange: (mode: ViewMode) => void
|
|
selectedUrl?: string
|
|
onOpenSettings: () => void
|
|
onRefresh?: () => void
|
|
isRefreshing?: boolean
|
|
lastFetchTime?: number | null
|
|
loading?: boolean
|
|
relayPool: RelayPool | null
|
|
isMobile?: boolean
|
|
settings?: UserSettings
|
|
readingPositions?: Map<string, number>
|
|
markedAsReadIds?: Set<string>
|
|
}
|
|
|
|
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|
bookmarks,
|
|
onSelectUrl,
|
|
isCollapsed,
|
|
onToggleCollapse,
|
|
onLogout,
|
|
viewMode,
|
|
onViewModeChange,
|
|
selectedUrl,
|
|
onOpenSettings,
|
|
onRefresh,
|
|
isRefreshing,
|
|
lastFetchTime,
|
|
loading = false,
|
|
relayPool,
|
|
isMobile = false,
|
|
settings,
|
|
readingPositions,
|
|
markedAsReadIds
|
|
}) => {
|
|
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[]) => {
|
|
if (!activeAccount || !relayPool) {
|
|
throw new Error('Please login to create bookmarks')
|
|
}
|
|
|
|
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
|
}
|
|
|
|
// Pull-to-refresh for bookmarks
|
|
const { isRefreshing: isPulling, pullPosition } = usePullToRefresh({
|
|
onRefresh: () => {
|
|
if (onRefresh) {
|
|
onRefresh()
|
|
}
|
|
},
|
|
maximumPullLength: 240,
|
|
refreshThreshold: 80,
|
|
isDisabled: !onRefresh
|
|
})
|
|
|
|
// Merge and flatten all individual bookmarks from all lists
|
|
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
|
|
}
|
|
})
|
|
|
|
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
|
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
|
const bookmarkSets = getBookmarkSets(filteredBookmarks)
|
|
|
|
// Group non-set bookmarks as before
|
|
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
|
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
|
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
|
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
|
{ key: 'web', title: 'Web Bookmarks', items: groups.web },
|
|
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
|
|
]
|
|
|
|
// Add bookmark sets as additional sections
|
|
bookmarkSets.forEach(set => {
|
|
sections.push({
|
|
key: `set-${set.name}`,
|
|
title: set.title || set.name,
|
|
items: set.bookmarks
|
|
})
|
|
})
|
|
|
|
if (isCollapsed) {
|
|
// Check if the selected URL is in bookmarks
|
|
const isBookmarked = selectedUrl && bookmarks.some(bookmark => {
|
|
const bookmarkUrl = bookmark.url
|
|
return bookmarkUrl === selectedUrl || selectedUrl.includes(bookmarkUrl) || bookmarkUrl.includes(selectedUrl)
|
|
})
|
|
|
|
return (
|
|
<div className="bookmarks-container collapsed">
|
|
<button
|
|
onClick={onToggleCollapse}
|
|
className={`toggle-sidebar-btn with-icon ${isBookmarked ? 'is-bookmarked' : ''}`}
|
|
title="Expand bookmarks sidebar"
|
|
aria-label="Expand bookmarks sidebar"
|
|
>
|
|
<FontAwesomeIcon icon={faChevronLeft} />
|
|
<FontAwesomeIcon icon={faBookmark} className={isBookmarked ? 'glow-blue' : ''} />
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="bookmarks-container">
|
|
<SidebarHeader
|
|
onToggleCollapse={onToggleCollapse}
|
|
onLogout={onLogout}
|
|
onOpenSettings={onOpenSettings}
|
|
isMobile={isMobile}
|
|
/>
|
|
|
|
{allIndividualBookmarks.length > 0 && (
|
|
<BookmarkFilters
|
|
selectedFilter={selectedFilter}
|
|
onFilterChange={setSelectedFilter}
|
|
/>
|
|
)}
|
|
|
|
{filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
|
|
<div className="empty-state">
|
|
<p>No bookmarks match this filter.</p>
|
|
</div>
|
|
) : allIndividualBookmarks.length === 0 ? (
|
|
loading ? (
|
|
<div className={`bookmarks-list ${viewMode}`} aria-busy="true">
|
|
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
|
{Array.from({ length: viewMode === 'large' ? 4 : viewMode === 'cards' ? 6 : 8 }).map((_, i) => (
|
|
<BookmarkSkeleton key={i} viewMode={viewMode} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="empty-state">
|
|
<p>No bookmarks found.</p>
|
|
<p>Add bookmarks using your nostr client to see them here.</p>
|
|
<p>If you aren't on nostr yet, start here: <a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">nstart.me</a></p>
|
|
</div>
|
|
)
|
|
) : (
|
|
<div
|
|
ref={bookmarksListRef}
|
|
className="bookmarks-list"
|
|
>
|
|
<RefreshIndicator
|
|
isRefreshing={isPulling || isRefreshing || false}
|
|
pullPosition={pullPosition}
|
|
/>
|
|
{sections.filter(s => s.items.length > 0).map(section => (
|
|
<div key={section.key} className="bookmarks-section">
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<h3 className="bookmarks-section-title" style={{ margin: 0, padding: '1.5rem 0.5rem 0.375rem', flex: 1 }}>{section.title}</h3>
|
|
{section.key === 'web' && activeAccount && (
|
|
<CompactButton
|
|
icon={faPlus}
|
|
onClick={() => setShowAddModal(true)}
|
|
title="Add web bookmark"
|
|
ariaLabel="Add web bookmark"
|
|
className="bookmark-section-action"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
|
{section.items.map((individualBookmark, index) => (
|
|
<BookmarkItem
|
|
key={`${section.key}-${individualBookmark.id}-${index}`}
|
|
bookmark={individualBookmark}
|
|
index={index}
|
|
onSelectUrl={onSelectUrl}
|
|
viewMode={viewMode}
|
|
readingProgress={markedAsReadIds?.has(individualBookmark.id) ? 1.0 : readingPositions?.get(individualBookmark.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</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
|
|
icon={faHeart}
|
|
onClick={() => navigate('/support')}
|
|
title="Support Boris"
|
|
ariaLabel="Support"
|
|
variant="ghost"
|
|
style={{ color: friendsColor }}
|
|
/>
|
|
</div>
|
|
<div className="view-mode-right">
|
|
{onRefresh && (
|
|
<IconButton
|
|
icon={faRotate}
|
|
onClick={onRefresh}
|
|
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
|
ariaLabel="Refresh bookmarks"
|
|
variant="ghost"
|
|
disabled={isRefreshing}
|
|
spin={isRefreshing}
|
|
/>
|
|
)}
|
|
<IconButton
|
|
icon={faList}
|
|
onClick={() => onViewModeChange('compact')}
|
|
title="Compact list view"
|
|
ariaLabel="Compact list view"
|
|
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
|
/>
|
|
<IconButton
|
|
icon={faThLarge}
|
|
onClick={() => onViewModeChange('cards')}
|
|
title="Cards view"
|
|
ariaLabel="Cards view"
|
|
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
|
/>
|
|
<IconButton
|
|
icon={faImage}
|
|
onClick={() => onViewModeChange('large')}
|
|
title="Large preview view"
|
|
ariaLabel="Large preview view"
|
|
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{showAddModal && (
|
|
<AddBookmarkModal
|
|
onClose={() => setShowAddModal(false)}
|
|
onSave={handleSaveBookmark}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|