diff --git a/packages/common/src/store/ui/modals/create-playlist-modal/index.ts b/packages/common/src/store/ui/modals/create-playlist-modal/index.ts new file mode 100644 index 00000000000..e1b8f1d5306 --- /dev/null +++ b/packages/common/src/store/ui/modals/create-playlist-modal/index.ts @@ -0,0 +1,21 @@ +import { createModal } from '../createModal' + +export type CreatePlaylistModalState = { + isAlbum?: boolean + initTrackId?: number +} + +const createPlaylistModal = createModal({ + reducerPath: 'CreatePlaylistModal', + initialState: { + isOpen: false, + isAlbum: false + }, + sliceSelector: (state) => state.ui.modals +}) + +export const { + hook: useCreatePlaylistModal, + reducer: createPlaylistModalReducer, + actions: createPlaylistModalActions +} = createPlaylistModal diff --git a/packages/common/src/store/ui/modals/index.ts b/packages/common/src/store/ui/modals/index.ts index 0c79117f184..c82a3b8a28b 100644 --- a/packages/common/src/store/ui/modals/index.ts +++ b/packages/common/src/store/ui/modals/index.ts @@ -41,3 +41,4 @@ export * from './receive-tokens-modal' export * from './send-tokens-modal' export * from './coin-success-modal' export * from './fan-club-details-modal' +export * from './create-playlist-modal' diff --git a/packages/common/src/store/ui/modals/parentSlice.ts b/packages/common/src/store/ui/modals/parentSlice.ts index c9edbd4b658..aa7743574a9 100644 --- a/packages/common/src/store/ui/modals/parentSlice.ts +++ b/packages/common/src/store/ui/modals/parentSlice.ts @@ -83,7 +83,8 @@ export const initialState: BasicModalsState = { CoinSuccessModal: { isOpen: false }, FanClubDetailsModal: { isOpen: false }, VerificationSuccess: { isOpen: false }, - VerificationError: { isOpen: false } + VerificationError: { isOpen: false }, + CreatePlaylistModal: { 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 d8a50ee311f..102820cd0f0 100644 --- a/packages/common/src/store/ui/modals/reducers.ts +++ b/packages/common/src/store/ui/modals/reducers.ts @@ -12,6 +12,7 @@ import { coinflowWithdrawModalReducer } from './coinflow-withdraw-modal' import { connectedWalletsModalReducer } from './connected-wallets-modal' import { chatBlastModalReducer } from './create-chat-blast-modal' import { createChatModalReducer } from './create-chat-modal' +import { createPlaylistModalReducer } from './create-playlist-modal' import { deleteTrackConfirmationModalReducer } from './delete-track-confirmation-modal' import { downloadTrackArchiveModalReducer } from './download-track-archive-modal' import { earlyReleaseConfirmationModalReducer } from './early-release-confirmation-modal' @@ -92,7 +93,8 @@ const combinedReducers = combineReducers({ ReceiveTokensModal: receiveTokensModalReducer, SendTokensModal: sendTokensModalReducer, CoinSuccessModal: coinSuccessModalReducer, - FanClubDetailsModal: fanClubDetailsModalReducer + FanClubDetailsModal: fanClubDetailsModalReducer, + CreatePlaylistModal: createPlaylistModalReducer }) /** diff --git a/packages/common/src/store/ui/modals/types.ts b/packages/common/src/store/ui/modals/types.ts index 7a5076fb250..f589e6d2d9f 100644 --- a/packages/common/src/store/ui/modals/types.ts +++ b/packages/common/src/store/ui/modals/types.ts @@ -119,6 +119,7 @@ export type Modals = | 'CoinSuccessModal' | 'VerificationSuccess' | 'VerificationError' + | 'CreatePlaylistModal' export type BasicModalsState = { [modal in Modals]: BaseModalState diff --git a/packages/web/src/components/create-playlist-modal/CreatePlaylistModal.tsx b/packages/web/src/components/create-playlist-modal/CreatePlaylistModal.tsx new file mode 100644 index 00000000000..c9660f37eb7 --- /dev/null +++ b/packages/web/src/components/create-playlist-modal/CreatePlaylistModal.tsx @@ -0,0 +1,192 @@ +import { useCallback, useState } from 'react' + +import { CreatePlaylistSource } from '@audius/common/models' +import { + cacheCollectionsActions, + useCreatePlaylistModal +} from '@audius/common/store' +import { getErrorMessage } from '@audius/common/utils' +import { + Button, + Flex, + IconPlaylists, + Modal, + ModalContent, + ModalFooter, + ModalHeader, + ModalTitle, + TextArea, + TextInput +} from '@audius/harmony' +import { useDispatch } from 'react-redux' + +import UploadArtwork from 'components/upload/UploadArtwork' +import { resizeImage } from 'utils/imageProcessingUtil' + +const { createPlaylist, createAlbum } = cacheCollectionsActions + +const messages = { + createPlaylistTitle: 'Create New Playlist', + createAlbumTitle: 'Create New Album', + nameLabel: 'Playlist Name', + nameLabelAlbum: 'Album Name', + namePlaceholder: 'Give your playlist a name', + namePlaceholderAlbum: 'Give your album a name', + descriptionLabel: 'Description', + descriptionPlaceholder: 'Describe what makes this special (optional)', + cancel: 'Cancel', + create: 'Create', + defaultName: 'New Playlist', + defaultAlbumName: 'New Album' +} + +type ArtworkValue = { + url: string + file: File + source?: string +} | null + +export const CreatePlaylistModal = () => { + const dispatch = useDispatch() + const { isOpen, onClose, onClosed, data } = useCreatePlaylistModal() + const { isAlbum = false, initTrackId } = data + + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [artwork, setArtwork] = useState(null) + const [imageProcessingError, setImageProcessingError] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + + const reset = useCallback(() => { + setName('') + setDescription('') + setArtwork(null) + setImageProcessingError(false) + setIsSubmitting(false) + }, []) + + const handleClose = useCallback(() => { + onClose() + }, [onClose]) + + const handleClosed = useCallback(() => { + reset() + onClosed() + }, [onClosed, reset]) + + const handleDropArtwork = useCallback( + async (selectedFiles: File[], source: string) => { + try { + let file = selectedFiles[0] + file = await resizeImage(file) + // @ts-ignore writing to read-only property; matches ArtworkField pattern + file.name = selectedFiles[0].name + const url = URL.createObjectURL(file) + setArtwork({ url, file, source }) + setImageProcessingError(false) + } catch (err) { + // eslint-disable-next-line no-console + console.error(getErrorMessage(err)) + setImageProcessingError(true) + } + }, + [] + ) + + const handleRemoveArtwork = useCallback(() => { + setArtwork(null) + }, []) + + const handleSubmit = useCallback(() => { + setIsSubmitting(true) + const trimmedName = name.trim() + const trimmedDescription = description.trim() + const playlistName = + trimmedName || + (isAlbum ? messages.defaultAlbumName : messages.defaultName) + const action = isAlbum ? createAlbum : createPlaylist + dispatch( + action( + { + playlist_name: playlistName, + description: trimmedDescription || undefined, + // Cast: optimisticallySavePlaylist accepts the form-shape artwork. + ...(artwork ? { artwork: artwork as any } : {}) + }, + CreatePlaylistSource.NAV, + initTrackId, + 'route' + ) + ) + onClose() + }, [name, description, artwork, isAlbum, initTrackId, dispatch, onClose]) + + const title = isAlbum + ? messages.createAlbumTitle + : messages.createPlaylistTitle + const nameLabel = isAlbum ? messages.nameLabelAlbum : messages.nameLabel + const namePlaceholder = isAlbum + ? messages.namePlaceholderAlbum + : messages.namePlaceholder + + return ( + + + } /> + + + + + + + setName(e.target.value)} + maxLength={64} + /> +