Skip to content

Commit 87badc2

Browse files
dylanjeffersclaude
andauthored
feat(web): replace quick-create playlist flow with a modal (#14318)
## Summary Replaces the one-shot "quick create" playlist flow (which immediately dispatched `createPlaylist({ playlist_name: 'New Playlist' })` and routed the user into the edit page) with a real Create Playlist modal that captures title, optional description, and optional artwork up front. - Adds a `CreatePlaylistModal` Redux modal slice (using the existing `createModal` helper), wired into the modals reducer / types / parent state. - Adds the `CreatePlaylistModal` component in `packages/web/src/components/create-playlist-modal/`. Uses Harmony `Modal` + `TextInput` + `TextArea` + the existing `UploadArtwork` component, plus the `resizeImage` pipeline for artwork. - The sidebar "New → Create Playlist" popup item (`CreatePlaylistLibraryItemButton`) and the empty-library nav link (`EmptyLibraryNavLink`) now open the modal instead of dispatching `createPlaylist` directly. - Playlists still default to private (enforced by the existing `optimisticallySavePlaylist` saga) and the saga still routes the user to the new playlist page after creation, so the post-create UX is unchanged. This is the first chunk of a larger playlist-editor UX initiative; follow-up PRs will add the duplicate-playlist secondary action and paste-tracks-by-URL. ## Test plan - [ ] Sign in to the web app - [ ] Left nav → click the `+` (New) button → Create Playlist - [ ] Verify the new modal opens with empty fields, an optional artwork uploader, a 64-char-capped title field, and a 1000-char description with counter - [ ] Create with empty title → playlist gets named "New Playlist", lands on the edit page, default visibility is Hidden/private - [ ] Create with a custom title + description + artwork → all three are reflected on the edit page - [ ] Open modal, click Cancel or backdrop → no playlist is created - [ ] Empty-library nav link in sidebar also opens this modal (sign in with a brand-new account that has zero playlists, or temporarily clear the library) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ee1362d commit 87badc2

9 files changed

Lines changed: 233 additions & 31 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { createModal } from '../createModal'
2+
3+
export type CreatePlaylistModalState = {
4+
isAlbum?: boolean
5+
initTrackId?: number
6+
}
7+
8+
const createPlaylistModal = createModal<CreatePlaylistModalState>({
9+
reducerPath: 'CreatePlaylistModal',
10+
initialState: {
11+
isOpen: false,
12+
isAlbum: false
13+
},
14+
sliceSelector: (state) => state.ui.modals
15+
})
16+
17+
export const {
18+
hook: useCreatePlaylistModal,
19+
reducer: createPlaylistModalReducer,
20+
actions: createPlaylistModalActions
21+
} = createPlaylistModal

packages/common/src/store/ui/modals/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,4 @@ export * from './receive-tokens-modal'
4141
export * from './send-tokens-modal'
4242
export * from './coin-success-modal'
4343
export * from './fan-club-details-modal'
44+
export * from './create-playlist-modal'

packages/common/src/store/ui/modals/parentSlice.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ export const initialState: BasicModalsState = {
8484
CoinSuccessModal: { isOpen: false },
8585
FanClubDetailsModal: { isOpen: false },
8686
VerificationSuccess: { isOpen: false },
87-
VerificationError: { isOpen: false }
87+
VerificationError: { isOpen: false },
88+
CreatePlaylistModal: { isOpen: false }
8889
}
8990

9091
const slice = createSlice({

packages/common/src/store/ui/modals/reducers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { coinflowWithdrawModalReducer } from './coinflow-withdraw-modal'
1212
import { connectedWalletsModalReducer } from './connected-wallets-modal'
1313
import { chatBlastModalReducer } from './create-chat-blast-modal'
1414
import { createChatModalReducer } from './create-chat-modal'
15+
import { createPlaylistModalReducer } from './create-playlist-modal'
1516
import { deleteTrackConfirmationModalReducer } from './delete-track-confirmation-modal'
1617
import { downloadTrackArchiveModalReducer } from './download-track-archive-modal'
1718
import { earlyReleaseConfirmationModalReducer } from './early-release-confirmation-modal'
@@ -92,7 +93,8 @@ const combinedReducers = combineReducers({
9293
ReceiveTokensModal: receiveTokensModalReducer,
9394
SendTokensModal: sendTokensModalReducer,
9495
CoinSuccessModal: coinSuccessModalReducer,
95-
FanClubDetailsModal: fanClubDetailsModalReducer
96+
FanClubDetailsModal: fanClubDetailsModalReducer,
97+
CreatePlaylistModal: createPlaylistModalReducer
9698
})
9799

98100
/**

packages/common/src/store/ui/modals/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export type Modals =
120120
| 'CoinSuccessModal'
121121
| 'VerificationSuccess'
122122
| 'VerificationError'
123+
| 'CreatePlaylistModal'
123124

124125
export type BasicModalsState = {
125126
[modal in Modals]: BaseModalState
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { useCallback, useState } from 'react'
2+
3+
import { CreatePlaylistSource } from '@audius/common/models'
4+
import {
5+
cacheCollectionsActions,
6+
useCreatePlaylistModal
7+
} from '@audius/common/store'
8+
import { getErrorMessage } from '@audius/common/utils'
9+
import {
10+
Button,
11+
Flex,
12+
IconPlaylists,
13+
Modal,
14+
ModalContent,
15+
ModalFooter,
16+
ModalHeader,
17+
ModalTitle,
18+
TextArea,
19+
TextInput
20+
} from '@audius/harmony'
21+
import { useDispatch } from 'react-redux'
22+
23+
import UploadArtwork from 'components/upload/UploadArtwork'
24+
import { resizeImage } from 'utils/imageProcessingUtil'
25+
26+
const { createPlaylist, createAlbum } = cacheCollectionsActions
27+
28+
const messages = {
29+
createPlaylistTitle: 'Create New Playlist',
30+
createAlbumTitle: 'Create New Album',
31+
nameLabel: 'Playlist Name',
32+
nameLabelAlbum: 'Album Name',
33+
namePlaceholder: 'Give your playlist a name',
34+
namePlaceholderAlbum: 'Give your album a name',
35+
descriptionLabel: 'Description',
36+
descriptionPlaceholder: 'Describe what makes this special (optional)',
37+
cancel: 'Cancel',
38+
create: 'Create',
39+
defaultName: 'New Playlist',
40+
defaultAlbumName: 'New Album'
41+
}
42+
43+
type ArtworkValue = {
44+
url: string
45+
file: File
46+
source?: string
47+
} | null
48+
49+
export const CreatePlaylistModal = () => {
50+
const dispatch = useDispatch()
51+
const { isOpen, onClose, onClosed, data } = useCreatePlaylistModal()
52+
const { isAlbum = false, initTrackId } = data
53+
54+
const [name, setName] = useState('')
55+
const [description, setDescription] = useState('')
56+
const [artwork, setArtwork] = useState<ArtworkValue>(null)
57+
const [imageProcessingError, setImageProcessingError] = useState(false)
58+
const [isSubmitting, setIsSubmitting] = useState(false)
59+
60+
const reset = useCallback(() => {
61+
setName('')
62+
setDescription('')
63+
setArtwork(null)
64+
setImageProcessingError(false)
65+
setIsSubmitting(false)
66+
}, [])
67+
68+
const handleClose = useCallback(() => {
69+
onClose()
70+
}, [onClose])
71+
72+
const handleClosed = useCallback(() => {
73+
reset()
74+
onClosed()
75+
}, [onClosed, reset])
76+
77+
const handleDropArtwork = useCallback(
78+
async (selectedFiles: File[], source: string) => {
79+
try {
80+
let file = selectedFiles[0]
81+
file = await resizeImage(file)
82+
// @ts-ignore writing to read-only property; matches ArtworkField pattern
83+
file.name = selectedFiles[0].name
84+
const url = URL.createObjectURL(file)
85+
setArtwork({ url, file, source })
86+
setImageProcessingError(false)
87+
} catch (err) {
88+
// eslint-disable-next-line no-console
89+
console.error(getErrorMessage(err))
90+
setImageProcessingError(true)
91+
}
92+
},
93+
[]
94+
)
95+
96+
const handleRemoveArtwork = useCallback(() => {
97+
setArtwork(null)
98+
}, [])
99+
100+
const handleSubmit = useCallback(() => {
101+
setIsSubmitting(true)
102+
const trimmedName = name.trim()
103+
const trimmedDescription = description.trim()
104+
const playlistName =
105+
trimmedName ||
106+
(isAlbum ? messages.defaultAlbumName : messages.defaultName)
107+
const action = isAlbum ? createAlbum : createPlaylist
108+
dispatch(
109+
action(
110+
{
111+
playlist_name: playlistName,
112+
description: trimmedDescription || undefined,
113+
// Cast: optimisticallySavePlaylist accepts the form-shape artwork.
114+
...(artwork ? { artwork: artwork as any } : {})
115+
},
116+
CreatePlaylistSource.NAV,
117+
initTrackId,
118+
'route'
119+
)
120+
)
121+
onClose()
122+
}, [name, description, artwork, isAlbum, initTrackId, dispatch, onClose])
123+
124+
const title = isAlbum
125+
? messages.createAlbumTitle
126+
: messages.createPlaylistTitle
127+
const nameLabel = isAlbum ? messages.nameLabelAlbum : messages.nameLabel
128+
const namePlaceholder = isAlbum
129+
? messages.namePlaceholderAlbum
130+
: messages.namePlaceholder
131+
132+
return (
133+
<Modal
134+
isOpen={isOpen}
135+
onClose={handleClose}
136+
onClosed={handleClosed}
137+
size='small'
138+
>
139+
<ModalHeader onClose={handleClose}>
140+
<ModalTitle title={title} icon={<IconPlaylists />} />
141+
</ModalHeader>
142+
<ModalContent>
143+
<Flex direction='column' gap='l'>
144+
<Flex justifyContent='center'>
145+
<UploadArtwork
146+
artworkUrl={artwork?.url}
147+
onDropArtwork={handleDropArtwork}
148+
onRemoveArtwork={artwork ? handleRemoveArtwork : undefined}
149+
imageProcessingError={imageProcessingError}
150+
size='small'
151+
isUpload
152+
/>
153+
</Flex>
154+
<TextInput
155+
label={nameLabel}
156+
placeholder={namePlaceholder}
157+
value={name}
158+
onChange={(e) => setName(e.target.value)}
159+
maxLength={64}
160+
/>
161+
<TextArea
162+
aria-label={messages.descriptionLabel}
163+
placeholder={messages.descriptionPlaceholder}
164+
value={description}
165+
onChange={(e) => setDescription(e.target.value)}
166+
maxLength={1000}
167+
showMaxLength
168+
grows
169+
/>
170+
</Flex>
171+
</ModalContent>
172+
<ModalFooter>
173+
<Flex gap='xl' flex={1}>
174+
<Button variant='secondary' fullWidth onClick={handleClose}>
175+
{messages.cancel}
176+
</Button>
177+
<Button
178+
variant='primary'
179+
fullWidth
180+
isLoading={isSubmitting}
181+
disabled={isSubmitting}
182+
onClick={handleSubmit}
183+
>
184+
{messages.create}
185+
</Button>
186+
</Flex>
187+
</ModalFooter>
188+
</Modal>
189+
)
190+
}
191+
192+
export default CreatePlaylistModal

packages/web/src/components/nav/desktop/PlaylistLibrary/CreatePlaylistLibraryItemButton.tsx

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { useCallback, useMemo, useState } from 'react'
22

33
import { useCurrentAccount, useUpdatePlaylistLibrary } from '@audius/common/api'
4-
import { CreatePlaylistSource } from '@audius/common/models'
54
import {
6-
cacheCollectionsActions,
7-
playlistLibraryHelpers
5+
playlistLibraryHelpers,
6+
useCreatePlaylistModal
87
} from '@audius/common/store'
98
import {
109
IconButton,
@@ -14,40 +13,32 @@ import {
1413
PopupMenu,
1514
PopupMenuItem
1615
} from '@audius/harmony'
17-
import { useDispatch } from 'react-redux'
1816

1917
import { useRequiresAccountCallback } from 'hooks/useRequiresAccount'
2018

21-
const { createPlaylist } = cacheCollectionsActions
2219
const { addFolderToLibrary, constructPlaylistFolder } = playlistLibraryHelpers
2320

2421
const messages = {
2522
new: 'New',
2623
newPlaylistOrFolderTooltip: 'New Playlist or Folder',
2724
createPlaylist: 'Create Playlist',
2825
createFolder: 'Create Folder',
29-
newPlaylistName: 'New Playlist',
3026
newFolderName: 'New Folder'
3127
}
3228

3329
// Allows user to create a playlist or playlist-folder
3430
export const CreatePlaylistLibraryItemButton = () => {
35-
const dispatch = useDispatch()
3631
const { data: library } = useCurrentAccount({
3732
select: (account) => account?.playlistLibrary
3833
})
3934
const { mutate: updatePlaylistLibrary } = useUpdatePlaylistLibrary()
35+
const { onOpen: openCreatePlaylistModal } = useCreatePlaylistModal()
4036
const [isActive, setIsActive] = useState(false)
4137
const [isHovered, setIsHovered] = useState(false)
4238

4339
const handleSubmitPlaylist = useCallback(() => {
44-
dispatch(
45-
createPlaylist(
46-
{ playlist_name: messages.newPlaylistName },
47-
CreatePlaylistSource.NAV
48-
)
49-
)
50-
}, [dispatch])
40+
openCreatePlaylistModal({ isAlbum: false })
41+
}, [openCreatePlaylistModal])
5142

5243
const handleSubmitFolder = useCallback(() => {
5344
if (!library) return null

packages/web/src/components/nav/desktop/PlaylistLibrary/EmptyLibraryNavLink.tsx

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,19 @@
11
import { useCallback } from 'react'
22

3-
import { CreatePlaylistSource } from '@audius/common/models'
4-
import { cacheCollectionsActions } from '@audius/common/store'
5-
import { useDispatch } from 'react-redux'
3+
import { useCreatePlaylistModal } from '@audius/common/store'
64

75
import { LeftNavLink } from '../LeftNavLink'
8-
const { createPlaylist } = cacheCollectionsActions
96

107
const messages = {
11-
empty: 'Create your first playlist!',
12-
newPlaylistName: 'New Playlist'
8+
empty: 'Create your first playlist!'
139
}
1410

1511
export const EmptyLibraryNavLink = () => {
16-
const dispatch = useDispatch()
12+
const { onOpen: openCreatePlaylistModal } = useCreatePlaylistModal()
1713

1814
const handleCreatePlaylist = useCallback(() => {
19-
dispatch(
20-
createPlaylist(
21-
{ playlist_name: messages.newPlaylistName },
22-
CreatePlaylistSource.NAV
23-
)
24-
)
25-
}, [dispatch])
15+
openCreatePlaylistModal({ isAlbum: false })
16+
}, [openCreatePlaylistModal])
2617

2718
return (
2819
<LeftNavLink disabled onClick={handleCreatePlaylist}>

packages/web/src/pages/modals/Modals.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { CoinSuccessModal } from 'components/CoinSuccessModal'
66
import AppCTAModal from 'components/app-cta-modal/AppCTAModal'
77
import BrowserPushConfirmationModal from 'components/browser-push-confirmation-modal/BrowserPushConfirmationModal'
88
import ConfirmerPreview from 'components/confirmer-preview/ConfirmerPreview'
9+
import { CreatePlaylistModal } from 'components/create-playlist-modal/CreatePlaylistModal'
910
import EmbedModal from 'components/embed-modal/EmbedModal'
1011
import { FeatureFlagOverrideModal } from 'components/feature-flag-override-modal'
1112
import FirstUploadModal from 'components/first-upload-modal/FirstUploadModal'
@@ -37,7 +38,8 @@ const commonModalsMap: { [Modal in ModalTypes]?: ComponentType } = {
3738
CommentSettings: CommentSettingsModal,
3839
BrowserPushPermissionConfirmation: BrowserPushConfirmationModal,
3940
CreateChatModal,
40-
StripeOnRamp: StripeOnRampModal
41+
StripeOnRamp: StripeOnRampModal,
42+
CreatePlaylistModal
4143
}
4244

4345
const commonModals = Object.entries(commonModalsMap) as [

0 commit comments

Comments
 (0)