mirror of
https://github.com/dergigi/boris.git
synced 2025-12-18 15:14:20 +01:00
feat: add visual reading progress indicator to archive cards
- Display reading position as a horizontal progress bar at bottom of blog post cards - Use blue (#6366f1) for progress <95%, green (#10b981) for >=95% complete - Load reading positions for all articles in Archive tab - Progress bar fills from left to right showing how much has been read - Only shown when reading progress exists and is >0% - Smooth transition animations on progress updates
This commit is contained in:
@@ -11,9 +11,10 @@ interface BlogPostCardProps {
|
|||||||
post: BlogPostPreview
|
post: BlogPostPreview
|
||||||
href: string
|
href: string
|
||||||
level?: 'mine' | 'friends' | 'nostrverse'
|
level?: 'mine' | 'friends' | 'nostrverse'
|
||||||
|
readingProgress?: number // 0-1 reading progress (optional)
|
||||||
}
|
}
|
||||||
|
|
||||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress }) => {
|
||||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||||
const displayName = profile?.name || profile?.display_name ||
|
const displayName = profile?.name || profile?.display_name ||
|
||||||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
||||||
@@ -23,11 +24,15 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
|||||||
addSuffix: true
|
addSuffix: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Calculate progress percentage and determine color
|
||||||
|
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
||||||
|
const progressColor = progressPercent >= 95 ? '#10b981' : '#6366f1' // green if >=95%, blue otherwise
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={href}
|
to={href}
|
||||||
className={`blog-post-card ${level ? `level-${level}` : ''}`}
|
className={`blog-post-card ${level ? `level-${level}` : ''}`}
|
||||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
style={{ textDecoration: 'none', color: 'inherit', position: 'relative' }}
|
||||||
>
|
>
|
||||||
<div className="blog-post-card-image">
|
<div className="blog-post-card-image">
|
||||||
{post.image ? (
|
{post.image ? (
|
||||||
@@ -58,6 +63,31 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reading progress indicator */}
|
||||||
|
{readingProgress !== undefined && readingProgress > 0 && (
|
||||||
|
<div
|
||||||
|
className="blog-post-reading-progress"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
height: '4px',
|
||||||
|
width: '100%',
|
||||||
|
background: 'var(--color-border)',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${progressPercent}%`,
|
||||||
|
background: progressColor,
|
||||||
|
transition: 'width 0.3s ease, background 0.3s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import RefreshIndicator from './RefreshIndicator'
|
|||||||
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
||||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
|
import { generateArticleIdentifier, loadReadingPosition } from '../services/readingPositionService'
|
||||||
|
|
||||||
interface MeProps {
|
interface MeProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
@@ -37,6 +38,7 @@ type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
|
|||||||
|
|
||||||
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const eventStore = Hooks.useEventStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||||
|
|
||||||
@@ -51,6 +53,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
||||||
|
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
|
||||||
|
|
||||||
// Update local state when prop changes
|
// Update local state when prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,6 +125,50 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
loadData()
|
loadData()
|
||||||
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
|
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
|
||||||
|
|
||||||
|
// Load reading positions for read articles (only for own profile)
|
||||||
|
useEffect(() => {
|
||||||
|
const loadPositions = async () => {
|
||||||
|
if (!isOwnProfile || !activeAccount || !relayPool || !eventStore || readArticles.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions = new Map<string, number>()
|
||||||
|
|
||||||
|
// Load positions for all read articles
|
||||||
|
await Promise.all(
|
||||||
|
readArticles.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('Failed to load reading position for article:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
setReadingPositions(positions)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPositions()
|
||||||
|
}, [readArticles, isOwnProfile, activeAccount, relayPool, eventStore])
|
||||||
|
|
||||||
// Pull-to-refresh
|
// Pull-to-refresh
|
||||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
onRefresh: () => {
|
onRefresh: () => {
|
||||||
@@ -319,6 +366,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
key={post.event.id}
|
key={post.event.id}
|
||||||
post={post}
|
post={post}
|
||||||
href={getPostUrl(post)}
|
href={getPostUrl(post)}
|
||||||
|
readingProgress={readingPositions.get(post.event.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user