Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/common/src/utils/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
12 changes: 12 additions & 0 deletions packages/web/src/app/web-player/WebPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down Expand Up @@ -250,6 +253,7 @@ const {
TRACK_REMIXES_PAGE,
PICK_WINNERS_PAGE,
CONTEST_PAGE,
HOST_REMIX_CONTEST_PAGE,
PROFILE_PAGE,
authenticatedRoutes,
EMPTY_PAGE,
Expand Down Expand Up @@ -1182,6 +1186,10 @@ const WebPlayer = (props: WebPlayerProps) => {
path={CONTEST_PAGE}
element={<ContestPage containerRef={mainContentRef} />}
/>
<Route
path={HOST_REMIX_CONTEST_PAGE}
element={<HostRemixContestPage />}
/>
<Route path={PICK_WINNERS_PAGE} element={<PickWinnersPage />} />
{isMobile ? (
<>
Expand Down Expand Up @@ -1556,6 +1564,10 @@ const WebPlayer = (props: WebPlayerProps) => {
path={CONTEST_PAGE}
element={<ContestPage containerRef={mainContentRef} />}
/>
<Route
path={HOST_REMIX_CONTEST_PAGE}
element={<HostRemixContestPage />}
/>
<Route path={PICK_WINNERS_PAGE} element={<PickWinnersPage />} />
<Route
path={REPOSTING_USERS_ROUTE}
Expand Down
11 changes: 9 additions & 2 deletions packages/web/src/components/menu/TrackMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { Dispatch } from 'redux'
import * as embedModalActions from 'components/embed-modal/store/actions'
import { ToastContext } from 'components/toast/ToastContext'
import { push } from 'utils/navigation'
import { albumPage } from 'utils/route'
import { albumPage, hostRemixContestPage } from 'utils/route'

const { requestOpen: requestOpenShareModal } = shareModalUIActions

Expand Down Expand Up @@ -309,7 +309,14 @@ const TrackMenu = ({
? messages.editRemixContest
: messages.hostRemixContest,
onClick: () => {
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 })
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
237 changes: 237 additions & 0 deletions packages/web/src/pages/host-remix-contest-page/AddSourceTrackModal.tsx
Original file line number Diff line number Diff line change
@@ -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<number[]>(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 (
<Modal isOpen={isOpen} onClose={onClose} size='medium'>
<ModalHeader onClose={onClose}>
<ModalTitle title={messages.title} />
</ModalHeader>
<ModalContent>
<Flex direction='column' gap='m'>
<TextInput
label={messages.search}
hideLabel
placeholder={messages.search}
value={search}
onChange={(e) => setSearch(e.target.value)}
startIcon={IconSearch}
/>
<Box
css={{
maxHeight: 360,
overflowY: 'auto',
borderTop: '1px solid var(--harmony-border-default)'
}}
>
{isPending ? (
<Flex justifyContent='center' p='l'>
<Text variant='body' color='subdued'>
{messages.loading}
</Text>
</Flex>
) : filtered.length === 0 ? (
<Flex justifyContent='center' p='l'>
<Text variant='body' color='subdued'>
{messages.empty}
</Text>
</Flex>
) : (
filtered.map((t) => (
<TrackRow
key={t.track_id}
trackId={t.track_id}
title={t.title}
ownerName={currentUser?.name ?? ''}
checked={selectedSet.has(t.track_id)}
onToggle={() => toggle(t.track_id)}
/>
))
)}
</Box>
</Flex>
</ModalContent>
<ModalFooter>
<Flex
justifyContent='space-between'
alignItems='center'
gap='m'
w='100%'
>
<Flex gap='m' alignItems='center'>
<Text variant='body' size='s' color='subdued'>
{messages.selectedCount(selectedIds.length)}
</Text>
{selectedIds.length > 0 ? (
<TextLink
variant='visible'
onClick={() => setViewOnlySelected((v) => !v)}
>
{messages.viewSelection}
</TextLink>
) : null}
</Flex>
<Flex gap='s'>
<Button variant='secondary' onClick={onClose}>
{messages.cancel}
</Button>
<Button
variant='primary'
onClick={handleDone}
disabled={selectedIds.length === 0}
>
{messages.done}
</Button>
</Flex>
</Flex>
</ModalFooter>
</Modal>
)
}

// ----- 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 (
<Flex
alignItems='center'
gap='m'
p='m'
css={{
borderBottom: '1px solid var(--harmony-border-default)',
cursor: 'pointer'
}}
onClick={onToggle}
>
<Box
css={{
width: 48,
height: 48,
borderRadius: 4,
backgroundImage: imageUrl ? `url(${imageUrl})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
flexShrink: 0
}}
/>
<Flex direction='column' css={{ flex: 1, minWidth: 0 }}>
<Text variant='body' size='m' ellipses>
{title}
</Text>
<Text variant='body' size='s' color='subdued' ellipses>
{ownerName}
</Text>
</Flex>
<Checkbox checked={checked} onChange={onToggle} aria-label={title} />
</Flex>
)
}
Loading
Loading