From 81db9b652bef9822ec41a6938b34749ee1cb9e38 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Fri, 29 May 2026 11:35:24 -0700 Subject: [PATCH 1/3] feat(collection): inline playlist create flow + edit-mode overhaul Replace the create-playlist modal with an inline /create/playlist flow that reuses the existing collection edit UI. The draft lives only in the query cache (nothing hits the backend) until Apply, which publishes metadata + ordered tracks in one shot and redirects to the new playlist. Also: fix discard, description-field contrast, anchor the unsaved-changes bar, add an unsaved-changes nav guard, make suggested tracks playable, and add an owned-vs-favorited indicator in the left nav. Co-Authored-By: Claude Opus 4.7 --- packages/web/src/app/web-player/WebPlayer.tsx | 13 +- .../collection/desktop/CollectionHeader.tsx | 2 +- .../edit-mode/EditModeNavigationGuard.tsx | 59 ++++++ .../desktop/edit-mode/PlaylistEditModeBar.tsx | 180 +++++++++--------- .../edit-mode/PlaylistEditModeContext.tsx | 106 ++++++++++- .../desktop/edit-mode/draftCollectionCache.ts | 47 +++++ .../desktop/edit-mode/draftCollections.ts | 19 ++ .../edit-mode/useCreateDraftPlaylist.ts | 109 +++++++++++ .../PlaylistLibrary/CollectionNavItem.tsx | 31 ++- .../CreatePlaylistLibraryItemButton.tsx | 9 +- .../PlaylistLibrary/EmptyLibraryNavLink.tsx | 10 +- .../SuggestedTracks.module.css | 27 +++ .../suggested-tracks/SuggestedTracks.tsx | 61 +++++- .../components/desktop/CollectionPage.tsx | 2 + .../CreatePlaylistPage.tsx | 105 ++++++++++ packages/web/src/utils/route.ts | 3 + 16 files changed, 671 insertions(+), 112 deletions(-) create mode 100644 packages/web/src/components/collection/desktop/edit-mode/EditModeNavigationGuard.tsx create mode 100644 packages/web/src/components/collection/desktop/edit-mode/draftCollectionCache.ts create mode 100644 packages/web/src/components/collection/desktop/edit-mode/draftCollections.ts create mode 100644 packages/web/src/components/collection/desktop/edit-mode/useCreateDraftPlaylist.ts create mode 100644 packages/web/src/pages/create-playlist-page/CreatePlaylistPage.tsx diff --git a/packages/web/src/app/web-player/WebPlayer.tsx b/packages/web/src/app/web-player/WebPlayer.tsx index d942668e67f..911d37b0825 100644 --- a/packages/web/src/app/web-player/WebPlayer.tsx +++ b/packages/web/src/app/web-player/WebPlayer.tsx @@ -80,7 +80,7 @@ import { getShowCookieBanner } from 'store/application/ui/cookieBanner/selectors import { getClient } from 'utils/clientUtil' import { shouldShowCookieBanner } from 'utils/gdpr' import 'utils/redirect' -import { getPathname } from 'utils/route' +import { CREATE_PLAYLIST_PAGE, getPathname } from 'utils/route' import styles from './WebPlayer.module.css' const { getFrostedSurfaceIntensity } = themeSelectors @@ -125,6 +125,9 @@ const CoinRedeemPage = lazy(() => const CollectionPage = lazy( () => import('pages/collection-page/CollectionPage') ) +const CreatePlaylistPage = lazy( + () => import('pages/create-playlist-page/CreatePlaylistPage') +) const CommentHistoryPage = lazy( () => import('pages/comment-history/CommentHistoryPage') ) @@ -1158,6 +1161,10 @@ const WebPlayer = (props: WebPlayerProps) => { element={} /> } /> + } + /> } @@ -1559,6 +1566,10 @@ const WebPlayer = (props: WebPlayerProps) => { element={} /> } /> + } + /> } diff --git a/packages/web/src/components/collection/desktop/CollectionHeader.tsx b/packages/web/src/components/collection/desktop/CollectionHeader.tsx index 90ccbf5c032..34c973f363e 100644 --- a/packages/web/src/components/collection/desktop/CollectionHeader.tsx +++ b/packages/web/src/components/collection/desktop/CollectionHeader.tsx @@ -307,7 +307,7 @@ export const CollectionHeader = (props: CollectionHeaderProps) => { gap='xl' direction='column' p='xl' - backgroundColor='surface1' + backgroundColor={isEditingThis ? 'default' : 'surface1'} borderTop='strong' borderBottom='strong' > diff --git a/packages/web/src/components/collection/desktop/edit-mode/EditModeNavigationGuard.tsx b/packages/web/src/components/collection/desktop/edit-mode/EditModeNavigationGuard.tsx new file mode 100644 index 00000000000..b8b49c2216c --- /dev/null +++ b/packages/web/src/components/collection/desktop/edit-mode/EditModeNavigationGuard.tsx @@ -0,0 +1,59 @@ +import { useEffect } from 'react' + +import { useBlocker } from 'react-router' + +import { ConfirmationModal } from 'components/confirmation-modal/ConfirmationModal' + +import { usePlaylistEditMode } from './PlaylistEditModeContext' + +const messages = { + header: 'Unsaved Changes', + description: + 'You have unsaved changes. If you leave this page, your changes will be lost.', + confirm: 'Leave', + cancel: 'Stay' +} + +/** + * Warns the user before navigating away (in-app route change or tab + * close/refresh) while there are unsaved edits in playlist edit/create mode. + */ +export const EditModeNavigationGuard = () => { + const { isEditMode, hasChanges, status } = usePlaylistEditMode() + // Don't block our own redirect once a save/create is underway. + const shouldBlock = isEditMode && hasChanges && status !== 'saving' + + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => + shouldBlock && currentLocation.pathname !== nextLocation.pathname + ) + + // Browser-level navigation (close tab, refresh, external link). + useEffect(() => { + if (!shouldBlock) return + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault() + e.returnValue = '' + } + window.addEventListener('beforeunload', handler) + return () => window.removeEventListener('beforeunload', handler) + }, [shouldBlock]) + + // If the changes are saved/discarded while a navigation is blocked, release. + useEffect(() => { + if (blocker.state === 'blocked' && !shouldBlock) { + blocker.proceed() + } + }, [blocker, shouldBlock]) + + return ( + blocker.state === 'blocked' && blocker.reset()} + messages={messages} + onConfirm={() => blocker.state === 'blocked' && blocker.proceed()} + onCancel={() => blocker.state === 'blocked' && blocker.reset()} + destructive + /> + ) +} diff --git a/packages/web/src/components/collection/desktop/edit-mode/PlaylistEditModeBar.tsx b/packages/web/src/components/collection/desktop/edit-mode/PlaylistEditModeBar.tsx index 677778e47f6..c5ec0d7df22 100644 --- a/packages/web/src/components/collection/desktop/edit-mode/PlaylistEditModeBar.tsx +++ b/packages/web/src/components/collection/desktop/edit-mode/PlaylistEditModeBar.tsx @@ -1,31 +1,45 @@ +import { ReactNode } from 'react' + import { Button, Flex, Text, useTheme } from '@audius/harmony' +import { usePortal } from 'hooks/usePortal' +import { useMainContentRef } from 'pages/MainContentContext' + import { usePlaylistEditMode } from './PlaylistEditModeContext' const messages = { pending: 'You have unsaved changes', + createPrompt: 'Finish setting up your playlist', conflictTitle: 'Out of date', conflictBody: 'Someone else updated this playlist while you were editing it. Reload to start over.', apply: 'Apply', + create: 'Create', discard: 'Discard', + cancel: 'Cancel', reload: 'Reload' } -export const PlaylistEditModeBar = () => { +const AnchoredBar = ({ children }: { children: ReactNode }) => { const { color } = useTheme() - const { isEditMode, hasChanges, status, apply, discard, resolveConflict } = - usePlaylistEditMode() + // Portal out of the main content wrapper — its `transform` creates a + // containing block that breaks position:fixed relative to the viewport. + // Anchor to the app shell so the bar sits above the play bar and inherits + // the --nav-width / --play-bar-height vars. + const mainContentRef = useMainContentRef() + const Portal = usePortal({ + container: mainContentRef.current?.parentElement ?? undefined + }) - if (!isEditMode) return null - - if (status === 'conflict') { - return ( + return ( + { alignItems='center' justifyContent='space-between' > - - - {messages.conflictTitle} - - - {messages.conflictBody} - - - + {children} - ) - } + + ) +} + +export const PlaylistEditModeBar = () => { + const { + isEditMode, + isCreate, + hasChanges, + canApply, + status, + apply, + discard, + resolveConflict + } = usePlaylistEditMode() + + if (!isEditMode) return null - if (!hasChanges && status !== 'saving') { - // Still in edit mode but no pending changes — show a slim bar with Discard - // (which exits edit mode). + if (status === 'conflict') { return ( - - - - + <> + {/* Reserve space so the fixed bar never covers page content */} +
+ + + + {messages.conflictTitle} + + + {messages.conflictBody} + + + + + ) } + const isSaving = status === 'saving' + return ( - - - {messages.pending} - - - - - - + <> +
+ + + {isCreate + ? messages.createPrompt + : hasChanges + ? messages.pending + : ''} + + + + + + + ) } diff --git a/packages/web/src/components/collection/desktop/edit-mode/PlaylistEditModeContext.tsx b/packages/web/src/components/collection/desktop/edit-mode/PlaylistEditModeContext.tsx index eb8faf9032c..94cb740b661 100644 --- a/packages/web/src/components/collection/desktop/edit-mode/PlaylistEditModeContext.tsx +++ b/packages/web/src/components/collection/desktop/edit-mode/PlaylistEditModeContext.tsx @@ -3,6 +3,7 @@ import { ReactNode, useCallback, useContext, + useEffect, useMemo, useRef, useState @@ -16,6 +17,10 @@ import { toastActions } from '@audius/common/store' import { useDispatch } from 'react-redux' +import { useNavigate } from 'react-router' + +import { isDraftCollection, removeDraftCollection } from './draftCollections' +import { useCreateDraftPlaylist } from './useCreateDraftPlaylist' const { editPlaylist } = cacheCollectionsActions const { toast } = toastActions @@ -39,10 +44,12 @@ type PlaylistEditModeContextValue = { collectionId?: ID isOwner: boolean isEditMode: boolean + isCreate: boolean status: Status draft: PlaylistMetadataDraft removedTrackIds: Set hasChanges: boolean + canApply: boolean canUndoRemoval: boolean canRedoRemoval: boolean enterEditMode: () => void @@ -86,7 +93,9 @@ const messages = { }, conflict: 'Heads up — someone else changed this playlist while you were editing. Reload and try again.', - failed: 'Could not save changes. Please try again.' + failed: 'Could not save changes. Please try again.', + created: 'Created playlist', + createFailed: 'Could not create playlist. Please try again.' } type ProviderProps = { @@ -101,8 +110,12 @@ export const PlaylistEditModeProvider = ({ children }: ProviderProps) => { const dispatch = useDispatch() + const navigate = useNavigate() const { data: collection } = useCollection(collectionId) const { data: tracks } = useCollectionTracks(collectionId) + const publishDraft = useCreateDraftPlaylist() + + const isCreate = isDraftCollection(collectionId) const [isEditMode, setIsEditMode] = useState(false) const [draft, setDraft] = useState({}) @@ -141,6 +154,15 @@ export const PlaylistEditModeProvider = ({ setEditModeLoadedAt(null) }, [resetRemovals]) + // For the inline create flow, the page mounts already in edit mode. + const autoEnteredRef = useRef(false) + useEffect(() => { + if (isCreate && !autoEnteredRef.current) { + autoEnteredRef.current = true + enterEditMode() + } + }, [isCreate, enterEditMode]) + const setField = useCallback( (field, value) => { setDraft((prev) => ({ ...prev, [field]: value })) @@ -190,7 +212,15 @@ export const PlaylistEditModeProvider = ({ const discard = useCallback(() => { setDraft({}) resetRemovals() - }, [resetRemovals]) + setStatus('idle') + setIsEditMode(false) + setEditModeLoadedAt(null) + if (isCreate && collectionId != null) { + // Abandon the unsaved draft and leave the create page. + removeDraftCollection(collectionId) + navigate(-1) + } + }, [resetRemovals, isCreate, collectionId, navigate]) const resolveConflict = useCallback(() => { setStatus('idle') @@ -200,8 +230,18 @@ export const PlaylistEditModeProvider = ({ setEditModeLoadedAt(null) }, [resetRemovals]) + const stagedName = + draft.playlist_name !== undefined + ? draft.playlist_name + : collection?.playlist_name + + const draftTrackCount = collection?.playlist_contents.track_ids.length ?? 0 + const hasChanges = useMemo(() => { if (!collection) return false + // In create mode the page is "dirty" the moment any content exists, so we + // warn before navigating away from an unsaved draft. + if (isCreate && draftTrackCount > 0) return true if (removedTrackIds.size > 0) return true const fields = ['playlist_name', 'description', 'is_private'] as const for (const f of fields) { @@ -214,10 +254,60 @@ export const PlaylistEditModeProvider = ({ } if (draft.artwork !== undefined && draft.artwork !== null) return true return false - }, [collection, draft, removedTrackIds]) + }, [collection, draft, removedTrackIds, isCreate, draftTrackCount]) + + // For create, the primary action is enabled as long as the playlist has a + // usable name. For edit, it requires actual changes. + const canApply = isCreate + ? !!stagedName && stagedName.trim().length > 0 + : hasChanges const apply = useCallback(() => { if (!collection || !collection.playlist_id) return + + if (isCreate) { + if (!canApply) return + setStatus('saving') + const trackIds = collection.playlist_contents.track_ids + .filter((t) => !removedTrackIds.has(t.track)) + .map((t) => t.track) + const metadata = { + ...(collection as unknown as EditCollectionValues), + playlist_name: draft.playlist_name ?? collection.playlist_name, + description: + draft.description !== undefined + ? draft.description + : collection.description, + is_private: + draft.is_private !== undefined + ? draft.is_private + : collection.is_private, + artwork: draft.artwork ?? undefined + } as EditCollectionValues + publishDraft.mutate( + { playlistId: collection.playlist_id, metadata, trackIds }, + { + onSuccess: (confirmed) => { + removeDraftCollection(collection.playlist_id) + setDraft({}) + resetRemovals() + setIsEditMode(false) + setStatus('idle') + setEditModeLoadedAt(null) + dispatch(toast({ content: messages.created })) + if (confirmed?.permalink) { + navigate(confirmed.permalink, { replace: true }) + } + }, + onError: () => { + setStatus('idle') + dispatch(toast({ content: messages.createFailed })) + } + } + ) + return + } + if (!hasChanges) { exitEditMode() return @@ -295,12 +385,16 @@ export const PlaylistEditModeProvider = ({ }) ) }, [ + canApply, collection, dispatch, draft, editModeLoadedAt, exitEditMode, hasChanges, + isCreate, + navigate, + publishDraft, removedTrackIds, resetRemovals, tracks @@ -314,10 +408,12 @@ export const PlaylistEditModeProvider = ({ collectionId, isOwner, isEditMode, + isCreate, status, draft, removedTrackIds, hasChanges, + canApply, canUndoRemoval, canRedoRemoval, enterEditMode, @@ -332,6 +428,7 @@ export const PlaylistEditModeProvider = ({ }), [ apply, + canApply, canRedoRemoval, canUndoRemoval, collectionId, @@ -340,6 +437,7 @@ export const PlaylistEditModeProvider = ({ enterEditMode, exitEditMode, hasChanges, + isCreate, isEditMode, isOwner, redoRemoval, @@ -369,10 +467,12 @@ export const usePlaylistEditMode = (): PlaylistEditModeContextValue => { collectionId: undefined, isOwner: false, isEditMode: false, + isCreate: false, status: 'idle', draft: {}, removedTrackIds: new Set(), hasChanges: false, + canApply: false, canUndoRemoval: false, canRedoRemoval: false, enterEditMode: () => {}, diff --git a/packages/web/src/components/collection/desktop/edit-mode/draftCollectionCache.ts b/packages/web/src/components/collection/desktop/edit-mode/draftCollectionCache.ts new file mode 100644 index 00000000000..21b804fca24 --- /dev/null +++ b/packages/web/src/components/collection/desktop/edit-mode/draftCollectionCache.ts @@ -0,0 +1,47 @@ +import { getCollectionQueryKey } from '@audius/common/api' +import { ID, Kind } from '@audius/common/models' +import { Uid } from '@audius/common/utils' +import { QueryClient } from '@tanstack/react-query' + +type DraftCollection = { + trackIds?: ID[] + track_count?: number + playlist_contents: { + track_ids: { track: ID; time: number; uid?: string }[] + } +} + +/** + * Appends a track to a locally-drafted collection in the query cache. Used by + * the inline create flow so "add" affordances mutate the draft instead of + * writing to the backend. Mutates both `trackIds` and `playlist_contents` so + * the lineup and dependent queries stay consistent. + */ +export const addTrackToDraftCollection = ( + queryClient: QueryClient, + collectionId: ID, + trackId: ID +) => { + queryClient.setQueryData( + getCollectionQueryKey(collectionId), + (prev) => { + if (!prev) return prev + if (prev.trackIds?.includes(trackId)) return prev + const time = Math.round(Date.now() / 1000) + const uid = new Uid(Kind.TRACKS, trackId, 'collection').toString() + const nextTrackIds = [...(prev.trackIds ?? []), trackId] + return { + ...prev, + trackIds: nextTrackIds, + track_count: nextTrackIds.length, + playlist_contents: { + ...prev.playlist_contents, + track_ids: [ + ...prev.playlist_contents.track_ids, + { track: trackId, time, uid } + ] + } + } + } + ) +} diff --git a/packages/web/src/components/collection/desktop/edit-mode/draftCollections.ts b/packages/web/src/components/collection/desktop/edit-mode/draftCollections.ts new file mode 100644 index 00000000000..24a636fd837 --- /dev/null +++ b/packages/web/src/components/collection/desktop/edit-mode/draftCollections.ts @@ -0,0 +1,19 @@ +import { ID } from '@audius/common/models' + +// Tracks collection ids that exist only as unsaved local drafts (the inline +// "create playlist" flow). A draft is primed into the query cache with a +// generated id but is not persisted to the backend until the user hits Apply. +// The edit-mode provider reads this to auto-enter edit mode and to treat Apply +// as a create rather than an edit. +const draftCollectionIds = new Set() + +export const addDraftCollection = (id: ID) => { + draftCollectionIds.add(id) +} + +export const removeDraftCollection = (id: ID) => { + draftCollectionIds.delete(id) +} + +export const isDraftCollection = (id: ID | null | undefined): boolean => + id != null && draftCollectionIds.has(id) diff --git a/packages/web/src/components/collection/desktop/edit-mode/useCreateDraftPlaylist.ts b/packages/web/src/components/collection/desktop/edit-mode/useCreateDraftPlaylist.ts new file mode 100644 index 00000000000..9ba7410723f --- /dev/null +++ b/packages/web/src/components/collection/desktop/edit-mode/useCreateDraftPlaylist.ts @@ -0,0 +1,109 @@ +import { + fileToSdk, + playlistMetadataForCreateWithSDK, + userCollectionMetadataFromSDK +} from '@audius/common/adapters' +import { + getCollectionQueryKey, + primeCollectionData, + useCurrentAccountUser, + useCurrentUserId, + useQueryContext +} from '@audius/common/api' +import { ID } from '@audius/common/models' +import { accountActions, EditCollectionValues } from '@audius/common/store' +import { Id, OptionalId } from '@audius/sdk' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useDispatch } from 'react-redux' + +const { addAccountPlaylist } = accountActions + +type CreateDraftPlaylistParams = { + playlistId: ID + metadata: EditCollectionValues + trackIds: ID[] +} + +/** + * Persists a locally-drafted playlist (see the inline create flow) to the + * backend in one shot: uploads artwork, writes metadata + ordered tracks, then + * primes the confirmed playlist into the cache and adds it to the account. + * Returns the confirmed collection so the caller can navigate to its permalink. + */ +export const useCreateDraftPlaylist = () => { + const { audiusSdk } = useQueryContext() + const queryClient = useQueryClient() + const dispatch = useDispatch() + const { data: currentUserId } = useCurrentUserId() + const { data: accountUser } = useCurrentAccountUser() + + return useMutation({ + mutationFn: async ({ + playlistId, + metadata, + trackIds + }: CreateDraftPlaylistParams) => { + const sdk = await audiusSdk() + if (!currentUserId) throw new Error('Not logged in') + + const coverArtFile = + metadata.artwork && 'file' in metadata.artwork + ? (metadata.artwork.file as File) + : undefined + + const now = Math.round(Date.now() / 1000) // seconds + const sdkMetadata = playlistMetadataForCreateWithSDK(metadata as any) + sdkMetadata.playlistId = Id.parse(playlistId) + sdkMetadata.playlistContents = trackIds.map((trackId) => ({ + trackId: Id.parse(trackId), + timestamp: now, + metadataTimestamp: now + })) + + await sdk.playlists.createPlaylist({ + userId: Id.parse(currentUserId), + imageFile: coverArtFile + ? fileToSdk(coverArtFile, 'cover_art') + : undefined, + metadata: sdkMetadata + }) + + const { data: playlist } = await sdk.playlists.getPlaylist({ + userId: OptionalId.parse(currentUserId), + playlistId: Id.parse(playlistId) + }) + + const confirmed = playlist?.[0] + ? userCollectionMetadataFromSDK(playlist[0]) + : null + if (!confirmed) { + throw new Error(`Could not load created playlist for id ${playlistId}`) + } + + primeCollectionData({ + collections: [confirmed], + queryClient, + forceReplace: true + }) + + if (accountUser) { + dispatch( + addAccountPlaylist({ + id: confirmed.playlist_id, + name: confirmed.playlist_name, + is_album: false, + user: { id: accountUser.user_id, handle: accountUser.handle }, + permalink: confirmed.permalink + }) + ) + } + + return confirmed + }, + onSuccess: (_, { playlistId }) => { + queryClient.invalidateQueries({ + queryKey: getCollectionQueryKey(playlistId) + }) + } + }) +} diff --git a/packages/web/src/components/nav/desktop/PlaylistLibrary/CollectionNavItem.tsx b/packages/web/src/components/nav/desktop/PlaylistLibrary/CollectionNavItem.tsx index b31f159ecc4..8dbedb046a2 100644 --- a/packages/web/src/components/nav/desktop/PlaylistLibrary/CollectionNavItem.tsx +++ b/packages/web/src/components/nav/desktop/PlaylistLibrary/CollectionNavItem.tsx @@ -28,6 +28,7 @@ import { IconTrash, PopupMenuItem, Text, + Tooltip, useTheme } from '@audius/harmony' import { pick } from 'lodash' @@ -62,7 +63,8 @@ const messages = { edit: 'Edit', share: 'Share', delete: 'Delete', - unfavorite: 'Unfavorite' + unfavorite: 'Unfavorite', + favorited: 'Favorited' } const acceptedKinds: DragDropKind[] = [ @@ -272,14 +274,27 @@ export const CollectionNavItem = (props: CollectionNavItemProps) => { css={{ position: 'relative' }} justifyContent='space-between' > - - {name} - + + {name} + + {!isOwned ? ( + + + + + + ) : null} + { select: (account) => account?.playlistLibrary }) const { mutate: updatePlaylistLibrary } = useUpdatePlaylistLibrary() - const { onOpen: openCreatePlaylistModal } = useCreatePlaylistModal() const { onOpen: openDuplicatePlaylistModal } = useDuplicatePlaylistModal() + const navigate = useNavigate() const [isActive, setIsActive] = useState(false) const [isHovered, setIsHovered] = useState(false) const handleSubmitPlaylist = useCallback(() => { - openCreatePlaylistModal({ isAlbum: false }) - }, [openCreatePlaylistModal]) + navigate(CREATE_PLAYLIST_PAGE) + }, [navigate]) const handleDuplicatePlaylist = useCallback(() => { openDuplicatePlaylistModal({ isAlbum: false }) diff --git a/packages/web/src/components/nav/desktop/PlaylistLibrary/EmptyLibraryNavLink.tsx b/packages/web/src/components/nav/desktop/PlaylistLibrary/EmptyLibraryNavLink.tsx index fc139795c62..c7fae619bc9 100644 --- a/packages/web/src/components/nav/desktop/PlaylistLibrary/EmptyLibraryNavLink.tsx +++ b/packages/web/src/components/nav/desktop/PlaylistLibrary/EmptyLibraryNavLink.tsx @@ -1,6 +1,8 @@ import { useCallback } from 'react' -import { useCreatePlaylistModal } from '@audius/common/store' +import { useNavigate } from 'react-router' + +import { CREATE_PLAYLIST_PAGE } from 'utils/route' import { LeftNavLink } from '../LeftNavLink' @@ -9,11 +11,11 @@ const messages = { } export const EmptyLibraryNavLink = () => { - const { onOpen: openCreatePlaylistModal } = useCreatePlaylistModal() + const navigate = useNavigate() const handleCreatePlaylist = useCallback(() => { - openCreatePlaylistModal({ isAlbum: false }) - }, [openCreatePlaylistModal]) + navigate(CREATE_PLAYLIST_PAGE) + }, [navigate]) return ( diff --git a/packages/web/src/components/suggested-tracks/SuggestedTracks.module.css b/packages/web/src/components/suggested-tracks/SuggestedTracks.module.css index 5d9d8391381..e9dc06eb941 100644 --- a/packages/web/src/components/suggested-tracks/SuggestedTracks.module.css +++ b/packages/web/src/components/suggested-tracks/SuggestedTracks.module.css @@ -55,6 +55,33 @@ border: 0.5px solid var(--harmony-n-100); } +.artworkButton { + all: unset; + position: relative; + cursor: pointer; + height: var(--harmony-unit-10); + width: var(--harmony-unit-10); + flex-shrink: 0; +} + +.playOverlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 2px; + background: rgba(0, 0, 0, 0.4); + opacity: 0; + transition: opacity var(--harmony-quick); +} + +.artworkButton:hover .playOverlay, +.artworkButton:focus-visible .playOverlay, +.playOverlay.isPlaying { + opacity: 1; +} + .trackInfo { display: flex; flex-direction: column; diff --git a/packages/web/src/components/suggested-tracks/SuggestedTracks.tsx b/packages/web/src/components/suggested-tracks/SuggestedTracks.tsx index 8faa9e83de1..ee7d02a1ba9 100644 --- a/packages/web/src/components/suggested-tracks/SuggestedTracks.tsx +++ b/packages/web/src/components/suggested-tracks/SuggestedTracks.tsx @@ -6,19 +6,27 @@ import { useSuggestedPlaylistTracks, useUser } from '@audius/common/api' +import { useToggleTrack } from '@audius/common/hooks' import { SquareSizes, ID, Track } from '@audius/common/models' +import { QueueSource, Queueable } from '@audius/common/store' import { Button, Divider, IconCaretDown, + IconPause, + IconPlay, IconRefresh, Paper, useTheme, Image } from '@audius/harmony' import { animated, useSpring } from '@react-spring/web' +import { useQueryClient } from '@tanstack/react-query' +import cn from 'classnames' import { useToggle } from 'react-use' +import { addTrackToDraftCollection } from 'components/collection/desktop/edit-mode/draftCollectionCache' +import { isDraftCollection } from 'components/collection/desktop/edit-mode/draftCollections' import { UserLink } from 'components/link/UserLink' import Skeleton from 'components/skeleton/Skeleton' import { useTrackCoverArt } from 'hooks/useTrackCoverArt' @@ -39,11 +47,12 @@ const messages = { type SuggestedTrackProps = { collectionId: ID track: Track + queueEntries: Queueable[] onAddTrack: (trackId: ID) => void } const SuggestedTrackRow = (props: SuggestedTrackProps) => { - const { collectionId, track, onAddTrack } = props + const { collectionId, track, queueEntries, onAddTrack } = props const { track_id, title, owner_id } = track const { data: user } = useUser(owner_id) const { data: collection } = useCollection(collectionId) @@ -52,6 +61,12 @@ const SuggestedTrackRow = (props: SuggestedTrackProps) => { size: SquareSizes.SIZE_150_BY_150 }) + const { togglePlay, isTrackPlaying } = useToggleTrack({ + id: track_id, + source: QueueSource.RECOMMENDED_TRACKS, + entries: queueEntries + }) + const trackIsInCollection = useMemo( () => collection?.playlist_contents.track_ids.some( @@ -67,7 +82,26 @@ const SuggestedTrackRow = (props: SuggestedTrackProps) => { return (
- +

{title}

{user ? : null} @@ -107,6 +141,7 @@ type SuggestedTracksProps = { export const SuggestedTracks = (props: SuggestedTracksProps) => { const { collectionId } = props const mainContentRef = useMainContentRef() + const queryClient = useQueryClient() const { suggestedTracks, onRefresh, @@ -114,13 +149,19 @@ export const SuggestedTracks = (props: SuggestedTracksProps) => { } = useSuggestedPlaylistTracks(collectionId) const [isExpanded, toggleIsExpanded] = useToggle(false) const { motion } = useTheme() + const isDraft = isDraftCollection(collectionId) // Preserve scroll position when adding track - prevents scroll-to-top on // optimistic update (e.g. from focus loss when Add button becomes disabled) const onAddTrack = useCallback( (trackId: ID) => { const scrollTop = mainContentRef?.current?.scrollTop ?? 0 - originalOnAddTrack(trackId) + if (isDraft) { + // Unsaved create flow: mutate the local draft, not the backend. + addTrackToDraftCollection(queryClient, collectionId, trackId) + } else { + originalOnAddTrack(trackId) + } // Restore scroll after React has committed the update requestAnimationFrame(() => { requestAnimationFrame(() => { @@ -130,7 +171,18 @@ export const SuggestedTracks = (props: SuggestedTracksProps) => { }) }) }, - [originalOnAddTrack, mainContentRef] + [originalOnAddTrack, mainContentRef, isDraft, queryClient, collectionId] + ) + + const queueEntries = useMemo( + () => + suggestedTracks + .filter((track): track is Track => !!track?.track_id) + .map((track) => ({ + id: track.track_id, + source: QueueSource.RECOMMENDED_TRACKS + })), + [suggestedTracks] ) const contentHeight = 66 + SUGGESTED_TRACK_COUNT * 74 @@ -167,6 +219,7 @@ export const SuggestedTracks = (props: SuggestedTracksProps) => { ) : ( 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 77696861d57..6b4d4ba547d 100644 --- a/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx +++ b/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx @@ -22,6 +22,7 @@ import { Id } from '@audius/sdk' import { CollectionDogEar } from 'components/collection' import { CollectionHeader } from 'components/collection/desktop/CollectionHeader' +import { EditModeNavigationGuard } from 'components/collection/desktop/edit-mode/EditModeNavigationGuard' import { PlaylistEditModeBar } from 'components/collection/desktop/edit-mode/PlaylistEditModeBar' import { PlaylistEditModeProvider } from 'components/collection/desktop/edit-mode/PlaylistEditModeContext' import { EditAwareTracksTable } from 'components/collection/desktop/edit-mode/tracks/EditAwareTracksTable' @@ -398,6 +399,7 @@ const CollectionPage = ({ type }: CollectionPageProps) => { ) : null} + diff --git a/packages/web/src/pages/create-playlist-page/CreatePlaylistPage.tsx b/packages/web/src/pages/create-playlist-page/CreatePlaylistPage.tsx new file mode 100644 index 00000000000..7aa09aee4d2 --- /dev/null +++ b/packages/web/src/pages/create-playlist-page/CreatePlaylistPage.tsx @@ -0,0 +1,105 @@ +import { useEffect, useRef, useState } from 'react' + +import { + primeCollectionData, + useCurrentAccountUser, + useQueryContext +} from '@audius/common/api' +import { CollectionMetadata } from '@audius/common/models' +import { newCollectionMetadata } from '@audius/common/schemas' +import { route } from '@audius/common/utils' +import { Flex, LoadingSpinner } from '@audius/harmony' +import { useQueryClient } from '@tanstack/react-query' +import { Navigate } from 'react-router' + +import { + addDraftCollection, + isDraftCollection +} from 'components/collection/desktop/edit-mode/draftCollections' +import { useRequiresAccount } from 'hooks/useRequiresAccount' + +const { collectionPage, PLAYLIST_ID_PAGE } = route + +const messages = { + defaultName: 'My Playlist' +} + +/** + * Entry point for the inline "create playlist" flow. Generates a playlist id, + * primes an unsaved draft collection into the query cache (nothing is written + * to the backend), registers it as a draft, then redirects to the standard + * collection page which renders the draft in edit/create mode. + */ +export const CreatePlaylistPage = () => { + useRequiresAccount() + const { audiusSdk } = useQueryContext() + const queryClient = useQueryClient() + const { data: accountUser } = useCurrentAccountUser() + + const [draftId, setDraftId] = useState(null) + const startedRef = useRef(false) + + useEffect(() => { + if (startedRef.current || !accountUser) return + startedRef.current = true + + const createDraft = async () => { + const sdk = await audiusSdk() + const id = await sdk.playlists.generatePlaylistId() + if (!id) return + + const { user_id, handle } = accountUser + const permalink = collectionPage( + handle, + messages.defaultName, + id, + undefined, + false + ) + + const draft = newCollectionMetadata({ + playlist_id: id, + playlist_owner_id: user_id, + playlist_name: messages.defaultName, + description: '', + is_private: true, + is_album: false, + playlist_contents: { track_ids: [] }, + track_count: 0, + save_count: 0, + repost_count: 0, + has_current_user_saved: false, + has_current_user_reposted: false, + is_delete: false, + permalink + }) as CollectionMetadata + + // forceReplace writes the draft into the cache; entityCacheOptions + // (staleTime/gcTime Infinity) keep it from being refetched/clobbered. + primeCollectionData({ + collections: [draft], + queryClient, + forceReplace: true + }) + + addDraftCollection(id) + setDraftId(id) + } + + createDraft() + }, [accountUser, audiusSdk, queryClient]) + + if (draftId != null && isDraftCollection(draftId)) { + return ( + + ) + } + + return ( + + + + ) +} + +export default CreatePlaylistPage diff --git a/packages/web/src/utils/route.ts b/packages/web/src/utils/route.ts index 4a53d45d52c..27a166f9173 100644 --- a/packages/web/src/utils/route.ts +++ b/packages/web/src/utils/route.ts @@ -10,6 +10,9 @@ import { encodeUrlName } from './urlUtils' // Local route functions to avoid importing from @audius/common/src/utils/route which pulls in formatUtil/dayjs const SIGN_UP_PAGE = '/signup' +// Inline "create playlist" flow entry point. +export const CREATE_PLAYLIST_PAGE = '/create/playlist' + export const profilePage = (handle: string | null | undefined) => { return `/${encodeUrlName(handle ?? '')}` } From 6e1de151fb794083be804ddddf62837ba6d846ec Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Fri, 29 May 2026 11:49:50 -0700 Subject: [PATCH 2/3] fix(collection): hash-encode draft id in create-playlist redirect The /create/playlist redirect targeted /playlists/:id with the raw numeric id, but the collection route parser decodes :id via OptionalHashId.parse, so the lookup returned null params and the page rendered blank. Encode the draft id with Id.parse so the parser resolves it back to the primed draft. Co-Authored-By: Claude Opus 4.7 --- .../src/pages/create-playlist-page/CreatePlaylistPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/web/src/pages/create-playlist-page/CreatePlaylistPage.tsx b/packages/web/src/pages/create-playlist-page/CreatePlaylistPage.tsx index 7aa09aee4d2..e24204fb0d4 100644 --- a/packages/web/src/pages/create-playlist-page/CreatePlaylistPage.tsx +++ b/packages/web/src/pages/create-playlist-page/CreatePlaylistPage.tsx @@ -9,6 +9,7 @@ import { CollectionMetadata } from '@audius/common/models' import { newCollectionMetadata } from '@audius/common/schemas' import { route } from '@audius/common/utils' import { Flex, LoadingSpinner } from '@audius/harmony' +import { Id } from '@audius/sdk' import { useQueryClient } from '@tanstack/react-query' import { Navigate } from 'react-router' @@ -91,7 +92,10 @@ export const CreatePlaylistPage = () => { if (draftId != null && isDraftCollection(draftId)) { return ( - + ) } From 4c9211a30a947be0fb1842d4704afc2c435667be Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Fri, 29 May 2026 12:08:08 -0700 Subject: [PATCH 3/3] style(collection): fix prettier formatting in CollectionNavItem Co-Authored-By: Claude Opus 4.7 --- .../nav/desktop/PlaylistLibrary/CollectionNavItem.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/web/src/components/nav/desktop/PlaylistLibrary/CollectionNavItem.tsx b/packages/web/src/components/nav/desktop/PlaylistLibrary/CollectionNavItem.tsx index 8dbedb046a2..0491d380388 100644 --- a/packages/web/src/components/nav/desktop/PlaylistLibrary/CollectionNavItem.tsx +++ b/packages/web/src/components/nav/desktop/PlaylistLibrary/CollectionNavItem.tsx @@ -274,11 +274,7 @@ export const CollectionNavItem = (props: CollectionNavItemProps) => { css={{ position: 'relative' }} justifyContent='space-between' > - +