mirror of
https://github.com/dergigi/boris.git
synced 2025-12-19 07:34:28 +01:00
feat: add reading position tracking with visual progress indicator
- Install position-indicator library for scroll position tracking - Create useReadingPosition hook for position management - Add ReadingProgressIndicator component with animated progress bar - Integrate reading progress in ContentPanel for text content only - Add CSS styles for fixed progress indicator with shimmer animation - Track reading completion at 90% threshold - Exclude video content from position tracking
This commit is contained in:
11
package-lock.json
generated
11
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.5.6",
|
"version": "0.5.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.5.6",
|
"version": "0.5.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"applesauce-relay": "^4.0.0",
|
"applesauce-relay": "^4.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"nostr-tools": "^2.4.0",
|
"nostr-tools": "^2.4.0",
|
||||||
|
"position-indicator": "^0.0.12",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -8972,6 +8973,12 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/position-indicator": {
|
||||||
|
"version": "0.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/position-indicator/-/position-indicator-0.0.12.tgz",
|
||||||
|
"integrity": "sha512-qHQejEylblB7rZ3MfXSI5hu1+Dq7EBv1BYwUIVWzJ3nZ8d6V7LFBi1zC5/XwT/01Wxddf9kaFoOy3L70/5tC+A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"applesauce-relay": "^4.0.0",
|
"applesauce-relay": "^4.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"nostr-tools": "^2.4.0",
|
"nostr-tools": "^2.4.0",
|
||||||
|
"position-indicator": "^0.0.12",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import { faBooks } from '../icons/customIcons'
|
|||||||
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
||||||
import { classifyUrl } from '../utils/helpers'
|
import { classifyUrl } from '../utils/helpers'
|
||||||
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||||
|
import { useReadingPosition } from '../hooks/useReadingPosition'
|
||||||
|
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
||||||
|
|
||||||
interface ContentPanelProps {
|
interface ContentPanelProps {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -115,6 +117,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
onClearSelection
|
onClearSelection
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Reading position tracking - only for text content, not videos
|
||||||
|
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
|
||||||
|
const { isReadingComplete, progressPercentage } = useReadingPosition({
|
||||||
|
enabled: isTextContent,
|
||||||
|
onReadingComplete: () => {
|
||||||
|
// Optional: Auto-mark as read when reading is complete
|
||||||
|
if (activeAccount && !isMarkedAsRead) {
|
||||||
|
// Could trigger auto-mark as read here if desired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Close menu when clicking outside
|
// Close menu when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
@@ -361,6 +375,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
||||||
|
{/* Reading Progress Indicator */}
|
||||||
|
{isTextContent && (
|
||||||
|
<ReadingProgressIndicator
|
||||||
|
progress={progressPercentage}
|
||||||
|
isComplete={isReadingComplete}
|
||||||
|
showPercentage={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Hidden markdown preview to convert markdown to HTML */}
|
{/* Hidden markdown preview to convert markdown to HTML */}
|
||||||
{markdown && (
|
{markdown && (
|
||||||
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
||||||
|
|||||||
31
src/components/ReadingProgressIndicator.tsx
Normal file
31
src/components/ReadingProgressIndicator.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface ReadingProgressIndicatorProps {
|
||||||
|
progress: number // 0 to 100
|
||||||
|
isComplete?: boolean
|
||||||
|
showPercentage?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> = ({
|
||||||
|
progress,
|
||||||
|
isComplete = false,
|
||||||
|
showPercentage = true,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={`reading-progress-indicator ${className}`}>
|
||||||
|
<div className="reading-progress-bar">
|
||||||
|
<div
|
||||||
|
className={`reading-progress-fill ${isComplete ? 'complete' : ''}`}
|
||||||
|
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{showPercentage && (
|
||||||
|
<div className="reading-progress-text">
|
||||||
|
{isComplete ? '✓ Complete' : `${progress}%`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
src/hooks/useReadingPosition.ts
Normal file
83
src/hooks/useReadingPosition.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
// @ts-ignore - position-indicator types issue
|
||||||
|
import { createPositionIndicator } from 'position-indicator'
|
||||||
|
|
||||||
|
interface ReadingPositionData {
|
||||||
|
position: number // 0 to 1
|
||||||
|
prevPosition: number
|
||||||
|
hasUpdated: boolean
|
||||||
|
hasScroll: boolean
|
||||||
|
eventType: 'scroll' | 'resize' | 'heightChange' | 'init'
|
||||||
|
eventDate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseReadingPositionOptions {
|
||||||
|
enabled?: boolean
|
||||||
|
onPositionChange?: (data: ReadingPositionData) => void
|
||||||
|
onReadingComplete?: () => void
|
||||||
|
readingCompleteThreshold?: number // Default 0.9 (90%)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useReadingPosition = ({
|
||||||
|
enabled = true,
|
||||||
|
onPositionChange,
|
||||||
|
onReadingComplete,
|
||||||
|
readingCompleteThreshold = 0.9
|
||||||
|
}: UseReadingPositionOptions = {}) => {
|
||||||
|
const [position, setPosition] = useState(0)
|
||||||
|
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
||||||
|
const positionIndicatorRef = useRef<any>(null)
|
||||||
|
const hasTriggeredComplete = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
const handleInit = (data: ReadingPositionData) => {
|
||||||
|
setPosition(data.position)
|
||||||
|
onPositionChange?.(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = (data: ReadingPositionData) => {
|
||||||
|
setPosition(data.position)
|
||||||
|
onPositionChange?.(data)
|
||||||
|
|
||||||
|
// Check if reading is complete
|
||||||
|
if (data.position >= readingCompleteThreshold && !hasTriggeredComplete.current) {
|
||||||
|
setIsReadingComplete(true)
|
||||||
|
hasTriggeredComplete.current = true
|
||||||
|
onReadingComplete?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const positionIndicator = createPositionIndicator({
|
||||||
|
onInit: handleInit,
|
||||||
|
onUpdate: handleUpdate,
|
||||||
|
useResizeListener: true,
|
||||||
|
useResizeObserver: true
|
||||||
|
})
|
||||||
|
|
||||||
|
positionIndicator.init()
|
||||||
|
positionIndicatorRef.current = positionIndicator
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (positionIndicatorRef.current) {
|
||||||
|
positionIndicatorRef.current.destroy()
|
||||||
|
positionIndicatorRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold])
|
||||||
|
|
||||||
|
// Reset reading complete state when enabled changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setIsReadingComplete(false)
|
||||||
|
hasTriggeredComplete.current = false
|
||||||
|
}
|
||||||
|
}, [enabled])
|
||||||
|
|
||||||
|
return {
|
||||||
|
position,
|
||||||
|
isReadingComplete,
|
||||||
|
progressPercentage: Math.round(position * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,4 +102,89 @@
|
|||||||
.reader-header-overlay .reader-title { font-size: 1.5rem; line-height: 1.3; }
|
.reader-header-overlay .reader-title { font-size: 1.5rem; line-height: 1.3; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reading Progress Indicator */
|
||||||
|
.reading-progress-indicator {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: rgba(26, 26, 26, 0.9);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #646cff, #8ab4f8);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-progress-fill.complete {
|
||||||
|
background: linear-gradient(90deg, #4ade80, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-progress-fill::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-progress-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #888;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-progress-text:contains('✓') {
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide progress indicator when not needed */
|
||||||
|
.reading-progress-indicator.hidden {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.reading-progress-indicator {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-progress-text {
|
||||||
|
font-size: 0.813rem;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user