diff --git a/packages/common/src/utils/route.ts b/packages/common/src/utils/route.ts index 9b5b0d77902..80031b03092 100644 --- a/packages/common/src/utils/route.ts +++ b/packages/common/src/utils/route.ts @@ -52,6 +52,7 @@ export const UPLOAD_ALBUM_PAGE = '/upload/album' export const UPLOAD_PLAYLIST_PAGE = '/upload/playlist' export const SETTINGS_PAGE = '/settings' export const HOME_PAGE = '/' +export const HOMEPAGE_PAGE = '/home' export const NOT_FOUND_PAGE = '/404' export const SIGN_IN_PAGE = '/signin' export const SIGN_IN_CONFIRM_EMAIL_PAGE = '/signin/confirm-email' @@ -337,6 +338,7 @@ export const orderedRoutes = [ SALES_PAGE, WITHDRAWALS_PAGE, NOT_FOUND_PAGE, + HOMEPAGE_PAGE, HOME_PAGE, PLAYLIST_PAGE, ALBUM_PAGE, @@ -350,6 +352,7 @@ export const orderedRoutes = [ ] export const staticRoutes = new Set([ + HOMEPAGE_PAGE, FEED_PAGE, TRENDING_PAGE, EXPLORE_PAGE, diff --git a/packages/harmony/src/assets/icons/Home.svg b/packages/harmony/src/assets/icons/Home.svg new file mode 100644 index 00000000000..2bffcc111a5 --- /dev/null +++ b/packages/harmony/src/assets/icons/Home.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/harmony/src/icons/icons.tsx b/packages/harmony/src/icons/icons.tsx index 1104bc4daea..69e9d75705c 100644 --- a/packages/harmony/src/icons/icons.tsx +++ b/packages/harmony/src/icons/icons.tsx @@ -12,6 +12,7 @@ export { IconGift } from './individual/IconGift' export { IconSettings } from './individual/IconSettings' export { IconArrowLeft } from './individual/IconArrowLeft' export { IconHeart } from './individual/IconHeart' +export { IconHome } from './individual/IconHome' export { IconShare } from './individual/IconShare' export { IconArrowRight } from './individual/IconArrowRight' export { IconMoneySend } from './individual/IconMoneySend' diff --git a/packages/harmony/src/icons/individual/IconHome.ts b/packages/harmony/src/icons/individual/IconHome.ts new file mode 100644 index 00000000000..89fbb636825 --- /dev/null +++ b/packages/harmony/src/icons/individual/IconHome.ts @@ -0,0 +1,5 @@ +import { IconComponent } from '~harmony/components' + +import IconSVG from '../../assets/icons/Home.svg' + +export const IconHome = IconSVG as IconComponent diff --git a/packages/mobile/ios/AudiusReactNative.xcodeproj/project.pbxproj b/packages/mobile/ios/AudiusReactNative.xcodeproj/project.pbxproj index 8af9e63935f..b1c1b820350 100644 --- a/packages/mobile/ios/AudiusReactNative.xcodeproj/project.pbxproj +++ b/packages/mobile/ios/AudiusReactNative.xcodeproj/project.pbxproj @@ -424,7 +424,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export NODE_BINARY=node\nWITH_ENVIRONMENT=\"${SRCROOT}/../node_modules/react-native/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"${SRCROOT}/../node_modules/react-native/scripts/react-native-xcode.sh\"\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n"; + shellScript = "export NODE_BINARY=node\nWITH_ENVIRONMENT=\"${SRCROOT}/../node_modules/react-native/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"${SRCROOT}/../node_modules/react-native/scripts/react-native-xcode.sh\"\n. \"$WITH_ENVIRONMENT\"\n\"$REACT_NATIVE_XCODE\"\n"; showEnvVarsInLog = 0; }; B73C21C532E1201FEFBAECDA /* [CP] Copy Pods Resources */ = { diff --git a/packages/mobile/ios/Podfile b/packages/mobile/ios/Podfile index cff6c13dbf0..498aa611a1a 100644 --- a/packages/mobile/ios/Podfile +++ b/packages/mobile/ios/Podfile @@ -50,9 +50,8 @@ target 'AudiusReactNative' do def strip_bitcode_from_framework(bitcode_strip_path, framework_relative_path) framework_path = File.join(Dir.pwd, framework_relative_path) - command = "#{bitcode_strip_path} #{framework_path} -r -o #{framework_path}" - puts "Stripping bitcode: #{command}" - system(command) + puts "Stripping bitcode: #{bitcode_strip_path} #{framework_path} -r -o #{framework_path}" + system(bitcode_strip_path, framework_path, "-r", "-o", framework_path) end framework_paths = [ @@ -66,6 +65,53 @@ target 'AudiusReactNative' do strip_bitcode_from_framework(bitcode_strip_path, framework_relative_path) end + def patch_react_native_scripts_for_paths_with_spaces(react_native_path) + hermes_script_path = File.join(react_native_path, "sdks/hermes-engine/utils/replace_hermes_version.js") + hermes_contents = File.read(hermes_script_path) + patched_hermes_contents = hermes_contents + .gsub( + "const {execSync} = require('child_process');", + "const {execFileSync} = require('child_process');" + ) + .gsub( + ' execSync(`tar -xf ${tarballURLPath} -C ${finalLocation}`);', + " execFileSync('tar', ['-xf', tarballURLPath, '-C', finalLocation]);" + ) + + if patched_hermes_contents != hermes_contents + puts "Patching Hermes replacement script for paths with spaces" + File.write(hermes_script_path, patched_hermes_contents) + end + + environment_script_path = File.join(react_native_path, "scripts/xcode/with-environment.sh") + environment_contents = File.read(environment_script_path) + patched_environment_contents = environment_contents.gsub( + "if [ -n \"$1\" ]; then\n $1\nfi", + "if [ \"$#\" -gt 0 ]; then\n \"$@\"\nfi" + ) + + if patched_environment_contents != environment_contents + puts "Patching React Native Xcode environment script for paths with spaces" + File.write(environment_script_path, patched_environment_contents) + end + end + + def patch_script_phase_for_paths_with_spaces(script_phase) + return if script_phase.shell_script.nil? + + patched_script = script_phase.shell_script.gsub( + '/bin/sh -c "$WITH_ENVIRONMENT $SCRIPT_PHASES_SCRIPT"', + '"$WITH_ENVIRONMENT" "$SCRIPT_PHASES_SCRIPT"' + ) + + if patched_script != script_phase.shell_script + puts "Patching #{script_phase.display_name} script phase for paths with spaces" + script_phase.shell_script = patched_script + end + end + + patch_react_native_scripts_for_paths_with_spaces(config[:reactNativePath]) + # https://github.com/facebook/react-native/blob/main/packages/react-native/scripts/react_native_pods.rb#L197-L202 react_native_post_install( installer, @@ -81,6 +127,25 @@ target 'AudiusReactNative' do # https://github.com/CocoaPods/CocoaPods/issues/11402#issuecomment-1201464693 installer.pods_project.targets.each do |target| + target.shell_script_build_phases.each do |script_phase| + patch_script_phase_for_paths_with_spaces(script_phase) + end + + target.build_configurations.each do |config| + deployment_target = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + next if deployment_target.nil? + + if Gem::Version.new(deployment_target) < Gem::Version.new('15.5') + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.5' + end + end + + if target.name == "fmt" + target.build_configurations.each do |config| + config.build_settings['CLANG_CXX_LANGUAGE_STANDARD'] = 'gnu++17' + end + end + if target.respond_to?(:product_type) and target.product_type == "com.apple.product-type.bundle" target.build_configurations.each do |config| config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO' @@ -101,4 +166,3 @@ target 'AudiusReactNative' do end end - diff --git a/packages/mobile/ios/Podfile.lock b/packages/mobile/ios/Podfile.lock index 59d294c1897..f5cd9b1619d 100644 --- a/packages/mobile/ios/Podfile.lock +++ b/packages/mobile/ios/Podfile.lock @@ -2907,6 +2907,6 @@ SPEC CHECKSUMS: TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654 Yoga: eca8dd841b7cd47d82d66be58af8e3aeb819012f -PODFILE CHECKSUM: b77c7dc423273b965d32a03d73fe6277c67877b7 +PODFILE CHECKSUM: e09d6543ec0ac3bf17229efe67e5637bc9ad987b COCOAPODS: 1.16.2 diff --git a/packages/web/src/app/web-player/WebPlayer.tsx b/packages/web/src/app/web-player/WebPlayer.tsx index 8cf115de535..0873232cdd2 100644 --- a/packages/web/src/app/web-player/WebPlayer.tsx +++ b/packages/web/src/app/web-player/WebPlayer.tsx @@ -154,6 +154,7 @@ const FbSharePage = lazy(() => })) ) const FeedPage = lazy(() => import('pages/feed-page/FeedPage')) +const HomePage = lazy(() => import('pages/home-page/HomePage')) const FollowersPage = lazy(() => import('pages/followers-page/FollowersPage')) const FollowingPage = lazy(() => import('pages/following-page/FollowingPage')) const HistoryPage = lazy(() => import('pages/history-page/HistoryPage')) @@ -245,6 +246,7 @@ const { UPLOAD_PLAYLIST_PAGE, SETTINGS_PAGE, HOME_PAGE, + HOMEPAGE_PAGE, NOT_FOUND_PAGE, SEARCH_PAGE, PLAYLIST_PAGE, @@ -427,9 +429,16 @@ const CoinExclusiveTracksLegacyRedirect = () => { type HomePageRedirectProps = { isGuestAccount: boolean + target?: string } -const HomePageRedirect = ({ isGuestAccount }: HomePageRedirectProps) => { +const HomePageRedirect = ({ + isGuestAccount, + // Default to FEED_PAGE to align with main's "feed is the default" choice + // for any caller that doesn't pass an explicit target. Active mounts in + // this file pass `target={HOMEPAGE_PAGE}` to land on the new home page. + target = FEED_PAGE +}: HomePageRedirectProps) => { const location = useLocation() const currentPath = getPathname(location) const to = { @@ -437,7 +446,7 @@ const HomePageRedirect = ({ isGuestAccount }: HomePageRedirectProps) => { currentPath === HOME_PAGE ? isGuestAccount ? LIBRARY_PAGE - : FEED_PAGE + : target : currentPath, search: includeSearch(location.search) ? location.search : '' } @@ -1282,9 +1291,15 @@ const WebPlayer = (props: WebPlayerProps) => { path={PROFILE_PAGE} element={} /> + } /> } + element={ + + } /> ) : ( @@ -1632,9 +1647,15 @@ const WebPlayer = (props: WebPlayerProps) => { path={PROFILE_PAGE} element={} /> + } /> } + element={ + + } /> )} diff --git a/packages/web/src/components/animated-switch/AnimatedSwitch.tsx b/packages/web/src/components/animated-switch/AnimatedSwitch.tsx index b112ab3de1b..054cc391eaf 100644 --- a/packages/web/src/components/animated-switch/AnimatedSwitch.tsx +++ b/packages/web/src/components/animated-switch/AnimatedSwitch.tsx @@ -26,12 +26,14 @@ const { FEED_PAGE, TRENDING_PAGE, EXPLORE_PAGE, - LIBRARY_PAGE + LIBRARY_PAGE, + HOMEPAGE_PAGE } = route const DISABLED_PAGES = new Set([ SIGN_IN_PAGE, SIGN_UP_PAGE, + HOMEPAGE_PAGE, FEED_PAGE, TRENDING_PAGE, EXPLORE_PAGE, diff --git a/packages/web/src/components/bottom-bar/BottomBar.tsx b/packages/web/src/components/bottom-bar/BottomBar.tsx index f1a7a033224..744b560b05f 100644 --- a/packages/web/src/components/bottom-bar/BottomBar.tsx +++ b/packages/web/src/components/bottom-bar/BottomBar.tsx @@ -6,7 +6,7 @@ import { useLocation } from 'react-router' import { RouterContext } from 'components/animated-switch/RouterContextProvider' import ExploreButton from 'components/bottom-bar/buttons/ExploreButton' import FeedButton from 'components/bottom-bar/buttons/FeedButton' -import LibraryButton from 'components/bottom-bar/buttons/LibraryButton' +import HomeButton from 'components/bottom-bar/buttons/HomeButton' import NotificationsButton from 'components/bottom-bar/buttons/NotificationsButton' import TrendingButton from 'components/bottom-bar/buttons/TrendingButton' @@ -24,17 +24,16 @@ const { FEED_PAGE, TRENDING_PAGE, EXPLORE_PAGE, - FAVORITES_PAGE, - LIBRARY_PAGE, + HOMEPAGE_PAGE, NOTIFICATION_PAGE } = route type Props = { currentPage: string + onClickHome: () => void onClickFeed: () => void onClickTrending: () => void onClickExplore: () => void - onClickLibrary: () => void onClickNotifications: () => void isDarkMode: boolean isMatrixMode: boolean @@ -42,10 +41,10 @@ type Props = { const BottomBar = ({ currentPage, + onClickHome, onClickFeed, onClickTrending, onClickExplore, - onClickLibrary, onClickNotifications, isDarkMode, isMatrixMode @@ -68,6 +67,14 @@ const BottomBar = ({ return window.ReactNativeWebView?.postMessage ? null : (
+ - { + const handleClick = useCallback( + (e: MouseEvent) => { + e.preventDefault() + onClick() + }, + [onClick] + ) + + const rootProps = { + onClick: handleClick, + className: styles.animatedButton + } + + const content = ( +
+ +
+ ) + + return href ? ( + + {content} + + ) : ( + + ) +} + +export default memo(HomeButton) diff --git a/packages/web/src/components/nav/desktop/LeftNav.tsx b/packages/web/src/components/nav/desktop/LeftNav.tsx index 49395522964..b2983b6aa9d 100644 --- a/packages/web/src/components/nav/desktop/LeftNav.tsx +++ b/packages/web/src/components/nav/desktop/LeftNav.tsx @@ -16,6 +16,7 @@ import { useNavSidebar } from './NavSidebarContext' import { NowPlayingArtworkTile } from './NowPlayingArtworkTile' import { RouteNav } from './RouteNav' import { + HomeNavItem, FeedNavItem, TrendingNavItem, ExploreNavItem, @@ -128,6 +129,7 @@ export const LeftNav = (props: OwnProps) => { flex='1 1 auto' css={{ overflow: 'hidden' }} > + diff --git a/packages/web/src/components/nav/desktop/nav-items/HomeNavItem.tsx b/packages/web/src/components/nav/desktop/nav-items/HomeNavItem.tsx new file mode 100644 index 00000000000..0ec45bb3fba --- /dev/null +++ b/packages/web/src/components/nav/desktop/nav-items/HomeNavItem.tsx @@ -0,0 +1,31 @@ +import React from 'react' + +import { route } from '@audius/common/utils' + +import { HomePageIcon } from 'pages/home-page/icon' + +import { LeftNavLink } from '../LeftNavLink' +import { NavSpeakerIcon } from '../NavSpeakerIcon' +import { useNavSourcePlayingStatus } from '../useNavSourcePlayingStatus' + +const { HOMEPAGE_PAGE } = route + +export const HomeNavItem = () => { + const playingFromRoute = useNavSourcePlayingStatus() + + return ( + + } + > + Home + + ) +} diff --git a/packages/web/src/components/nav/desktop/nav-items/index.ts b/packages/web/src/components/nav/desktop/nav-items/index.ts index f107bfb092a..943561a6264 100644 --- a/packages/web/src/components/nav/desktop/nav-items/index.ts +++ b/packages/web/src/components/nav/desktop/nav-items/index.ts @@ -1,3 +1,4 @@ +export { HomeNavItem } from './HomeNavItem' export { FeedNavItem } from './FeedNavItem' export { TrendingNavItem } from './TrendingNavItem' export { ExploreNavItem } from './ExploreNavItem' diff --git a/packages/web/src/components/nav/mobile/ConnectedBottomBar.tsx b/packages/web/src/components/nav/mobile/ConnectedBottomBar.tsx index c25f01800c5..f094b21002b 100644 --- a/packages/web/src/components/nav/mobile/ConnectedBottomBar.tsx +++ b/packages/web/src/components/nav/mobile/ConnectedBottomBar.tsx @@ -16,7 +16,7 @@ const { FEED_PAGE, TRENDING_PAGE, EXPLORE_PAGE, - LIBRARY_PAGE, + HOMEPAGE_PAGE, NOTIFICATION_PAGE } = route @@ -32,15 +32,15 @@ const ConnectedBottomBar = () => { isGuestAccount: selectIsGuestAccount(user) }) }) - const { handle, isGuestAccount } = accountData ?? {} + const { handle } = accountData ?? {} // Memoize navRoutes to avoid recreating Set on every render const navRoutes = useMemo(() => { return new Set([ + HOMEPAGE_PAGE, TRENDING_PAGE, FEED_PAGE, EXPLORE_PAGE, - LIBRARY_PAGE, NOTIFICATION_PAGE ]) }, []) @@ -48,7 +48,7 @@ const ConnectedBottomBar = () => { // Use ref to track last nav route synchronously (avoids render loops) // This is critical for React Router v7 compatibility where location updates // can happen before component re-renders - const lastNavRouteRef = useRef(FEED_PAGE) + const lastNavRouteRef = useRef(HOMEPAGE_PAGE) const currentRoute = getPathname(location) // Compute current page synchronously: use current route if it's a nav route, @@ -78,6 +78,10 @@ const ConnectedBottomBar = () => { dispatch(showRequiresAccountToast()) }, [dispatch]) + const goToHome = useCallback(() => { + goToRoute(HOMEPAGE_PAGE) + }, [goToRoute]) + const goToFeed = useCallback(() => { if (!handle) { handleOpenSignOn() @@ -94,14 +98,6 @@ const ConnectedBottomBar = () => { goToRoute(EXPLORE_PAGE) }, [goToRoute]) - const goToLibrary = useCallback(() => { - if (!handle && !isGuestAccount) { - handleOpenSignOn() - } else { - goToRoute(LIBRARY_PAGE) - } - }, [goToRoute, handle, isGuestAccount, handleOpenSignOn]) - const goToNotifications = useCallback(() => { if (!handle) { handleOpenSignOn() @@ -113,10 +109,10 @@ const ConnectedBottomBar = () => { return ( { onNavigate={handleNavigate} /> ) : null} + {currentUserId ? ( + + ) : null} { + const incomplete = list.filter((e) => !e.isCompleted) + const complete = list.filter((e) => e.isCompleted) + return [...incomplete, ...complete] +} + +type ProfileCompletionHeroCardProps = { + isDismissed?: boolean + onDismiss?: () => void + /** + * When true, bypasses the dismissal/auto-hide gates and forces the meter + * visible. Intended for testing/QA. + */ + forceVisible?: boolean +} + /** - * ProfileCompletionHeroCard is the hero card that shows the profile completion percentage, - * the progress meter, and the list of completed stages. It handles its own state management - * and animations. + * ProfileCompletionHeroCard is the larger profile completion meter shown on + * surfaces with more horizontal space (e.g. /home and the profile page). + * + * Layout: badge (percentage + bar) on the left and task grid on the right at + * wide card widths; stacks vertically below `STACK_BREAKPOINT_PX`. Stacking + * is driven by container queries on the card itself, not viewport, so the + * card behaves correctly inside any-width container. + * + * The task grid uses CSS `repeat(auto-fit, minmax(...))` so columns reflow + * continuously as the card narrows — at every intermediate width tasks fit + * cleanly into however many columns work. + * + * Dismiss lives in a footer row (no absolute positioning) so the layout has + * no hidden overflow risk. + * + * Pass `isDismissed`/`onDismiss` to override the default profile-page-scoped + * dismissal state (e.g. for use on /home). */ -export const ProfileCompletionHeroCard = () => { +export const ProfileCompletionHeroCard = ( + props: ProfileCompletionHeroCardProps = {} +) => { const dispatch = useDispatch() + const theme = useTheme() const isAccountLoaded = useIsAccountLoaded() const completionStages = useOrderedCompletionStages() - const isDismissed = useSelector(getProfilePageMeterDismissed) - const theme = useTheme() - const { color } = theme + const reduxIsDismissed = useSelector(getProfilePageMeterDismissed) - const onDismiss = () => dispatch(profileMeterDismissed()) + const isDismissed = props.isDismissed ?? reduxIsDismissed + const onDismiss = props.onDismiss ?? (() => dispatch(profileMeterDismissed())) const { isHidden, shouldNeverShow } = useProfileCompletionDismissal({ onDismiss, @@ -61,7 +104,8 @@ export const ProfileCompletionHeroCard = () => { isDismissed }) - const transitions = useVerticalCollapse(!isHidden, ORIGINAL_HEIGHT_PIXELS) + const effectiveIsHidden = props.forceVisible ? false : isHidden + const effectiveShouldNeverShow = props.forceVisible ? false : shouldNeverShow const stepsCompleted = getStepsCompleted(completionStages) const percentageCompleted = getPercentageComplete(completionStages) @@ -70,95 +114,127 @@ export const ProfileCompletionHeroCard = () => { from: { animatedPercentage: 0 } }) - if (shouldNeverShow) return null + if (effectiveShouldNeverShow || effectiveIsHidden) return null + + const sortedStages = sortIncompleteFirst(completionStages) return ( - <> - {transitions.map(({ item, key, props }) => - item ? ( - - - - - - {animatedPercentage.interpolate((v: unknown) => - (v as number).toFixed() - )} - - % - - - - {messages.complete} - - - - - - - - - - - ) : null - )} - + + + + + + {animatedPercentage.interpolate((v: unknown) => + (v as number).toFixed() + )} + + % + + + {messages.complete} + + + + + + {sortedStages.map((stage) => ( + + ))} + + + + {messages.dismiss} + + + + + ) } diff --git a/packages/web/src/components/profile-progress/components/TaskCompletionItem.tsx b/packages/web/src/components/profile-progress/components/TaskCompletionItem.tsx index fe20be9cf18..d22594302ea 100644 --- a/packages/web/src/components/profile-progress/components/TaskCompletionItem.tsx +++ b/packages/web/src/components/profile-progress/components/TaskCompletionItem.tsx @@ -49,6 +49,7 @@ export const TaskCompletionItem = ({ borderRadius={variant === 'surface' ? 's' : undefined} pv={variant === 'surface' ? 's' : undefined} ph={variant === 'surface' ? 'm' : undefined} + css={{ flexShrink: 0, flexWrap: 'nowrap' }} > {title} diff --git a/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx b/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx index 2b168d96db1..99cf53f112f 100644 --- a/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx +++ b/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx @@ -29,7 +29,7 @@ import EmptyFeed from 'pages/feed-page/components/EmptyFeed' import { FeedTabs } from 'pages/feed-page/components/FeedTabs' const messages = { - feedHeaderTitle: 'Your Feed', + feedHeaderTitle: 'Feed', feedTitle: 'Feed', feedDescription: 'Listen to what people you follow are sharing' } diff --git a/packages/web/src/pages/feed-page/components/mobile/FeedPageContent.tsx b/packages/web/src/pages/feed-page/components/mobile/FeedPageContent.tsx index 850b00dc22a..088fcccc048 100644 --- a/packages/web/src/pages/feed-page/components/mobile/FeedPageContent.tsx +++ b/packages/web/src/pages/feed-page/components/mobile/FeedPageContent.tsx @@ -35,7 +35,7 @@ import styles from './FeedPageContent.module.css' const { FEED_PAGE } = route const messages = { - title: 'Your Feed', + title: 'Feed', feedTitle: 'Feed', feedDescription: 'Listen to what people you follow are sharing' } diff --git a/packages/web/src/pages/home-page/HomePage.tsx b/packages/web/src/pages/home-page/HomePage.tsx new file mode 100644 index 00000000000..dbb6ddbf51f --- /dev/null +++ b/packages/web/src/pages/home-page/HomePage.tsx @@ -0,0 +1,24 @@ +import { useIsMobile } from 'hooks/useIsMobile' + +import { DesktopHomePage } from './components/desktop/HomePage' +import { MobileHomePage } from './components/mobile/HomePage' + +const messages = { + title: 'Home', + pageTitle: 'Your home on Audius', + description: 'Your personalized home on Audius' +} + +export const HomePage = () => { + const isMobile = useIsMobile() + const Component = isMobile ? MobileHomePage : DesktopHomePage + return ( + + ) +} + +export default HomePage diff --git a/packages/web/src/pages/home-page/components/ActiveContestsStrip.tsx b/packages/web/src/pages/home-page/components/ActiveContestsStrip.tsx new file mode 100644 index 00000000000..693c693db1a --- /dev/null +++ b/packages/web/src/pages/home-page/components/ActiveContestsStrip.tsx @@ -0,0 +1,83 @@ +import { useMemo } from 'react' + +import { + useAllRemixContests, + useCurrentUserId, + useUserRemixContests +} from '@audius/common/api' +import { route } from '@audius/common/utils' +import { Box } from '@audius/harmony' +import { + GetContestsByUserStatusEnum, + GetRemixContestsStatusEnum +} from '@audius/sdk' + +import { ContestCard, ContestCardSkeleton } from 'components/contest-card' + +import { Carousel } from '../../search-explore-page/components/desktop/Carousel' +import { CONTEST_CARD_WIDTH } from '../../search-explore-page/components/desktop/constants' + +const SKELETON_COUNT = 6 +const MAX_TILES = 12 + +const messages = { + title: 'Active Contests', + empty: 'No active contests right now' +} + +export const ActiveContestsStrip = () => { + const { data: currentUserId } = useCurrentUserId() + + const { + data: allActiveTrackIds, + isPending: isAllPending, + isError: isAllError + } = useAllRemixContests({ + status: GetRemixContestsStatusEnum.Active + }) + + const { data: hostedTrackIds, isPending: isHostedPending } = + useUserRemixContests( + { + userId: currentUserId, + status: GetContestsByUserStatusEnum.Active + }, + { enabled: !!currentUserId } + ) + + const ordered = useMemo(() => { + const all = allActiveTrackIds ?? [] + const hosted = new Set(hostedTrackIds ?? []) + if (hosted.size === 0) return all.slice(0, MAX_TILES) + const top: number[] = [] + const rest: number[] = [] + for (const id of all) { + if (hosted.has(id)) top.push(id) + else rest.push(id) + } + return [...top, ...rest].slice(0, MAX_TILES) + }, [allActiveTrackIds, hostedTrackIds]) + + if (isAllError) return null + + const isLoading = + isAllPending || (!!currentUserId && isHostedPending && !hostedTrackIds) + + if (!isLoading && ordered.length === 0) return null + + return ( + + {isLoading + ? Array.from({ length: SKELETON_COUNT }).map((_, i) => ( + + + + )) + : ordered.map((trackId) => ( + + + + ))} + + ) +} diff --git a/packages/web/src/pages/home-page/components/FromPeopleYouFollowSection.tsx b/packages/web/src/pages/home-page/components/FromPeopleYouFollowSection.tsx new file mode 100644 index 00000000000..7bd17db852e --- /dev/null +++ b/packages/web/src/pages/home-page/components/FromPeopleYouFollowSection.tsx @@ -0,0 +1,45 @@ +import { useFeed } from '@audius/common/api' +import { route } from '@audius/common/utils' +import { EntityType } from '@audius/sdk' + +import { CollectionCard } from 'components/collection' +import { TrackCard, TrackCardSkeleton } from 'components/track/TrackCard' +import { useIsMobile } from 'hooks/useIsMobile' + +import { Carousel } from '../../search-explore-page/components/desktop/Carousel' + +const PAGE_SIZE = 10 +const SKELETON_COUNT = 6 + +const messages = { + title: 'Recent from People You Follow' +} + +export const FromPeopleYouFollowSection = () => { + const isMobile = useIsMobile() + const { data, isLoading, isError, isSuccess } = useFeed({ + initialPageSize: PAGE_SIZE + }) + + if (isError || (isSuccess && !data?.length)) { + return null + } + + return ( + + {isLoading || !data + ? Array.from({ length: SKELETON_COUNT }).map((_, i) => ( + + )) + : data + .slice(0, PAGE_SIZE) + .map(({ id, type }) => + type === EntityType.TRACK ? ( + + ) : ( + + ) + )} + + ) +} diff --git a/packages/web/src/pages/home-page/components/QuickLinks.tsx b/packages/web/src/pages/home-page/components/QuickLinks.tsx new file mode 100644 index 00000000000..96c3fe198f8 --- /dev/null +++ b/packages/web/src/pages/home-page/components/QuickLinks.tsx @@ -0,0 +1,299 @@ +import { useMemo } from 'react' + +import { + useArtistCreatedFanClub, + useCurrentAccountUser, + useCurrentUserId, + useTrack, + useUserRemixContests +} from '@audius/common/api' +import { useChallengeCooldownSchedule, useIsArtist } from '@audius/common/hooks' +import { formatNumberCommas, route } from '@audius/common/utils' +import { + Flex, + IconCloudUpload, + IconDiscord, + IconFanClub, + IconGift, + IconQuestionCircle, + IconTrophy, + IconUser, + IconVerified, + Paper, + Text, + useTheme +} from '@audius/harmony' +import { GetContestsByUserStatusEnum } from '@audius/sdk' +import { useNavigate } from 'react-router' + +import { useIsMobile } from 'hooks/useIsMobile' + +const { + AUDIUS_DISCORD_LINK, + AUDIUS_HELP_LINK, + CHECK_PAGE, + CLUBS_CREATE_PAGE, + CONTESTS_PAGE, + HOST_REMIX_CONTEST_ROOT_PAGE, + REWARDS_PAGE, + SIGN_UP_PAGE, + UPLOAD_PAGE, + clubPage, + profilePage +} = route + +const messages = { + hostContest: 'Host a Contest', + manageContest: 'Manage Contest', + manageMultipleContests: 'Manage Contests', + launchFanClub: 'Launch a Fan Club', + manageFanClub: 'Manage Fan Club', + uploadTrack: 'Upload', + yourProfile: 'Your Profile', + getVerified: 'Get Verified', + joinDiscord: 'Discord', + support: 'Support', + signUp: 'Sign Up', + rewards: 'Rewards', + audioUnit: '$AUDIO' +} + +type Pill = { + key: string + label: string + icon: React.ComponentType + href?: string + to?: string + external?: boolean + highlight?: boolean +} + +const PillItem = ({ pill }: { pill: Pill }) => { + const navigate = useNavigate() + const { color } = useTheme() + const onClick = () => { + if (pill.external && pill.href) { + window.open(pill.href, '_blank', 'noopener,noreferrer') + return + } + if (pill.to) navigate(pill.to) + } + return ( + + + + {pill.label} + + + ) +} + +export const QuickLinks = () => { + const isMobile = useIsMobile() + const { data: currentUserId } = useCurrentUserId() + const { data: currentUser } = useCurrentAccountUser() + const isAuthed = !!currentUserId + + const { claimableAmount, isEmpty: isRewardsEmpty } = + useChallengeCooldownSchedule({ multiple: true }) + + const { data: hostedContestTrackIds } = useUserRemixContests( + { + userId: currentUserId, + status: GetContestsByUserStatusEnum.Active + }, + { enabled: isAuthed } + ) + const singleHostedTrackId = + hostedContestTrackIds?.length === 1 ? hostedContestTrackIds[0] : null + const { data: hostedTrack } = useTrack(singleHostedTrackId ?? undefined) + + const { data: createdFanClub } = useArtistCreatedFanClub(currentUserId, { + enabled: isAuthed + }) + + const isArtist = useIsArtist({ id: currentUserId ?? undefined }) + const isVerified = !!currentUser?.is_verified + const currentUserHandle = currentUser?.handle + + const pills = useMemo(() => { + if (!isAuthed) { + return [ + { + key: 'discord', + label: messages.joinDiscord, + icon: IconDiscord, + href: AUDIUS_DISCORD_LINK, + external: true + }, + { + key: 'support', + label: messages.support, + icon: IconQuestionCircle, + href: AUDIUS_HELP_LINK, + external: true + }, + { + key: 'signup', + label: messages.signUp, + icon: IconUser, + to: SIGN_UP_PAGE + } + ] + } + + const items: Pill[] = [] + + const hasClaimable = !isRewardsEmpty && claimableAmount > 0 + items.push({ + key: 'rewards', + label: hasClaimable + ? `${formatNumberCommas(claimableAmount)} ${messages.audioUnit}` + : messages.rewards, + icon: IconGift, + to: REWARDS_PAGE, + highlight: hasClaimable + }) + + items.push({ + key: 'upload', + label: messages.uploadTrack, + icon: IconCloudUpload, + to: UPLOAD_PAGE + }) + + if (currentUserHandle) { + items.push({ + key: 'your-profile', + label: messages.yourProfile, + icon: IconUser, + to: profilePage(currentUserHandle) + }) + } + + const hostedCount = hostedContestTrackIds?.length ?? 0 + if (hostedCount === 0) { + // Only artists can host a contest — non-artists never see the empty CTA. + if (isArtist) { + items.push({ + key: 'host-contest', + label: messages.hostContest, + icon: IconTrophy, + to: HOST_REMIX_CONTEST_ROOT_PAGE + }) + } + } else if (hostedCount === 1 && hostedTrack?.permalink) { + items.push({ + key: 'manage-contest', + label: messages.manageContest, + icon: IconTrophy, + to: hostedTrack.permalink + }) + } else { + items.push({ + key: 'manage-contests', + label: messages.manageMultipleContests, + icon: IconTrophy, + to: CONTESTS_PAGE + }) + } + + if (createdFanClub?.ticker) { + items.push({ + key: 'manage-fan-club', + label: messages.manageFanClub, + icon: IconFanClub, + to: clubPage(createdFanClub.ticker) + }) + } else if (isVerified) { + items.push({ + key: 'launch-fan-club', + label: messages.launchFanClub, + icon: IconFanClub, + to: CLUBS_CREATE_PAGE + }) + } + + if (!isVerified) { + items.push({ + key: 'verify', + label: messages.getVerified, + icon: IconVerified, + to: CHECK_PAGE + }) + } + + items.push( + { + key: 'discord', + label: messages.joinDiscord, + icon: IconDiscord, + href: AUDIUS_DISCORD_LINK, + external: true + }, + { + key: 'support', + label: messages.support, + icon: IconQuestionCircle, + href: AUDIUS_HELP_LINK, + external: true + } + ) + + return items + }, [ + isAuthed, + isRewardsEmpty, + claimableAmount, + hostedContestTrackIds, + hostedTrack, + createdFanClub, + isArtist, + isVerified, + currentUserHandle + ]) + + return ( + + + {pills.map((pill) => ( + + ))} + + + ) +} diff --git a/packages/web/src/pages/home-page/components/RewardsSummaryCard.tsx b/packages/web/src/pages/home-page/components/RewardsSummaryCard.tsx new file mode 100644 index 00000000000..27c729de9e4 --- /dev/null +++ b/packages/web/src/pages/home-page/components/RewardsSummaryCard.tsx @@ -0,0 +1,88 @@ +import { useCallback } from 'react' + +import { useChallengeCooldownSchedule } from '@audius/common/hooks' +import { route, formatNumberCommas } from '@audius/common/utils' +import { + Button, + Flex, + IconArrowRight, + Paper, + PlainButton, + Text +} from '@audius/harmony' +import { useNavigate } from 'react-router' + +import { useModalState } from 'common/hooks/useModalState' + +const { REWARDS_PAGE } = route + +const messages = { + yourRewards: 'Your Rewards', + readyToClaim: 'Ready to Claim', + claimAll: 'Claim All', + viewAll: 'View All Rewards' +} + +type RewardsSummaryCardProps = { + /** + * When true, bypasses the empty-state hide. Intended for testing/QA only. + */ + forceVisible?: boolean +} + +export const RewardsSummaryCard = ({ + forceVisible = false +}: RewardsSummaryCardProps = {}) => { + const { claimableAmount, isEmpty } = useChallengeCooldownSchedule({ + multiple: true + }) + const [, setClaimAllRewardsVisibility] = useModalState('ClaimAllRewards') + const navigate = useNavigate() + + const onClickClaim = useCallback(() => { + setClaimAllRewardsVisibility(true) + }, [setClaimAllRewardsVisibility]) + + const onClickViewAll = useCallback(() => { + navigate(REWARDS_PAGE) + }, [navigate]) + + if (isEmpty && !forceVisible) return null + + return ( + + + + {messages.yourRewards} + + + {messages.viewAll} + + + + + + + {formatNumberCommas(claimableAmount)} + + + $AUDIO + + + + {messages.readyToClaim} + + + {claimableAmount > 0 ? ( + + ) : null} + + + ) +} diff --git a/packages/web/src/pages/home-page/components/StatusZone.tsx b/packages/web/src/pages/home-page/components/StatusZone.tsx new file mode 100644 index 00000000000..274c54982b1 --- /dev/null +++ b/packages/web/src/pages/home-page/components/StatusZone.tsx @@ -0,0 +1,71 @@ +import { useCallback, useEffect, useState } from 'react' + +import { useCurrentUserId } from '@audius/common/api' +import { Flex } from '@audius/harmony' + +import { ProfileCompletionHeroCard } from 'components/profile-progress/components/ProfileCompletionHeroCard' + +import { QuickLinks } from './QuickLinks' +import { RewardsSummaryCard } from './RewardsSummaryCard' + +const dismissalKey = (userId: number | null | undefined) => + userId ? `audius-home-profile-meter-dismissed-${userId}` : null + +const useHomeProfileMeterDismissal = () => { + const { data: currentUserId } = useCurrentUserId() + const key = dismissalKey(currentUserId) + const [isDismissed, setIsDismissed] = useState(false) + + useEffect(() => { + if (!key) { + setIsDismissed(false) + return + } + try { + setIsDismissed(window.localStorage.getItem(key) === 'true') + } catch { + setIsDismissed(false) + } + }, [key]) + + const onDismiss = useCallback(() => { + if (!key) return + try { + window.localStorage.setItem(key, 'true') + } catch { + // localStorage may be unavailable; fall back to in-memory state + } + setIsDismissed(true) + }, [key]) + + return { isDismissed, onDismiss } +} + +type StatusZoneProps = { + variant: 'desktop' | 'mobile' +} + +export const StatusZone = ({ variant }: StatusZoneProps) => { + const { isDismissed, onDismiss } = useHomeProfileMeterDismissal() + + if (variant === 'mobile') { + // Mobile: skip RewardsSummaryCard + ProfileCompletionHeroCard; rewards + // surfaces as a pill in QuickLinks instead. + return ( + + + + ) + } + + return ( + + + + + + ) +} diff --git a/packages/web/src/pages/home-page/components/UnauthHero.tsx b/packages/web/src/pages/home-page/components/UnauthHero.tsx new file mode 100644 index 00000000000..40d3617e0e8 --- /dev/null +++ b/packages/web/src/pages/home-page/components/UnauthHero.tsx @@ -0,0 +1,48 @@ +import { useCallback } from 'react' + +import { route } from '@audius/common/utils' +import { Button, Flex, IconArrowRight, Paper, Text } from '@audius/harmony' +import { useNavigate } from 'react-router' + +import { useIsMobile } from 'hooks/useIsMobile' + +const { SIGN_UP_PAGE } = route + +const messages = { + title: 'Find your people. Grow your scene.', + subtitle: + 'Audius is the community-run platform for artists, labels, and music lovers pushing scenes forward. Free to use, ad-free, no upload limits.', + signUp: 'Get Started' +} + +export const UnauthHero = () => { + const navigate = useNavigate() + const isMobile = useIsMobile() + const onSignUp = useCallback(() => { + navigate(SIGN_UP_PAGE) + }, [navigate]) + + return ( + + + + + {messages.title} + + + {messages.subtitle} + + + + + + ) +} diff --git a/packages/web/src/pages/home-page/components/YourTopArtistsSection.tsx b/packages/web/src/pages/home-page/components/YourTopArtistsSection.tsx new file mode 100644 index 00000000000..9ba5405b4e7 --- /dev/null +++ b/packages/web/src/pages/home-page/components/YourTopArtistsSection.tsx @@ -0,0 +1,146 @@ +import { useMemo } from 'react' + +import { useTracks, useLibraryTracks } from '@audius/common/api' +import { ID } from '@audius/common/models' +import { + GetUserLibraryTracksSortDirectionEnum, + GetUserLibraryTracksSortMethodEnum, + GetUserLibraryTracksTypeEnum +} from '@audius/sdk' + +import { UserCard, UserCardSkeleton } from 'components/user-card' +import { useIsMobile } from 'hooks/useIsMobile' + +import { Carousel } from '../../search-explore-page/components/desktop/Carousel' + +const PAGE_SIZE = 50 +const TOP_N = 12 +const NINETY_DAYS_MS = 1000 * 60 * 60 * 24 * 90 +const FAVORITE_WEIGHT = 1.0 +const REPOST_WEIGHT = 1.0 +const BOTH_BOOST = 1.5 + +const messages = { + title: 'Your Top Artists' +} + +type LibraryItem = { + id: ID + type: string + timestamp?: string +} + +const filterRecent = (items: LibraryItem[], cutoffMs: number) => { + return items.filter((item) => { + if (!item.timestamp) return false + const t = new Date(item.timestamp).getTime() + if (Number.isNaN(t)) return false + return t >= cutoffMs + }) +} + +export const YourTopArtistsSection = () => { + const isMobile = useIsMobile() + + const { + data: favoriteData, + isLoading: isFavoritesLoading, + isError: isFavoritesError + } = useLibraryTracks({ + category: GetUserLibraryTracksTypeEnum.Favorite, + sortMethod: GetUserLibraryTracksSortMethodEnum.AddedDate, + sortDirection: GetUserLibraryTracksSortDirectionEnum.Desc, + pageSize: PAGE_SIZE + }) + + const { + data: repostData, + isLoading: isRepostsLoading, + isError: isRepostsError + } = useLibraryTracks({ + category: GetUserLibraryTracksTypeEnum.Repost, + sortMethod: GetUserLibraryTracksSortMethodEnum.AddedDate, + sortDirection: GetUserLibraryTracksSortDirectionEnum.Desc, + pageSize: PAGE_SIZE + }) + + const allIds = useMemo(() => { + const set = new Set() + favoriteData?.forEach((d) => set.add(d.id as ID)) + repostData?.forEach((d) => set.add(d.id as ID)) + return Array.from(set) + }, [favoriteData, repostData]) + + const { byId: trackById, isLoading: isTracksLoading } = useTracks(allIds) + + const topArtistIds = useMemo(() => { + if (!favoriteData || !repostData) return [] + const cutoff = Date.now() - NINETY_DAYS_MS + + const recentFavorites = filterRecent(favoriteData as LibraryItem[], cutoff) + const recentReposts = filterRecent(repostData as LibraryItem[], cutoff) + + type Bucket = { score: number; latestTs: number } + const perArtist = new Map() + const seenTrackOwner = new Map() + + const addAction = (trackId: ID, ts: number, weight: number) => { + const track = trackById[trackId] + if (!track) return + const ownerId = track.owner_id + if (!ownerId) return + + // Track-level dedupe: if same track has been seen, boost weight + const existing = seenTrackOwner.get(trackId) + if (existing) { + // Both favorite and repost present — replace contribution with boosted weight + const bucket = perArtist.get(ownerId) + if (bucket) { + bucket.score = bucket.score - existing.weight + BOTH_BOOST + bucket.latestTs = Math.max(bucket.latestTs, Math.min(existing.ts, ts)) + } + seenTrackOwner.set(trackId, { weight: BOTH_BOOST, ts }) + return + } + + seenTrackOwner.set(trackId, { weight, ts }) + const bucket = perArtist.get(ownerId) + if (bucket) { + bucket.score += weight + bucket.latestTs = Math.max(bucket.latestTs, ts) + } else { + perArtist.set(ownerId, { score: weight, latestTs: ts }) + } + } + + recentFavorites.forEach((item) => { + const ts = item.timestamp ? new Date(item.timestamp).getTime() : 0 + addAction(item.id, ts, FAVORITE_WEIGHT) + }) + recentReposts.forEach((item) => { + const ts = item.timestamp ? new Date(item.timestamp).getTime() : 0 + addAction(item.id, ts, REPOST_WEIGHT) + }) + + return Array.from(perArtist.entries()) + .sort((a, b) => b[1].score - a[1].score) + .slice(0, TOP_N) + .map(([artistId]) => artistId) + }, [favoriteData, repostData, trackById]) + + if (isFavoritesError || isRepostsError) return null + + const isLoading = isFavoritesLoading || isRepostsLoading || isTracksLoading + + if (!isLoading && topArtistIds.length === 0) return null + + return ( + + {isLoading + ? Array.from({ length: 6 }).map((_, i) => ( + + )) + : topArtistIds.map((id) => )} + + ) +} diff --git a/packages/web/src/pages/home-page/components/desktop/HomePage.tsx b/packages/web/src/pages/home-page/components/desktop/HomePage.tsx new file mode 100644 index 00000000000..29ba8065297 --- /dev/null +++ b/packages/web/src/pages/home-page/components/desktop/HomePage.tsx @@ -0,0 +1,154 @@ +import { Fragment, ReactNode, useCallback, useMemo } from 'react' + +import { useCurrentUserId, useIsAccountLoaded } from '@audius/common/api' +import { route } from '@audius/common/utils' +import { Flex } from '@audius/harmony' +import type { Mood } from '@audius/sdk' +import { useNavigate } from 'react-router' + +import { MIN_DESKTOP_CONTENT_WIDTH_PX } from 'common/utils/layout' +import { Header } from 'components/header/desktop/Header' +import Page from 'components/page/Page' +import { localStorage } from 'services/local-storage' + +import { ArtistSpotlightSection } from '../../../search-explore-page/components/desktop/ArtistSpotlightSection' +import { FeaturedPlaylistsSection } from '../../../search-explore-page/components/desktop/FeaturedPlaylistsSection' +import { MoodGrid } from '../../../search-explore-page/components/desktop/MoodGrid' +import { RecentlyPlayedSection } from '../../../search-explore-page/components/desktop/RecentlyPlayedSection' +import { RecommendedTracksSection } from '../../../search-explore-page/components/desktop/RecommendedTracksSection' +import { UndergroundTrendingTracksSection } from '../../../search-explore-page/components/desktop/UndergroundTrendingTracksSection' +import { HomePageIcon } from '../../icon' +import { ActiveContestsStrip } from '../ActiveContestsStrip' +import { FromPeopleYouFollowSection } from '../FromPeopleYouFollowSection' +import { StatusZone } from '../StatusZone' +import { UnauthHero } from '../UnauthHero' +import { YourTopArtistsSection } from '../YourTopArtistsSection' + +const messages = { + title: 'Home' +} + +export type DesktopHomePageProps = { + title: string + pageTitle: string + description: string +} + +export const DesktopHomePage = ({ + pageTitle, + description +}: DesktopHomePageProps) => { + const navigate = useNavigate() + const { data: currentUserId } = useCurrentUserId() + const isAccountLoaded = useIsAccountLoaded() + // While the account is still resolving, fall back to the synchronous + // localStorage hint to decide what to render. Without this, unauth visitors + // flash the personalized layout before the unauth filler swaps in (and + // authed users flashed the unauth filler before personalized loaded). + const cachedHasAccount = localStorage.getAudiusAccountSync()?.userId != null + const showUserContextualContent = isAccountLoaded + ? !!currentUserId + : cachedHasAccount + + const onMoodClick = useCallback( + (mood: Mood) => { + navigate(route.searchPage({ category: 'tracks', mood })) + }, + [navigate] + ) + + const sectionConfigs = useMemo< + { key: string; shouldRender: boolean; element: ReactNode }[] + >( + () => [ + { + key: 'unauth-hero', + shouldRender: !showUserContextualContent, + element: + }, + { + key: 'status-zone', + shouldRender: showUserContextualContent, + element: + }, + { + key: 'recommended-tracks', + shouldRender: showUserContextualContent, + element: + }, + { + key: 'recently-played', + shouldRender: showUserContextualContent, + element: + }, + { + key: 'active-contests', + shouldRender: true, + element: + }, + { + key: 'featured-playlists', + shouldRender: true, + element: + }, + { + key: 'top-artists', + shouldRender: showUserContextualContent, + element: + }, + { + key: 'from-follows', + shouldRender: showUserContextualContent, + element: + }, + { + key: 'underground-trending', + shouldRender: true, + element: + }, + { + key: 'artist-spotlight', + shouldRender: true, + element: + }, + { + key: 'mood-grid', + shouldRender: true, + element: + } + ], + [showUserContextualContent, onMoodClick] + ) + + const header =
+ + return ( + + + + {sectionConfigs.map(({ key, shouldRender, element }) => + shouldRender ? {element} : null + )} + + + + ) +} diff --git a/packages/web/src/pages/home-page/components/mobile/HomePage.tsx b/packages/web/src/pages/home-page/components/mobile/HomePage.tsx new file mode 100644 index 00000000000..86a9d33d77c --- /dev/null +++ b/packages/web/src/pages/home-page/components/mobile/HomePage.tsx @@ -0,0 +1,154 @@ +import { + Fragment, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo +} from 'react' + +import { useCurrentUserId, useIsAccountLoaded } from '@audius/common/api' +import { route } from '@audius/common/utils' +import { Flex } from '@audius/harmony' +import type { Mood } from '@audius/sdk' +import { useNavigate } from 'react-router' + +import Header from 'components/header/mobile/Header' +import { HeaderContext } from 'components/header/mobile/HeaderContextProvider' +import MobilePageContainer from 'components/mobile-page-container/MobilePageContainer' +import NavContext, { CenterPreset } from 'components/nav/mobile/NavContext' +import { localStorage } from 'services/local-storage' + +import { ArtistSpotlightSection } from '../../../search-explore-page/components/desktop/ArtistSpotlightSection' +import { FeaturedPlaylistsSection } from '../../../search-explore-page/components/desktop/FeaturedPlaylistsSection' +import { MoodGrid } from '../../../search-explore-page/components/desktop/MoodGrid' +import { RecentlyPlayedSection } from '../../../search-explore-page/components/desktop/RecentlyPlayedSection' +import { RecommendedTracksSection } from '../../../search-explore-page/components/desktop/RecommendedTracksSection' +import { UndergroundTrendingTracksSection } from '../../../search-explore-page/components/desktop/UndergroundTrendingTracksSection' +import { ActiveContestsStrip } from '../ActiveContestsStrip' +import { FromPeopleYouFollowSection } from '../FromPeopleYouFollowSection' +import { StatusZone } from '../StatusZone' +import { UnauthHero } from '../UnauthHero' +import { YourTopArtistsSection } from '../YourTopArtistsSection' + +const messages = { + title: 'Home' +} + +export type MobileHomePageProps = { + title: string + pageTitle: string + description: string +} + +export const MobileHomePage = (_props: MobileHomePageProps) => { + const navigate = useNavigate() + const { data: currentUserId } = useCurrentUserId() + const isAccountLoaded = useIsAccountLoaded() + // While the account is still resolving, fall back to the synchronous + // localStorage hint to decide what to render. Without this, unauth visitors + // flash the personalized layout before the unauth filler swaps in (and + // authed users flashed the unauth filler before personalized loaded). + const cachedHasAccount = localStorage.getAudiusAccountSync()?.userId != null + const showUserContextualContent = isAccountLoaded + ? !!currentUserId + : cachedHasAccount + + const { setCenter, setRight } = useContext(NavContext)! + const { setHeader } = useContext(HeaderContext) + + useEffect(() => { + setRight(null) + setCenter(CenterPreset.LOGO) + }, [setCenter, setRight]) + + useEffect(() => { + setHeader(
) + }, [setHeader]) + + const onMoodClick = useCallback( + (mood: Mood) => { + navigate(route.searchPage({ category: 'tracks', mood })) + }, + [navigate] + ) + + const sectionConfigs = useMemo< + { key: string; shouldRender: boolean; element: ReactNode }[] + >( + () => [ + { + key: 'unauth-hero', + shouldRender: !showUserContextualContent, + element: + }, + { + key: 'status-zone', + shouldRender: showUserContextualContent, + element: + }, + { + key: 'recommended-tracks', + shouldRender: showUserContextualContent, + element: + }, + { + key: 'recently-played', + shouldRender: showUserContextualContent, + element: + }, + { + key: 'active-contests', + shouldRender: true, + element: + }, + { + key: 'featured-playlists', + shouldRender: true, + element: + }, + { + key: 'top-artists', + shouldRender: showUserContextualContent, + element: + }, + { + key: 'from-follows', + shouldRender: showUserContextualContent, + element: + }, + { + key: 'underground-trending', + shouldRender: true, + element: + }, + { + key: 'artist-spotlight', + shouldRender: true, + element: + }, + { + key: 'mood-grid', + shouldRender: true, + element: + } + ], + [showUserContextualContent, onMoodClick] + ) + + return ( + + + + {sectionConfigs.map(({ key, shouldRender, element }) => + shouldRender ? {element} : null + )} + + + + ) +} diff --git a/packages/web/src/pages/home-page/icon.ts b/packages/web/src/pages/home-page/icon.ts new file mode 100644 index 00000000000..86459e99e96 --- /dev/null +++ b/packages/web/src/pages/home-page/icon.ts @@ -0,0 +1,3 @@ +import { IconHome } from '@audius/harmony' + +export const HomePageIcon = IconHome diff --git a/packages/web/src/pages/home-page/index.ts b/packages/web/src/pages/home-page/index.ts new file mode 100644 index 00000000000..e58765bab49 --- /dev/null +++ b/packages/web/src/pages/home-page/index.ts @@ -0,0 +1,2 @@ +export { HomePage as default } from './HomePage' +export { HomePage } from './HomePage' diff --git a/packages/web/src/pages/profile-page/components/desktop/ProfilePage.tsx b/packages/web/src/pages/profile-page/components/desktop/ProfilePage.tsx index b96970d0177..bbbe75cac25 100644 --- a/packages/web/src/pages/profile-page/components/desktop/ProfilePage.tsx +++ b/packages/web/src/pages/profile-page/components/desktop/ProfilePage.tsx @@ -49,7 +49,6 @@ import NavBanner, { EmptyNavBanner } from 'components/nav-banner/NavBanner' import { FlushPageContainer } from 'components/page/FlushPageContainer' import Page from 'components/page/Page' import ProfilePicture from 'components/profile-picture/ProfilePicture' -import { ProfileCompletionHeroCard } from 'components/profile-progress/components/ProfileCompletionHeroCard' import { EmptyStatBanner, StatBanner } from 'components/stat-banner/StatBanner' import { Tab, TabList } from 'components/tabs' import UploadChip from 'components/upload/UploadChip' @@ -168,9 +167,6 @@ const ProfilePage = ({ containerRef }: ProfilePageProps) => { onCloseUnblockUserConfirmationModal, onCloseMuteUserConfirmationModal } = useProfilePage() - const renderProfileCompletionCard = () => { - return isOwner ? : null - } const isDeactivated = !!profile?.is_deactivated @@ -321,7 +317,6 @@ const ProfilePage = ({ containerRef }: ProfilePageProps) => { // Default: Tracks return ( - {renderProfileCompletionCard()} {status === Status.SUCCESS ? ( tracksEmpty ? ( <> @@ -372,7 +367,6 @@ const ProfilePage = ({ containerRef }: ProfilePageProps) => { // Default: Reposts return ( - {renderProfileCompletionCard()} {userRepostsEmpty ? ( ( ({ title, children, viewAllLink }, ref) => { const [canScrollLeft, setCanScrollLeft] = useState(false) const [canScrollRight, setCanScrollRight] = useState(true) const scrollContainerRef = useRef(null) const rafRef = useRef(null) + const animRef = useRef(null) const isMobile = useIsMobile() + const theme = useTheme() + const pageBg = theme.color.background.default const updateScrollButtons = useCallback(() => { if (rafRef.current !== null) return @@ -61,25 +74,101 @@ export const Carousel = forwardRef( // so card shadows (including hover states) are not cut between carousels. const railShadowPaddingTop = isMobile ? 12 : 10 const railShadowPaddingBottom = isMobile ? 12 : 20 + + // JS-driven smooth scroll for caret presses. Native smooth scroll + + // scroll-snap fight each other in this layout, so we rAF-drive scrollLeft + // directly with ease-out. We disable snap for the duration of the + // animation, but pre-compute the exact snap-aligned scrollLeft target so + // that when snap re-engages at the end, the browser sees we're already + // aligned and doesn't pull anywhere — no end-of-animation jump. const handleScrollBy = useCallback( (direction: -1 | 1) => { const container = scrollContainerRef.current if (!container) return + const innerRail = container.firstElementChild as HTMLElement | null + if (!innerRail) return + if (animRef.current !== null) { + cancelAnimationFrame(animRef.current) + animRef.current = null + } + + const scrollPaddingLeft = railInset + contentInset + const start = container.scrollLeft + const maxScroll = container.scrollWidth - container.clientWidth + const containerLeft = container.getBoundingClientRect().left + + // Build the list of snap-aligned scrollLeft positions, one per card. + // Mirrors the CSS snap rules: padding on the container, plus + // scroll-margin-left on every non-first card. + const cards = Array.from(innerRail.children) as HTMLElement[] + const snapPositions = cards.map((card, i) => { + const cardLeftInContainer = + card.getBoundingClientRect().left - containerLeft + start + const cardMargin = i === 0 ? 0 : NON_FIRST_SNAP_MARGIN_PX + return Math.max( + 0, + Math.min( + cardLeftInContainer - cardMargin - scrollPaddingLeft, + maxScroll + ) + ) + }) - // Scroll by nearly one viewport of rail content so nav remains aligned - // across responsive widths without hardcoded pixel jumps. - const scrollAmount = Math.max( + // Pick the snap position closest to where a viewport-sized scroll in + // the requested direction would have landed. + const desiredDistance = Math.max( 240, - container.clientWidth - (railInset + contentInset) * 2 - 24 + container.clientWidth - scrollPaddingLeft * 2 - 24 ) - container.scrollBy({ - left: direction * scrollAmount, - behavior: 'smooth' - }) + const desiredTarget = Math.max( + 0, + Math.min(start + direction * desiredDistance, maxScroll) + ) + let target = desiredTarget + let bestDist = Infinity + for (const pos of snapPositions) { + // Skip snap targets in the wrong direction (with a small epsilon so + // we don't get stuck at the current position). + if (direction === 1 && pos <= start + 1) continue + if (direction === -1 && pos >= start - 1) continue + const dist = Math.abs(pos - desiredTarget) + if (dist < bestDist) { + bestDist = dist + target = pos + } + } + + const startTime = performance.now() + const easeOut = (t: number) => 1 - Math.pow(1 - t, 3) + const previousSnapType = container.style.scrollSnapType + container.style.scrollSnapType = 'none' + const tick = (now: number) => { + const elapsed = now - startTime + const progress = Math.min(elapsed / SCROLL_DURATION_MS, 1) + container.scrollLeft = start + (target - start) * easeOut(progress) + if (progress < 1) { + animRef.current = requestAnimationFrame(tick) + } else { + animRef.current = null + // We landed exactly on a snap-aligned scrollLeft; restoring snap + // here is a no-op for the browser. + container.style.scrollSnapType = previousSnapType + } + } + animRef.current = requestAnimationFrame(tick) }, [railInset] ) + useEffect( + () => () => { + if (animRef.current !== null) { + cancelAnimationFrame(animRef.current) + } + }, + [] + ) + return ( ( ) : null} - + - {children} + *': { scrollSnapAlign: 'start' }, + // For non-first cards, push the snap point inward so the prior + // card peeks at the left edge (and catches the fade overlay) + // rather than being scrolled fully off-screen. + '& > * + *': { + scrollMarginLeft: `${NON_FIRST_SNAP_MARGIN_PX}px` + } + }} + > + {children} + - + {/* Edge-fade overlays (desktop only). Always mounted; visibility is + driven by opacity transitions instead of conditional rendering so + we don't remount the gradient div every time canScrollLeft / + canScrollRight flip (which can happen rapidly during snap + settling and was causing the overlay flicker). */} + {!isMobile ? ( + <> + + + + ) : null} + ) } diff --git a/packages/web/src/pages/search-explore-page/components/desktop/MoodGrid.tsx b/packages/web/src/pages/search-explore-page/components/desktop/MoodGrid.tsx index b986eda0aa5..b63f6fddef8 100644 --- a/packages/web/src/pages/search-explore-page/components/desktop/MoodGrid.tsx +++ b/packages/web/src/pages/search-explore-page/components/desktop/MoodGrid.tsx @@ -9,7 +9,11 @@ import { useSearchCategory } from 'pages/search-page/hooks' import { labelByCategoryView } from 'pages/search-page/types' import { MOODS } from 'utils/Moods' -export const MoodGrid = () => { +type MoodGridProps = { + onMoodClick?: (mood: Mood) => void +} + +export const MoodGrid = ({ onMoodClick }: MoodGridProps = {}) => { const [category, setCategory] = useSearchCategory() const { color } = useTheme() @@ -17,13 +21,17 @@ export const MoodGrid = () => { const handleMoodPress = useCallback( (mood: Mood) => { + if (onMoodClick) { + onMoodClick(mood) + return + } if (category === 'all') { setCategory('tracks', { mood }) } else { setCategory(category, { mood }) } }, - [category, setCategory] + [category, setCategory, onMoodClick] ) return ( diff --git a/packages/web/src/public-site/components/Footer.tsx b/packages/web/src/public-site/components/Footer.tsx index d4b448645bf..bdf85b1fde8 100644 --- a/packages/web/src/public-site/components/Footer.tsx +++ b/packages/web/src/public-site/components/Footer.tsx @@ -22,7 +22,7 @@ const { TERMS_OF_SERVICE, API_TERMS, OPEN_MUSIC_LICENSE_LINK, - TRENDING_PAGE, + HOMEPAGE_PAGE, AUDIUS_BLOG_LINK, DOWNLOAD_LINK, AUDIUS_HELP_LINK, @@ -128,7 +128,7 @@ const Footer = (props: FooterProps) => {

{messages.product}

{ const navigate = useNavigate() const onGetStarted = (e: MouseEvent) => { - handleClickRoute(TRENDING_PAGE, props.setRenderPublicSite, navigate)(e) + handleClickRoute(HOMEPAGE_PAGE, props.setRenderPublicSite, navigate)(e) } return ( diff --git a/packages/web/src/public-site/pages/landing-2026/components/Nav2026.tsx b/packages/web/src/public-site/pages/landing-2026/components/Nav2026.tsx index 83fa45b423a..3bebd42a2c4 100644 --- a/packages/web/src/public-site/pages/landing-2026/components/Nav2026.tsx +++ b/packages/web/src/public-site/pages/landing-2026/components/Nav2026.tsx @@ -28,7 +28,7 @@ import IconHelpSupport from '../assets/icon-help-support.svg' import styles from './Nav2026.module.css' -const { SIGN_UP_PAGE, TRENDING_PAGE, DOWNLOAD_LINK } = route +const { SIGN_UP_PAGE, HOMEPAGE_PAGE, DOWNLOAD_LINK } = route const messages = { signUp: 'Sign Up', @@ -154,7 +154,7 @@ export const Nav2026 = (props: Nav2026Props) => { const onCtaClick = (e: MouseEvent) => { setIsMobileOverlayOpen(false) - const routeToUse = isAuthenticated ? TRENDING_PAGE : SIGN_UP_PAGE + const routeToUse = isAuthenticated ? HOMEPAGE_PAGE : SIGN_UP_PAGE handleClickRoute(routeToUse, setRenderPublicSite, navigate)(e) } diff --git a/packages/web/src/ssr/util.ts b/packages/web/src/ssr/util.ts index 8336fc62394..f1cc00b9363 100644 --- a/packages/web/src/ssr/util.ts +++ b/packages/web/src/ssr/util.ts @@ -13,6 +13,7 @@ const invalidPaths = new Set(['undefined']) // Reserved paths that have their own SSR handlers and should NOT match /@handle patterns // This prevents /upload from being matched as a profile with handle="upload" const reservedPaths = new Set([ + 'home', 'upload', 'explore', 'audio', @@ -36,7 +37,7 @@ const reservedPaths = new Set([ ]) // Static routes that should skip SSR (only the root now, all others have SSR handlers) -const staticRoutes = new Set(['/']) +const staticRoutes = new Set(['/', '/home']) // Paths that should not use SSR even if they match a route const nonSsrPaths = [ diff --git a/packages/web/src/store/lineup/lineupForRoute.js b/packages/web/src/store/lineup/lineupForRoute.js index ba9694ce104..51ddbe0f7c3 100644 --- a/packages/web/src/store/lineup/lineupForRoute.js +++ b/packages/web/src/store/lineup/lineupForRoute.js @@ -27,7 +27,8 @@ const { SETTINGS_PAGE, NOT_FOUND_PAGE, LIBRARY_PAGE, - TRACK_EDIT_PAGE + TRACK_EDIT_PAGE, + HOMEPAGE_PAGE } = route const { getCollectionTracksLineup } = collectionPageSelectors const { getDiscoverFeedLineup } = feedPageSelectors @@ -50,7 +51,8 @@ export const getLineupSelectorForRoute = (location) => { matchPage(UPLOAD_PAGE) || matchPage(DASHBOARD_PAGE) || matchPage(SETTINGS_PAGE) || - matchPage(NOT_FOUND_PAGE) + matchPage(NOT_FOUND_PAGE) || + matchPage(HOMEPAGE_PAGE) ) { return () => null }