diff --git a/packages/common/src/models/Track.ts b/packages/common/src/models/Track.ts index eab37577380..a4f424f560e 100644 --- a/packages/common/src/models/Track.ts +++ b/packages/common/src/models/Track.ts @@ -118,24 +118,24 @@ export const isContentCollectibleGated = ( nft_collection: | AccessConditionsEthNFTCollection | AccessConditionsSolNFTCollection -} => 'nft_collection' in (gatedConditions ?? {}) +} => !!gatedConditions && 'nft_collection' in (gatedConditions ?? {}) export const isContentFollowGated = ( gatedConditions?: Nullable ): gatedConditions is FollowGatedConditions => - 'follow_user_id' in (gatedConditions ?? {}) + !!gatedConditions && 'follow_user_id' in (gatedConditions ?? {}) export const isContentTipGated = ( gatedConditions?: Nullable ): gatedConditions is TipGatedConditions => - 'tip_user_id' in (gatedConditions ?? {}) + !!gatedConditions && 'tip_user_id' in (gatedConditions ?? {}) export const isContentUSDCPurchaseGated = ( gatedConditions?: Nullable< AccessConditions | DeepOmit > // data coming from upload/edit forms will not have splits on the type ): gatedConditions is USDCPurchaseConditions => - 'usdc_purchase' in (gatedConditions ?? {}) + !!gatedConditions && 'usdc_purchase' in (gatedConditions ?? {}) export type AccessSignature = { data: string diff --git a/packages/common/src/store/account/selectors.ts b/packages/common/src/store/account/selectors.ts index da5da767295..f1d23edc411 100644 --- a/packages/common/src/store/account/selectors.ts +++ b/packages/common/src/store/account/selectors.ts @@ -177,6 +177,14 @@ export const getAccountWithAlbums = createSelector( } ) +export const getAccountAlbums = createSelector( + [getAccountWithCollections], + (account) => { + if (!account) return undefined + return account.collections.filter((c) => c.is_album) + } +) + export const getAccountWithNameSortedPlaylistsAndAlbums = createSelector( [getAccountWithCollections], (account) => { diff --git a/packages/harmony/src/components/input/TextInput/TextInput.tsx b/packages/harmony/src/components/input/TextInput/TextInput.tsx index 34d8b148ff5..ee3ef939b56 100644 --- a/packages/harmony/src/components/input/TextInput/TextInput.tsx +++ b/packages/harmony/src/components/input/TextInput/TextInput.tsx @@ -173,7 +173,11 @@ export const TextInput = forwardRef( )} > {StartIcon ? ( - + ) : null} {shouldShowLabel ? ( diff --git a/packages/web/src/pages/dashboard-page/DashboardPage.tsx b/packages/web/src/pages/dashboard-page/DashboardPage.tsx index 3e5713fd060..c1bb9a187d9 100644 --- a/packages/web/src/pages/dashboard-page/DashboardPage.tsx +++ b/packages/web/src/pages/dashboard-page/DashboardPage.tsx @@ -1,6 +1,7 @@ import { useState, Suspense, ReactNode, useEffect, useCallback } from 'react' import { Status, Track } from '@audius/common/models' +import { FeatureFlags } from '@audius/common/services' import { themeSelectors } from '@audius/common/store' import { formatCount } from '@audius/common/utils' import cn from 'classnames' @@ -12,15 +13,17 @@ import Header from 'components/header/desktop/Header' import LoadingSpinner from 'components/loading-spinner/LoadingSpinner' import Page from 'components/page/Page' import { useGoToRoute } from 'hooks/useGoToRoute' +import { useFlag } from 'hooks/useRemoteConfig' import lazyWithPreload from 'utils/lazyWithPreload' import styles from './DashboardPage.module.css' import { ArtistCard } from './components/ArtistCard' +import { ArtistContentSection } from './components/ArtistContentSection' import { TracksTableContainer, - DataSourceTrack, - tablePageSize + DataSourceTrack } from './components/TracksTableContainer' +import { TABLE_PAGE_SIZE } from './components/constants' import { getDashboardListenData, getDashboardStatus, @@ -67,6 +70,9 @@ const StatTile = (props: { title: string; value: any }) => { export const DashboardPage = () => { const goToRoute = useGoToRoute() const dispatch = useDispatch() + const { isEnabled: isPremiumAlbumsEnabled } = useFlag( + FeatureFlags.PREMIUM_ALBUMS_ENABLED + ) const [selectedTrack, setSelectedTrack] = useState(-1) const { account, tracks, stats } = useSelector(makeGetDashboard()) const listenData = useSelector(getDashboardListenData) @@ -76,7 +82,7 @@ export const DashboardPage = () => { const header =
useEffect(() => { - dispatch(fetch({ offset: 0, limit: tablePageSize })) + dispatch(fetch({ offset: 0, limit: TABLE_PAGE_SIZE })) TotalPlaysChart.preload() return () => { dispatch(reset({})) @@ -194,7 +200,7 @@ export const DashboardPage = () => {
{renderChart()} {renderStats()} - {renderTable()} + {isPremiumAlbumsEnabled ? : renderTable()}
)} diff --git a/packages/web/src/pages/dashboard-page/components/ArtistContentSection.tsx b/packages/web/src/pages/dashboard-page/components/ArtistContentSection.tsx new file mode 100644 index 00000000000..a28c589674b --- /dev/null +++ b/packages/web/src/pages/dashboard-page/components/ArtistContentSection.tsx @@ -0,0 +1,153 @@ +import { useState, useCallback } from 'react' + +import { Nullable } from '@audius/common/utils' +import { + SelectablePill, + IconSearch, + Paper, + Flex, + FilterButton, + TextInput, + TextInputSize +} from '@audius/harmony' + +import { ArtistDashboardAlbumsTab } from './ArtistDashboardAlbumsTab' +import { ArtistDashboardTracksTab } from './ArtistDashboardTracksTab' +import { + useArtistDashboardAlbumFilters, + useArtistDashboardTrackFilters, + useFormattedAlbumData, + useFormattedTrackData +} from './hooks' +import { AlbumFilters, TrackFilters } from './types' + +const messages = { + allReleases: 'All Releases', + tracks: 'Tracks', + albums: 'Albums', + search: (type: Pills) => + `Search ${type === Pills.TRACKS ? 'Tracks' : 'Albums'}` +} + +enum Pills { + TRACKS, + ALBUMS +} + +export const ArtistContentSection = () => { + const [filterText, setFilterText] = useState('') + const [selectedPill, setSelectedPill] = useState(Pills.TRACKS) + const [selectedTrackFilter, setSelectedTrackFilter] = + useState>(null) + const [selectedAlbumFilter, setSelectedAlbumFilter] = + useState>(null) + const isTracks = selectedPill === Pills.TRACKS + const tracks = useFormattedTrackData() + const albums = useFormattedAlbumData() + + const { + filterButtonOptions: filterButtonTrackOptions, + hasOnlyOneSection: hasOnlyOneTrackSection + } = useArtistDashboardTrackFilters() + const { + filterButtonOptions: filterButtonAlbumOptions, + hasOnlyOneSection: hasOnlyOneAlbumSection + } = useArtistDashboardAlbumFilters() + + const filterButtonOptions = isTracks + ? filterButtonTrackOptions + : filterButtonAlbumOptions + const shouldShowFilterButton = + (isTracks && !hasOnlyOneTrackSection) || + (!isTracks && !hasOnlyOneAlbumSection) + const shouldShowPills = tracks.length && albums.length + + const onClickPill = useCallback( + (pill: Pills) => { + setSelectedPill(pill) + setFilterText('') + + // Reset filter button state when switching content types + if (!isTracks && pill === Pills.TRACKS) { + setSelectedAlbumFilter(null) + } else if (isTracks && pill === Pills.ALBUMS) { + setSelectedTrackFilter(null) + } + }, + [isTracks] + ) + + const handleSelectFilter = (value: string) => { + if (isTracks) { + setSelectedTrackFilter(value as TrackFilters) + } else { + setSelectedAlbumFilter(value as AlbumFilters) + } + } + + const handleFilterChange = (e: React.ChangeEvent) => { + const val = e.target.value + setFilterText(val) + } + + if (!tracks.length && !albums.length) return null + + return ( + + + + {shouldShowPills ? ( + + onClickPill(Pills.TRACKS)} + /> + onClickPill(Pills.ALBUMS)} + /> + + ) : null} + { + // Only show filter button if there are multiple sections for the selected content type + shouldShowFilterButton ? ( + + ) : null + } + + + + + + {selectedPill === Pills.TRACKS ? ( + + ) : ( + + )} + + ) +} diff --git a/packages/web/src/pages/dashboard-page/components/ArtistDashboardAlbumsTab.tsx b/packages/web/src/pages/dashboard-page/components/ArtistDashboardAlbumsTab.tsx new file mode 100644 index 00000000000..644bd98a35c --- /dev/null +++ b/packages/web/src/pages/dashboard-page/components/ArtistDashboardAlbumsTab.tsx @@ -0,0 +1,64 @@ +import { useCallback } from 'react' + +import { Nullable } from '@audius/common/utils' +import { Paper } from '@audius/harmony' +import { useSelector } from 'react-redux' + +import { TracksTable, TracksTableColumn } from 'components/tracks-table' +import { useGoToRoute } from 'hooks/useGoToRoute' + +import { makeGetDashboard } from '../store/selectors' + +import { SHOW_MORE_LIMIT, TABLE_PAGE_SIZE } from './constants' +import { useFilteredAlbumData } from './hooks' +import { AlbumFilters } from './types' + +const albumTableColumns: TracksTableColumn[] = [ + 'spacer', + 'trackName', + 'releaseDate', + 'reposts', + 'overflowMenu' +] + +type ArtistDashboardAlbumsTabProps = { + selectedFilter: Nullable + filterText: string +} + +export const ArtistDashboardAlbumsTab = ({ + selectedFilter, + filterText +}: ArtistDashboardAlbumsTabProps) => { + const goToRoute = useGoToRoute() + const { account } = useSelector(makeGetDashboard()) + const filteredData = useFilteredAlbumData({ + selectedFilter, + filterText + }) + + const onClickRow = useCallback( + (collection: any) => { + if (!account) return + goToRoute(collection.permalink) + }, + [account, goToRoute] + ) + + if (!filteredData.length || !account) return null + + return ( + + + + ) +} diff --git a/packages/web/src/pages/dashboard-page/components/ArtistDashboardTracksTab.tsx b/packages/web/src/pages/dashboard-page/components/ArtistDashboardTracksTab.tsx new file mode 100644 index 00000000000..b9c1dee747a --- /dev/null +++ b/packages/web/src/pages/dashboard-page/components/ArtistDashboardTracksTab.tsx @@ -0,0 +1,79 @@ +import { useCallback } from 'react' + +import { Status } from '@audius/common/models' +import { Nullable } from '@audius/common/utils' +import { useDispatch, useSelector } from 'react-redux' + +import { TracksTable, TracksTableColumn } from 'components/tracks-table' +import { useGoToRoute } from 'hooks/useGoToRoute' + +import { getDashboardTracksStatus, makeGetDashboard } from '../store/selectors' +import { fetchTracks } from '../store/slice' + +import { SHOW_MORE_LIMIT, TABLE_PAGE_SIZE } from './constants' +import { useFilteredTrackData } from './hooks' +import { TrackFilters } from './types' + +const tracksTableColumns: TracksTableColumn[] = [ + 'spacer', + 'trackName', + 'releaseDate', + 'length', + 'plays', + 'reposts', + 'overflowMenu' +] + +type ArtistDashboardTracksTabProps = { + selectedFilter: Nullable + filterText: string +} + +export const ArtistDashboardTracksTab = ({ + selectedFilter, + filterText +}: ArtistDashboardTracksTabProps) => { + const dispatch = useDispatch() + const goToRoute = useGoToRoute() + const tracksStatus = useSelector(getDashboardTracksStatus) + const { account } = useSelector(makeGetDashboard()) + const filteredData = useFilteredTrackData({ + selectedFilter, + filterText + }) + + const handleFetchPage = useCallback( + (page: number) => { + dispatch( + fetchTracks({ offset: page * TABLE_PAGE_SIZE, limit: TABLE_PAGE_SIZE }) + ) + }, + [dispatch] + ) + + const onClickRow = useCallback( + (track: any) => { + if (!account) return + goToRoute(track.permalink) + }, + [account, goToRoute] + ) + + if (!filteredData.length || !account) return null + + return ( + + ) +} diff --git a/packages/web/src/pages/dashboard-page/components/constants.ts b/packages/web/src/pages/dashboard-page/components/constants.ts new file mode 100644 index 00000000000..a072640d12e --- /dev/null +++ b/packages/web/src/pages/dashboard-page/components/constants.ts @@ -0,0 +1,3 @@ +export const SHOW_MORE_LIMIT = 5 + +export const TABLE_PAGE_SIZE = 50 diff --git a/packages/web/src/pages/dashboard-page/components/hooks.ts b/packages/web/src/pages/dashboard-page/components/hooks.ts new file mode 100644 index 00000000000..65c9c8ac699 --- /dev/null +++ b/packages/web/src/pages/dashboard-page/components/hooks.ts @@ -0,0 +1,395 @@ +import { useMemo } from 'react' + +import { + Collection, + Track, + isContentCollectibleGated, + isContentFollowGated, + isContentTipGated, + isContentUSDCPurchaseGated +} from '@audius/common/models' +import { accountSelectors } from '@audius/common/store' +import { + IconCart, + IconCollectible, + IconSpecialAccess, + IconVisibilityHidden, + IconVisibilityPublic +} from '@audius/harmony' +import { useSelector } from 'react-redux' +import { Nullable } from 'vitest' + +import { makeGetDashboard } from '../store/selectors' + +import { + AlbumFilters, + DataSourceAlbum, + DataSourceTrack, + TrackFilters +} from './types' + +const { getAccountAlbums } = accountSelectors + +const messages = { + public: 'Public', + premium: 'Premium', + specialAcess: 'SpecialAccess', + gated: 'Gated', + hidden: 'Hidden' +} + +/** ------------------------ Tracks ------------------------ */ + +const formatTrackMetadata = (metadata: Track, i: number): DataSourceTrack => { + return { + ...metadata, + key: `${metadata.title}_${metadata.dateListened}_${i}`, + name: metadata.title, + date: metadata.created_at, + time: metadata.duration, + saves: metadata.save_count, + reposts: metadata.repost_count, + plays: metadata.play_count + } +} + +/** Returns the logged-in user's tracks, formatted for Artist Dashboard tracks table */ +export const useFormattedTrackData = () => { + const { tracks } = useSelector(makeGetDashboard()) + const tracksFormatted = useMemo(() => { + return tracks + .map((track: Track, i: number) => formatTrackMetadata(track, i)) + .filter((meta) => !meta.is_invalid) + }, [tracks]) + return tracksFormatted +} + +/** + * Returns a set of arrays that contain the logged-in user's tracks filtered by + * whether the tracks are public, special access, hidden, premium, or collectible gated. + * Also returns a boolean indicating whether the user has only one type of track. + */ +const useSegregatedTrackData = () => { + const tracks = useFormattedTrackData() + const { + hasOnlyOneSection, + publicTracks, + specialAccessTracks, + hiddenTracks, + premiumTracks, + collectibleGatedTracks + } = useMemo(() => { + const publicTracks = tracks.filter( + (data) => data.is_unlisted === false && !data.is_stream_gated + ) + const specialAccessTracks = tracks.filter( + (data) => + data.is_stream_gated && + (isContentFollowGated(data.stream_conditions) || + isContentTipGated(data.stream_conditions)) + ) + const hiddenTracks = tracks.filter((data) => !!data.is_unlisted) + const premiumTracks = tracks.filter( + (data) => + data.is_stream_gated && + isContentUSDCPurchaseGated(data.stream_conditions) + ) + const collectibleGatedTracks = tracks.filter( + (data) => + data.is_stream_gated && + isContentCollectibleGated(data.stream_conditions) + ) + + const arrays = [ + publicTracks, + specialAccessTracks, + hiddenTracks, + premiumTracks, + collectibleGatedTracks + ] + const nonEmptyArrays = arrays.filter((arr) => arr.length > 0) + const hasOnlyOneSection = nonEmptyArrays.length === 1 + + return { + hasOnlyOneSection, + publicTracks, + specialAccessTracks, + hiddenTracks, + premiumTracks, + collectibleGatedTracks + } + }, [tracks]) + + return { + hasOnlyOneSection, + publicTracks, + specialAccessTracks, + hiddenTracks, + premiumTracks, + collectibleGatedTracks + } +} + +/** + * Returns the logged-in user's tracks, filtered by the selected filter and search text. + */ +export const useFilteredTrackData = ({ + selectedFilter, + filterText +}: { + selectedFilter: Nullable + filterText: string +}) => { + const tracks = useFormattedTrackData() + const { + publicTracks, + specialAccessTracks, + hiddenTracks, + premiumTracks, + collectibleGatedTracks + } = useSegregatedTrackData() + + const filteredData = useMemo(() => { + let filteredData: DataSourceTrack[] = tracks + switch (selectedFilter) { + case TrackFilters.PUBLIC: + filteredData = publicTracks + break + case TrackFilters.PREMIUM: + filteredData = premiumTracks + break + case TrackFilters.SPECIAL_ACCESS: + filteredData = specialAccessTracks + break + case TrackFilters.COLLECTIBLE_GATED: + filteredData = collectibleGatedTracks + break + case TrackFilters.HIDDEN: + filteredData = hiddenTracks + break + default: + filteredData = tracks + break + } + + if (filterText) { + filteredData = filteredData.filter((data) => + data.name.toLowerCase().includes(filterText.toLowerCase()) + ) + } + + return filteredData + }, [ + collectibleGatedTracks, + filterText, + hiddenTracks, + premiumTracks, + publicTracks, + selectedFilter, + specialAccessTracks, + tracks + ]) + + return filteredData +} + +/** + * Returns a list of filter options for the logged-in user's tracks, eg. + * the "hidden" option will only be available if the user has hidden tracks. + */ +export const useArtistDashboardTrackFilters = () => { + const { + specialAccessTracks, + hiddenTracks, + premiumTracks, + collectibleGatedTracks, + hasOnlyOneSection + } = useSegregatedTrackData() + + const filterButtonOptions = useMemo(() => { + const filterButtonTrackOptions = [ + { + id: TrackFilters.PUBLIC, + label: messages.public, + icon: IconVisibilityPublic, + value: TrackFilters.PUBLIC + } + ] + if (premiumTracks.length) { + filterButtonTrackOptions.push({ + id: TrackFilters.PREMIUM, + label: messages.premium, + icon: IconCart, + value: TrackFilters.PREMIUM + }) + } + if (specialAccessTracks.length) { + filterButtonTrackOptions.push({ + id: TrackFilters.SPECIAL_ACCESS, + label: messages.specialAcess, + icon: IconSpecialAccess, + value: TrackFilters.SPECIAL_ACCESS + }) + } + if (collectibleGatedTracks.length) { + filterButtonTrackOptions.push({ + id: TrackFilters.COLLECTIBLE_GATED, + label: messages.gated, + icon: IconCollectible, + value: TrackFilters.COLLECTIBLE_GATED + }) + } + if (hiddenTracks.length) { + filterButtonTrackOptions.push({ + id: TrackFilters.HIDDEN, + label: messages.hidden, + icon: IconVisibilityHidden, + value: TrackFilters.HIDDEN + }) + } + return filterButtonTrackOptions + }, [collectibleGatedTracks, hiddenTracks, premiumTracks, specialAccessTracks]) + + return { filterButtonOptions, hasOnlyOneSection } +} + +/** ------------------------ Albums ------------------------ */ + +const formatAlbumMetadata = (album: Collection): DataSourceAlbum => { + return { + ...album, + key: String(album.playlist_id), + name: album.playlist_name, + date: album.created_at, + saves: album.save_count, + reposts: album.repost_count + } +} + +/** Returns the logged-in user's albums, formatted for Artist Dashboard albums table */ +export const useFormattedAlbumData = () => { + const albums = useSelector(getAccountAlbums) + const albumsFormatted = useMemo(() => { + return albums?.map((album) => formatAlbumMetadata(album)) + }, [albums]) + return albumsFormatted ?? [] +} + +const useSegregatedAlbumData = () => { + const albums = useFormattedAlbumData() + + const { hasOnlyOneSection, publicAlbums, hiddenAlbums, premiumAlbums } = + useMemo(() => { + const publicAlbums = albums.filter( + (data) => data.is_private === false && !data.is_stream_gated + ) + const hiddenAlbums = albums.filter((data) => !!data.is_private) + const premiumAlbums = albums.filter( + (data) => + data.is_stream_gated && + isContentUSDCPurchaseGated(data.stream_conditions) + ) + + const arrays = [publicAlbums, hiddenAlbums, premiumAlbums] + const nonEmptyArrays = arrays.filter((arr) => arr.length > 0) + const hasOnlyOneSection = nonEmptyArrays.length === 1 + + return { + hasOnlyOneSection, + publicAlbums, + hiddenAlbums, + premiumAlbums + } + }, [albums]) + + return { hasOnlyOneSection, publicAlbums, hiddenAlbums, premiumAlbums } +} + +/** + * Returns the logged-in user's albums, filtered by the selected filter and search text. + */ +export const useFilteredAlbumData = ({ + selectedFilter, + filterText +}: { + selectedFilter: Nullable + filterText: string +}) => { + const albums = useFormattedAlbumData() + const { publicAlbums, hiddenAlbums, premiumAlbums } = useSegregatedAlbumData() + + const filteredData = useMemo(() => { + let filteredData: DataSourceAlbum[] = albums + switch (selectedFilter) { + case AlbumFilters.PUBLIC: + filteredData = publicAlbums + break + case AlbumFilters.PREMIUM: + filteredData = premiumAlbums + break + case AlbumFilters.HIDDEN: + filteredData = hiddenAlbums + break + default: + filteredData = albums + break + } + + if (filterText) { + filteredData = filteredData.filter((data) => + data.name.toLowerCase().includes(filterText.toLowerCase()) + ) + } + + return filteredData + }, [ + albums, + filterText, + hiddenAlbums, + premiumAlbums, + publicAlbums, + selectedFilter + ]) + + return filteredData +} + +/** + * Returns a list of filter options for the logged-in user's albums, eg. + * the "hidden" option will only be available if the user has hidden albums. + */ +export const useArtistDashboardAlbumFilters = () => { + const { hiddenAlbums, premiumAlbums, hasOnlyOneSection } = + useSegregatedAlbumData() + + const filterButtonOptions = useMemo(() => { + const filterButtonAlbumOptions = [ + { + id: AlbumFilters.PUBLIC, + label: messages.public, + icon: IconVisibilityPublic, + value: AlbumFilters.PUBLIC + } + ] + if (premiumAlbums.length) { + filterButtonAlbumOptions.push({ + id: AlbumFilters.PREMIUM, + label: messages.premium, + icon: IconCart, + value: AlbumFilters.PREMIUM + }) + } + if (hiddenAlbums.length) { + filterButtonAlbumOptions.push({ + id: AlbumFilters.HIDDEN, + label: messages.hidden, + icon: IconVisibilityHidden, + value: AlbumFilters.HIDDEN + }) + } + + return filterButtonAlbumOptions + }, [hiddenAlbums, premiumAlbums]) + + return { filterButtonOptions, hasOnlyOneSection } +} diff --git a/packages/web/src/pages/dashboard-page/components/types.ts b/packages/web/src/pages/dashboard-page/components/types.ts new file mode 100644 index 00000000000..c44661769fc --- /dev/null +++ b/packages/web/src/pages/dashboard-page/components/types.ts @@ -0,0 +1,33 @@ +import { Collection, Track } from '@audius/common/models' + +export type DataSourceTrack = Track & { + key: string + name: string + date: string + time?: number + saves: number + reposts: number + plays: number +} + +export enum TrackFilters { + PUBLIC = 'Public', + PREMIUM = 'Premium', + SPECIAL_ACCESS = 'SpecialAccess', + COLLECTIBLE_GATED = 'CollectibleGated', + HIDDEN = 'Hidden' +} + +export type DataSourceAlbum = Collection & { + key: string + name: string + date: string + saves: number + reposts: number +} + +export enum AlbumFilters { + PUBLIC = 'Public', + PREMIUM = 'Premium', + HIDDEN = 'Hidden' +}