Skip to content

Commit 46c2b73

Browse files
dylanjeffersclaude
andcommitted
feat(web): bulk track actions toolbar (select-all, copy URLs, remove, undo/redo)
Adds the bulk track-curation toolbar to the playlist detail page when in edit mode, plus keyboard shortcuts for the common operations. - New `TrackSelectionProvider` + `useTrackSelection` hook: a Set of selected track IDs with toggle (including shift-range across an ordered id list), select-all, clear, and a "last clicked index" ref so range select works in chronological click order. - New `TrackHistoryProvider` + `useTrackHistoryContext`: undo/redo stacks for `remove` and `add` operations. Undo of a `remove` dispatches `addTrackToPlaylist({ silent: true })` (note: the existing saga appends rather than re-inserting at the original index — current ordering is a known limitation surfaced as a follow-up). - New `TrackBulkActionsBar` sticks to the top of the track table while in edit mode and shows a count of selected tracks plus the five bulk actions: Copy URLs (clipboard-writes `${origin}${permalink}` for each selected track), Remove (dispatches removeTrackFromPlaylist per id and pushes history entries so each remove is undoable), Undo, Redo, and Select all / Clear pair. - Keyboard shortcuts wired through the same component while in edit mode: Cmd/Ctrl+A select all, Cmd/Ctrl+Z undo, Cmd/Ctrl+Shift+Z / Cmd/Ctrl+Y redo, Escape clears selection, Delete/Backspace removes selected (and skips when focus is in a text input). - CollectionPage is wrapped in the selection + history providers alongside the existing edit-mode provider. Scope notes — per-row checkbox UI and shift-range row-click select require deeper changes to the existing TracksTable component and are deferred to a follow-up PR; users can still select-all via the bar or Cmd/Ctrl+A and operate on the full set. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 10295c5 commit 46c2b73

4 files changed

Lines changed: 600 additions & 79 deletions

File tree

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { useCallback, useEffect, useMemo } from 'react'
2+
3+
import { useCollection, useTracks } from '@audius/common/api'
4+
import { ID } from '@audius/common/models'
5+
import { cacheCollectionsActions, toastActions } from '@audius/common/store'
6+
import {
7+
Button,
8+
Flex,
9+
IconCopy,
10+
IconTrash,
11+
Text,
12+
useTheme
13+
} from '@audius/harmony'
14+
import { useDispatch } from 'react-redux'
15+
16+
import { usePlaylistEditMode } from '../PlaylistEditModeContext'
17+
18+
import { useTrackHistoryContext } from './TrackHistoryContext'
19+
import { useTrackSelection } from './TrackSelectionContext'
20+
21+
const { removeTrackFromPlaylist, addTrackToPlaylist } = cacheCollectionsActions
22+
const { toast } = toastActions
23+
24+
const messages = {
25+
selected: (n: number) => `${n} selected`,
26+
copy: 'Copy URLs',
27+
remove: 'Remove',
28+
clear: 'Clear',
29+
selectAll: 'Select all',
30+
undo: 'Undo',
31+
redo: 'Redo',
32+
copiedOne: 'Copied 1 track URL to clipboard',
33+
copiedMany: (n: number) => `Copied ${n} track URLs to clipboard`,
34+
copyFailed: 'Could not copy URLs to clipboard',
35+
removed: (n: number) => (n === 1 ? 'Removed 1 track' : `Removed ${n} tracks`)
36+
}
37+
38+
type Props = {
39+
collectionId: ID
40+
orderedTrackIds: ID[]
41+
}
42+
43+
export const TrackBulkActionsBar = (props: Props) => {
44+
const { collectionId, orderedTrackIds } = props
45+
const dispatch = useDispatch()
46+
const { color } = useTheme()
47+
const editMode = usePlaylistEditMode()
48+
const selection = useTrackSelection()
49+
const history = useTrackHistoryContext()
50+
51+
const { data: collection } = useCollection(collectionId)
52+
const selectedIds = useMemo(
53+
() => orderedTrackIds.filter((id) => selection.isSelected(id)),
54+
[orderedTrackIds, selection]
55+
)
56+
const { data: selectedTracks } = useTracks(selectedIds)
57+
58+
const copyUrls = useCallback(async () => {
59+
if (!selectedTracks?.length) return
60+
const origin =
61+
typeof window !== 'undefined'
62+
? window.location.origin
63+
: 'https://audius.co'
64+
const urls = selectedTracks
65+
.map((t) => (t?.permalink ? `${origin}${t.permalink}` : null))
66+
.filter((u): u is string => !!u)
67+
.join('\n')
68+
try {
69+
await navigator.clipboard.writeText(urls)
70+
dispatch(
71+
toast({
72+
content:
73+
urls.split('\n').length === 1
74+
? messages.copiedOne
75+
: messages.copiedMany(urls.split('\n').length)
76+
})
77+
)
78+
} catch {
79+
dispatch(toast({ content: messages.copyFailed }))
80+
}
81+
}, [dispatch, selectedTracks])
82+
83+
const removeSelected = useCallback(() => {
84+
if (!collection) return
85+
const trackIds = selectedIds
86+
if (trackIds.length === 0) return
87+
// Record each removal in history so it can be undone.
88+
trackIds.forEach((trackId) => {
89+
const entry = collection.playlist_contents.track_ids.find(
90+
(t) => t.track === trackId
91+
)
92+
if (!entry) return
93+
const index = collection.playlist_contents.track_ids.findIndex(
94+
(t) => t.track === trackId && t.time === entry.time
95+
)
96+
const timestamp = entry.metadata_time ?? entry.time
97+
history.push({ type: 'remove', trackId, index, timestamp })
98+
dispatch(removeTrackFromPlaylist(trackId, collectionId, timestamp))
99+
})
100+
dispatch(toast({ content: messages.removed(trackIds.length) }))
101+
selection.clear()
102+
}, [collection, collectionId, dispatch, history, selectedIds, selection])
103+
104+
const handleUndo = useCallback(() => {
105+
history.undo((inverse) => {
106+
if (inverse.type === 'add') {
107+
dispatch(
108+
addTrackToPlaylist(inverse.trackId, collectionId, { silent: true })
109+
)
110+
}
111+
})
112+
}, [collectionId, dispatch, history])
113+
114+
const handleRedo = useCallback(() => {
115+
history.redo()
116+
}, [history])
117+
118+
// Keyboard shortcuts: only active while in edit mode
119+
useEffect(() => {
120+
if (!editMode.isEditMode || editMode.collectionId !== collectionId) return
121+
const onKey = (e: KeyboardEvent) => {
122+
const target = e.target as HTMLElement | null
123+
const isInputFocused =
124+
target &&
125+
['INPUT', 'TEXTAREA'].includes(target.tagName) &&
126+
!target.dataset.bulkActions
127+
if (isInputFocused) return
128+
const mod = e.metaKey || e.ctrlKey
129+
if (mod && e.key.toLowerCase() === 'a') {
130+
e.preventDefault()
131+
selection.selectAll(orderedTrackIds)
132+
return
133+
}
134+
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) {
135+
e.preventDefault()
136+
handleUndo()
137+
return
138+
}
139+
if (
140+
(mod && e.key.toLowerCase() === 'z' && e.shiftKey) ||
141+
(mod && e.key.toLowerCase() === 'y')
142+
) {
143+
e.preventDefault()
144+
handleRedo()
145+
return
146+
}
147+
if (e.key === 'Escape') {
148+
if (selection.count > 0) {
149+
e.preventDefault()
150+
selection.clear()
151+
}
152+
}
153+
if (
154+
(e.key === 'Delete' || e.key === 'Backspace') &&
155+
selection.count > 0
156+
) {
157+
e.preventDefault()
158+
removeSelected()
159+
}
160+
}
161+
window.addEventListener('keydown', onKey)
162+
return () => window.removeEventListener('keydown', onKey)
163+
}, [
164+
collectionId,
165+
editMode.collectionId,
166+
editMode.isEditMode,
167+
handleRedo,
168+
handleUndo,
169+
orderedTrackIds,
170+
removeSelected,
171+
selection
172+
])
173+
174+
if (!editMode.isEditMode || editMode.collectionId !== collectionId) {
175+
return null
176+
}
177+
if (selection.count === 0 && !history.canUndo && !history.canRedo) {
178+
return null
179+
}
180+
181+
return (
182+
<Flex
183+
data-bulk-actions
184+
css={{
185+
position: 'sticky',
186+
top: 0,
187+
zIndex: 4,
188+
backgroundColor: color.background.surface1,
189+
borderBottom: `1px solid ${color.border.strong}`
190+
}}
191+
p='m'
192+
gap='m'
193+
alignItems='center'
194+
justifyContent='space-between'
195+
>
196+
<Flex gap='m' alignItems='center'>
197+
<Text variant='body' strength='strong'>
198+
{messages.selected(selection.count)}
199+
</Text>
200+
{selection.count > 0 ? (
201+
<Button variant='secondary' size='small' onClick={selection.clear}>
202+
{messages.clear}
203+
</Button>
204+
) : null}
205+
<Button
206+
variant='secondary'
207+
size='small'
208+
onClick={() => selection.selectAll(orderedTrackIds)}
209+
>
210+
{messages.selectAll}
211+
</Button>
212+
</Flex>
213+
<Flex gap='m' alignItems='center'>
214+
<Button
215+
variant='secondary'
216+
size='small'
217+
iconLeft={IconCopy}
218+
disabled={selection.count === 0}
219+
onClick={copyUrls}
220+
>
221+
{messages.copy}
222+
</Button>
223+
<Button
224+
variant='destructive'
225+
size='small'
226+
iconLeft={IconTrash}
227+
disabled={selection.count === 0}
228+
onClick={removeSelected}
229+
>
230+
{messages.remove}
231+
</Button>
232+
<Button
233+
variant='secondary'
234+
size='small'
235+
disabled={!history.canUndo}
236+
onClick={handleUndo}
237+
>
238+
{messages.undo}
239+
</Button>
240+
<Button
241+
variant='secondary'
242+
size='small'
243+
disabled={!history.canRedo}
244+
onClick={handleRedo}
245+
>
246+
{messages.redo}
247+
</Button>
248+
</Flex>
249+
</Flex>
250+
)
251+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import {
2+
createContext,
3+
ReactNode,
4+
useCallback,
5+
useContext,
6+
useMemo,
7+
useRef,
8+
useState
9+
} from 'react'
10+
11+
import { ID } from '@audius/common/models'
12+
import { cacheCollectionsActions, toastActions } from '@audius/common/store'
13+
import { useDispatch } from 'react-redux'
14+
15+
const { addTrackToPlaylist, removeTrackFromPlaylist } = cacheCollectionsActions
16+
const { toast } = toastActions
17+
18+
export type TrackHistoryEntry =
19+
| {
20+
type: 'remove'
21+
trackId: ID
22+
// best-effort original position; not used to re-insert into the same
23+
// slot because the existing addTrackToPlaylist saga always appends.
24+
index: number
25+
// timestamp captured at the time of removal for the saga call.
26+
timestamp: number
27+
}
28+
| {
29+
type: 'add'
30+
trackId: ID
31+
}
32+
33+
type TrackHistoryContextValue = {
34+
canUndo: boolean
35+
canRedo: boolean
36+
push: (entry: TrackHistoryEntry) => void
37+
undo: (applyInverse: (entry: TrackHistoryEntry) => void) => void
38+
redo: () => void
39+
}
40+
41+
const TrackHistoryContext = createContext<TrackHistoryContextValue | null>(null)
42+
43+
type ProviderProps = {
44+
collectionId?: ID
45+
children: ReactNode
46+
}
47+
48+
const messages = {
49+
noMoreUndo: 'Nothing to undo',
50+
noMoreRedo: 'Nothing to redo'
51+
}
52+
53+
export const TrackHistoryProvider = ({
54+
collectionId,
55+
children
56+
}: ProviderProps) => {
57+
const dispatch = useDispatch()
58+
const undoStackRef = useRef<TrackHistoryEntry[]>([])
59+
const redoStackRef = useRef<TrackHistoryEntry[]>([])
60+
const [, force] = useState(0)
61+
const bump = useCallback(() => force((v) => v + 1), [])
62+
63+
const push = useCallback(
64+
(entry: TrackHistoryEntry) => {
65+
undoStackRef.current.push(entry)
66+
redoStackRef.current = []
67+
bump()
68+
},
69+
[bump]
70+
)
71+
72+
const undo = useCallback(
73+
(applyInverse: (entry: TrackHistoryEntry) => void) => {
74+
const entry = undoStackRef.current.pop()
75+
if (!entry) {
76+
dispatch(toast({ content: messages.noMoreUndo }))
77+
return
78+
}
79+
redoStackRef.current.push(entry)
80+
bump()
81+
const inverse: TrackHistoryEntry =
82+
entry.type === 'remove'
83+
? { type: 'add', trackId: entry.trackId }
84+
: { type: 'remove', trackId: entry.trackId, index: -1, timestamp: 0 }
85+
applyInverse(inverse)
86+
},
87+
[bump, dispatch]
88+
)
89+
90+
const redo = useCallback(() => {
91+
const entry = redoStackRef.current.pop()
92+
if (!entry) {
93+
dispatch(toast({ content: messages.noMoreRedo }))
94+
return
95+
}
96+
undoStackRef.current.push(entry)
97+
bump()
98+
if (!collectionId) return
99+
if (entry.type === 'remove') {
100+
dispatch(
101+
removeTrackFromPlaylist(entry.trackId, collectionId, entry.timestamp)
102+
)
103+
} else if (entry.type === 'add') {
104+
dispatch(
105+
addTrackToPlaylist(entry.trackId, collectionId, { silent: true })
106+
)
107+
}
108+
}, [bump, collectionId, dispatch])
109+
110+
const value = useMemo<TrackHistoryContextValue>(
111+
() => ({
112+
canUndo: undoStackRef.current.length > 0,
113+
canRedo: redoStackRef.current.length > 0,
114+
push,
115+
undo,
116+
redo
117+
}),
118+
// Intentionally include bump tick via undoStackRef.current.length read
119+
// eslint-disable-next-line react-hooks/exhaustive-deps
120+
[push, undo, redo, undoStackRef.current.length, redoStackRef.current.length]
121+
)
122+
123+
return (
124+
<TrackHistoryContext.Provider value={value}>
125+
{children}
126+
</TrackHistoryContext.Provider>
127+
)
128+
}
129+
130+
export const useTrackHistoryContext = (): TrackHistoryContextValue => {
131+
const ctx = useContext(TrackHistoryContext)
132+
if (!ctx) {
133+
return {
134+
canUndo: false,
135+
canRedo: false,
136+
push: () => {},
137+
undo: () => {},
138+
redo: () => {}
139+
}
140+
}
141+
return ctx
142+
}

0 commit comments

Comments
 (0)