From 0b3a05e7e52e296920d9f713068e998e022560e1 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Thu, 21 May 2026 12:12:49 -0700 Subject: [PATCH 1/3] fix(web): use relative paths when navigating after contest submit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host-contest page passed full absolute URLs (fullContestPage, fullTrackPage) to react-router's navigate(), which treats them as relative path segments — producing blank pages at URLs like /host-contest/https:/audius.co//contest/. Switch to the relative-path helpers so submit, cancel, and delete navigate to a real route. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../host-remix-contest-page/HostRemixContestPage.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/web/src/pages/host-remix-contest-page/HostRemixContestPage.tsx b/packages/web/src/pages/host-remix-contest-page/HostRemixContestPage.tsx index 893801b874c..7972f7ee3c7 100644 --- a/packages/web/src/pages/host-remix-contest-page/HostRemixContestPage.tsx +++ b/packages/web/src/pages/host-remix-contest-page/HostRemixContestPage.tsx @@ -48,7 +48,7 @@ import Page from 'components/page/Page' import { useRequiresAccount } from 'hooks/useRequiresAccount' import { useTrackCoverArt } from 'hooks/useTrackCoverArt' import { track, make } from 'services/analytics' -import { fullContestPage, fullTrackPage } from 'utils/route' +import { contestPage } from 'utils/route' import { TimeInput, @@ -442,11 +442,7 @@ export const HostRemixContestPage = () => { navigate(CONTESTS_PAGE) return } - navigate( - isEdit - ? fullContestPage(primaryPermalink) - : fullTrackPage(primaryPermalink) - ) + navigate(isEdit ? contestPage(primaryPermalink) : primaryPermalink) }, [clearDraft, isEdit, navigate, primaryPermalink]) const handleSubmit = useCallback(() => { @@ -519,7 +515,7 @@ export const HostRemixContestPage = () => { clearDraft() if (effectivePermalink) { - navigate(fullContestPage(effectivePermalink)) + navigate(contestPage(effectivePermalink)) } else { navigate(CONTESTS_PAGE) } @@ -561,7 +557,7 @@ export const HostRemixContestPage = () => { } clearDraft() if (primaryPermalink) { - navigate(fullTrackPage(primaryPermalink)) + navigate(primaryPermalink) } }, [ clearDraft, From 98102f2120ddeaaeee5bf0c3446ef30c2e545b88 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Thu, 21 May 2026 12:53:39 -0700 Subject: [PATCH 2/3] fix(web): await createEvent before navigating to the new contest page useCreateEvent's onMutate awaits sdk.events.generateEventId() before priming the optimistic cache. The previous fire-and-forget mutate() meant handleSubmit kept going and navigated to the contest page while the optimistic write was still pending, so the contest query found nothing in cache and the page stuck on its skeleton until the indexer caught up. Switch to mutateAsync and await it so the optimistic event is in the cache (and the SDK call has landed on the server) before we redirect. On error we now bail out of navigation so the user can retry from the form instead of landing on a half-created contest page. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../HostRemixContestPage.tsx | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/web/src/pages/host-remix-contest-page/HostRemixContestPage.tsx b/packages/web/src/pages/host-remix-contest-page/HostRemixContestPage.tsx index 7972f7ee3c7..2037abf9dbe 100644 --- a/packages/web/src/pages/host-remix-contest-page/HostRemixContestPage.tsx +++ b/packages/web/src/pages/host-remix-contest-page/HostRemixContestPage.tsx @@ -161,7 +161,7 @@ export const HostRemixContestPage = () => { isContestEntry: true }) - const { mutate: createEvent } = useCreateEvent() + const { mutateAsync: createEvent } = useCreateEvent() const { mutate: updateEvent } = useUpdateEvent() const { mutate: deleteEvent } = useDeleteEvent() @@ -445,7 +445,7 @@ export const HostRemixContestPage = () => { navigate(isEdit ? contestPage(primaryPermalink) : primaryPermalink) }, [clearDraft, isEdit, navigate, primaryPermalink]) - const handleSubmit = useCallback(() => { + const handleSubmit = useCallback(async () => { const parsedTime = parseTime(timeValue) if (!parsedTime) return @@ -496,14 +496,21 @@ export const HostRemixContestPage = () => { }) ) } else { - createEvent({ - eventType: EventEventTypeEnum.RemixContest, - entityType: EventEntityTypeEnum.Track, - entityId: entityTrackId, - eventData, - endDate, - userId: currentUserId - }) + try { + await createEvent({ + eventType: EventEventTypeEnum.RemixContest, + entityType: EventEntityTypeEnum.Track, + entityId: entityTrackId, + eventData, + endDate, + userId: currentUserId + }) + } catch { + // Mutation's onError already surfaces a toast; stay on the form so + // the user can retry instead of navigating to a half-created + // contest page. + return + } track( make({ From 578f6c4a8e74676c19926e17e3187703908b49fe Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Fri, 22 May 2026 09:55:05 -0700 Subject: [PATCH 3/3] feat(web): inline-edit playlist title and description in collection header Owners can now edit the playlist/album title (Enter to save, Escape to cancel) and description (blur to save, Escape to cancel) directly from the collection page header instead of being deep-linked to the dedicated edit page. Saves dispatch through the existing editPlaylist saga. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../desktop/CollectionHeader.module.css | 63 +++++++++ .../collection/desktop/CollectionHeader.tsx | 120 ++++++++++-------- .../desktop/EditableCollectionDescription.tsx | 104 +++++++++++++++ .../desktop/EditableCollectionTitle.tsx | 98 ++++++++++++++ 4 files changed, 330 insertions(+), 55 deletions(-) create mode 100644 packages/web/src/components/collection/desktop/EditableCollectionDescription.tsx create mode 100644 packages/web/src/components/collection/desktop/EditableCollectionTitle.tsx diff --git a/packages/web/src/components/collection/desktop/CollectionHeader.module.css b/packages/web/src/components/collection/desktop/CollectionHeader.module.css index b5109aa6d2c..9d796538bb8 100644 --- a/packages/web/src/components/collection/desktop/CollectionHeader.module.css +++ b/packages/web/src/components/collection/desktop/CollectionHeader.module.css @@ -88,6 +88,16 @@ cursor: pointer; } +.editableTitleButton { + background: none; + border: 0; + padding: 0; + margin: 0; + text-align: left; + font: inherit; + color: inherit; +} + .titleHeader { transition: opacity var(--harmony-calm), @@ -107,6 +117,59 @@ opacity: 1; } +.titleInput { + width: 100%; + background: var(--harmony-white); + border: 1px solid var(--harmony-border-default); + border-radius: var(--harmony-unit-1); + padding: var(--harmony-unit-1) var(--harmony-unit-2); + font-family: inherit; + font-weight: 700; + font-size: clamp(24px, calc(1.6cqi + 18.75px), 36px); + line-height: 1.33; + color: var(--harmony-text-default); + outline: none; +} + +.titleInput:focus { + border-color: var(--harmony-border-accent); +} + +.editableDescription { + background: none; + border: 0; + padding: 0; + margin: 0; + text-align: left; + font: inherit; + color: inherit; + cursor: pointer; + width: 100%; +} + +.editableDescription:hover .editIcon { + opacity: 1; +} + +.descriptionInput { + width: 100%; + background: var(--harmony-white); + border: 1px solid var(--harmony-border-default); + border-radius: var(--harmony-unit-1); + padding: var(--harmony-unit-2); + font-family: inherit; + font-size: var(--harmony-font-s); + line-height: var(--harmony-line-height-s); + color: var(--harmony-text-default); + outline: none; + resize: vertical; + min-height: 60px; +} + +.descriptionInput:focus { + border-color: var(--harmony-border-accent); +} + .imageEditButtonWrapper { opacity: 0; transition: opacity var(--harmony-expressive); diff --git a/packages/web/src/components/collection/desktop/CollectionHeader.tsx b/packages/web/src/components/collection/desktop/CollectionHeader.tsx index 5818ec62122..ea09fdd16e4 100644 --- a/packages/web/src/components/collection/desktop/CollectionHeader.tsx +++ b/packages/web/src/components/collection/desktop/CollectionHeader.tsx @@ -1,20 +1,23 @@ +import { useCallback } from 'react' + import { useCollection, useCurrentUserId } from '@audius/common/api' import { ModalSource, isContentUSDCPurchaseGated } from '@audius/common/models' -import { PurchaseableContentType } from '@audius/common/store' +import { + EditCollectionValues, + PurchaseableContentType, + cacheCollectionsActions +} from '@audius/common/store' import { dayjs, formatReleaseDate } from '@audius/common/utils' import { Text, IconVisibilityHidden, - IconPencil, Flex, IconCart, useTheme, MusicBadge, IconCalendarMonth } from '@audius/harmony' -import cn from 'classnames' -import { pick } from 'lodash' -import { Link } from 'react-router' +import { useDispatch } from 'react-redux' import { UserLink } from 'components/link' import Skeleton from 'components/skeleton/Skeleton' @@ -28,6 +31,10 @@ import { CollectionHeaderProps } from '../types' import { Artwork } from './Artwork' import { CollectionActionButtons } from './CollectionActionButtons' import styles from './CollectionHeader.module.css' +import { EditableCollectionDescription } from './EditableCollectionDescription' +import { EditableCollectionTitle } from './EditableCollectionTitle' + +const { editPlaylist } = cacheCollectionsActions const messages = { premiumLabel: 'premium', @@ -64,22 +71,40 @@ export const CollectionHeader = (props: CollectionHeaderProps) => { } = props const { spacing } = useTheme() + const dispatch = useDispatch() const { data: currentUserId } = useCurrentUserId() - const { data: partialCollection } = useCollection(collectionId, { - select: (collection) => - pick(collection, [ - 'is_scheduled_release', - 'release_date', - 'permalink', - 'is_private' - ]) - }) + const { data: fullCollection } = useCollection(collectionId) const { is_scheduled_release: isScheduledRelease, release_date: releaseDate, - permalink, is_private: isPrivate - } = partialCollection ?? {} + } = fullCollection ?? {} + + const handleSaveTitle = useCallback( + (next: string) => { + if (!fullCollection || !collectionId) return + dispatch( + editPlaylist(collectionId, { + ...fullCollection, + playlist_name: next + } as unknown as EditCollectionValues) + ) + }, + [dispatch, fullCollection, collectionId] + ) + + const handleSaveDescription = useCallback( + (next: string) => { + if (!fullCollection || !collectionId) return + dispatch( + editPlaylist(collectionId, { + ...fullCollection, + description: next + } as unknown as EditCollectionValues) + ) + }, + [dispatch, fullCollection, collectionId] + ) const hasStreamAccess = access?.stream const shouldShowStats = !isPrivate || isOwner @@ -134,44 +159,24 @@ export const CollectionHeader = (props: CollectionHeaderProps) => { gap='s' className={styles.titleArtistSection} > - - {isLoading ? ( - - ) : ( - <> - - {title} - - - {!isLoading && isOwner ? ( - - ) : null} - - )} - + {isLoading ? ( + + ) : isOwner ? ( + + ) : ( + + {title} + + )} {isLoading ? ( ) : userId !== null ? ( @@ -263,7 +268,12 @@ export const CollectionHeader = (props: CollectionHeaderProps) => { ) : ( - {description ? ( + {isOwner ? ( + + ) : description ? ( void +} + +export const EditableCollectionDescription = ({ + value, + onSave +}: EditableCollectionDescriptionProps) => { + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(value) + const textareaRef = useRef(null) + + useEffect(() => { + if (!editing) setDraft(value) + }, [value, editing]) + + useEffect(() => { + if (editing) { + const el = textareaRef.current + if (!el) return + el.focus() + el.setSelectionRange(el.value.length, el.value.length) + } + }, [editing]) + + const commit = useCallback(() => { + if (draft !== value) onSave(draft) + setEditing(false) + }, [draft, value, onSave]) + + const cancel = useCallback(() => { + setDraft(value) + setEditing(false) + }, [value]) + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + cancel() + } + }, + [cancel] + ) + + if (editing) { + return ( +