Skip to content

Commit ba2fa57

Browse files
dylanjeffersclaude
andcommitted
feat(web): inline edit mode on playlist detail page with staged Apply
Brings playlist detail-page editing inline per the new UX spec. The existing /edit route still works for advanced fields (audience, price, genre, etc.), but the common metadata flow is now handled directly on the detail page. - New `PlaylistEditModeProvider` context holds the edit-mode flag, the staged metadata draft, conflict status, and saving status. The matching `usePlaylistEditMode` hook safely returns a no-op shape when not inside the provider, so shared components stay backwards-compatible. - Wrapped the desktop CollectionPage in the provider and rendered a sticky `PlaylistEditModeBar` at the page footer. The bar shows a Discard / Apply pair when there are pending changes, a slim "no changes yet" footer while in edit mode without changes, and a conflict banner with a Reload action when the playlist was changed remotely since edit mode started. - The `EditButton` pencil in the owner action row now toggles inline edit mode instead of routing to /edit (the legacy link is kept as a fallback when no provider is mounted). - CollectionHeader (desktop) renders an inline `TextInput` for the title, a `TextArea` for the description, and a `Switch` for visibility while in edit mode. Otherwise behavior is unchanged. - Desktop Artwork supports inline upload (file picker) when edit mode is on; the staged image previews immediately and is sent through on Apply along with the metadata draft. - Apply checks the collection's `updated_at` against the timestamp captured when edit mode started; if it has advanced, the bar flips to the conflict state and aborts the save. - Success messages are specific ("Saved details", "Saved artwork", or "Saved details and artwork") based on which fields the user changed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 24e8a3b commit ba2fa57

6 files changed

Lines changed: 719 additions & 138 deletions

File tree

packages/web/src/components/collection/desktop/Artwork.tsx

Lines changed: 77 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1+
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'
2+
13
import { useCollection } from '@audius/common/api'
24
import { imageBlank } from '@audius/common/assets'
35
import { SquareSizes } from '@audius/common/models'
6+
import { getErrorMessage } from '@audius/common/utils'
47
import { Button, IconPencil, Image } from '@audius/harmony'
58
import cn from 'classnames'
69
import { pick } from 'lodash'
710
import { Link } from 'react-router'
811

912
import { useCollectionCoverArt } from 'hooks/useCollectionCoverArt'
13+
import { resizeImage } from 'utils/imageProcessingUtil'
1014

1115
import styles from './CollectionHeader.module.css'
16+
import { usePlaylistEditMode } from './edit-mode/PlaylistEditModeContext'
1217

1318
const messages = {
1419
addArtwork: 'Add Artwork',
@@ -38,31 +43,85 @@ export const Artwork = (props: ArtworkProps) => {
3843

3944
const hasImage = image && image !== imageBlank
4045

46+
const editMode = usePlaylistEditMode()
47+
const isEditingThis =
48+
editMode.isEditMode && editMode.collectionId === collectionId
49+
50+
const fileInputRef = useRef<HTMLInputElement>(null)
51+
const [error, setError] = useState<string | null>(null)
52+
53+
useEffect(() => {
54+
if (!isEditingThis) setError(null)
55+
}, [isEditingThis])
56+
57+
const handleFileChange = useCallback(
58+
async (e: ChangeEvent<HTMLInputElement>) => {
59+
const file = e.target.files?.[0]
60+
e.target.value = ''
61+
if (!file) return
62+
try {
63+
const resized = await resizeImage(file)
64+
// @ts-ignore writing to read-only property; matches ArtworkField pattern
65+
resized.name = file.name
66+
const url = URL.createObjectURL(resized)
67+
editMode.setField('artwork', { url, file: resized, source: 'inline' })
68+
setError(null)
69+
} catch (err) {
70+
setError(getErrorMessage(err))
71+
}
72+
},
73+
[editMode]
74+
)
75+
76+
const draftArtwork = editMode.draft.artwork
77+
const displaySrc = isEditingThis && draftArtwork ? draftArtwork.url : image
78+
4179
return (
4280
<Image
4381
className={cn(styles.coverArtWrapper, styles.coverArt)}
4482
alt={messages.coverArtAltText}
45-
src={image}
83+
src={displaySrc}
4684
>
4785
{isOwner ? (
4886
<span className={styles.imageEditButtonWrapper}>
49-
<Button variant='tertiary' iconLeft={IconPencil} asChild>
50-
<Link
51-
to={{
52-
pathname: `${permalink}/edit`,
53-
search:
54-
hasImage && !is_image_autogenerated
55-
? undefined
56-
: '?focus=artwork'
57-
}}
58-
>
59-
{hasImage && !is_image_autogenerated
60-
? messages.removeArtwork
61-
: hasImage
62-
? messages.changeArtwork
63-
: messages.addArtwork}
64-
</Link>
65-
</Button>
87+
{isEditingThis ? (
88+
<>
89+
<Button
90+
variant='tertiary'
91+
iconLeft={IconPencil}
92+
onClick={() => fileInputRef.current?.click()}
93+
>
94+
{messages.changeArtwork}
95+
</Button>
96+
<input
97+
ref={fileInputRef}
98+
type='file'
99+
accept='image/*'
100+
style={{ display: 'none' }}
101+
onChange={handleFileChange}
102+
aria-label={messages.changeArtwork}
103+
aria-invalid={!!error}
104+
/>
105+
</>
106+
) : (
107+
<Button variant='tertiary' iconLeft={IconPencil} asChild>
108+
<Link
109+
to={{
110+
pathname: `${permalink}/edit`,
111+
search:
112+
hasImage && !is_image_autogenerated
113+
? undefined
114+
: '?focus=artwork'
115+
}}
116+
>
117+
{hasImage && !is_image_autogenerated
118+
? messages.removeArtwork
119+
: hasImage
120+
? messages.changeArtwork
121+
: messages.addArtwork}
122+
</Link>
123+
</Button>
124+
)}
66125
</span>
67126
) : null}
68127
</Image>

packages/web/src/components/collection/desktop/CollectionHeader.tsx

Lines changed: 119 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import {
1010
IconCart,
1111
useTheme,
1212
MusicBadge,
13-
IconCalendarMonth
13+
IconCalendarMonth,
14+
Switch,
15+
TextArea,
16+
TextInput
1417
} from '@audius/harmony'
1518
import cn from 'classnames'
1619
import { pick } from 'lodash'
@@ -28,13 +31,21 @@ import { CollectionHeaderProps } from '../types'
2831
import { Artwork } from './Artwork'
2932
import { CollectionActionButtons } from './CollectionActionButtons'
3033
import styles from './CollectionHeader.module.css'
34+
import { usePlaylistEditMode } from './edit-mode/PlaylistEditModeContext'
3135

3236
const messages = {
3337
premiumLabel: 'premium',
3438
by: 'By ',
3539
hidden: 'Hidden',
3640
releases: (releaseDate: string) =>
37-
`Releases ${formatReleaseDate({ date: releaseDate, withHour: true })}`
41+
`Releases ${formatReleaseDate({ date: releaseDate, withHour: true })}`,
42+
titleLabel: 'Playlist title',
43+
titleAlbumLabel: 'Album title',
44+
descriptionLabel: 'Description',
45+
descriptionPlaceholder: 'Add a description',
46+
visibility: 'Visibility',
47+
publicLabel: 'Public',
48+
privateLabel: 'Hidden'
3849
}
3950

4051
export const CollectionHeader = (props: CollectionHeaderProps) => {
@@ -71,16 +82,35 @@ export const CollectionHeader = (props: CollectionHeaderProps) => {
7182
'is_scheduled_release',
7283
'release_date',
7384
'permalink',
74-
'is_private'
85+
'is_private',
86+
'is_album'
7587
])
7688
})
7789
const {
7890
is_scheduled_release: isScheduledRelease,
7991
release_date: releaseDate,
8092
permalink,
81-
is_private: isPrivate
93+
is_private: isPrivate,
94+
is_album: isAlbumFromCollection
8295
} = partialCollection ?? {}
8396

97+
const editMode = usePlaylistEditMode()
98+
const isEditingThis =
99+
editMode.isEditMode && editMode.collectionId === collectionId
100+
101+
const stagedTitle =
102+
editMode.draft.playlist_name !== undefined
103+
? editMode.draft.playlist_name
104+
: title
105+
const stagedDescription =
106+
editMode.draft.description !== undefined
107+
? editMode.draft.description
108+
: description
109+
const stagedIsPrivate =
110+
editMode.draft.is_private !== undefined
111+
? editMode.draft.is_private
112+
: (isPrivate ?? false)
113+
84114
const hasStreamAccess = access?.stream
85115
const shouldShowStats = !isPrivate || isOwner
86116
const shouldShowScheduledRelease =
@@ -134,44 +164,62 @@ export const CollectionHeader = (props: CollectionHeaderProps) => {
134164
gap='s'
135165
className={styles.titleArtistSection}
136166
>
137-
<Flex
138-
as={isOwner ? Link : 'span'}
139-
css={{ background: 0, border: 0, padding: 0, margin: 0 }}
140-
gap='s'
141-
alignItems='center'
142-
className={cn({
143-
[styles.editableTitle]: isOwner
144-
})}
145-
// @ts-ignore -- Flex Link doesn't type `to` correctly
146-
to={
147-
isOwner
148-
? { pathname: `${permalink}/edit`, search: '?focus=name' }
149-
: undefined
150-
}
151-
>
152-
{isLoading ? (
153-
<Skeleton height='48px' width='300px' />
154-
) : (
155-
<>
156-
<Text
157-
variant='heading'
158-
size='xl'
159-
className={cn(styles.titleHeader)}
160-
textAlign='left'
161-
css={{
162-
fontSize: 'clamp(24px, calc(1.6cqi + 18.75px), 36px)',
163-
lineHeight: 1.33
164-
}}
165-
>
166-
{title}
167-
</Text>
167+
{isEditingThis ? (
168+
<Flex direction='column' gap='s'>
169+
<TextInput
170+
label={
171+
isAlbumFromCollection
172+
? messages.titleAlbumLabel
173+
: messages.titleLabel
174+
}
175+
value={stagedTitle}
176+
onChange={(e) =>
177+
editMode.setField('playlist_name', e.target.value)
178+
}
179+
maxLength={64}
180+
autoFocus
181+
/>
182+
</Flex>
183+
) : (
184+
<Flex
185+
as={isOwner ? Link : 'span'}
186+
css={{ background: 0, border: 0, padding: 0, margin: 0 }}
187+
gap='s'
188+
alignItems='center'
189+
className={cn({
190+
[styles.editableTitle]: isOwner
191+
})}
192+
// @ts-ignore -- Flex Link doesn't type `to` correctly
193+
to={
194+
isOwner
195+
? { pathname: `${permalink}/edit`, search: '?focus=name' }
196+
: undefined
197+
}
198+
>
199+
{isLoading ? (
200+
<Skeleton height='48px' width='300px' />
201+
) : (
202+
<>
203+
<Text
204+
variant='heading'
205+
size='xl'
206+
className={cn(styles.titleHeader)}
207+
textAlign='left'
208+
css={{
209+
fontSize: 'clamp(24px, calc(1.6cqi + 18.75px), 36px)',
210+
lineHeight: 1.33
211+
}}
212+
>
213+
{title}
214+
</Text>
168215

169-
{!isLoading && isOwner ? (
170-
<IconPencil className={styles.editIcon} color='subdued' />
171-
) : null}
172-
</>
173-
)}
174-
</Flex>
216+
{!isLoading && isOwner ? (
217+
<IconPencil className={styles.editIcon} color='subdued' />
218+
) : null}
219+
</>
220+
)}
221+
</Flex>
222+
)}
175223
{isLoading ? (
176224
<Skeleton height='24px' width='150px' />
177225
) : userId !== null ? (
@@ -191,6 +239,25 @@ export const CollectionHeader = (props: CollectionHeaderProps) => {
191239
<UserLink userId={userId} popover variant='visible' />
192240
</Text>
193241
) : null}
242+
{isEditingThis ? (
243+
<Flex alignItems='center' gap='m' mt='s'>
244+
<Text variant='label' size='m' color='subdued'>
245+
{messages.visibility}
246+
</Text>
247+
<Switch
248+
checked={!stagedIsPrivate}
249+
onChange={(e) =>
250+
editMode.setField('is_private', !e.target.checked)
251+
}
252+
aria-label={messages.visibility}
253+
/>
254+
<Text variant='body' size='s' color='subdued'>
255+
{stagedIsPrivate
256+
? messages.privateLabel
257+
: messages.publicLabel}
258+
</Text>
259+
</Flex>
260+
) : null}
194261
</Flex>
195262
<div className={styles.statsDesktop}>{renderStatsRow(isLoading)}</div>
196263
</Flex>
@@ -263,7 +330,17 @@ export const CollectionHeader = (props: CollectionHeaderProps) => {
263330
<Skeleton height='40px' width='100%' />
264331
) : (
265332
<Flex gap='l' direction='column'>
266-
{description ? (
333+
{isEditingThis ? (
334+
<TextArea
335+
aria-label={messages.descriptionLabel}
336+
placeholder={messages.descriptionPlaceholder}
337+
value={stagedDescription ?? ''}
338+
onChange={(e) => editMode.setField('description', e.target.value)}
339+
maxLength={1000}
340+
showMaxLength
341+
grows
342+
/>
343+
) : description ? (
267344
<UserGeneratedText
268345
size='s'
269346
linkSource='collection page'

0 commit comments

Comments
 (0)