Skip to content

Commit 29fd906

Browse files
Add draggable slider for quick image navigation
- Replace dot indicators with a draggable progress slider - Click anywhere on the slider to jump to that position - Drag the handle to scrub through images quickly - Shows filled track with teal-to-orange gradient - Segment markers for visual reference - Smooth transition animations
1 parent 6e64f24 commit 29fd906

File tree

1 file changed

+82
-20
lines changed

1 file changed

+82
-20
lines changed

frontend/app/components/ThumbnailRibbon.tsx

Lines changed: 82 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useRef, useEffect } from 'react'
3+
import { useRef, useEffect, useState, useCallback } from 'react'
44
import { ImageData } from '../types'
55
import { ChevronLeft, ChevronRight } from 'lucide-react'
66

@@ -13,6 +13,8 @@ interface ThumbnailRibbonProps {
1313
export default function ThumbnailRibbon({ images, selectedIndex, onSelect }: ThumbnailRibbonProps) {
1414
const scrollContainerRef = useRef<HTMLDivElement>(null)
1515
const thumbnailRefs = useRef<(HTMLButtonElement | null)[]>([])
16+
const progressBarRef = useRef<HTMLDivElement>(null)
17+
const [isDragging, setIsDragging] = useState(false)
1618

1719
// Scroll to selected thumbnail when it changes
1820
useEffect(() => {
@@ -58,6 +60,47 @@ export default function ThumbnailRibbon({ images, selectedIndex, onSelect }: Thu
5860
}
5961
}
6062

63+
// Handle click/drag on progress bar to jump to position
64+
const handleProgressInteraction = useCallback((clientX: number) => {
65+
if (!progressBarRef.current || images.length === 0) return
66+
67+
const rect = progressBarRef.current.getBoundingClientRect()
68+
const relativeX = Math.max(0, Math.min(clientX - rect.left, rect.width))
69+
const percentage = relativeX / rect.width
70+
const newIndex = Math.min(
71+
Math.floor(percentage * images.length),
72+
images.length - 1
73+
)
74+
onSelect(newIndex)
75+
}, [images.length, onSelect])
76+
77+
const handleMouseDown = (e: React.MouseEvent) => {
78+
setIsDragging(true)
79+
handleProgressInteraction(e.clientX)
80+
}
81+
82+
const handleMouseMove = useCallback((e: MouseEvent) => {
83+
if (isDragging) {
84+
handleProgressInteraction(e.clientX)
85+
}
86+
}, [isDragging, handleProgressInteraction])
87+
88+
const handleMouseUp = useCallback(() => {
89+
setIsDragging(false)
90+
}, [])
91+
92+
// Add/remove mouse event listeners for drag
93+
useEffect(() => {
94+
if (isDragging) {
95+
window.addEventListener('mousemove', handleMouseMove)
96+
window.addEventListener('mouseup', handleMouseUp)
97+
}
98+
return () => {
99+
window.removeEventListener('mousemove', handleMouseMove)
100+
window.removeEventListener('mouseup', handleMouseUp)
101+
}
102+
}, [isDragging, handleMouseMove, handleMouseUp])
103+
61104
return (
62105
<div className="bg-white/80 backdrop-blur-md rounded-xl border border-agi-teal/10 shadow-sm p-2 md:p-4">
63106
<div className="relative flex items-center">
@@ -141,29 +184,48 @@ export default function ThumbnailRibbon({ images, selectedIndex, onSelect }: Thu
141184
</button>
142185
</div>
143186

144-
{/* Progress indicator */}
145-
<div className="mt-3 flex items-center justify-center gap-2">
146-
<div className="flex gap-1">
147-
{(() => {
148-
// Calculate group size to always show ~10 dots max
149-
const maxDots = 10
150-
const groupSize = Math.max(1, Math.ceil(images.length / maxDots))
151-
const numDots = Math.ceil(images.length / groupSize)
152-
const currentGroup = Math.floor(selectedIndex / groupSize)
153-
154-
return Array.from({ length: numDots }).map((_, i) => (
187+
{/* Progress indicator - clickable/draggable slider */}
188+
<div className="mt-3 flex items-center justify-center gap-3">
189+
<div
190+
ref={progressBarRef}
191+
className={`relative w-64 md:w-96 h-6 flex items-center cursor-pointer group ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
192+
onMouseDown={handleMouseDown}
193+
role="slider"
194+
aria-label="Image position"
195+
aria-valuemin={1}
196+
aria-valuemax={images.length}
197+
aria-valuenow={selectedIndex + 1}
198+
tabIndex={0}
199+
>
200+
{/* Track background */}
201+
<div className="absolute inset-x-0 h-2 bg-agi-teal/10 rounded-full" />
202+
203+
{/* Filled track */}
204+
<div
205+
className="absolute left-0 h-2 bg-gradient-to-r from-agi-teal to-agi-orange rounded-full transition-all duration-75"
206+
style={{ width: `${((selectedIndex + 1) / images.length) * 100}%` }}
207+
/>
208+
209+
{/* Thumb/handle */}
210+
<div
211+
className={`absolute w-4 h-4 bg-white border-2 border-agi-teal rounded-full shadow-md transform -translate-x-1/2 transition-transform ${
212+
isDragging ? 'scale-125' : 'group-hover:scale-110'
213+
}`}
214+
style={{ left: `${((selectedIndex + 1) / images.length) * 100}%` }}
215+
/>
216+
217+
{/* Segment markers */}
218+
<div className="absolute inset-x-0 flex justify-between px-0.5">
219+
{Array.from({ length: 11 }).map((_, i) => (
155220
<div
156221
key={i}
157-
className={`h-1 rounded-full transition-all ${
158-
currentGroup === i
159-
? 'w-8 bg-gradient-to-r from-agi-teal to-agi-orange'
160-
: 'w-2 bg-agi-teal/20'
161-
}`}
222+
className="w-0.5 h-1 bg-agi-teal/30 rounded-full"
162223
/>
163-
))
164-
})()}
224+
))}
225+
</div>
165226
</div>
166-
<span className="text-xs text-agi-teal-600 ml-2">
227+
228+
<span className="text-xs text-agi-teal-600 font-medium min-w-[60px]">
167229
{selectedIndex + 1} / {images.length}
168230
</span>
169231
</div>

0 commit comments

Comments
 (0)