From a5d61e65b863310209aec81ec15c9867ba6a6d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schie=C3=9Fl?= <150372753+johannesschiessl@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:41:38 +0200 Subject: [PATCH 1/2] Improve setlist song card dragging and editing - Add pointer-based drag previews and auto-scrolling - Support inline song name and artist edits --- .../routes/shows/$showId/setlist/index.tsx | 430 +++++++++++++----- 1 file changed, 320 insertions(+), 110 deletions(-) diff --git a/apps/web/src/routes/shows/$showId/setlist/index.tsx b/apps/web/src/routes/shows/$showId/setlist/index.tsx index 0069591..0b079ef 100644 --- a/apps/web/src/routes/shows/$showId/setlist/index.tsx +++ b/apps/web/src/routes/shows/$showId/setlist/index.tsx @@ -9,7 +9,7 @@ import { GripVerticalIcon, ListMusicIcon, } from "lucide-react"; -import type { ShowId, SongId } from "@showtime/contracts"; +import type { ShowId, SongArtist, SongId, SongName } from "@showtime/contracts"; import { Empty, EmptyDescription, @@ -17,6 +17,9 @@ import { EmptyMedia, EmptyTitle, } from "@/components/ui/empty"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { songAtoms, songsRpcReactivityKey, type SongListItem } from "@/client"; @@ -26,23 +29,59 @@ export const Route = createFileRoute("/shows/$showId/setlist/")({ component: RouteComponent, }); +type DragPosition = { + readonly left: number; + readonly top: number; + readonly width: number; + readonly height: number; +}; + +type ActivePointer = { + readonly id: SongId; + readonly pointerId: number; + readonly startX: number; + readonly startY: number; + readonly offsetY: number; + readonly rect: DOMRect; + started: boolean; +}; + function RouteComponent() { const { showId } = Route.useParams(); const typedShowId = showId as ShowId; const result = useAtomValue(songAtoms(typedShowId).songs); const reorder = useAtomSet(songAtoms(typedShowId).reorder, { mode: "promiseExit" }); + const edit = useAtomSet(songAtoms(typedShowId).edit, { mode: "promiseExit" }); const songs = AsyncResult.isSuccess(result) ? result.value : []; const [previewSongs, setPreviewSongs] = React.useState>(songs); const [draggedId, setDraggedId] = React.useState(); - const didDropRef = React.useRef(false); + const [dragPosition, setDragPosition] = React.useState(); const [isReordering, setIsReordering] = React.useState(false); const [announcement, setAnnouncement] = React.useState(""); const [error, setError] = React.useState(); + const songsRef = React.useRef(songs); + const previewSongsRef = React.useRef(previewSongs); + const activePointerRef = React.useRef(undefined); + const rowRefs = React.useRef(new Map()); + + songsRef.current = songs; + previewSongsRef.current = previewSongs; React.useEffect(() => { - if (!draggedId) setPreviewSongs(songs); + if (!draggedId) { + previewSongsRef.current = songs; + setPreviewSongs(songs); + } }, [songs, draggedId]); + React.useEffect( + () => () => { + document.body.style.removeProperty("user-select"); + document.body.style.removeProperty("cursor"); + }, + [], + ); + const commitOrder = async (ordered: ReadonlyArray, movedName: string) => { setIsReordering(true); setError(undefined); @@ -54,52 +93,144 @@ function RouteComponent() { const message = rpcErrorMessageFromCause(mutation.cause); setError(message); setAnnouncement(`Could not move ${movedName}. ${message}`); + previewSongsRef.current = songsRef.current; + setPreviewSongs(songsRef.current); } setIsReordering(false); }; const move = (id: SongId, destination: number) => { - const current = previewSongs.findIndex((song) => song.id === id); + const currentSongs = previewSongsRef.current; + const current = currentSongs.findIndex((song) => song.id === id); if (current < 0 || current === destination || isReordering) return; - const ordered = [...previewSongs]; + const ordered = [...currentSongs]; const [moved] = ordered.splice(current, 1); if (!moved) return; ordered.splice(destination, 0, moved); - setAnnouncement(`${moved.name} moved to position ${destination + 1}.`); - void commitOrder(ordered, moved.name); + previewSongsRef.current = ordered; + setPreviewSongs(ordered); + setAnnouncement(`${moved.name || "Song"} moved to position ${destination + 1}.`); + void commitOrder(ordered, moved.name || "song"); }; - const previewMove = (id: SongId, destination: number) => { - setPreviewSongs((currentSongs) => { - const current = currentSongs.findIndex((song) => song.id === id); - if (current < 0 || current === destination) return currentSongs; - const ordered = [...currentSongs]; - const [moved] = ordered.splice(current, 1); - if (!moved) return currentSongs; - ordered.splice(destination, 0, moved); - return ordered; + const previewAtPointer = (id: SongId, pointerY: number) => { + const currentSongs = previewSongsRef.current; + const withoutDragged = currentSongs.filter((song) => song.id !== id); + let destination = withoutDragged.length; + + for (let index = 0; index < withoutDragged.length; index += 1) { + const row = rowRefs.current.get(withoutDragged[index]!.id); + if (!row) continue; + const rect = row.getBoundingClientRect(); + if (pointerY < rect.top + rect.height / 2) { + destination = index; + break; + } + } + + const dragged = currentSongs.find((song) => song.id === id); + if (!dragged || currentSongs[destination]?.id === id) return; + const ordered = [...withoutDragged]; + ordered.splice(destination, 0, dragged); + previewSongsRef.current = ordered; + setPreviewSongs(ordered); + }; + + const startPointer = (event: React.PointerEvent, song: SongListItem) => { + if (event.button !== 0 || isReordering || song.pending) return; + + const card = event.currentTarget.closest("[data-song-card]"); + if (!card) return; + const rect = card.getBoundingClientRect(); + activePointerRef.current = { + id: song.id, + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + offsetY: event.clientY - rect.top, + rect, + started: false, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const updatePointer = (event: React.PointerEvent) => { + const active = activePointerRef.current; + if (!active || active.pointerId !== event.pointerId) return; + + if (!active.started) { + if (Math.hypot(event.clientX - active.startX, event.clientY - active.startY) < 4) return; + active.started = true; + previewSongsRef.current = songsRef.current; + setPreviewSongs(songsRef.current); + setDraggedId(active.id); + document.body.style.userSelect = "none"; + document.body.style.cursor = "grabbing"; + } + + event.preventDefault(); + setDragPosition({ + left: active.rect.left, + top: event.clientY - active.offsetY, + width: active.rect.width, + height: active.rect.height, }); + previewAtPointer(active.id, event.clientY); + + const edgeSize = 72; + if (event.clientY < edgeSize) window.scrollBy(0, -Math.ceil((edgeSize - event.clientY) / 6)); + if (event.clientY > window.innerHeight - edgeSize) { + window.scrollBy(0, Math.ceil((event.clientY - window.innerHeight + edgeSize) / 6)); + } }; - const commitDrag = () => { - if (!draggedId) return; - didDropRef.current = true; - const moved = previewSongs.find((song) => song.id === draggedId); - const changed = previewSongs.some((song, index) => song.id !== songs[index]?.id); - setDraggedId(undefined); - if (moved && changed) { - setAnnouncement(`${moved.name} moved to position ${previewSongs.indexOf(moved) + 1}.`); - void commitOrder(previewSongs, moved.name); + const finishPointer = (event: React.PointerEvent, cancelled = false) => { + const active = activePointerRef.current; + if (!active || active.pointerId !== event.pointerId) return; + activePointerRef.current = undefined; + document.body.style.removeProperty("user-select"); + document.body.style.removeProperty("cursor"); + + if (active.started) { + const ordered = previewSongsRef.current; + const moved = ordered.find((song) => song.id === active.id); + const changed = ordered.some((song, index) => song.id !== songsRef.current[index]?.id); + setDraggedId(undefined); + setDragPosition(undefined); + if (cancelled) { + previewSongsRef.current = songsRef.current; + setPreviewSongs(songsRef.current); + } else if (moved && changed) { + const destination = ordered.findIndex((song) => song.id === active.id); + setAnnouncement(`${moved.name || "Song"} moved to position ${destination + 1}.`); + void commitOrder(ordered, moved.name || "song"); + } } }; - const cancelDrag = () => { - if (didDropRef.current) { - didDropRef.current = false; - return; + const saveSong = async (song: SongListItem, name: string, artist: string) => { + setError(undefined); + const mutation = await edit({ + payload: { + showId: typedShowId, + id: song.id, + name: name.trim() as SongName, + artist: artist.trim() as SongArtist, + notes: song.notes, + mixAssignments: song.mixAssignments, + microphoneNames: song.microphoneNames ?? [], + mixNames: song.mixNames ?? [], + }, + reactivityKeys: songsRpcReactivityKey(typedShowId), + }); + if (Exit.isFailure(mutation)) { + const message = rpcErrorMessageFromCause(mutation.cause); + setError(message); + setAnnouncement(`Could not update ${song.name || "song"}. ${message}`); + return false; } - setDraggedId(undefined); - setPreviewSongs(songs); + setAnnouncement(`${name.trim() || "Song"} updated.`); + return true; }; return ( @@ -138,90 +269,82 @@ function RouteComponent() { ) : ( -
- {previewSongs.map((song, index) => ( -
{ - event.preventDefault(); - if (draggedId) previewMove(draggedId, index); - }} - onDrop={(event) => { - event.preventDefault(); - commitDrag(); - }} - className="group flex min-h-16 items-center border-b transition-[background-color,opacity,transform] last:border-b-0 hover:bg-accent/50" - > - - - - {index + 1} - - - {song.name || "New song"} - - {song.artist} - - - -
- - + +
startPointer(event, song)} + > + +
+ + {index + 1} + + +
+ + +
+
-
- ))} + ); + })} )}

@@ -230,3 +353,90 @@ function RouteComponent() { ); } + +function InlineSongFields({ + song, + onSave, +}: { + readonly song: SongListItem; + readonly onSave: (song: SongListItem, name: string, artist: string) => Promise; +}) { + const [name, setName] = React.useState(song.name as string); + const [artist, setArtist] = React.useState(song.artist as string); + const [isSaving, setIsSaving] = React.useState(false); + const nameFocusedRef = React.useRef(false); + const artistFocusedRef = React.useRef(false); + + React.useEffect(() => { + if (!nameFocusedRef.current) setName(song.name); + if (!artistFocusedRef.current) setArtist(song.artist); + }, [song.name, song.artist]); + + const save = async () => { + const nextName = name.trim(); + const nextArtist = artist.trim(); + setName(nextName); + setArtist(nextArtist); + if (nextName === song.name && nextArtist === song.artist) return; + setIsSaving(true); + const saved = await onSave(song, nextName, nextArtist); + setIsSaving(false); + if (!saved) { + setName(song.name); + setArtist(song.artist); + } + }; + + return ( +

+ { + nameFocusedRef.current = true; + }} + onChange={(event) => setName(event.target.value)} + onBlur={() => { + nameFocusedRef.current = false; + void save(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + setName(song.name); + event.currentTarget.blur(); + } + }} + /> + {song.artist !== "" && ( + + { + artistFocusedRef.current = true; + }} + onChange={(event) => setArtist(event.target.value)} + onBlur={() => { + artistFocusedRef.current = false; + void save(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + setArtist(song.artist); + event.currentTarget.blur(); + } + }} + /> + + )} +
+ ); +} From 5c78c7d65bb094af076428b5e5b64ffbf0e68a43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schie=C3=9Fl?= <150372753+johannesschiessl@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:10:24 +0200 Subject: [PATCH 2/2] Avoid saving inline song edits on Escape --- .../web/src/routes/shows/$showId/setlist/index.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/web/src/routes/shows/$showId/setlist/index.tsx b/apps/web/src/routes/shows/$showId/setlist/index.tsx index 0b079ef..f86e990 100644 --- a/apps/web/src/routes/shows/$showId/setlist/index.tsx +++ b/apps/web/src/routes/shows/$showId/setlist/index.tsx @@ -366,6 +366,8 @@ function InlineSongFields({ const [isSaving, setIsSaving] = React.useState(false); const nameFocusedRef = React.useRef(false); const artistFocusedRef = React.useRef(false); + const cancelNameBlurSaveRef = React.useRef(false); + const cancelArtistBlurSaveRef = React.useRef(false); React.useEffect(() => { if (!nameFocusedRef.current) setName(song.name); @@ -397,15 +399,21 @@ function InlineSongFields({ className="pointer-events-auto w-auto max-w-full border-transparent bg-transparent shadow-none [field-sizing:content] focus-visible:bg-input/30 disabled:bg-transparent dark:bg-transparent dark:disabled:bg-transparent dark:focus-visible:bg-input/30" onFocus={() => { nameFocusedRef.current = true; + cancelNameBlurSaveRef.current = false; }} onChange={(event) => setName(event.target.value)} onBlur={() => { nameFocusedRef.current = false; + if (cancelNameBlurSaveRef.current) { + cancelNameBlurSaveRef.current = false; + return; + } void save(); }} onKeyDown={(event) => { if (event.key === "Enter") event.currentTarget.blur(); if (event.key === "Escape") { + cancelNameBlurSaveRef.current = true; setName(song.name); event.currentTarget.blur(); } @@ -421,15 +429,21 @@ function InlineSongFields({ className="h-auto min-w-0 max-w-full w-auto border-0 bg-transparent p-0 leading-none shadow-none [field-sizing:content] focus-visible:ring-0 disabled:bg-transparent disabled:opacity-100 dark:bg-transparent dark:disabled:bg-transparent" onFocus={() => { artistFocusedRef.current = true; + cancelArtistBlurSaveRef.current = false; }} onChange={(event) => setArtist(event.target.value)} onBlur={() => { artistFocusedRef.current = false; + if (cancelArtistBlurSaveRef.current) { + cancelArtistBlurSaveRef.current = false; + return; + } void save(); }} onKeyDown={(event) => { if (event.key === "Enter") event.currentTarget.blur(); if (event.key === "Escape") { + cancelArtistBlurSaveRef.current = true; setArtist(song.artist); event.currentTarget.blur(); }