diff --git a/packages/common/src/store/ui/modals/duplicate-playlist-modal/index.ts b/packages/common/src/store/ui/modals/duplicate-playlist-modal/index.ts new file mode 100644 index 00000000000..5a98a2ff94f --- /dev/null +++ b/packages/common/src/store/ui/modals/duplicate-playlist-modal/index.ts @@ -0,0 +1,20 @@ +import { createModal } from '../createModal' + +export type DuplicatePlaylistModalState = { + isAlbum?: boolean +} + +const duplicatePlaylistModal = createModal({ + reducerPath: 'DuplicatePlaylistModal', + initialState: { + isOpen: false, + isAlbum: false + }, + sliceSelector: (state) => state.ui.modals +}) + +export const { + hook: useDuplicatePlaylistModal, + reducer: duplicatePlaylistModalReducer, + actions: duplicatePlaylistModalActions +} = duplicatePlaylistModal diff --git a/packages/common/src/store/ui/modals/index.ts b/packages/common/src/store/ui/modals/index.ts index c82a3b8a28b..2de9f131c2e 100644 --- a/packages/common/src/store/ui/modals/index.ts +++ b/packages/common/src/store/ui/modals/index.ts @@ -42,3 +42,4 @@ export * from './send-tokens-modal' export * from './coin-success-modal' export * from './fan-club-details-modal' export * from './create-playlist-modal' +export * from './duplicate-playlist-modal' diff --git a/packages/common/src/store/ui/modals/parentSlice.ts b/packages/common/src/store/ui/modals/parentSlice.ts index aa7743574a9..709dfd15720 100644 --- a/packages/common/src/store/ui/modals/parentSlice.ts +++ b/packages/common/src/store/ui/modals/parentSlice.ts @@ -84,7 +84,8 @@ export const initialState: BasicModalsState = { FanClubDetailsModal: { isOpen: false }, VerificationSuccess: { isOpen: false }, VerificationError: { isOpen: false }, - CreatePlaylistModal: { isOpen: false } + CreatePlaylistModal: { 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 102820cd0f0..b338b219b62 100644 --- a/packages/common/src/store/ui/modals/reducers.ts +++ b/packages/common/src/store/ui/modals/reducers.ts @@ -15,6 +15,7 @@ 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 { duplicatePlaylistModalReducer } from './duplicate-playlist-modal' import { earlyReleaseConfirmationModalReducer } from './early-release-confirmation-modal' import { editAccessConfirmationModalReducer } from './edit-access-confirmation-modal' import { externalWalletSignUpModalReducer } from './external-wallet-sign-up-modal' @@ -94,7 +95,8 @@ const combinedReducers = combineReducers({ SendTokensModal: sendTokensModalReducer, CoinSuccessModal: coinSuccessModalReducer, FanClubDetailsModal: fanClubDetailsModalReducer, - CreatePlaylistModal: createPlaylistModalReducer + CreatePlaylistModal: createPlaylistModalReducer, + DuplicatePlaylistModal: duplicatePlaylistModalReducer }) /** diff --git a/packages/common/src/store/ui/modals/types.ts b/packages/common/src/store/ui/modals/types.ts index f589e6d2d9f..9cd30c91f2d 100644 --- a/packages/common/src/store/ui/modals/types.ts +++ b/packages/common/src/store/ui/modals/types.ts @@ -120,6 +120,7 @@ export type Modals = | 'VerificationSuccess' | 'VerificationError' | 'CreatePlaylistModal' + | 'DuplicatePlaylistModal' export type BasicModalsState = { [modal in Modals]: BaseModalState diff --git a/packages/web/src/components/duplicate-playlist-modal/DuplicatePlaylistModal.tsx b/packages/web/src/components/duplicate-playlist-modal/DuplicatePlaylistModal.tsx new file mode 100644 index 00000000000..060fabedeb8 --- /dev/null +++ b/packages/web/src/components/duplicate-playlist-modal/DuplicatePlaylistModal.tsx @@ -0,0 +1,350 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { useCollectionByPermalink } from '@audius/common/api' +import { CreatePlaylistSource, SquareSizes } from '@audius/common/models' +import { + cacheCollectionsActions, + useDuplicatePlaylistModal +} from '@audius/common/store' +import { getErrorMessage, getPathFromPlaylistUrl } from '@audius/common/utils' +import { + Artwork, + Button, + Flex, + IconCopy, + Modal, + ModalContent, + ModalFooter, + ModalHeader, + ModalTitle, + Switch, + Text, + TextArea, + TextInput +} from '@audius/harmony' +import { useDispatch } from 'react-redux' + +import UploadArtwork from 'components/upload/UploadArtwork' +import { useCollectionCoverArt } from 'hooks/useCollectionCoverArt' +import { resizeImage } from 'utils/imageProcessingUtil' + +const { createPlaylist, createAlbum } = cacheCollectionsActions + +const messages = { + title: 'Duplicate Playlist', + titleAlbum: 'Duplicate Album', + urlLabel: 'Audius Playlist URL', + urlPlaceholder: 'https://audius.co/handle/playlist/your-playlist', + urlHelper: 'Paste a link to any public Audius playlist to copy its details.', + invalidUrl: 'Enter a valid Audius playlist URL', + notFound: 'We could not find that playlist. Check the link and try again.', + customizeTitle: 'Customize title', + customizeDescription: 'Customize description', + customizeArtwork: 'Customize artwork', + source: 'Source', + sourceTitle: 'Title', + sourceDescription: 'Description', + sourceArtwork: 'Artwork', + newTitleLabel: 'New playlist name', + newTitleLabelAlbum: 'New album name', + newTitlePlaceholder: 'Give your playlist a name', + newTitlePlaceholderAlbum: 'Give your album a name', + newDescriptionLabel: 'New description', + newDescriptionPlaceholder: 'Describe what makes this special', + cancel: 'Cancel', + duplicate: 'Duplicate', + trackCopyNote: + 'Tracks are not copied automatically — you can add them after duplicating.', + copySuffix: ' (Copy)' +} + +type ArtworkValue = { + url: string + file: File + source?: string +} | null + +export const DuplicatePlaylistModal = () => { + const dispatch = useDispatch() + const { isOpen, onClose, onClosed, data } = useDuplicatePlaylistModal() + const { isAlbum = false } = data + + const [url, setUrl] = useState('') + const [customizeTitle, setCustomizeTitle] = useState(false) + const [customizeDescription, setCustomizeDescription] = useState(false) + const [customizeArtwork, setCustomizeArtwork] = useState(false) + const [customTitle, setCustomTitle] = useState('') + const [customDescription, setCustomDescription] = useState('') + const [customArtwork, setCustomArtwork] = useState(null) + const [imageProcessingError, setImageProcessingError] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + + const trimmedUrl = url.trim() + const permalink = useMemo( + () => (trimmedUrl ? getPathFromPlaylistUrl(trimmedUrl) : null), + [trimmedUrl] + ) + const isInvalidUrl = trimmedUrl.length > 0 && permalink === null + + const { data: sourceCollection, isPending: sourceLoading } = + useCollectionByPermalink(permalink, { enabled: !!permalink }) + + const sourceCollectionId = sourceCollection?.playlist_id + const { imageUrl: sourceImageUrl } = useCollectionCoverArt({ + collectionId: sourceCollectionId, + size: SquareSizes.SIZE_480_BY_480 + }) + + const reset = useCallback(() => { + setUrl('') + setCustomizeTitle(false) + setCustomizeDescription(false) + setCustomizeArtwork(false) + setCustomTitle('') + setCustomDescription('') + setCustomArtwork(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 fileUrl = URL.createObjectURL(file) + setCustomArtwork({ url: fileUrl, file, source }) + setImageProcessingError(false) + } catch (err) { + // eslint-disable-next-line no-console + console.error(getErrorMessage(err)) + setImageProcessingError(true) + } + }, + [] + ) + + const handleRemoveCustomArtwork = useCallback(() => { + setCustomArtwork(null) + }, []) + + // Seed custom fields when source loads so toggling a switch shows the + // current source value as a starting point. + useEffect(() => { + if (sourceCollection) { + if (!customTitle) { + setCustomTitle( + `${sourceCollection.playlist_name ?? ''}${messages.copySuffix}` + ) + } + if (!customDescription) { + setCustomDescription(sourceCollection.description ?? '') + } + } + // We only want to seed once when the source becomes available + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sourceCollection?.playlist_id]) + + const handleSubmit = useCallback(() => { + if (!sourceCollection) return + setIsSubmitting(true) + const playlistName = customizeTitle + ? customTitle.trim() || + `${sourceCollection.playlist_name}${messages.copySuffix}` + : `${sourceCollection.playlist_name}${messages.copySuffix}` + const description = customizeDescription + ? customDescription.trim() + : (sourceCollection.description ?? '') + + const formFields: Record = { + playlist_name: playlistName, + description + } + if (customizeArtwork && customArtwork) { + formFields.artwork = customArtwork + } else if (!customizeArtwork && sourceCollection.cover_art_sizes) { + formFields.cover_art_sizes = sourceCollection.cover_art_sizes + formFields.is_image_autogenerated = + sourceCollection.is_image_autogenerated ?? false + } + + const action = isAlbum ? createAlbum : createPlaylist + dispatch(action(formFields, CreatePlaylistSource.NAV, undefined, 'route')) + onClose() + }, [ + customArtwork, + customDescription, + customTitle, + customizeArtwork, + customizeDescription, + customizeTitle, + dispatch, + isAlbum, + onClose, + sourceCollection + ]) + + const canSubmit = !!sourceCollection && !isSubmitting + const newTitleLabel = isAlbum + ? messages.newTitleLabelAlbum + : messages.newTitleLabel + const newTitlePlaceholder = isAlbum + ? messages.newTitlePlaceholderAlbum + : messages.newTitlePlaceholder + + return ( + + + } + /> + + + + setUrl(e.target.value)} + error={isInvalidUrl} + helperText={isInvalidUrl ? messages.invalidUrl : messages.urlHelper} + /> + {permalink && !sourceLoading && !sourceCollection ? ( + + {messages.notFound} + + ) : null} + {sourceCollection ? ( + + + + + + {messages.source} + + + {sourceCollection.playlist_name} + + {sourceCollection.description ? ( + + {sourceCollection.description} + + ) : null} + + + + + + {messages.customizeTitle} + setCustomizeTitle(e.target.checked)} + /> + + {customizeTitle ? ( + setCustomTitle(e.target.value)} + maxLength={64} + /> + ) : null} + + + {messages.customizeDescription} + setCustomizeDescription(e.target.checked)} + /> + + {customizeDescription ? ( +