diff --git a/packages/web/src/components/collection/desktop/edit-mode/tracks/TrackBulkActionsBar.tsx b/packages/web/src/components/collection/desktop/edit-mode/tracks/TrackBulkActionsBar.tsx new file mode 100644 index 00000000000..687403bfd99 --- /dev/null +++ b/packages/web/src/components/collection/desktop/edit-mode/tracks/TrackBulkActionsBar.tsx @@ -0,0 +1,251 @@ +import { useCallback, useEffect, useMemo } from 'react' + +import { useCollection, useTracks } from '@audius/common/api' +import { ID } from '@audius/common/models' +import { cacheCollectionsActions, toastActions } from '@audius/common/store' +import { + Button, + Flex, + IconCopy, + IconTrash, + Text, + useTheme +} from '@audius/harmony' +import { useDispatch } from 'react-redux' + +import { usePlaylistEditMode } from '../PlaylistEditModeContext' + +import { useTrackHistoryContext } from './TrackHistoryContext' +import { useTrackSelection } from './TrackSelectionContext' + +const { removeTrackFromPlaylist, addTrackToPlaylist } = cacheCollectionsActions +const { toast } = toastActions + +const messages = { + selected: (n: number) => `${n} selected`, + copy: 'Copy URLs', + remove: 'Remove', + clear: 'Clear', + selectAll: 'Select all', + undo: 'Undo', + redo: 'Redo', + copiedOne: 'Copied 1 track URL to clipboard', + copiedMany: (n: number) => `Copied ${n} track URLs to clipboard`, + copyFailed: 'Could not copy URLs to clipboard', + removed: (n: number) => (n === 1 ? 'Removed 1 track' : `Removed ${n} tracks`) +} + +type Props = { + collectionId: ID + orderedTrackIds: ID[] +} + +export const TrackBulkActionsBar = (props: Props) => { + const { collectionId, orderedTrackIds } = props + const dispatch = useDispatch() + const { color } = useTheme() + const editMode = usePlaylistEditMode() + const selection = useTrackSelection() + const history = useTrackHistoryContext() + + const { data: collection } = useCollection(collectionId) + const selectedIds = useMemo( + () => orderedTrackIds.filter((id) => selection.isSelected(id)), + [orderedTrackIds, selection] + ) + const { data: selectedTracks } = useTracks(selectedIds) + + const copyUrls = useCallback(async () => { + if (!selectedTracks?.length) return + const origin = + typeof window !== 'undefined' + ? window.location.origin + : 'https://audius.co' + const urls = selectedTracks + .map((t) => (t?.permalink ? `${origin}${t.permalink}` : null)) + .filter((u): u is string => !!u) + .join('\n') + try { + await navigator.clipboard.writeText(urls) + dispatch( + toast({ + content: + urls.split('\n').length === 1 + ? messages.copiedOne + : messages.copiedMany(urls.split('\n').length) + }) + ) + } catch { + dispatch(toast({ content: messages.copyFailed })) + } + }, [dispatch, selectedTracks]) + + const removeSelected = useCallback(() => { + if (!collection) return + const trackIds = selectedIds + if (trackIds.length === 0) return + // Record each removal in history so it can be undone. + trackIds.forEach((trackId) => { + const entry = collection.playlist_contents.track_ids.find( + (t) => t.track === trackId + ) + if (!entry) return + const index = collection.playlist_contents.track_ids.findIndex( + (t) => t.track === trackId && t.time === entry.time + ) + const timestamp = entry.metadata_time ?? entry.time + history.push({ type: 'remove', trackId, index, timestamp }) + dispatch(removeTrackFromPlaylist(trackId, collectionId, timestamp)) + }) + dispatch(toast({ content: messages.removed(trackIds.length) })) + selection.clear() + }, [collection, collectionId, dispatch, history, selectedIds, selection]) + + const handleUndo = useCallback(() => { + history.undo((inverse) => { + if (inverse.type === 'add') { + dispatch( + addTrackToPlaylist(inverse.trackId, collectionId, { silent: true }) + ) + } + }) + }, [collectionId, dispatch, history]) + + const handleRedo = useCallback(() => { + history.redo() + }, [history]) + + // Keyboard shortcuts: only active while in edit mode + useEffect(() => { + if (!editMode.isEditMode || editMode.collectionId !== collectionId) return + const onKey = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null + const isInputFocused = + target && + ['INPUT', 'TEXTAREA'].includes(target.tagName) && + !target.dataset.bulkActions + if (isInputFocused) return + const mod = e.metaKey || e.ctrlKey + if (mod && e.key.toLowerCase() === 'a') { + e.preventDefault() + selection.selectAll(orderedTrackIds) + return + } + if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { + e.preventDefault() + handleUndo() + return + } + if ( + (mod && e.key.toLowerCase() === 'z' && e.shiftKey) || + (mod && e.key.toLowerCase() === 'y') + ) { + e.preventDefault() + handleRedo() + return + } + if (e.key === 'Escape') { + if (selection.count > 0) { + e.preventDefault() + selection.clear() + } + } + if ( + (e.key === 'Delete' || e.key === 'Backspace') && + selection.count > 0 + ) { + e.preventDefault() + removeSelected() + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [ + collectionId, + editMode.collectionId, + editMode.isEditMode, + handleRedo, + handleUndo, + orderedTrackIds, + removeSelected, + selection + ]) + + if (!editMode.isEditMode || editMode.collectionId !== collectionId) { + return null + } + if (selection.count === 0 && !history.canUndo && !history.canRedo) { + return null + } + + return ( + + + + {messages.selected(selection.count)} + + {selection.count > 0 ? ( + + ) : null} + + + + + + + + + + ) +} diff --git a/packages/web/src/components/collection/desktop/edit-mode/tracks/TrackHistoryContext.tsx b/packages/web/src/components/collection/desktop/edit-mode/tracks/TrackHistoryContext.tsx new file mode 100644 index 00000000000..73e20d075a5 --- /dev/null +++ b/packages/web/src/components/collection/desktop/edit-mode/tracks/TrackHistoryContext.tsx @@ -0,0 +1,142 @@ +import { + createContext, + ReactNode, + useCallback, + useContext, + useMemo, + useRef, + useState +} from 'react' + +import { ID } from '@audius/common/models' +import { cacheCollectionsActions, toastActions } from '@audius/common/store' +import { useDispatch } from 'react-redux' + +const { addTrackToPlaylist, removeTrackFromPlaylist } = cacheCollectionsActions +const { toast } = toastActions + +export type TrackHistoryEntry = + | { + type: 'remove' + trackId: ID + // best-effort original position; not used to re-insert into the same + // slot because the existing addTrackToPlaylist saga always appends. + index: number + // timestamp captured at the time of removal for the saga call. + timestamp: number + } + | { + type: 'add' + trackId: ID + } + +type TrackHistoryContextValue = { + canUndo: boolean + canRedo: boolean + push: (entry: TrackHistoryEntry) => void + undo: (applyInverse: (entry: TrackHistoryEntry) => void) => void + redo: () => void +} + +const TrackHistoryContext = createContext(null) + +type ProviderProps = { + collectionId?: ID + children: ReactNode +} + +const messages = { + noMoreUndo: 'Nothing to undo', + noMoreRedo: 'Nothing to redo' +} + +export const TrackHistoryProvider = ({ + collectionId, + children +}: ProviderProps) => { + const dispatch = useDispatch() + const undoStackRef = useRef([]) + const redoStackRef = useRef([]) + const [, force] = useState(0) + const bump = useCallback(() => force((v) => v + 1), []) + + const push = useCallback( + (entry: TrackHistoryEntry) => { + undoStackRef.current.push(entry) + redoStackRef.current = [] + bump() + }, + [bump] + ) + + const undo = useCallback( + (applyInverse: (entry: TrackHistoryEntry) => void) => { + const entry = undoStackRef.current.pop() + if (!entry) { + dispatch(toast({ content: messages.noMoreUndo })) + return + } + redoStackRef.current.push(entry) + bump() + const inverse: TrackHistoryEntry = + entry.type === 'remove' + ? { type: 'add', trackId: entry.trackId } + : { type: 'remove', trackId: entry.trackId, index: -1, timestamp: 0 } + applyInverse(inverse) + }, + [bump, dispatch] + ) + + const redo = useCallback(() => { + const entry = redoStackRef.current.pop() + if (!entry) { + dispatch(toast({ content: messages.noMoreRedo })) + return + } + undoStackRef.current.push(entry) + bump() + if (!collectionId) return + if (entry.type === 'remove') { + dispatch( + removeTrackFromPlaylist(entry.trackId, collectionId, entry.timestamp) + ) + } else if (entry.type === 'add') { + dispatch( + addTrackToPlaylist(entry.trackId, collectionId, { silent: true }) + ) + } + }, [bump, collectionId, dispatch]) + + const value = useMemo( + () => ({ + canUndo: undoStackRef.current.length > 0, + canRedo: redoStackRef.current.length > 0, + push, + undo, + redo + }), + // Intentionally include bump tick via undoStackRef.current.length read + // eslint-disable-next-line react-hooks/exhaustive-deps + [push, undo, redo, undoStackRef.current.length, redoStackRef.current.length] + ) + + return ( + + {children} + + ) +} + +export const useTrackHistoryContext = (): TrackHistoryContextValue => { + const ctx = useContext(TrackHistoryContext) + if (!ctx) { + return { + canUndo: false, + canRedo: false, + push: () => {}, + undo: () => {}, + redo: () => {} + } + } + return ctx +} diff --git a/packages/web/src/components/collection/desktop/edit-mode/tracks/TrackSelectionContext.tsx b/packages/web/src/components/collection/desktop/edit-mode/tracks/TrackSelectionContext.tsx new file mode 100644 index 00000000000..f2f34d214d8 --- /dev/null +++ b/packages/web/src/components/collection/desktop/edit-mode/tracks/TrackSelectionContext.tsx @@ -0,0 +1,109 @@ +import { + createContext, + ReactNode, + useCallback, + useContext, + useMemo, + useRef, + useState +} from 'react' + +import { ID } from '@audius/common/models' + +type SelectionContextValue = { + selected: Set + count: number + isSelected: (id: ID) => boolean + toggle: (id: ID, index: number, opts?: { shift?: boolean }) => void + selectAll: (ids: ID[]) => void + clear: () => void + setSelected: (next: Set) => void +} + +const TrackSelectionContext = createContext(null) + +type ProviderProps = { + orderedIds: ID[] + children: ReactNode +} + +export const TrackSelectionProvider = ({ + orderedIds, + children +}: ProviderProps) => { + const [selected, setSelectedState] = useState>(new Set()) + const lastIndexRef = useRef(null) + + const setSelected = useCallback((next: Set) => { + setSelectedState(new Set(next)) + }, []) + + const isSelected = useCallback((id: ID) => selected.has(id), [selected]) + + const toggle = useCallback( + (id, index, opts) => { + setSelectedState((prev) => { + const next = new Set(prev) + if (opts?.shift && lastIndexRef.current !== null) { + const [a, b] = [lastIndexRef.current, index].sort((x, y) => x - y) + for (let i = a; i <= b; i += 1) { + const sliceId = orderedIds[i] + if (sliceId !== undefined) next.add(sliceId) + } + } else if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + lastIndexRef.current = index + return next + }) + }, + [orderedIds] + ) + + const selectAll = useCallback((ids: ID[]) => { + setSelectedState(new Set(ids)) + }, []) + + const clear = useCallback(() => { + setSelectedState(new Set()) + lastIndexRef.current = null + }, []) + + const value = useMemo( + () => ({ + selected, + count: selected.size, + isSelected, + toggle, + selectAll, + clear, + setSelected + }), + [selected, isSelected, toggle, selectAll, clear, setSelected] + ) + + return ( + + {children} + + ) +} + +export const useTrackSelection = (): SelectionContextValue => { + const ctx = useContext(TrackSelectionContext) + if (!ctx) { + // Safe no-op fallback for usage outside the provider. + return { + selected: new Set(), + count: 0, + isSelected: () => false, + toggle: () => {}, + selectAll: () => {}, + clear: () => {}, + setSelected: () => {} + } + } + return ctx +} diff --git a/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx b/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx index aeaef0a1c3a..f4a99a34b57 100644 --- a/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx +++ b/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx @@ -24,6 +24,9 @@ import { CollectionDogEar } from 'components/collection' import { CollectionHeader } from 'components/collection/desktop/CollectionHeader' import { PlaylistEditModeBar } from 'components/collection/desktop/edit-mode/PlaylistEditModeBar' import { PlaylistEditModeProvider } from 'components/collection/desktop/edit-mode/PlaylistEditModeContext' +import { TrackBulkActionsBar } from 'components/collection/desktop/edit-mode/tracks/TrackBulkActionsBar' +import { TrackHistoryProvider } from 'components/collection/desktop/edit-mode/tracks/TrackHistoryContext' +import { TrackSelectionProvider } from 'components/collection/desktop/edit-mode/tracks/TrackSelectionContext' import FilterInput from 'components/filter-input/FilterInput' import Page from 'components/page/Page' import { SuggestedTracks } from 'components/suggested-tracks' @@ -300,92 +303,108 @@ const CollectionPage = ({ type }: CollectionPageProps) => { ) : null const collectionMessages = getMessages(isAlbum ? 'album' : 'playlist') + const orderedTrackIds = dataSource + .map((t: CollectionTrack) => t.track_id) + .filter((id): id is number => typeof id === 'number') return ( - - - -
{topSection}
- {!pageLoading && isEmpty ? ( - - ) : !pageLoading && - tracks.status === Status.SUCCESS && - dataSource.length === 0 ? ( - - ) : ( -
- + + + + -
- )} -
+
{topSection}
+ {playlistId != null ? ( + + ) : null} + {!pageLoading && isEmpty ? ( + + ) : !pageLoading && + tracks.status === Status.SUCCESS && + dataSource.length === 0 ? ( + + ) : ( +
+ +
+ )} + - {playlistId != null && isOwner && !isAlbum ? ( - - - - - ) : null} - -
+ {playlistId != null && isOwner && !isAlbum ? ( + + + + + ) : null} + + + +
) }