From b81b646e593c45e61dd13bf462e69ea807a8e3d6 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Fri, 29 May 2026 17:38:10 -0700 Subject: [PATCH 1/2] feat(collection): move add-by-URL into SuggestedTracks panel; remove confusing IconLink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ray flagged the copy-link-looking IconButton on the playlist owner header as confusing — its actual behavior (open a "paste track URLs" modal) wasn't discoverable from the icon, and adding tracks by URL belongs in the same section users already think of as "where I add tracks." Per Julian's UX suggestion, the natural action for "add by URL" is to paste — no separate modal or icon required. Moves the feature inline into the SuggestedTracks panel (already titled "Add some tracks", already the obvious place to add) as an always-visible TextInput that paste-detects. Changes: - packages/web/src/components/suggested-tracks/SuggestedTracks.tsx: add an always-visible TextInput row between the heading and the collapsible suggestion list. onPaste: if the clipboard contains at least one valid Audius track URL (parsed via getPathFromTrackUrl), prevent default, resolve via sdk.tracks.getBulkTracks, run the same dedupe + capacity + delay-spaced dispatch the modal did, toast a summary. onKeyDown(Enter) handles a typed URL too. If the pasted text isn't a URL the input behaves like a normal text field — search-field UX. Routes adds through draftCollectionCache when the collection is unsaved (mirrors the suggested-row Add button's draft branch). - packages/web/src/components/collection/desktop/OwnerActionButtons.tsx: drop the IconLink IconButton and its modal-opener handler / messages / hook usage. Header is now lean (Edit, Share, Publish, OverflowMenu). - Delete the now-unused AddTracksByUrlModal entirely: the React component (packages/web/src/components/add-tracks-by-url-modal/), the common store slice (packages/common/src/store/ui/modals/add-tracks-by-url-modal/), and its registrations in parentSlice, reducers, types, the modals re-export, and Modals.tsx. No other call sites. Feature parity preserved: - Same parser (one URL per line, or comma/tab separated) — multi-URL paste still works. - Same dedupe vs existing playlist contents. - Same PLAYLIST_TRACK_LIMIT (100) capacity check. - Same getBulkTracks SDK call + userTrackMetadataFromSDK transform. - Same summary toast format (added • duplicates • not found • invalid • over-limit). - Same playlist-only / non-DDEX surface (SuggestedTracks already gated on isOwner && !isAlbum && playlist, mirrors the old IconLink condition). Verification: - tsc --noEmit clean in packages/common and packages/web. - Visual smoke needs the full protocol stack (audius web requires Docker per CLAUDE.md) — flagging on the PR for manual review. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../modals/add-tracks-by-url-modal/index.ts | 22 -- packages/common/src/store/ui/modals/index.ts | 1 - .../common/src/store/ui/modals/parentSlice.ts | 3 +- .../common/src/store/ui/modals/reducers.ts | 4 +- packages/common/src/store/ui/modals/types.ts | 1 - .../AddTracksByUrlModal.tsx | 289 ------------------ .../collection/desktop/OwnerActionButtons.tsx | 32 +- .../suggested-tracks/SuggestedTracks.tsx | 271 +++++++++++++++- packages/web/src/pages/modals/Modals.tsx | 4 +- 9 files changed, 272 insertions(+), 355 deletions(-) delete mode 100644 packages/common/src/store/ui/modals/add-tracks-by-url-modal/index.ts delete mode 100644 packages/web/src/components/add-tracks-by-url-modal/AddTracksByUrlModal.tsx diff --git a/packages/common/src/store/ui/modals/add-tracks-by-url-modal/index.ts b/packages/common/src/store/ui/modals/add-tracks-by-url-modal/index.ts deleted file mode 100644 index b7ecf9ea476..00000000000 --- a/packages/common/src/store/ui/modals/add-tracks-by-url-modal/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ID } from '../../../../models' -import { createModal } from '../createModal' - -export type AddTracksByUrlModalState = { - collectionId?: ID - isAlbum?: boolean -} - -const addTracksByUrlModal = createModal({ - reducerPath: 'AddTracksByUrlModal', - initialState: { - isOpen: false, - isAlbum: false - }, - sliceSelector: (state) => state.ui.modals -}) - -export const { - hook: useAddTracksByUrlModal, - reducer: addTracksByUrlModalReducer, - actions: addTracksByUrlModalActions -} = addTracksByUrlModal diff --git a/packages/common/src/store/ui/modals/index.ts b/packages/common/src/store/ui/modals/index.ts index 3f1b34bb7a4..2de9f131c2e 100644 --- a/packages/common/src/store/ui/modals/index.ts +++ b/packages/common/src/store/ui/modals/index.ts @@ -43,4 +43,3 @@ export * from './coin-success-modal' export * from './fan-club-details-modal' export * from './create-playlist-modal' export * from './duplicate-playlist-modal' -export * from './add-tracks-by-url-modal' diff --git a/packages/common/src/store/ui/modals/parentSlice.ts b/packages/common/src/store/ui/modals/parentSlice.ts index f86425b4e57..25354fbe5d6 100644 --- a/packages/common/src/store/ui/modals/parentSlice.ts +++ b/packages/common/src/store/ui/modals/parentSlice.ts @@ -86,8 +86,7 @@ export const initialState: BasicModalsState = { VerificationSuccess: { isOpen: false }, VerificationError: { isOpen: false }, CreatePlaylistModal: { isOpen: false }, - DuplicatePlaylistModal: { isOpen: false }, - AddTracksByUrlModal: { isOpen: false } + DuplicatePlaylistModal: { isOpen: false } } const slice = createSlice({ diff --git a/packages/common/src/store/ui/modals/reducers.ts b/packages/common/src/store/ui/modals/reducers.ts index e4b69013ba9..b338b219b62 100644 --- a/packages/common/src/store/ui/modals/reducers.ts +++ b/packages/common/src/store/ui/modals/reducers.ts @@ -1,7 +1,6 @@ import { Action, combineReducers, Reducer } from '@reduxjs/toolkit' import { addCashModalReducer } from './add-cash-modal' -import { addTracksByUrlModalReducer } from './add-tracks-by-url-modal' import { albumTrackRemoveConfirmationModalReducer } from './album-track-remove-confirmation-modal' import { announcementModalReducer } from './announcement-modal' import { artistPickModalReducer } from './artist-pick-modal' @@ -97,8 +96,7 @@ const combinedReducers = combineReducers({ CoinSuccessModal: coinSuccessModalReducer, FanClubDetailsModal: fanClubDetailsModalReducer, CreatePlaylistModal: createPlaylistModalReducer, - DuplicatePlaylistModal: duplicatePlaylistModalReducer, - AddTracksByUrlModal: addTracksByUrlModalReducer + DuplicatePlaylistModal: duplicatePlaylistModalReducer }) /** diff --git a/packages/common/src/store/ui/modals/types.ts b/packages/common/src/store/ui/modals/types.ts index bf6b6d977d3..6e2787d3193 100644 --- a/packages/common/src/store/ui/modals/types.ts +++ b/packages/common/src/store/ui/modals/types.ts @@ -122,7 +122,6 @@ export type Modals = | 'VerificationError' | 'CreatePlaylistModal' | 'DuplicatePlaylistModal' - | 'AddTracksByUrlModal' export type BasicModalsState = { [modal in Modals]: BaseModalState diff --git a/packages/web/src/components/add-tracks-by-url-modal/AddTracksByUrlModal.tsx b/packages/web/src/components/add-tracks-by-url-modal/AddTracksByUrlModal.tsx deleted file mode 100644 index 83b57255d2c..00000000000 --- a/packages/web/src/components/add-tracks-by-url-modal/AddTracksByUrlModal.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import { useCallback, useMemo, useState } from 'react' - -import { userTrackMetadataFromSDK } from '@audius/common/adapters' -import { - useCollection, - useQueryContext, - useCurrentUserId -} from '@audius/common/api' -import { - cacheCollectionsActions, - toastActions, - useAddTracksByUrlModal -} from '@audius/common/store' -import { getErrorMessage, getPathFromTrackUrl } from '@audius/common/utils' -import { - Button, - Flex, - IconLink, - Modal, - ModalContent, - ModalFooter, - ModalHeader, - ModalTitle, - Text, - TextArea -} from '@audius/harmony' -import { OptionalId } from '@audius/sdk' -import { useDispatch } from 'react-redux' - -const { addTrackToPlaylist } = cacheCollectionsActions -const { toast } = toastActions - -const PLAYLIST_TRACK_LIMIT = 100 -const INTER_DISPATCH_DELAY_MS = 30 - -const messages = { - title: 'Add Tracks by URL', - helper: - 'Paste Audius track links — one per line, or separated by commas or tabs. Up to 100 tracks per playlist.', - textareaLabel: 'Track URLs', - textareaPlaceholder: - 'https://audius.co/artist/track-one\nhttps://audius.co/artist/track-two', - cancel: 'Cancel', - addTracks: 'Add Tracks', - noValidLinks: 'No valid Audius track links found.', - playlistFull: (limit: number) => - `This playlist already has ${limit} tracks — can't add more.`, - resolveFailed: 'Could not load tracks. Check your connection and try again.', - summary: ({ - added, - duplicates, - invalid, - unresolved, - overLimit - }: { - added: number - duplicates: number - invalid: number - unresolved: number - overLimit: number - }) => { - const parts: string[] = [] - if (added > 0) { - parts.push(`Added ${added} ${added === 1 ? 'track' : 'tracks'}`) - } - if (duplicates > 0) { - parts.push(`${duplicates} already in playlist`) - } - if (unresolved > 0) { - parts.push(`${unresolved} not found`) - } - if (invalid > 0) { - parts.push(`${invalid} invalid ${invalid === 1 ? 'link' : 'links'}`) - } - if (overLimit > 0) { - parts.push(`${overLimit} skipped (playlist limit reached)`) - } - return parts.length > 0 ? parts.join(' • ') : 'No tracks were added.' - } -} - -type ParseResult = { - permalinks: string[] - invalidCount: number -} - -const parseTrackUrls = (raw: string): ParseResult => { - const lines = raw - .split(/[\n\r,\t]+/) - .map((s) => s.trim()) - .filter(Boolean) - const permalinks: string[] = [] - const seen = new Set() - let invalidCount = 0 - for (const line of lines) { - const permalink = getPathFromTrackUrl(line) - if (permalink) { - if (!seen.has(permalink)) { - seen.add(permalink) - permalinks.push(permalink) - } - } else { - invalidCount += 1 - } - } - return { permalinks, invalidCount } -} - -export const AddTracksByUrlModal = () => { - const dispatch = useDispatch() - const { isOpen, onClose, onClosed, data } = useAddTracksByUrlModal() - const { collectionId } = data - const { audiusSdk } = useQueryContext() - const { data: currentUserId } = useCurrentUserId() - - const { data: existingTrackIdSet } = useCollection(collectionId, { - select: (c) => - new Set(c?.playlist_contents.track_ids.map((t) => t.track) ?? []) - }) - const currentTrackCount = existingTrackIdSet?.size ?? 0 - const remainingCapacity = Math.max( - 0, - PLAYLIST_TRACK_LIMIT - currentTrackCount - ) - - const [input, setInput] = useState('') - const [isSubmitting, setIsSubmitting] = useState(false) - - const parsed = useMemo(() => parseTrackUrls(input), [input]) - const livePreview = useMemo(() => { - if (!input.trim()) return null - return `${parsed.permalinks.length} valid${ - parsed.invalidCount > 0 ? ` • ${parsed.invalidCount} invalid` : '' - }` - }, [input, parsed]) - - const reset = useCallback(() => { - setInput('') - setIsSubmitting(false) - }, []) - - const handleClose = useCallback(() => { - onClose() - }, [onClose]) - - const handleClosed = useCallback(() => { - reset() - onClosed() - }, [onClosed, reset]) - - const handleSubmit = useCallback(async () => { - if (!collectionId) return - if (remainingCapacity === 0) { - dispatch(toast({ content: messages.playlistFull(PLAYLIST_TRACK_LIMIT) })) - return - } - const { permalinks, invalidCount } = parsed - if (permalinks.length === 0) { - dispatch(toast({ content: messages.noValidLinks })) - return - } - setIsSubmitting(true) - try { - const sdk = await audiusSdk() - const { data: sdkData = [] } = await sdk.tracks.getBulkTracks({ - permalink: permalinks, - userId: OptionalId.parse(currentUserId) - }) - const resolvedTracks = sdkData - .map((t) => userTrackMetadataFromSDK(t)) - .filter((t): t is NonNullable => t != null) - - const unresolved = permalinks.length - resolvedTracks.length - const seenIds = new Set() - const newTracks: typeof resolvedTracks = [] - let duplicates = 0 - for (const track of resolvedTracks) { - if (seenIds.has(track.track_id)) continue - seenIds.add(track.track_id) - if (existingTrackIdSet?.has(track.track_id)) { - duplicates += 1 - } else { - newTracks.push(track) - } - } - - const tracksToAdd = newTracks.slice(0, remainingCapacity) - const overLimit = newTracks.length - tracksToAdd.length - - for (const track of tracksToAdd) { - dispatch( - addTrackToPlaylist(track.track_id, collectionId, { silent: true }) - ) - // Space out dispatches so each saga's optimistic update lands - // before the next one reads the playlist state. - // eslint-disable-next-line no-await-in-loop - await new Promise((resolve) => - setTimeout(resolve, INTER_DISPATCH_DELAY_MS) - ) - } - - dispatch( - toast({ - content: messages.summary({ - added: tracksToAdd.length, - duplicates, - invalid: invalidCount, - unresolved, - overLimit - }) - }) - ) - setInput('') - onClose() - } catch (err) { - // eslint-disable-next-line no-console - console.error(getErrorMessage(err)) - dispatch(toast({ content: messages.resolveFailed })) - } finally { - setIsSubmitting(false) - } - }, [ - audiusSdk, - collectionId, - currentUserId, - dispatch, - existingTrackIdSet, - onClose, - parsed, - remainingCapacity - ]) - - const canSubmit = - !isSubmitting && parsed.permalinks.length > 0 && remainingCapacity > 0 - - return ( - - - } /> - - - - - {messages.helper} - -