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}
+
+
+
+
+ {/* Section: Video Link */}
+
+
+
+ {messages.videoLabel}
+
+
+ {messages.videoHelper}
+
+
+ setVideoUrl(e.target.value)}
+ />
+
+
+ {/* Section: Submission Deadline */}
+
+
+
+ {messages.deadlineLabel}
+
+ {messages.required}
+
+
+
+ {messages.deadlineHelper}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Section: Cover Photo */}
+
+
+
+ {messages.coverPhotoLabel}
+
+ {messages.required}
+
+
+
+ {messages.coverPhotoHelper}
+
+
+ coverFileInputRef.current?.click()}
+ onDragOver={(e) => {
+ e.preventDefault()
+ }}
+ onDrop={(e) => {
+ e.preventDefault()
+ const file = e.dataTransfer?.files?.[0]
+ if (file) handleCoverFileSelected(file).catch(() => {})
+ }}
+ css={{
+ border: '1px dashed var(--harmony-border-default)',
+ borderRadius: 8,
+ backgroundImage: resolvedCoverUrl
+ ? `linear-gradient(rgba(0,0,0,0.35), rgba(0,0,0,0.35)), url(${resolvedCoverUrl})`
+ : undefined,
+ backgroundSize: 'cover',
+ backgroundPosition: 'center',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ cursor: isCoverUploading ? 'wait' : 'pointer'
+ }}
+ >
+
+ {isCoverUploading ? (
+
+ ) : (
+
+ )}
+
+ {messages.coverPhotoPlaceholder}
+
+
+
+ {
+ const file = e.target.files?.[0] ?? null
+ handleCoverFileSelected(file).catch(() => {})
+ // Reset so the same file can be re-selected after a mistake.
+ e.target.value = ''
+ }}
+ />
+
+
+ {/* Section: Prizes */}
+
+
+
+ {messages.prizesLabel}
+
+
+ {messages.prizesHelper}
+
+
+
+
+ {/* Section: Source Tracks */}
+
+
+
+ {messages.sourceTracksLabel}
+
+
+ {messages.sourceTracksHelper}
+
+
+
+
+
+
+ {sourceTrackIds.length > 0 ? (
+
+
+ {messages.sourceTracksSectionLabel}
+
+
+ {sourceTrackIds.map((id) => (
+ handleRemoveSourceTrack(id)}
+ onManageStems={() => setManageStemsTargetId(id)}
+ />
+ ))}
+
+
+ ) : null}
+
+
+ {/* Actions row */}
+
+
+
+ {displayTurnOffButton ? (
+
+ ) : null}
+ {/* Save Draft is scoped out for now (Chunk 2) — no draft
+ model exists on the backend yet. */}
+
+
+
+
+
+
+
+ setIsAddTracksOpen(false)}
+ initialSelectedIds={sourceTrackIds}
+ onDone={handleSourceTracksSelected}
+ />
+ setManageStemsTargetId(null)}
+ trackId={manageStemsTargetId}
+ />
+
+ )
+}
+
+// ----- Source-track row ------------------------------------------------------
+
+type SourceTrackRowProps = {
+ sourceTrackId: number
+ onRemove: () => void
+ onManageStems: () => void
+}
+
+const SourceTrackRow = ({
+ sourceTrackId,
+ onRemove,
+ onManageStems
+}: SourceTrackRowProps) => {
+ const navigate = useNavigate()
+ const { data: trackData } = useTrack(sourceTrackId, {
+ select: (t) =>
+ t
+ ? {
+ title: t.title,
+ owner_id: t.owner_id,
+ permalink: t.permalink,
+ is_downloadable: t.is_downloadable,
+ stem_of: t.stem_of
+ }
+ : undefined
+ })
+ const { data: owner } = useUser(trackData?.owner_id)
+ const { imageUrl: artworkUrl } = useTrackCoverArt({
+ trackId: sourceTrackId,
+ size: SquareSizes.SIZE_150_BY_150
+ })
+ // getTrackStems is a fast one-shot query that returns the full stem
+ // tracks; caching means subsequent rows hit the warm slot. Null when
+ // the request hasn't resolved so we can distinguish "still loading"
+ // from "actually 0 stems" if we want to.
+ const { data: stems } = useStems(sourceTrackId)
+ const stemsCount = stems?.length ?? 0
+
+ // Label matches the Figma's three states: "X Stems" when there are
+ // stems, "No Stems" otherwise. Until useStems resolves we render
+ // nothing — avoids a flash of "No Stems" on tracks that turn out to
+ // have stems.
+ const stemsLabel =
+ stems === undefined
+ ? ''
+ : stemsCount > 0
+ ? messages.stemsCount(stemsCount)
+ : messages.noStems
+
+ return (
+
+
+
+
+
+ {trackData?.title ?? '—'}
+
+
+ {owner?.name ?? ''}
+
+
+
+
+
+ {stemsLabel}
+
+ {
+ if (trackData?.permalink) navigate(trackData.permalink)
+ }
+ },
+ {
+ text: messages.editTrack,
+ onClick: () => {
+ if (trackData?.permalink) {
+ navigate(`${trackData.permalink}/edit`)
+ }
+ }
+ },
+ {
+ text: messages.manageStems,
+ onClick: onManageStems
+ },
+ { text: messages.remove, onClick: onRemove }
+ ]}
+ renderTrigger={(ref, triggerPopup) => (
+
+
+ )
+}
+
+export default HostRemixContestPage
+
+// Re-export the icon name used by the header decoration so WebPlayer can
+// pass `` directly without a duplicate import.
+export { IconTrophy }
diff --git a/packages/web/src/pages/host-remix-contest-page/ManageStemsModal.tsx b/packages/web/src/pages/host-remix-contest-page/ManageStemsModal.tsx
new file mode 100644
index 00000000000..2fce2429dff
--- /dev/null
+++ b/packages/web/src/pages/host-remix-contest-page/ManageStemsModal.tsx
@@ -0,0 +1,229 @@
+import { useCallback, useEffect, useState } from 'react'
+
+import { useTrack, useUpdateTrack } from '@audius/common/api'
+import type { AccessConditions, ID } from '@audius/common/models'
+import {
+ Box,
+ Button,
+ Divider,
+ Flex,
+ IconCart,
+ IconCloudUpload,
+ IconReceive,
+ IconUserFollowing,
+ IconVisibilityPublic,
+ Modal,
+ ModalContent,
+ ModalHeader,
+ ModalTitle,
+ SegmentedControl,
+ Switch,
+ Text
+} from '@audius/harmony'
+
+const messages = {
+ title: 'Stems & Downloads',
+ fullTrackDownloadLabel: 'Full Track Download',
+ fullTrackDownloadHelper:
+ 'Allow your fans to download a lossless copy of your full track.',
+ availabilityLabel: 'Download Availability',
+ availabilityHelper: 'Specify who has access to download your files.',
+ public: 'Public',
+ followers: 'Followers',
+ premium: 'Premium',
+ uploadHeading: 'Upload Additional Files',
+ uploadHelper: 'Provide FLAC, WAV, ALAC, or AIFF for highest audio quality.',
+ uploadPlaceholder: 'Drag-and-drop audio files here, or ',
+ browseToUpload: 'browse to upload',
+ uploadComingSoon:
+ 'Additional stem file uploads coming soon — managed from the track edit page today.',
+ save: 'Save',
+ cancel: 'Cancel'
+}
+
+type Availability = 'public' | 'followers' | 'premium'
+
+type ManageStemsModalProps = {
+ isOpen: boolean
+ onClose: () => void
+ trackId: ID | null
+}
+
+/**
+ * Host-facing "Manage Stems" modal. Opened from the source-track kebab on
+ * the Create Contest page. Edits the *track itself* (no per-contest
+ * override) — toggling Full Track Download + Download Availability maps
+ * straight onto the track's is_downloadable / download_conditions.
+ *
+ * Upload of additional stem files is scoped out for this pass — it lives
+ * on the track edit page today, so we link users there instead.
+ */
+export const ManageStemsModal = ({
+ isOpen,
+ onClose,
+ trackId
+}: ManageStemsModalProps) => {
+ const { data: trackMeta } = useTrack(trackId ?? undefined, {
+ select: (t) =>
+ t
+ ? {
+ is_downloadable: t.is_downloadable,
+ download_conditions: t.download_conditions
+ }
+ : undefined
+ })
+
+ const { mutate: updateTrack, isPending: isSaving } = useUpdateTrack()
+
+ const [isDownloadable, setIsDownloadable] = useState(false)
+ const [availability, setAvailability] = useState('public')
+
+ // Seed form state from the track whenever the modal opens / the target
+ // track changes.
+ useEffect(() => {
+ if (!isOpen || !trackMeta) return
+ setIsDownloadable(!!trackMeta.is_downloadable)
+ const cond = trackMeta.download_conditions as AccessConditions | null
+ if (!cond) {
+ setAvailability('public')
+ } else if ('follow_user_id' in (cond ?? {})) {
+ setAvailability('followers')
+ } else if (
+ 'usdc_purchase' in (cond ?? {}) ||
+ 'nft_collection' in (cond ?? {})
+ ) {
+ setAvailability('premium')
+ } else {
+ setAvailability('public')
+ }
+ }, [isOpen, trackMeta])
+
+ const handleSave = useCallback(() => {
+ if (!trackId) return
+ // Only "public" availability is round-trippable without more form
+ // state (we'd need a USDC price or NFT gate to save premium; the
+ // follower-gate needs the host's own user id). For anything richer,
+ // the user should go through the full track edit page — link
+ // in-modal rather than silently failing.
+ const nextConditions: AccessConditions | null =
+ availability === 'public'
+ ? null
+ : (trackMeta?.download_conditions ?? null)
+
+ updateTrack({
+ trackId,
+ metadata: {
+ is_downloadable: isDownloadable,
+ download_conditions: nextConditions
+ } as any
+ })
+ onClose()
+ }, [
+ trackId,
+ isDownloadable,
+ availability,
+ trackMeta?.download_conditions,
+ updateTrack,
+ onClose
+ ])
+
+ return (
+
+
+
+
+
+
+ {/* Full Track Download */}
+
+
+
+ {messages.fullTrackDownloadLabel}
+
+
+ {messages.fullTrackDownloadHelper}
+
+
+ setIsDownloadable(e.target.checked)}
+ />
+
+
+
+
+ {/* Download Availability */}
+
+
+
+ {messages.availabilityLabel}
+
+
+ {messages.availabilityHelper}
+
+
+
+ fullWidth
+ selected={availability}
+ onSelectOption={setAvailability}
+ options={[
+ {
+ key: 'public',
+ text: messages.public,
+ icon:
+ },
+ {
+ key: 'followers',
+ text: messages.followers,
+ icon:
+ },
+ {
+ key: 'premium',
+ text: messages.premium,
+ icon:
+ }
+ ]}
+ />
+
+
+
+
+ {/* Upload Additional Files (stub) */}
+
+
+ {messages.uploadHeading}
+
+
+ {messages.uploadHelper}
+
+
+
+
+
+ {messages.uploadComingSoon}
+
+
+
+
+
+
+
+ {messages.save}
+
+
+
+
+
+ )
+}
diff --git a/packages/web/src/pages/host-remix-contest-page/index.ts b/packages/web/src/pages/host-remix-contest-page/index.ts
new file mode 100644
index 00000000000..ec860b9fb1b
--- /dev/null
+++ b/packages/web/src/pages/host-remix-contest-page/index.ts
@@ -0,0 +1 @@
+export { HostRemixContestPage, default } from './HostRemixContestPage'
diff --git a/packages/web/src/pages/host-remix-contest-page/useUploadContestCover.ts b/packages/web/src/pages/host-remix-contest-page/useUploadContestCover.ts
new file mode 100644
index 00000000000..49a4605a901
--- /dev/null
+++ b/packages/web/src/pages/host-remix-contest-page/useUploadContestCover.ts
@@ -0,0 +1,62 @@
+import { useCallback, useState } from 'react'
+
+import { useQueryContext } from '@audius/common/api'
+
+/**
+ * Handles mediorum upload of a contest cover photo. Mirrors the pattern
+ * used by useLaunchCoin / useUpdateFanClub for banner images — uploads the
+ * file with `img_backdrop` template (wide aspect-ratio) and resolves to a
+ * content-node URL the form can persist in `eventData.coverPhotoUrl`.
+ */
+export const useUploadContestCover = () => {
+ const { audiusSdk, reportToSentry } = useQueryContext()
+ const [isUploading, setIsUploading] = useState(false)
+
+ const upload = useCallback(
+ async (file: File): Promise => {
+ setIsUploading(true)
+ try {
+ const sdk = await audiusSdk()
+ const uploadResponse = await sdk.services.storage
+ .uploadFile({
+ file,
+ metadata: { template: 'img_backdrop' }
+ })
+ .start()
+
+ const results = (uploadResponse.results ?? {}) as Record
+ const prioritizedSizes = ['2000x', '1500x', '1280x', '1000x', '640x']
+ let cid: string | undefined
+ for (const size of prioritizedSizes) {
+ if (results[size]) {
+ cid = results[size]
+ break
+ }
+ }
+ if (!cid) cid = Object.values(results)[0]
+ if (!cid) throw new Error('Upload returned no CID')
+
+ const contentNodeEndpoint = await (
+ sdk.services.storage as any
+ ).storageNodeSelector?.getSelectedNode()
+ if (!contentNodeEndpoint) {
+ throw new Error('No content node available')
+ }
+
+ return `${contentNodeEndpoint}/content/${cid}`
+ } catch (error) {
+ reportToSentry({
+ error: error as Error,
+ name: 'HostRemixContest',
+ additionalInfo: { where: 'useUploadContestCover' }
+ })
+ return null
+ } finally {
+ setIsUploading(false)
+ }
+ },
+ [audiusSdk, reportToSentry]
+ )
+
+ return { upload, isUploading }
+}
diff --git a/packages/web/src/utils/route.ts b/packages/web/src/utils/route.ts
index 7c605f9616a..b010a3d979d 100644
--- a/packages/web/src/utils/route.ts
+++ b/packages/web/src/utils/route.ts
@@ -68,6 +68,13 @@ export const fullContestPage = (permalink: string) => {
return `${fullTrackPage(permalink)}/contest`
}
+export const hostRemixContestPage = (permalink: string) => {
+ return `${permalink}/host-contest`
+}
+export const fullHostRemixContestPage = (permalink: string) => {
+ return `${fullTrackPage(permalink)}/host-contest`
+}
+
export const fullAiPage = (handle: string) => {
return `${fullProfilePage(handle)}/ai`
}