diff --git a/packages/common/src/utils/route.ts b/packages/common/src/utils/route.ts index f3525c89096..f1dd4e79048 100644 --- a/packages/common/src/utils/route.ts +++ b/packages/common/src/utils/route.ts @@ -150,6 +150,7 @@ export const TRACK_REMIXES_PAGE = '/:handle/:slug/remixes' export const TRACK_COMMENTS_PAGE = '/:handle/:slug/comments' export const PICK_WINNERS_PAGE = '/:handle/:slug/pick-winners' export const CONTEST_PAGE = '/:handle/:slug/contest' +export const HOST_REMIX_CONTEST_PAGE = '/:handle/:slug/host-contest' export const PROFILE_PAGE = '/:handle' export const PROFILE_PAGE_TRACKS = '/:handle/tracks' export const PROFILE_PAGE_ALBUMS = '/:handle/albums' diff --git a/packages/web/src/app/web-player/WebPlayer.tsx b/packages/web/src/app/web-player/WebPlayer.tsx index 825ade787da..575b5c61314 100644 --- a/packages/web/src/app/web-player/WebPlayer.tsx +++ b/packages/web/src/app/web-player/WebPlayer.tsx @@ -184,6 +184,9 @@ const PickWinnersPage = lazy(() => const ProfilePage = lazy(() => import('pages/profile-page/ProfilePage')) const RemixesPage = lazy(() => import('pages/remixes-page/RemixesPage')) const ContestPage = lazy(() => import('pages/contest-page/ContestPage')) +const HostRemixContestPage = lazy( + () => import('pages/host-remix-contest-page/HostRemixContestPage') +) const RepostsPage = lazy(() => import('pages/reposts-page/RepostsPage')) const RequiresUpdate = lazy(() => import('pages/requires-update/RequiresUpdate').then((m) => ({ @@ -250,6 +253,7 @@ const { TRACK_REMIXES_PAGE, PICK_WINNERS_PAGE, CONTEST_PAGE, + HOST_REMIX_CONTEST_PAGE, PROFILE_PAGE, authenticatedRoutes, EMPTY_PAGE, @@ -1182,6 +1186,10 @@ const WebPlayer = (props: WebPlayerProps) => { path={CONTEST_PAGE} element={} /> + } + /> } /> {isMobile ? ( <> @@ -1556,6 +1564,10 @@ const WebPlayer = (props: WebPlayerProps) => { path={CONTEST_PAGE} element={} /> + } + /> } /> { - openHostRemixContest({ trackId }) + const permalink = trackPermalink || partialTrack?.permalink + if (permalink) { + goToRoute(hostRemixContestPage(permalink)) + } else { + // Fallback for call sites that don't pass a permalink — keep the + // modal as a safety net until every entry point is migrated. + openHostRemixContest({ trackId }) + } } } diff --git a/packages/web/src/pages/contest-page/components/desktop/ContestPage.tsx b/packages/web/src/pages/contest-page/components/desktop/ContestPage.tsx index ccf0765cf7c..392592d7745 100644 --- a/packages/web/src/pages/contest-page/components/desktop/ContestPage.tsx +++ b/packages/web/src/pages/contest-page/components/desktop/ContestPage.tsx @@ -43,7 +43,11 @@ import { useRequiresAccountCallback } from 'hooks/useRequiresAccount' import { useTrackCoverArt } from 'hooks/useTrackCoverArt' import { RemixContestDetailsTab } from 'pages/track-page/components/desktop/RemixContestDetailsTab' import { RemixContestPrizesTab } from 'pages/track-page/components/desktop/RemixContestPrizesTab' -import { fullContestPage, pickWinnersPage } from 'utils/route' +import { + fullContestPage, + hostRemixContestPage, + pickWinnersPage +} from 'utils/route' import { ContestCommentsSection } from '../ContestCommentsSection' import { EventFollowersCard } from '../EventFollowersCard' @@ -248,9 +252,16 @@ const ContestPage = ({ containerRef: _containerRef }: ContestPageProps) => { const submissionsCount = lineup.data?.length const handleEditContest = useCallback(() => { - if (!trackId) return - openHostRemixContest({ trackId }) - }, [trackId, openHostRemixContest]) + if (track?.permalink) { + navigate(hostRemixContestPage(track.permalink)) + return + } + if (trackId) { + // Fallback to the legacy modal if we somehow don't have a permalink + // for the track (which the page navigation route is built from). + openHostRemixContest({ trackId }) + } + }, [track?.permalink, trackId, navigate, openHostRemixContest]) const handlePickWinners = useCallback(() => { if (!track?.permalink) return diff --git a/packages/web/src/pages/host-remix-contest-page/AddSourceTrackModal.tsx b/packages/web/src/pages/host-remix-contest-page/AddSourceTrackModal.tsx new file mode 100644 index 00000000000..db7abe14b44 --- /dev/null +++ b/packages/web/src/pages/host-remix-contest-page/AddSourceTrackModal.tsx @@ -0,0 +1,237 @@ +import { useCallback, useMemo, useState } from 'react' + +import { + useCurrentUserId, + useUser, + useUserTracksByHandle +} from '@audius/common/api' +import { SquareSizes } from '@audius/common/models' +import { + Box, + Button, + Checkbox, + Flex, + Modal, + ModalContent, + ModalFooter, + ModalHeader, + ModalTitle, + TextInput, + Text, + IconSearch, + TextLink +} from '@audius/harmony' + +import { useTrackCoverArt } from 'hooks/useTrackCoverArt' + +const messages = { + title: 'Add Source Track', + search: 'Search for Tracks', + done: 'Done', + cancel: 'Cancel', + viewSelection: 'View Selection', + selectedCount: (n: number) => `${n} Track${n === 1 ? '' : 's'} Selected`, + empty: 'No tracks found.', + loading: 'Loading tracks…' +} + +type AddSourceTrackModalProps = { + isOpen: boolean + onClose: () => void + /** Tracks already selected on the parent form, pre-checked in the picker. */ + initialSelectedIds: number[] + /** Called with the final set of selected track IDs when "Done" is clicked. */ + onDone: (selectedIds: number[]) => void +} + +/** + * Multi-select track picker modal used by the Host Remix Contest page's + * Source Tracks section. Searches over the signed-in user's own tracks + * (client-side filter for now; falls back to fetch-all). + */ +export const AddSourceTrackModal = ({ + isOpen, + onClose, + initialSelectedIds, + onDone +}: AddSourceTrackModalProps) => { + const { data: currentUserId } = useCurrentUserId() + const { data: currentUser } = useUser(currentUserId) + const handle = currentUser?.handle + + // Fetch the host's public + unlisted tracks. Page size is generous — we + // filter client-side and assume the host's own catalog fits. If we see + // real perf issues we'll swap this for a server-side search hook. + const { data: tracks, isPending } = useUserTracksByHandle( + { handle, filterTracks: 'all', limit: 200 }, + { enabled: !!handle } + ) + + const [search, setSearch] = useState('') + const [selectedIds, setSelectedIds] = useState(initialSelectedIds) + const [viewOnlySelected, setViewOnlySelected] = useState(false) + + const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]) + + const filtered = useMemo(() => { + const term = search.trim().toLowerCase() + return (tracks ?? []).filter((t) => { + if (viewOnlySelected && !selectedSet.has(t.track_id)) return false + if (!term) return true + return t.title.toLowerCase().includes(term) + }) + }, [tracks, search, viewOnlySelected, selectedSet]) + + const toggle = useCallback((id: number) => { + setSelectedIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] + ) + }, []) + + const handleDone = useCallback(() => { + onDone(selectedIds) + onClose() + }, [selectedIds, onDone, onClose]) + + return ( + + + + + + + setSearch(e.target.value)} + startIcon={IconSearch} + /> + + {isPending ? ( + + + {messages.loading} + + + ) : filtered.length === 0 ? ( + + + {messages.empty} + + + ) : ( + filtered.map((t) => ( + toggle(t.track_id)} + /> + )) + )} + + + + + + + + {messages.selectedCount(selectedIds.length)} + + {selectedIds.length > 0 ? ( + setViewOnlySelected((v) => !v)} + > + {messages.viewSelection} + + ) : null} + + + + + + + + + ) +} + +// ----- one row --------------------------------------------------------------- + +type TrackRowProps = { + trackId: number + title: string + ownerName: string + checked: boolean + onToggle: () => void +} + +const TrackRow = ({ + trackId, + title, + ownerName, + checked, + onToggle +}: TrackRowProps) => { + const { imageUrl } = useTrackCoverArt({ + trackId, + size: SquareSizes.SIZE_150_BY_150 + }) + return ( + + + + + {title} + + + {ownerName} + + + + + ) +} diff --git a/packages/web/src/pages/host-remix-contest-page/HostRemixContestPage.tsx b/packages/web/src/pages/host-remix-contest-page/HostRemixContestPage.tsx new file mode 100644 index 00000000000..6ddf1de9e93 --- /dev/null +++ b/packages/web/src/pages/host-remix-contest-page/HostRemixContestPage.tsx @@ -0,0 +1,874 @@ +import { useCallback, useMemo, useRef, useState } from 'react' + +import { + useCreateEvent, + useCurrentUserId, + useDeleteEvent, + useRemixContest, + useRemixesLineup, + useStems, + useTrack, + useTrackByPermalink, + useUpdateEvent, + useUser +} from '@audius/common/api' +import { remixMessages } from '@audius/common/messages' +import { Name, SquareSizes } from '@audius/common/models' +import { dayjs } from '@audius/common/utils' +import { + Box, + Button, + Flex, + IconCloudUpload, + IconKebabHorizontal, + IconTrophy, + LoadingSpinner, + PopupMenu, + Paper, + Select, + Text, + TextInput, + TextArea +} from '@audius/harmony' +import { EventEntityTypeEnum, EventEventTypeEnum } from '@audius/sdk' +import { useNavigate, useParams } from 'react-router' + +import { DatePicker } from 'components/edit/fields/DatePickerField' +import { mergeReleaseDateValues } from 'components/edit/fields/visibility/mergeReleaseDateValues' +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 { + TimeInput, + parseTime +} from '../../components/host-remix-contest-modal/TimeInput' + +import { AddSourceTrackModal } from './AddSourceTrackModal' +import { ManageStemsModal } from './ManageStemsModal' +import { useUploadContestCover } from './useUploadContestCover' + +const messages = { + pageTitle: 'Create Contest', + editPageTitle: 'Edit Contest', + required: ' *', + titleLabel: 'Contest Title', + titleHelper: 'This is the public title of your remix contest.', + titlePlaceholder: 'Contest Title', + descriptionLabel: 'Description', + descriptionHelper: + 'Tell artists what the contest is about, rules, judging criteria…', + descriptionPlaceholder: + 'Tell artists what the contest is about, rules, judging criteria…', + videoLabel: 'Video Link', + videoHelper: 'Add a YouTube or Vimeo link to embed it on your page.', + videoPlaceholder: 'https://www.youtube.com/watch?v=...', + deadlineLabel: 'Submission Deadline', + deadlineHelper: + 'Remixes submitted after this date will not be accepted. Local timezone applies.', + coverPhotoLabel: 'Cover Photo', + coverPhotoHelper: 'This image will represent your remix contest.', + coverPhotoPlaceholder: 'Drag-and-drop an image here, or browse to upload', + coverPhotoComingSoon: + 'Upload coming soon — temporarily paste an image URL below to override the track artwork default.', + coverPhotoUrlLabel: 'Cover image URL (optional)', + prizesLabel: 'Prizes', + prizesHelper: 'Describe all prizes, rewards, or other incentives.', + prizesPlaceholder: '1st place gets $500. 2nd place gets $250…', + sourceTracksLabel: 'Source Tracks', + sourceTracksHelper: + 'Choose one or more tracks to be linked to this contest. Any stems included in that track will also be part of this contest.', + sourceTracksSectionLabel: 'SOURCE TRACKS', + addTrack: '+ Add Track', + uploadNow: 'Upload Now', + cancel: 'Cancel', + saveDraft: 'Save Draft', + launch: 'Launch', + save: 'Save', + turnOff: 'Turn off contest', + visitTrack: 'Visit Track', + editTrack: 'Edit Track', + manageStems: 'Manage Stems', + remove: 'Remove', + noStems: 'No Stems', + stemsCount: (n: number) => `${n} Stem${n === 1 ? '' : 's'}` +} + +const SOURCE_TRACK_ROW_HEIGHT = 56 +const COVER_PHOTO_HEIGHT = 200 + +type ContestEventData = { + description?: string + prizeInfo?: string + winners?: unknown[] + title?: string + videoUrl?: string + coverPhotoUrl?: string + sourceTrackIds?: number[] +} + +/** + * Full-page Create / Edit Remix Contest flow. Replaces the legacy + * HostRemixContestModal. Route: /:handle/:slug/host-contest. + * + * When the target track already has a remix_contest event, the form + * loads into "edit" mode pre-filled from event_data. + * + * New fields (Title, Video Link, Cover Photo, Source Tracks) live as + * extra keys inside event_data — the backend entity_manager accepts + * arbitrary JSON, so no schema change is required. The single + * `entity_id` still points at the primary track; additional source + * tracks live in `event_data.sourceTrackIds`. + */ +export const HostRemixContestPage = () => { + const navigate = useNavigate() + useRequiresAccount() + const { handle, slug } = useParams<{ handle: string; slug: string }>() + + const { data: currentUserId } = useCurrentUserId() + const { data: primaryTrack } = useTrackByPermalink( + handle && slug ? `/${handle}/${slug}` : null + ) + const trackId = primaryTrack?.track_id + const { data: remixContest } = useRemixContest(trackId) + const { data: remixes, isLoading: remixesLoading } = useRemixesLineup({ + trackId, + isContestEntry: true + }) + + const { mutate: createEvent } = useCreateEvent() + const { mutate: updateEvent } = useUpdateEvent() + const { mutate: deleteEvent } = useDeleteEvent() + + const isEdit = !!remixContest + const hasContestEntries = remixesLoading || (remixes && remixes.length > 0) + const displayTurnOffButton = !hasContestEntries && isEdit + const contestMinDate = useMemo( + () => (remixContest ? dayjs(remixContest.endDate) : dayjs()), + [remixContest] + ) + const existingEventData = (remixContest?.eventData ?? {}) as ContestEventData + + const primaryPermalink = primaryTrack?.permalink ?? '' + + // --------------------------------------------------------------------------- + // Form state + // --------------------------------------------------------------------------- + const [title, setTitle] = useState(existingEventData.title ?? '') + const [description, setDescription] = useState( + existingEventData.description ?? '' + ) + const [descriptionError, setDescriptionError] = useState(false) + const [videoUrl, setVideoUrl] = useState(existingEventData.videoUrl ?? '') + const [coverPhotoUrl, setCoverPhotoUrl] = useState( + existingEventData.coverPhotoUrl ?? '' + ) + const [prizeInfo, setPrizeInfo] = useState(existingEventData.prizeInfo ?? '') + + const [contestEndDate, setContestEndDate] = useState( + remixContest ? dayjs(remixContest.endDate) : null + ) + const [endDateTouched, setEndDateTouched] = useState(false) + const [endDateError, setEndDateError] = useState(false) + const [timeValue, setTimeValue] = useState( + contestEndDate ? dayjs(contestEndDate).format('hh:mm') : '' + ) + const [timeError, setTimeError] = useState(false) + const [meridianValue, setMeridianValue] = useState( + contestEndDate ? dayjs(contestEndDate).format('A') : '' + ) + + const [sourceTrackIds, setSourceTrackIds] = useState( + existingEventData.sourceTrackIds ?? (trackId ? [trackId] : []) + ) + + // Modal state + const [isAddTracksOpen, setIsAddTracksOpen] = useState(false) + const [manageStemsTargetId, setManageStemsTargetId] = useState( + null + ) + + // --------------------------------------------------------------------------- + // Cover-photo upload + fallback to track artwork + // --------------------------------------------------------------------------- + const { imageUrl: trackArtworkUrl } = useTrackCoverArt({ + trackId, + size: SquareSizes.SIZE_1000_BY_1000 + }) + const resolvedCoverUrl = coverPhotoUrl || trackArtworkUrl + const { upload: uploadCover, isUploading: isCoverUploading } = + useUploadContestCover() + const coverFileInputRef = useRef(null) + + const handleCoverFileSelected = useCallback( + async (file: File | null) => { + if (!file) return + const url = await uploadCover(file) + if (url) setCoverPhotoUrl(url) + }, + [uploadCover] + ) + + // --------------------------------------------------------------------------- + // Handlers + // --------------------------------------------------------------------------- + const handleEndDateChange = useCallback( + (value: string) => { + setContestEndDate(dayjs(value)) + if (value && !timeValue) { + setTimeValue('11:59') + setMeridianValue('PM') + } + setEndDateError(false) + }, + [timeValue] + ) + + const handleTimeChange = useCallback((value: string) => { + setTimeValue(value) + setEndDateError(false) + }, []) + + const handleTimeError = useCallback((hasError: boolean) => { + setTimeError(hasError) + }, []) + + const handleMeridianChange = useCallback((value: string) => { + setMeridianValue(value) + setEndDateError(false) + }, []) + + const handleAddSourceTrack = useCallback(() => { + setIsAddTracksOpen(true) + }, []) + + const handleRemoveSourceTrack = useCallback((id: number) => { + setSourceTrackIds((prev) => prev.filter((x) => x !== id)) + }, []) + + const handleSourceTracksSelected = useCallback((ids: number[]) => { + setSourceTrackIds((prev) => { + const merged = Array.from(new Set([...prev, ...ids])) + return merged + }) + }, []) + + const handleCancel = useCallback(() => { + if (!primaryPermalink) { + navigate(-1) + return + } + navigate( + isEdit + ? fullContestPage(primaryPermalink) + : fullTrackPage(primaryPermalink) + ) + }, [isEdit, navigate, primaryPermalink]) + + const handleSubmit = useCallback(() => { + const parsedTime = parseTime(timeValue) + if (!parsedTime) return + + const parsedDate = mergeReleaseDateValues( + contestEndDate?.toISOString() ?? '', + parsedTime, + meridianValue + ) + + const hasDescriptionError = !description + const hasDateError = + !parsedDate || + dayjs(parsedDate.toISOString()).isBefore(contestMinDate) || + dayjs(parsedDate.toISOString()).isAfter(dayjs().add(90, 'days')) + const hasError = hasDateError || hasDescriptionError + + setEndDateTouched(true) + setEndDateError(hasDateError) + setDescriptionError(hasDescriptionError) + if (hasError || !trackId || !currentUserId) return + + const endDate = parsedDate.toISOString() + const eventData: ContestEventData = { + description, + prizeInfo, + winners: existingEventData.winners ?? [], + title: title.trim() || undefined, + videoUrl: videoUrl.trim() || undefined, + coverPhotoUrl: coverPhotoUrl.trim() || undefined, + sourceTrackIds: sourceTrackIds.length > 0 ? sourceTrackIds : undefined + } + + if (isEdit && remixContest) { + updateEvent({ + eventId: remixContest.eventId, + eventData, + endDate, + userId: currentUserId + }) + + track( + make({ + eventName: Name.REMIX_CONTEST_UPDATE, + remixContestId: remixContest.eventId, + trackId + }) + ) + } else { + createEvent({ + eventType: EventEventTypeEnum.RemixContest, + entityType: EventEntityTypeEnum.Track, + entityId: trackId, + eventData, + endDate, + userId: currentUserId + }) + + track( + make({ + eventName: Name.REMIX_CONTEST_CREATE, + trackId + }) + ) + } + + if (primaryPermalink) { + navigate(fullContestPage(primaryPermalink)) + } + }, [ + timeValue, + contestEndDate, + meridianValue, + description, + prizeInfo, + title, + videoUrl, + coverPhotoUrl, + sourceTrackIds, + existingEventData.winners, + contestMinDate, + trackId, + currentUserId, + isEdit, + remixContest, + updateEvent, + createEvent, + primaryPermalink, + navigate + ]) + + const handleDeleteEvent = useCallback(() => { + if (!remixContest || !currentUserId) return + deleteEvent({ eventId: remixContest.eventId, userId: currentUserId }) + + if (trackId) { + track( + make({ + eventName: Name.REMIX_CONTEST_DELETE, + remixContestId: remixContest.eventId, + trackId + }) + ) + } + if (primaryPermalink) { + navigate(fullTrackPage(primaryPermalink)) + } + }, [ + remixContest, + currentUserId, + deleteEvent, + trackId, + primaryPermalink, + navigate + ]) + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + if (!primaryTrack) { + return null + } + + return ( + + + + + {isEdit ? messages.editPageTitle : messages.pageTitle} + + + {/* Section: Contest Title */} + + + + {messages.titleLabel} + + {messages.required} + + + + {messages.titleHelper} + + + setTitle(e.target.value)} + /> + + + {/* Section: Description */} + + + + {messages.descriptionLabel} + + {messages.required} + + + + {messages.descriptionHelper} + + +