feat: add touch-friendly drag handles for queue reordering (#428)

- Add 6-dot grip handle on left side of each queue track for reordering
- Implement touch event handlers for mobile drag-and-drop
- Track visually follows finger during drag with translateY
- Drop target highlights while dragging over other tracks
- Always show drag handles and remove buttons on touch devices
- Desktop drag-and-drop still works by dragging anywhere on the track

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub 0e0396a9 91b2b00f

Changed files
+157 -5
frontend
src
lib
components
+157 -5
frontend/src/lib/components/Queue.svelte
··· 5 5 let draggedIndex = $state<number | null>(null); 6 6 let dragOverIndex = $state<number | null>(null); 7 7 8 + // touch drag state 9 + let touchDragIndex = $state<number | null>(null); 10 + let touchStartY = $state(0); 11 + let touchCurrentY = $state(0); 12 + let touchDragElement = $state<HTMLElement | null>(null); 13 + let queueTracksElement = $state<HTMLElement | null>(null); 14 + 8 15 const currentTrack = $derived.by<Track | null>(() => queue.tracks[queue.currentIndex] ?? null); 9 16 const upcoming = $derived.by<{ track: Track; index: number }[]>(() => { 10 17 return queue.tracks ··· 12 19 .filter(({ index }) => index > queue.currentIndex); 13 20 }); 14 21 15 - function handleDragStart(index: number) { 22 + // desktop drag and drop 23 + function handleDragStart(event: DragEvent, index: number) { 16 24 draggedIndex = index; 25 + if (event.dataTransfer) { 26 + event.dataTransfer.effectAllowed = 'move'; 27 + } 17 28 } 18 29 19 30 function handleDragOver(event: DragEvent, index: number) { ··· 34 45 draggedIndex = null; 35 46 dragOverIndex = null; 36 47 } 48 + 49 + // touch drag and drop 50 + function handleTouchStart(event: TouchEvent, index: number) { 51 + const touch = event.touches[0]; 52 + touchDragIndex = index; 53 + touchStartY = touch.clientY; 54 + touchCurrentY = touch.clientY; 55 + touchDragElement = event.currentTarget as HTMLElement; 56 + 57 + // add dragging class 58 + touchDragElement.classList.add('touch-dragging'); 59 + } 60 + 61 + function handleTouchMove(event: TouchEvent) { 62 + if (touchDragIndex === null || !touchDragElement || !queueTracksElement) return; 63 + 64 + event.preventDefault(); 65 + const touch = event.touches[0]; 66 + touchCurrentY = touch.clientY; 67 + 68 + // calculate visual offset 69 + const offset = touchCurrentY - touchStartY; 70 + touchDragElement.style.transform = `translateY(${offset}px)`; 71 + 72 + // find which track we're hovering over 73 + const tracks = queueTracksElement.querySelectorAll('.queue-track'); 74 + for (let i = 0; i < tracks.length; i++) { 75 + const track = tracks[i] as HTMLElement; 76 + const rect = track.getBoundingClientRect(); 77 + const midY = rect.top + rect.height / 2; 78 + 79 + if (touch.clientY < midY && i > 0) { 80 + // get the actual index from the data attribute 81 + const targetIndex = parseInt(track.dataset.index || '0'); 82 + if (targetIndex !== touchDragIndex) { 83 + dragOverIndex = targetIndex; 84 + } 85 + break; 86 + } else if (touch.clientY >= midY) { 87 + const targetIndex = parseInt(track.dataset.index || '0'); 88 + if (targetIndex !== touchDragIndex) { 89 + dragOverIndex = targetIndex; 90 + } 91 + } 92 + } 93 + } 94 + 95 + function handleTouchEnd() { 96 + if (touchDragIndex !== null && dragOverIndex !== null && touchDragIndex !== dragOverIndex) { 97 + queue.moveTrack(touchDragIndex, dragOverIndex); 98 + } 99 + 100 + // cleanup 101 + if (touchDragElement) { 102 + touchDragElement.classList.remove('touch-dragging'); 103 + touchDragElement.style.transform = ''; 104 + } 105 + 106 + touchDragIndex = null; 107 + dragOverIndex = null; 108 + touchDragElement = null; 109 + } 37 110 </script> 38 111 39 112 {#if queue.tracks.length > 0} ··· 74 147 </div> 75 148 76 149 {#if upcoming.length > 0} 77 - <div class="queue-tracks"> 150 + <div 151 + class="queue-tracks" 152 + bind:this={queueTracksElement} 153 + ontouchmove={handleTouchMove} 154 + ontouchend={handleTouchEnd} 155 + ontouchcancel={handleTouchEnd} 156 + > 78 157 {#each upcoming as { track, index } (`${track.file_id}:${index}`)} 79 158 <div 80 159 class="queue-track" 81 - class:drag-over={dragOverIndex === index} 160 + class:drag-over={dragOverIndex === index && touchDragIndex !== index} 161 + class:is-dragging={touchDragIndex === index || draggedIndex === index} 162 + data-index={index} 82 163 draggable="true" 83 164 role="button" 84 165 tabindex="0" 85 - ondragstart={() => handleDragStart(index)} 166 + ondragstart={(e) => handleDragStart(e, index)} 86 167 ondragover={(e) => handleDragOver(e, index)} 87 168 ondrop={(e) => handleDrop(e, index)} 88 169 ondragend={handleDragEnd} 89 170 onclick={() => queue.goTo(index)} 90 171 onkeydown={(e) => e.key === 'Enter' && queue.goTo(index)} 91 172 > 173 + <!-- drag handle for reordering --> 174 + <button 175 + class="drag-handle" 176 + ontouchstart={(e) => handleTouchStart(e, index)} 177 + onclick={(e) => e.stopPropagation()} 178 + aria-label="drag to reorder" 179 + title="drag to reorder" 180 + > 181 + <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> 182 + <circle cx="5" cy="3" r="1.5"></circle> 183 + <circle cx="11" cy="3" r="1.5"></circle> 184 + <circle cx="5" cy="8" r="1.5"></circle> 185 + <circle cx="11" cy="8" r="1.5"></circle> 186 + <circle cx="5" cy="13" r="1.5"></circle> 187 + <circle cx="11" cy="13" r="1.5"></circle> 188 + </svg> 189 + </button> 190 + 92 191 <div class="track-info"> 93 192 <div class="track-title">{track.title}</div> 94 193 <div class="track-artist"> ··· 271 370 .queue-track { 272 371 display: flex; 273 372 align-items: center; 274 - justify-content: space-between; 373 + gap: 0.5rem; 275 374 padding: 0.85rem 0.9rem; 276 375 border-radius: 8px; 277 376 cursor: pointer; 278 377 transition: all 0.2s; 279 378 border: 1px solid var(--border-subtle); 280 379 background: var(--bg-secondary); 380 + position: relative; 281 381 } 282 382 283 383 .queue-track:hover { ··· 290 390 background: color-mix(in srgb, var(--accent) 12%, transparent); 291 391 } 292 392 393 + .queue-track.is-dragging { 394 + opacity: 0.9; 395 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 396 + z-index: 10; 397 + } 398 + 399 + /* applied dynamically via JS during touch drag */ 400 + :global(.queue-track.touch-dragging) { 401 + z-index: 100; 402 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); 403 + } 404 + 405 + .drag-handle { 406 + display: flex; 407 + align-items: center; 408 + justify-content: center; 409 + padding: 0.35rem; 410 + background: transparent; 411 + border: none; 412 + color: var(--text-muted); 413 + cursor: grab; 414 + touch-action: none; 415 + border-radius: 4px; 416 + transition: all 0.2s; 417 + flex-shrink: 0; 418 + } 419 + 420 + .drag-handle:hover { 421 + color: var(--text-secondary); 422 + background: var(--bg-tertiary); 423 + } 424 + 425 + .drag-handle:active { 426 + cursor: grabbing; 427 + color: var(--accent); 428 + } 429 + 430 + /* always show drag handle on touch devices */ 431 + @media (pointer: coarse) { 432 + .drag-handle { 433 + color: var(--text-tertiary); 434 + } 435 + } 436 + 293 437 .track-info { 294 438 flex: 1; 295 439 min-width: 0; ··· 334 478 transition: all 0.2s; 335 479 border-radius: 4px; 336 480 opacity: 0; 481 + flex-shrink: 0; 337 482 } 338 483 339 484 .queue-track:hover .remove-btn { ··· 343 488 .remove-btn:hover { 344 489 color: var(--error); 345 490 background: color-mix(in srgb, var(--error) 12%, transparent); 491 + } 492 + 493 + /* always show remove button on touch devices */ 494 + @media (pointer: coarse) { 495 + .remove-btn { 496 + opacity: 1; 497 + } 346 498 } 347 499 348 500 .empty-up-next {