1
0
Quellcode durchsuchen

Release v3.1.0

Release v3.1.0
attemka vor 1 Jahr
Ursprung
Commit
68d248e46b
69 geänderte Dateien mit 2279 neuen und 345 gelöschten Zeilen
  1. 16 0
      CHANGELOG.md
  2. 1 0
      package.json
  3. 27 23
      packages/atlas/atlas.config.yml
  4. 1 1
      packages/atlas/package.json
  5. 1 0
      packages/atlas/src/.env
  6. BIN
      packages/atlas/src/assets/images/discover-view.webp
  7. 2 0
      packages/atlas/src/components/AppKV/AppKV.styles.ts
  8. 20 7
      packages/atlas/src/components/FilterButton/FilterButton.stories.tsx
  9. 51 18
      packages/atlas/src/components/FilterButton/FilterButton.tsx
  10. 102 0
      packages/atlas/src/components/MobileFilterButton/MobileFilterButton.stories.tsx
  11. 155 0
      packages/atlas/src/components/MobileFilterButton/MobileFilterButton.tsx
  12. 1 0
      packages/atlas/src/components/MobileFilterButton/index.ts
  13. 346 0
      packages/atlas/src/components/Section/Section.stories.tsx
  14. 11 0
      packages/atlas/src/components/Section/Section.styles.ts
  15. 47 0
      packages/atlas/src/components/Section/Section.tsx
  16. 1 1
      packages/atlas/src/components/Section/SectionContent/SectionContent.stories.tsx
  17. 0 0
      packages/atlas/src/components/Section/SectionContent/SectionContent.styles.ts
  18. 3 4
      packages/atlas/src/components/Section/SectionContent/SectionContent.tsx
  19. 0 0
      packages/atlas/src/components/Section/SectionContent/index.ts
  20. 1 1
      packages/atlas/src/components/Section/SectionFooter/SectionFooter.stories.tsx
  21. 0 0
      packages/atlas/src/components/Section/SectionFooter/SectionFooter.tsx
  22. 0 0
      packages/atlas/src/components/Section/SectionFooter/index.ts
  23. 18 0
      packages/atlas/src/components/Section/SectionHeader/DynamicSearch/DynamicSearch.styles.ts
  24. 43 0
      packages/atlas/src/components/Section/SectionHeader/DynamicSearch/DynamicSearch.tsx
  25. 58 0
      packages/atlas/src/components/Section/SectionHeader/SectionFilters/SectionFilters.styles.ts
  26. 100 0
      packages/atlas/src/components/Section/SectionHeader/SectionFilters/SectionFilters.tsx
  27. 270 0
      packages/atlas/src/components/Section/SectionHeader/SectionHeader.stories.tsx
  28. 70 0
      packages/atlas/src/components/Section/SectionHeader/SectionHeader.styles.ts
  29. 166 0
      packages/atlas/src/components/Section/SectionHeader/SectionHeader.tsx
  30. 16 0
      packages/atlas/src/components/Section/SectionHeader/SectionTitle/SectionTitle.styles.ts
  31. 51 0
      packages/atlas/src/components/Section/SectionHeader/SectionTitle/SectionTitle.tsx
  32. 1 0
      packages/atlas/src/components/Section/SectionHeader/index.ts
  33. 5 1
      packages/atlas/src/components/Tabs/Tabs.stories.tsx
  34. 1 2
      packages/atlas/src/components/Tabs/Tabs.styles.ts
  35. 7 63
      packages/atlas/src/components/Tabs/Tabs.tsx
  36. 3 1
      packages/atlas/src/components/_content/TopTenVideos/TopTenVideos.tsx
  37. 9 2
      packages/atlas/src/components/_inputs/CheckboxGroup/CheckboxGroup.tsx
  38. 1 0
      packages/atlas/src/components/_inputs/Input/Input.styles.ts
  39. 1 2
      packages/atlas/src/components/_inputs/Select/Select.tsx
  40. 14 6
      packages/atlas/src/components/_inputs/ToggleButtonGroup/ToggleButtonGroup.stories.tsx
  41. 19 15
      packages/atlas/src/components/_inputs/ToggleButtonGroup/ToggleButtonGroup.styles.ts
  42. 49 76
      packages/atlas/src/components/_inputs/ToggleButtonGroup/ToggleButtonGroup.tsx
  43. 7 2
      packages/atlas/src/components/_navigation/SidenavStudio/SidenavStudio.tsx
  44. 2 3
      packages/atlas/src/components/_navigation/TopbarStudio/TopbarStudio.tsx
  45. 5 0
      packages/atlas/src/components/_overlays/AdminModal/AdminModal.styles.ts
  46. 10 1
      packages/atlas/src/components/_overlays/AdminModal/AdminModal.tsx
  47. 21 0
      packages/atlas/src/components/_overlays/ContentTypeDialog/ContentTypeDialog.styles.ts
  48. 75 0
      packages/atlas/src/components/_overlays/ContentTypeDialog/ContentTypeDialog.tsx
  49. 1 0
      packages/atlas/src/components/_overlays/ContentTypeDialog/index.ts
  50. 5 4
      packages/atlas/src/components/_video/VideoGallery/VideoGallery.tsx
  51. 0 1
      packages/atlas/src/components/_video/VideoPlayer/VideoOverlays/EndingOverlay.styles.ts
  52. 8 2
      packages/atlas/src/components/_ypp/BenefitCard/BenefitCard.stories.tsx
  53. 75 11
      packages/atlas/src/components/_ypp/BenefitCard/BenefitCard.tsx
  54. 3 3
      packages/atlas/src/config/config.ts
  55. 7 1
      packages/atlas/src/config/configSchema.ts
  56. 103 0
      packages/atlas/src/hooks/useHorizonthalFade.ts
  57. 25 0
      packages/atlas/src/joystream-lib/lib.ts
  58. 33 0
      packages/atlas/src/providers/transactions/transactions.hooks.ts
  59. 77 5
      packages/atlas/src/providers/user/user.provider.tsx
  60. 9 3
      packages/atlas/src/providers/user/user.store.ts
  61. 4 2
      packages/atlas/src/providers/user/user.types.ts
  62. 49 2
      packages/atlas/src/providers/videoWorkspace/provider.tsx
  63. 4 0
      packages/atlas/src/providers/videoWorkspace/types.ts
  64. 5 6
      packages/atlas/src/utils/styles.ts
  65. 15 9
      packages/atlas/src/views/global/YppLandingView/YppRewardSection.tsx
  66. 3 7
      packages/atlas/src/views/studio/MyUploadsView/MyUploadsView.tsx
  67. 10 32
      packages/atlas/src/views/studio/MyVideosView/MyVideosView.tsx
  68. 34 28
      packages/atlas/src/views/studio/YppDashboard/tabs/YppDashboardMainTab.tsx
  69. 1 0
      yarn.lock

+ 16 - 0
CHANGELOG.md

@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [3.1.0] - 2023-04-14
+
+### Added
+
+- New content focus modal
+- Channel's balance is now displayed publicly
+
+### Changed
+
+- YPP TnCs were updated
+
+### Fixed
+
+- Fixed carousel styling for mobile screens
+- Various markup fixes
+
 ## [3.0.0] - 2023-04-12
 
 ### Added

+ 1 - 0
package.json

@@ -46,6 +46,7 @@
   },
   "devDependencies": {
     "@emotion/eslint-plugin": "^11.10.0",
+    "@joystream/prettier-config": "^1.0.0",
     "@stylelint/postcss-css-in-js": "^0.38.0",
     "@trivago/prettier-plugin-sort-imports": "^4.0.0",
     "@types/node": "^16.18.8",

+ 27 - 23
packages/atlas/atlas.config.yml

@@ -1,5 +1,5 @@
 general:
-  appName: 'Atlas' # Application name - used in the copy throughout the app, in index.html, open graph meta tags, etc
+  appName: Atlas # Application name - used in the copy throughout the app, in index.html, open graph meta tags, etc. Don't use env variables here
   appDescription: 'The streaming platform empowering viewers, creators, and builders. Built on and operated by the Joystream blockchain and DAO.' # Application description - used in index.html meta tags
   appTagline: 'The streaming platform empowering viewers, creators, and builders. Built on and operated by the Joystream blockchain and DAO.'
   appId: '$VITE_APP_ID' # App ID for Apps as first-class citizens
@@ -61,23 +61,25 @@ features:
         shortDescription: Connect your YouTube channels via a simple step-by-step flow and get your first reward.
         baseAmount: 5000 # Base amount that will be multiplied by tier multiplier
       - title: Sync videos from your YouTube channel
-        shortDescription: Opt in to auto-sync feature upon sign up and all YouTube content will get uploaded to Atlas automatically. Get paid for each new video synced to your Atlas channel.
-        stepsDescription: Publishing your existing and new content with Atlas is the fastest way to earn more JOY tokens.
+        shortDescription: Opt in to auto-sync feature upon sign up and all YouTube content will get uploaded to $VITE_APP_NAME automatically. Get paid for every new video synced to your $VITE_APP_NAME channel.
+        stepsDescription: Publishing your existing and new content with $VITE_APP_NAME is the fastest way to earn more JOY tokens.
         steps:
           - Make sure the auto-sync feature enabled.
+          - Wait till your videos get synced to your $VITE_APP_NAME channel.
           - Publish new videos to your YouTube channel.
-          - Wait till it gets fully synced to your Atlas channel.
-          - Get rewarded for every new video synced.
+          - Get rewarded for every new video synced to $VITE_APP_NAME.
         baseAmount: 300
       - title: Refer another YouTube creator
-        shortDescription: Get JOY for every new creator who signs up to YPP program using your referral link.
+        shortDescription: Get JOY for every new creator who signs up to YPP program using your referral link. Referrals multiplier depends on the popularity tier of the channel signed up using referral link.
         stepsDescription: Earn when another YouTube creator signs up to the program by using your your referral link.
         steps:
           - Copy your link with get referral link button.
           - Send it to as many Web3 YouTube creators as you want.
-          - Get rewarded for every new successful sign up, that uses your referral link.
+          - Get rewarded for every new successful sign up, that uses your referral link. Referral reward depends on their popularity tier.
           - If signed up without the link they can simply add your channel name to the referral field in the registration flow.
-        baseAmount: 1000
+        baseAmount:
+          min: 1000
+          max: 5000
         actionButtonText: Get referral link
         actionButtonAction: copyReferral
     widgets: # Widgets on Ypp landing page
@@ -98,9 +100,9 @@ features:
         ## Rewards
           - Sign up to YouTube Partnership Program: 5000 Joy
           - Refer new program subscribers: 1000 Joy
-          - For every video synced from YouTube to Joystream channel: 300 Joy
+          - For every new video synced from YouTube: 300 Joy
 
-        The tokens pool allocated for this program is limited, so the program has limited duration.
+        ❗️ The tokens pool allocated for this program is limited, so the program has limited duration.
 
         ## Tiers Multiplier
 
@@ -110,9 +112,11 @@ features:
           - Tier 2 - x2.5 rewards - 5,000 to 50,000 subscribers
           - Tier 3 - x5 rewards - 50,000+ subscribers
 
+        Referrals multiplier depends on the popularity tier of the channel signed up using referral link.
+
         ## Example Rewards Calculation
 
-        For a channel with 7000 subscribers, which signed up and remained in the program with auto-sync service enabled for 1 month. During this month that channel uploaded 5 new videos to their YouTube channel, 2 videos directly to Joystream channel with manual upload, and referred 3 other YouTube channels that ended up signing up.
+        For a channel with 7000 subscribers, which signed up and remained in the program with auto-sync service enabled for 1 month. During this month that channel uploaded 5 new videos to their YouTube channel, 2 videos directly to Joystream channel with manual upload, and successfully referred 3 other YouTube channels that had circa 10k subscribers each. 
 
         In the end of this month, the payout to this channel's account will be:
           5000 * 2.5 + 5 * 300 * 2.5 + 1000 * 3 * 2.5 = **23,750** JOY
@@ -122,9 +126,9 @@ features:
         1. Become enrolled to the YouTube Partnership Program.
         2. Your YouTube channel information and email will be stored in the data base, operated by JS Genesis.
         3. Qualify for the rewards paid in JOY tokens for signing up to to this program.
-        4. If you chose to opt in to auto-sync service, you grant permission for JS Genesis operated service to upload all videos from your YouTube channel to Joystream blockchain and storage system, available to display on Gleev App or any other app connected to Joystream blockchain and qualify for the additional rewards paid in JOY tokens for new videos added to your Joystream channel via automatic upload service.
+        4. If you chose to opt in to auto-sync service, you grant permission for JS Genesis operated service to upload all videos from your YouTube channel to Joystream blockchain and storage system, available to display on $VITE_APP_NAME App or any other app connected to Joystream blockchain and qualify for the additional rewards paid in JOY tokens for new videos added to your Joystream channel via automatic upload service.
         5. Qualify for additional rewards paid in JOY tokens when other creators sign up to this program, referencing your channel as referrer.
-        6. Will remain in the program unless access rights to Gleev service is revoked from your google account settings, opt out is triggered from YPP dashboard or suspended by the members of JS Genesis team.
+        6. Will remain in the program unless access rights to $VITE_APP_NAME service is revoked from your google account settings, opt out is triggered from YPP dashboard or suspended by the members of JS Genesis team.
 
         Rewards are subject to the verified status of the channel, assigned by the program operators.
 
@@ -140,13 +144,13 @@ features:
           1. Be created not earlier than 90 days before the sign up.
           2. Have at least 50 followers and the channel followers must be set to public view.
           3. Have at least 10 videos, each created at least 30 days before the sign up.
-          4. Channel must be focussed on Web3/ Crypto content, matching one of the categories supported by Gleev App.
+          4. Channel must be focussed on Web3/ Crypto content, matching one of the categories supported by $VITE_APP_NAME App.
 
         Newly created Joystream channel has to have description, avatar and background image set up. The criteria for qualification can be reviewed at any time without prior notice.
 
         ## How to sign up
 
-        To sign up, user has to go through the onboarding flow provided in the [Gleev web app](https://gleev.xyz/ypp) and authorise with the Google Account, connected to their YouTube channel. During the authorisation, the access to the YouTube content `youtube.readonly` scope, to fetch channel meta data and content information; and access to email address for the YouTube account has to be granted for the JSG operated Backend Application (API client) that connects to YouTube API.
+        To sign up, user has to go through the onboarding flow provided in the [$VITE_APP_NAME web app](https://gleev.xyz/ypp) and authorise with the Google Account, connected to their YouTube channel. During the authorisation, the access to the YouTube content `youtube.readonly` scope, to fetch channel meta data and content information; and access to email address for the YouTube account has to be granted for the JSG operated Backend Application (API client) that connects to YouTube API.
 
           Mandatory fields need to be populated to the web input form to progress, such as email, video category, and terms and conditions need to be accepted.
 
@@ -181,9 +185,9 @@ features:
 
         ## How to withdraw rewards
 
-        In order to create Joystream channel, a Joystream membership is required. It can be created free of charge using the Gleev App, hosted on gleev.xyz or Pioneer app hosted on  pioneerapp.xyz. Joystream membership requires a substrate account, created using any wallets compatible with Polkadot ecosystem.
+        In order to create Joystream channel, a Joystream membership is required. It can be created free of charge using the $VITE_APP_NAME App, hosted on gleev.xyz or Pioneer app hosted on  pioneerapp.xyz. Joystream membership requires a substrate account, created using any wallets compatible with Polkadot ecosystem.
 
-        From the channel account JOY token can be transferred (withdrawn) to the member account via Gleev app interfaces or polkadot.js app (calling transfer extrinsic). This transaction must be signed with the membership controller account, that was used to create Joystream channel.
+        From the channel account JOY token can be transferred (withdrawn) to the member account via $VITE_APP_NAME app interfaces or polkadot.js app (calling transfer extrinsic). This transaction must be signed with the membership controller account, that was used to create Joystream channel.
 
         ## Program Partners Terms and Conditions
 
@@ -191,7 +195,7 @@ features:
 
         ## Auto-sync service
 
-        In order to simplify the upload of content to Gleev App, JS Genesis team has built a dedicated backend application, integrated with YouTube and Gleev to facilitate the content upload. It is hosted on the JSGenesis operated infrastructure and operated by JS Genesis team.
+        In order to simplify the upload of content to $VITE_APP_NAME App, JS Genesis team has built a dedicated backend application, integrated with YouTube and $VITE_APP_NAME to facilitate the content upload. It is hosted on the JSGenesis operated infrastructure and operated by JS Genesis team.
 
         This requests access to the necessary information via YouTube API, namely "read_only" scope to your YouTube channel data. This happens during the authorisation flow as part of the YPP program onboarding. The App fetches only the information that YouTube exposes in the read_only scope and required to effectively operate the program via the open API.
 
@@ -199,7 +203,7 @@ features:
         Channel Info: name, date created, URL, number of followers, number of comments, number of views, number of videos,
         Videos info: video titles, videos date uploaded, video subtitles.
 
-        If auto-sync service is enabled, it will refetch channel information once every 24 hours, and poll video information once every 30 minutes to detect new uploads and automatically upload them to your Gleev Channel.
+        If auto-sync service is enabled, it will refetch channel information once every 24 hours, and poll video information once every 30 minutes to detect new uploads and automatically upload them to your $VITE_APP_NAME Channel.
 
         Only public videos are synced. Videos are synced in resolution of 720px or lower. HD videos will be supported as the program matures. Subtitles synced only in English, and all other languages are not supported at the time of program launch, but will be supported in the future.  The queue of syncing is defined based on the proprietary algorithm developed and maintained by JSGenesis team. It may be updated at any time without prior notice.
 
@@ -217,7 +221,7 @@ features:
 
         ## Opt out
 
-        Channel can opt out from auto-sync feature by choosing this option in the settings tab of the YPP dashboard. Opting out from Auto-sync would stop automated upload of all new content published to YouTube channel, but would keep the Gleev channel in the YPP program, meaning that referral rewards can still be collected.
+        Channel can opt out from auto-sync feature by choosing this option in the settings tab of the YPP dashboard. Opting out from Auto-sync would stop automated upload of all new content published to YouTube channel, but would keep the $VITE_APP_NAME channel in the YPP program, meaning that referral rewards can still be collected.
 
         Opting out from the entire program can also be done from the settings tab. This will result the channel marked in the internal data base as "opted-out" and payout calculation for this channel will stop for any activities undertaken.
 
@@ -235,7 +239,7 @@ features:
 
         Manual process is involved, so errors are not inevitable.  JSG team is not liable for any reconciliations, but is committed to spend reasonable efforts to support all participants in reconciliation of rewards calculation where it deems to be operationally viable and commercially sensible to do so.
 
-        JSG team is not liable or obliged to do the payments and in case of errors will seek to provide the support in reconciliation of payments but not be obliged for channel rewards. In case of any disputes over content quality and qualification for rewards, JSG has no liability to compensate the channels and these terms are not binding, but payouts are made on total discretion of the Gleev App operator and the JS Genesis AS team. In case program budget runs out before some of the apps are paid, there is no obligation to pay late subscribers.
+        JSG team is not liable or obliged to do the payments and in case of errors will seek to provide the support in reconciliation of payments but not be obliged for channel rewards. In case of any disputes over content quality and qualification for rewards, JSG has no liability to compensate the channels and these terms are not binding, but payouts are made on total discretion of the $VITE_APP_NAME App operator and the JS Genesis AS team. In case program budget runs out before some of the apps are paid, there is no obligation to pay late subscribers.
 
         ## Severability
 
@@ -247,7 +251,7 @@ features:
 
         ## Governing Law
 
-        Governing law of these terms are the same as the general Terms of Service for Gleev App as described on [this page](https://gleev.xyz/legal/tos)
+        Governing law of these terms are the same as the general Terms of Service for $VITE_APP_NAME App as described on [this page](https://gleev.xyz/legal/tos)
 
         ## Miscellaneous
 
@@ -499,7 +503,7 @@ legal:
 
     Last updated on the 25th of January 2023
 
-    This Terms of Service ("Agreement") is a binding obligation between you ("User") and Jsgenesis AS ("Company", "We", "Us", "Our") for use of our Joystream Player interface ("Atlas") hosted at play.joystream.org and all other products (collectively "Software") developed and published by Us.
+    This Terms of Service ("Agreement") is a binding obligation between you ("User") and Jsgenesis AS ("Company", "We", "Us", "Our") for use of our Joystream Player interface ("$VITE_APP_NAME") hosted at play.joystream.org and all other products (collectively "Software") developed and published by Us.
 
     ## 1. Agreement to Terms
 

+ 1 - 1
packages/atlas/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@joystream/atlas",
   "description": "UI for consuming Joystream - a user governed video platform",
-  "version": "3.0.0",
+  "version": "3.1.0",
   "license": "GPL-3.0",
   "scripts": {
     "start": "vite",

+ 1 - 0
packages/atlas/src/.env

@@ -5,6 +5,7 @@ VITE_ENV=development
 
 # App configuration
 VITE_APP_ID=4414-2
+VITE_APP_NAME=Atlas
 
 VITE_AVATAR_SERVICE_URL=https://atlas-services.joystream.org/avatars
 VITE_GEOLOCATION_SERVICE_URL=https://geolocation.joystream.org

BIN
packages/atlas/src/assets/images/discover-view.webp


+ 2 - 0
packages/atlas/src/components/AppKV/AppKV.styles.ts

@@ -30,6 +30,8 @@ export const LogoWrapper = styled.div`
 
 export const AppLogoContainer = styled.div`
   max-height: 48px;
+  display: flex;
+  justify-content: center;
 `
 export const StyledAppLogo = styled(AppLogo)`
   max-height: 100%;

+ 20 - 7
packages/atlas/src/components/FilterButton/FilterButton.stories.tsx

@@ -3,7 +3,7 @@ import { useState } from 'react'
 
 import { SvgActionShoppingCart } from '@/assets/icons'
 
-import { FilterButton, FilterButtonProps } from './FilterButton'
+import { FilterButton, FilterButtonOption, FilterButtonProps } from './FilterButton'
 
 export default {
   title: 'inputs/FilterButton',
@@ -12,11 +12,15 @@ export default {
     options: [
       {
         label: 'Small',
-        id: 'small',
+        selected: false,
+        applied: false,
+        value: 'small',
       },
       {
         label: 'Medium',
-        id: 'medium',
+        selected: false,
+        applied: false,
+        value: 'medium',
       },
     ],
     label: 'Label',
@@ -24,9 +28,18 @@ export default {
   },
 } as Meta<FilterButtonProps>
 
-const Template: StoryFn<FilterButtonProps> = (args) => {
-  const [value, setValue] = useState<number[]>([])
-  return <FilterButton {...args} selected={value} onApply={setValue} />
+const CheckboxTemplate: StoryFn<FilterButtonProps> = (args) => {
+  const [options, setOptions] = useState<FilterButtonOption[]>(args.options || [])
+
+  return <FilterButton {...args} type="checkbox" options={options} onChange={setOptions} />
+}
+
+export const Checkbox = CheckboxTemplate.bind({})
+
+const RadioTemplate: StoryFn<FilterButtonProps> = (args) => {
+  const [options, setOptions] = useState<FilterButtonOption[]>(args.options || [])
+
+  return <FilterButton {...args} type="radio" options={options} onChange={setOptions} />
 }
 
-export const Default = Template.bind({})
+export const Radio = RadioTemplate.bind({})

+ 51 - 18
packages/atlas/src/components/FilterButton/FilterButton.tsx

@@ -1,50 +1,69 @@
-import { FC, ReactNode, useRef, useState } from 'react'
+import { ChangeEvent, FC, ReactNode, useRef } from 'react'
 
 import { Counter, StyledButton } from '@/components/FilterButton/FilterButton.styles'
-import { CheckboxProps } from '@/components/_inputs/Checkbox'
 import { CheckboxGroup } from '@/components/_inputs/CheckboxGroup'
 import { DialogPopover } from '@/components/_overlays/DialogPopover'
 
+import { RadioButtonGroup } from '../_inputs/RadioButtonGroup'
+
+export type FilterButtonOption = {
+  value: string
+  label: string
+  selected: boolean
+  applied: boolean
+}
+
 export type FilterButtonProps = {
-  options: CheckboxProps[]
-  selected?: number[]
-  onApply: (ids: number[]) => void
+  name: string
+  type: 'checkbox' | 'radio'
   label?: string
   icon?: ReactNode
   className?: string
+  onChange: (selectedOptions: FilterButtonOption[]) => void
+  options?: FilterButtonOption[]
 }
 
-export const FilterButton: FC<FilterButtonProps> = ({ label, icon, options, onApply, selected = [], className }) => {
-  const [localSelection, setLocalSelection] = useState<number[]>([])
+export type SectionFilter = Omit<FilterButtonProps, 'onChange'>
+
+export const FilterButton: FC<FilterButtonProps> = ({ type, name, onChange, className, icon, label, options = [] }) => {
+  const counter = options.filter((option) => option.applied)?.length
   const triggerRef = useRef<HTMLButtonElement>(null)
 
   const handleApply = () => {
-    onApply(localSelection)
+    onChange(options.map((option) => ({ ...option, applied: option.selected })))
     triggerRef.current?.click()
   }
 
-  const handleSelection = (num: number) => {
-    setLocalSelection((prev) => {
-      if (prev.includes(num)) {
-        return prev.filter((prevNum) => prevNum !== num)
-      } else {
-        return [...prev, num]
+  const handleCheckboxSelection = (num: number) => {
+    const selected = options.map((option, idx) => {
+      if (num === idx) {
+        return { ...option, selected: !option.selected }
       }
+      return option
     })
+    onChange(selected)
+  }
+
+  const handleRadioButtonClick = (e: ChangeEvent<Omit<HTMLInputElement, 'value'> & { value: string | boolean }>) => {
+    const optionIdx = options.findIndex((option) => option.value === e.currentTarget.value)
+    const selected = options.map((option, idx) => ({ ...option, selected: optionIdx === idx }))
+    onChange(selected)
   }
 
   const handleClear = () => {
-    onApply([])
+    onChange(options.map((option) => ({ ...option, selected: false, applied: false })))
     triggerRef.current?.click()
   }
 
   return (
     <DialogPopover
       className={className}
+      flipEnabled
+      appendTo={document.body}
       trigger={
         <StyledButton
           ref={triggerRef}
-          icon={selected?.length ? <Counter>{selected.length}</Counter> : icon}
+          icon={counter ? <Counter>{counter}</Counter> : icon}
           iconPlacement="right"
           variant="secondary"
         >
@@ -56,9 +75,23 @@ export const FilterButton: FC<FilterButtonProps> = ({ label, icon, options, onAp
         text: 'Clear',
         onClick: handleClear,
       }}
-      onShow={() => setLocalSelection(selected)}
     >
-      <CheckboxGroup options={options} checkedIds={localSelection} onChange={handleSelection} />
+      {type === 'checkbox' && (
+        <CheckboxGroup
+          name={name}
+          options={options.map((option) => ({ ...option, value: option.selected }))}
+          checkedIds={options.map((option, index) => (option.selected ? index : -1)).filter((index) => index !== -1)}
+          onChange={handleCheckboxSelection}
+        />
+      )}
+      {type === 'radio' && (
+        <RadioButtonGroup
+          name={name}
+          options={options}
+          value={options.find((option) => option.selected)?.value}
+          onChange={handleRadioButtonClick}
+        />
+      )}
     </DialogPopover>
   )
 }

+ 102 - 0
packages/atlas/src/components/MobileFilterButton/MobileFilterButton.stories.tsx

@@ -0,0 +1,102 @@
+import { Meta, StoryFn } from '@storybook/react'
+import { useState } from 'react'
+
+import { FilterButtonOption, SectionFilter } from '@/components/FilterButton'
+import { OverlayManagerProvider } from '@/providers/overlayManager'
+
+import { MobileFilterButton, MobileFilterButtonProps } from './MobileFilterButton'
+
+const NFT_STATUSES: FilterButtonOption[] = [
+  {
+    value: 'AuctionTypeEnglish',
+    selected: false,
+    applied: false,
+    label: 'Timed auction',
+  },
+  {
+    value: 'AuctionTypeOpen',
+    selected: false,
+    applied: false,
+    label: 'Open auction',
+  },
+  {
+    value: 'TransactionalStatusBuyNow',
+    selected: false,
+    applied: false,
+    label: 'Fixed price',
+  },
+  {
+    value: 'TransactionalStatusIdle',
+    selected: false,
+    applied: false,
+    label: 'Not for sale',
+  },
+]
+
+const DATE_UPLOADED: FilterButtonOption[] = [
+  {
+    label: 'Last 24 hours',
+    selected: false,
+    applied: false,
+    value: '1',
+  },
+  {
+    label: 'Last 7 days',
+    selected: false,
+    applied: false,
+    value: '7',
+  },
+  {
+    label: 'Last 30 days',
+    selected: false,
+    applied: false,
+    value: '30',
+  },
+  {
+    label: 'Last 365 days',
+    selected: false,
+    applied: false,
+    value: '365',
+  },
+]
+
+const LENGTHS: FilterButtonOption[] = [
+  { label: 'Less than 4 minutes', selected: false, applied: false, value: '0-to-4' },
+  { label: '4 to 10 minutes', selected: false, applied: false, value: '4-to-10' },
+  { label: 'More than 10 minutes', selected: false, applied: false, value: '4-to-9999' },
+]
+
+const OTHER: FilterButtonOption[] = [
+  { label: 'Paid promotional material', selected: false, applied: false, value: 'promotional' },
+  { label: 'Mature content rating', selected: false, applied: false, value: 'mature' },
+]
+
+export default {
+  title: 'other/MobileFilterButton',
+  component: MobileFilterButton,
+  args: {
+    start: {
+      type: 'title',
+      title: 'Videos',
+    },
+  },
+  decorators: [
+    (Story: StoryFn) => (
+      <OverlayManagerProvider>
+        <Story />
+      </OverlayManagerProvider>
+    ),
+  ],
+} as Meta<MobileFilterButtonProps>
+
+const DefaultTemplate: StoryFn<MobileFilterButtonProps> = (args) => {
+  const [filters, setFilters] = useState<SectionFilter[]>([
+    { name: 'nft-statuses', type: 'checkbox', options: NFT_STATUSES, label: 'Status' },
+    { name: 'date-uploaded', type: 'radio', options: DATE_UPLOADED, label: 'Date uploaded' },
+    { name: 'lengths', type: 'radio', options: LENGTHS, label: 'Length' },
+    { name: 'other', type: 'checkbox', options: OTHER, label: 'Other' },
+  ])
+  return <MobileFilterButton {...args} filters={filters} onChangeFilters={setFilters} />
+}
+
+export const Default = DefaultTemplate.bind({})

+ 155 - 0
packages/atlas/src/components/MobileFilterButton/MobileFilterButton.tsx

@@ -0,0 +1,155 @@
+import { ChangeEvent, FC, ReactNode, useState } from 'react'
+
+import { SvgActionFilters } from '@/assets/icons'
+
+import { SectionFilter } from '../FilterButton'
+import { Counter } from '../FilterButton/FilterButton.styles'
+import { MobileFilterContainer } from '../FiltersBar/FiltersBar.styles'
+import { Text } from '../Text'
+import { Button } from '../_buttons/Button'
+import { CheckboxGroup } from '../_inputs/CheckboxGroup'
+import { RadioButtonGroup } from '../_inputs/RadioButtonGroup'
+import { DialogModal, DialogModalProps } from '../_overlays/DialogModal'
+
+export type MobileFilterButtonProps = {
+  filters: SectionFilter[]
+  onChangeFilters?: (newFilters: SectionFilter[]) => void
+}
+
+export const MobileFilterButton: FC<MobileFilterButtonProps> = ({ filters, onChangeFilters }) => {
+  const [isFiltersOpen, setIsFiltersOpen] = useState(false)
+  const counter = filters.reduce((prev, curr) => {
+    return prev + (curr.options?.filter((option) => option.applied).length || 0)
+  }, 0)
+
+  const handleApply = () => {
+    const newFilters = filters.map((filter) => ({
+      ...filter,
+      options: filter.options?.map((option) => ({ ...option, applied: option.selected })),
+    }))
+
+    onChangeFilters?.(newFilters)
+    setIsFiltersOpen(false)
+  }
+
+  const handleCheckboxSelection = (name: string, num: number) => {
+    const filterIdxToEdit = filters.findIndex((filter) => filter.name === name)
+    if (filterIdxToEdit === -1) {
+      return
+    }
+
+    const newFilters = filters.map((filter, filterIdx) => {
+      if (filterIdx === filterIdxToEdit) {
+        return {
+          ...filter,
+          options: filter.options?.map((option, optionIdx) => {
+            if (num === optionIdx) {
+              return { ...option, selected: !option.selected }
+            }
+            return option
+          }),
+        }
+      }
+      return filter
+    })
+
+    onChangeFilters?.(newFilters)
+  }
+
+  const handleRadioButtonClick = (e: ChangeEvent<Omit<HTMLInputElement, 'value'> & { value: string | boolean }>) => {
+    const filterIdxToEdit = filters.findIndex((filter) => filter.name === e.currentTarget.name)
+    if (filterIdxToEdit === -1) {
+      return
+    }
+    const newFilters = filters.map((filter, filterIdx) => {
+      if (filterIdx === filterIdxToEdit) {
+        const optionIdx = filter.options?.findIndex((option) => option.value === e.currentTarget.value)
+        return {
+          ...filter,
+          options: filter.options?.map((option, idx) => {
+            if (optionIdx === idx) {
+              return { ...option, selected: true }
+            }
+            return { ...option, selected: false }
+          }),
+        }
+      }
+      return filter
+    })
+
+    onChangeFilters?.(newFilters)
+  }
+
+  const handleClear = () => {
+    const newFilters = filters.map((filter) => ({
+      ...filter,
+      options: filter.options?.map((option) => ({ ...option, selected: false, applied: false })),
+    }))
+
+    onChangeFilters?.(newFilters)
+    setIsFiltersOpen(false)
+  }
+
+  return (
+    <>
+      <Button
+        icon={counter ? <Counter>{counter}</Counter> : <SvgActionFilters />}
+        iconPlacement="right"
+        variant="secondary"
+        onClick={() => setIsFiltersOpen(true)}
+      >
+        Filters
+      </Button>
+      <MobileFilterDialog
+        title="Filters"
+        primaryButton={{
+          text: 'Apply',
+          onClick: handleApply,
+        }}
+        secondaryButton={{
+          text: 'Clear',
+          onClick: handleClear,
+        }}
+        onExitClick={() => setIsFiltersOpen(false)}
+        show={isFiltersOpen}
+        content={
+          <>
+            {filters.map(({ name, options = [], label, type }, idx) => (
+              <MobileFilterContainer key={idx}>
+                <Text as="span" variant="h300">
+                  {label}
+                </Text>
+                {type === 'checkbox' && (
+                  <CheckboxGroup
+                    name={name}
+                    onChange={(num) => handleCheckboxSelection(name, num)}
+                    options={options?.map((option) => ({ ...option, value: option.selected }))}
+                    checkedIds={options
+                      .map((option, index) => (option.selected ? index : -1))
+                      .filter((index) => index !== -1)}
+                  />
+                )}
+                {type === 'radio' && (
+                  <RadioButtonGroup
+                    name={name}
+                    onChange={handleRadioButtonClick}
+                    options={options}
+                    value={options.find((option) => option.selected)?.value}
+                  />
+                )}
+              </MobileFilterContainer>
+            ))}
+          </>
+        }
+      />
+    </>
+  )
+}
+
+const MobileFilterDialog: FC<{ content: ReactNode } & DialogModalProps> = ({ content, ...dialogModalProps }) => {
+  return (
+    <DialogModal {...dialogModalProps} dividers>
+      {content}
+    </DialogModal>
+  )
+}

+ 1 - 0
packages/atlas/src/components/MobileFilterButton/index.ts

@@ -0,0 +1 @@
+export * from './MobileFilterButton'

+ 346 - 0
packages/atlas/src/components/Section/Section.stories.tsx

@@ -0,0 +1,346 @@
+import { ApolloProvider } from '@apollo/client'
+import { Meta, StoryFn } from '@storybook/react'
+import { useState } from 'react'
+import { BrowserRouter } from 'react-router-dom'
+
+import { createApolloClient } from '@/api'
+import {
+  SvgActionAuction,
+  SvgActionCalendar,
+  SvgActionChevronR,
+  SvgActionClock,
+  SvgActionSettings,
+} from '@/assets/icons'
+import { ConfirmationModalProvider } from '@/providers/confirmationModal'
+import { OverlayManagerProvider } from '@/providers/overlayManager'
+import { UserProvider } from '@/providers/user/user.provider'
+import { createPlaceholderData } from '@/utils/data'
+
+import { Section, SectionProps } from './Section'
+
+import { FilterButtonOption, SectionFilter } from '../FilterButton'
+import { RankingNumberTile } from '../RankingNumberTile'
+import { SelectItem } from '../_inputs/Select'
+import { NftTile } from '../_nft/NftTile'
+import { VideoTile } from '../_video/VideoTile'
+
+const TABS = [
+  {
+    name: 'Videos',
+  },
+  {
+    name: 'NFTs',
+  },
+  {
+    name: 'Token',
+  },
+  {
+    name: 'Information',
+  },
+  {
+    name: 'About',
+  },
+]
+
+const ORDER_ITEMS: SelectItem[] = [
+  { name: 'Newest', value: 'newest' },
+  { name: 'Oldest', value: 'oldest' },
+  { name: 'Most popular', value: 'popular' },
+]
+
+const NFT_STATUSES: FilterButtonOption[] = [
+  {
+    value: 'AuctionTypeEnglish',
+    selected: false,
+    applied: false,
+    label: 'Timed auction',
+  },
+  {
+    value: 'AuctionTypeOpen',
+    selected: false,
+    applied: false,
+    label: 'Open auction',
+  },
+  {
+    value: 'TransactionalStatusBuyNow',
+    selected: false,
+    applied: false,
+    label: 'Fixed price',
+  },
+  {
+    value: 'TransactionalStatusIdle',
+    selected: false,
+    applied: false,
+    label: 'Not for sale',
+  },
+]
+
+const DATE_UPLOADED: FilterButtonOption[] = [
+  {
+    label: 'Last 24 hours',
+    selected: false,
+    applied: false,
+    value: '1',
+  },
+  {
+    label: 'Last 7 days',
+    selected: false,
+    applied: false,
+    value: '7',
+  },
+  {
+    label: 'Last 30 days',
+    selected: false,
+    applied: false,
+    value: '30',
+  },
+  {
+    label: 'Last 365 days',
+    selected: false,
+    applied: false,
+    value: '365',
+  },
+]
+
+const LENGTHS: FilterButtonOption[] = [
+  { label: 'Less than 4 minutes', selected: false, applied: false, value: '0-to-4' },
+  { label: '4 to 10 minutes', selected: false, applied: false, value: '4-to-10' },
+  { label: 'More than 10 minutes', selected: false, applied: false, value: '4-to-9999' },
+]
+
+const OTHER: FilterButtonOption[] = [
+  { label: 'Paid promotional material', selected: false, applied: false, value: 'promotional' },
+  { label: 'Mature content rating', selected: false, applied: false, value: 'mature' },
+]
+
+const INITIAL_STATE: SectionFilter[] = [
+  {
+    name: 'date-uploaded',
+    type: 'radio',
+    options: DATE_UPLOADED,
+    label: 'Date uploaded',
+    icon: <SvgActionCalendar />,
+  },
+  { name: 'lengths', type: 'radio', options: LENGTHS, label: 'Length', icon: <SvgActionClock /> },
+  { name: 'nft-statuses', type: 'checkbox', options: NFT_STATUSES, label: 'Status', icon: <SvgActionAuction /> },
+  { name: 'other', type: 'checkbox', options: OTHER, label: 'Other', icon: <SvgActionSettings /> },
+]
+export default {
+  title: 'other/Section',
+  component: Section,
+  decorators: [
+    (Story) => {
+      const apolloClient = createApolloClient()
+      return (
+        <BrowserRouter>
+          <ApolloProvider client={apolloClient}>
+            <ConfirmationModalProvider>
+              <UserProvider>
+                <OverlayManagerProvider>
+                  <Story />
+                </OverlayManagerProvider>
+              </UserProvider>
+            </ConfirmationModalProvider>
+          </ApolloProvider>
+        </BrowserRouter>
+      )
+    },
+  ],
+} as Meta
+
+const DefaultTemplate: StoryFn<SectionProps> = () => {
+  const [filters, setFilters] = useState<SectionFilter[]>(INITIAL_STATE)
+  const [placeholdersCount, setPlaceholdersCount] = useState(8)
+  const [secondPlaceholdersCount, setSecondPlaceholdersCount] = useState(8)
+
+  const placeholderItems = createPlaceholderData(placeholdersCount)
+  const secondPlaceholderItems = createPlaceholderData(secondPlaceholdersCount)
+  return (
+    <div style={{ paddingBottom: 48, display: 'grid', gap: 60 }}>
+      <Section
+        headerProps={{
+          start: {
+            type: 'title',
+            title: 'All content',
+          },
+          button: {
+            children: 'Browse',
+            iconPlacement: 'right',
+            icon: <SvgActionChevronR />,
+          },
+        }}
+        contentProps={{
+          type: 'grid',
+          minChildrenWidth: 200,
+          children: placeholderItems.map((_, idx) => (
+            <VideoTile key={idx} loadingDetails={true} loadingAvatar={true} loadingThumbnail={true} />
+          )),
+        }}
+        footerProps={{
+          type: 'link',
+          label: 'Load more',
+          handleLoadMore: async () => setPlaceholdersCount((count) => count + 8),
+        }}
+      />
+      <Section
+        headerProps={{
+          start: {
+            type: 'tabs',
+            tabsProps: {
+              tabs: TABS,
+              onSelectTab: () => null,
+            },
+          },
+          sort: {
+            type: 'select',
+            selectProps: {
+              value: 'oldest',
+              inlineLabel: 'Sort by',
+              items: ORDER_ITEMS,
+            },
+          },
+          filters: filters,
+          onApplyFilters: setFilters,
+        }}
+        contentProps={{
+          type: 'grid',
+          minChildrenWidth: 200,
+          children: secondPlaceholderItems.map((_, idx) => (
+            <VideoTile key={idx} loadingDetails={true} loadingAvatar={true} loadingThumbnail={true} />
+          )),
+        }}
+        footerProps={{
+          type: 'pagination',
+          page: 1,
+          itemsPerPage: 10,
+          maxPaginationLinks: 3,
+          onChangePage: () => null,
+          totalCount: 20,
+        }}
+      />
+      <Section
+        headerProps={{
+          start: {
+            type: 'title',
+            title: 'Nfts on sale',
+          },
+          filters: filters,
+          onApplyFilters: setFilters,
+        }}
+        contentProps={{
+          type: 'grid',
+          minChildrenWidth: 200,
+          children: secondPlaceholderItems.map((_, idx) => (
+            <VideoTile key={idx} loadingDetails={true} loadingAvatar={true} loadingThumbnail={true} />
+          )),
+        }}
+        footerProps={{
+          type: 'infinite',
+          fetchMore: async () => setSecondPlaceholdersCount((count) => count + 8),
+        }}
+      />
+    </div>
+  )
+}
+
+export const Default = DefaultTemplate.bind({})
+
+const CarouselTemplate: StoryFn<SectionProps> = () => {
+  const placeholderItems = createPlaceholderData(10)
+  return (
+    <div style={{ display: 'grid', gap: 64, paddingBottom: 200 }}>
+      <Section
+        headerProps={{
+          start: {
+            type: 'title',
+            title: 'Carousel',
+          },
+        }}
+        contentProps={{
+          type: 'carousel',
+          slidesPerView: 3,
+          children: placeholderItems.map((_, idx) => (
+            <RankingNumberTile key={idx} number={idx + 1}>
+              <VideoTile
+                loadingDetails={true}
+                loadingAvatar={true}
+                thumbnailUrl={`http://placekitten.com/g/${320 + idx}/180`}
+              />
+            </RankingNumberTile>
+          )),
+        }}
+      />
+      <Section
+        headerProps={{
+          start: {
+            type: 'tabs',
+            tabsProps: {
+              tabs: TABS,
+              onSelectTab: () => null,
+            },
+          },
+        }}
+        contentProps={{
+          type: 'carousel',
+          slidesPerView: 3,
+          children: placeholderItems.map((_, idx) => (
+            <RankingNumberTile key={idx} number={idx + 1}>
+              <VideoTile
+                loadingDetails={true}
+                loadingAvatar={true}
+                thumbnailUrl={`https://place.dog/${320 + idx}/180`}
+              />
+            </RankingNumberTile>
+          )),
+        }}
+      />
+      <Section
+        headerProps={{
+          start: {
+            type: 'tabs',
+            tabsProps: {
+              tabs: TABS,
+              onSelectTab: () => null,
+            },
+          },
+        }}
+        contentProps={{
+          type: 'carousel',
+          slidesPerView: 5,
+          children: placeholderItems.map((_, idx) => (
+            <NftTile
+              key={idx}
+              thumbnail={{ type: 'video', thumbnailUrl: `https://place.dog/${320 + idx}/180` }}
+              title={`Nft number ${idx}`}
+            />
+          )),
+        }}
+      />
+      <Section
+        headerProps={{
+          start: {
+            type: 'tabs',
+            tabsProps: {
+              tabs: TABS,
+              onSelectTab: () => null,
+            },
+          },
+        }}
+        contentProps={{
+          type: 'carousel',
+          slidesPerView: 5,
+          children: placeholderItems.map((_, idx) => (
+            <VideoTile
+              key={idx}
+              loadingDetails={true}
+              loadingAvatar={true}
+              thumbnailUrl={`https://place.dog/${320 + idx}/180`}
+            />
+          )),
+        }}
+      />
+    </div>
+  )
+}
+
+export const Carousel = CarouselTemplate.bind({})

+ 11 - 0
packages/atlas/src/components/Section/Section.styles.ts

@@ -0,0 +1,11 @@
+import styled from '@emotion/styled'
+
+import { media, sizes } from '@/styles'
+
+export const SectionWrapper = styled.section`
+  display: grid;
+  gap: ${sizes(4)};
+  ${media.sm} {
+    gap: ${sizes(6)};
+  }
+`

+ 47 - 0
packages/atlas/src/components/Section/Section.tsx

@@ -0,0 +1,47 @@
+import { FC, PropsWithChildren, useRef } from 'react'
+
+import { SectionWrapper } from './Section.styles'
+import { SectionContent, SectionContentProps } from './SectionContent'
+import { SectionFooter, SectionFooterProps } from './SectionFooter'
+import { SectionHeader, SectionHeaderProps } from './SectionHeader'
+
+import { SwiperInstance } from '../Carousel'
+
+export type SectionProps = {
+  headerProps: Omit<SectionHeaderProps, 'isCarousel'>
+  contentProps: SectionContentProps
+  footerProps?: SectionFooterProps
+  className?: string
+}
+
+export const Section: FC<PropsWithChildren<SectionProps>> = ({ headerProps, contentProps, footerProps, className }) => {
+  const isCarousel = contentProps.type === 'carousel'
+  const gliderRef = useRef<SwiperInstance>()
+
+  const handleSlideLeft = () => {
+    gliderRef.current?.slidePrev()
+  }
+  const handleSlideRight = () => {
+    gliderRef.current?.slideNext()
+  }
+  return (
+    <SectionWrapper className={className}>
+      {isCarousel ? (
+        <SectionHeader
+          {...headerProps}
+          isCarousel
+          onMoveCarouselLeft={handleSlideLeft}
+          onMoveCarouselRight={handleSlideRight}
+        />
+      ) : (
+        <SectionHeader {...headerProps} />
+      )}
+      {isCarousel ? (
+        <SectionContent {...contentProps} onSwiper={(swiper) => (gliderRef.current = swiper)} />
+      ) : (
+        <SectionContent {...contentProps} />
+      )}
+      {footerProps && <SectionFooter {...footerProps} />}
+    </SectionWrapper>
+  )
+}

+ 1 - 1
packages/atlas/src/components/Section/components/SectionContent/SectionContent.stories.tsx → packages/atlas/src/components/Section/SectionContent/SectionContent.stories.tsx

@@ -1,7 +1,7 @@
 import styled from '@emotion/styled'
 import { Meta, StoryFn } from '@storybook/react'
 
-import { SectionContent, SectionContentProps } from '@/components/Section/components/SectionContent'
+import { SectionContent, SectionContentProps } from '@/components/Section/SectionContent'
 
 export default {
   title: 'other/SectionContent',

+ 0 - 0
packages/atlas/src/components/Section/components/SectionContent/SectionContent.styles.ts → packages/atlas/src/components/Section/SectionContent/SectionContent.styles.ts


+ 3 - 4
packages/atlas/src/components/Section/components/SectionContent/SectionContent.tsx → packages/atlas/src/components/Section/SectionContent/SectionContent.tsx

@@ -1,7 +1,7 @@
-import { ReactElement } from 'react'
+import { FC, ReactElement } from 'react'
 
 import { Carousel, CarouselProps } from '@/components/Carousel'
-import { GridWrapper } from '@/components/Section/components/SectionContent/SectionContent.styles'
+import { GridWrapper } from '@/components/Section/SectionContent/SectionContent.styles'
 
 type SectionGridTypeProps = {
   type: 'grid'
@@ -19,7 +19,7 @@ export type SectionContentProps = {
   children: ReactElement[]
 } & SectionContentTypes
 
-export const SectionContent = (props: SectionContentProps) => {
+export const SectionContent: FC<SectionContentProps> = (props) => {
   if (props.type === 'grid') {
     return (
       <GridWrapper className={props.className} minWidth={props.minChildrenWidth}>
@@ -28,6 +28,5 @@ export const SectionContent = (props: SectionContentProps) => {
     )
   }
 
-  // todo: replace with new carousel when implemented by #3775
   return <Carousel {...props}>{props.children}</Carousel>
 }

+ 0 - 0
packages/atlas/src/components/Section/components/SectionContent/index.ts → packages/atlas/src/components/Section/SectionContent/index.ts


+ 1 - 1
packages/atlas/src/components/Section/components/SectionFooter/SectionFooter.stories.tsx → packages/atlas/src/components/Section/SectionFooter/SectionFooter.stories.tsx

@@ -2,7 +2,7 @@ import styled from '@emotion/styled'
 import { useState } from '@storybook/addons'
 import { Meta, StoryFn } from '@storybook/react'
 
-import { SectionFooter } from '@/components/Section/components/SectionFooter/SectionFooter'
+import { SectionFooter } from '@/components/Section/SectionFooter/SectionFooter'
 import { SkeletonLoader } from '@/components/_loaders/SkeletonLoader'
 
 export default {

+ 0 - 0
packages/atlas/src/components/Section/components/SectionFooter/SectionFooter.tsx → packages/atlas/src/components/Section/SectionFooter/SectionFooter.tsx


+ 0 - 0
packages/atlas/src/components/Section/components/SectionFooter/index.ts → packages/atlas/src/components/Section/SectionFooter/index.ts


+ 18 - 0
packages/atlas/src/components/Section/SectionHeader/DynamicSearch/DynamicSearch.styles.ts

@@ -0,0 +1,18 @@
+import styled from '@emotion/styled'
+
+import { Input } from '@/components/_inputs/Input'
+import { media } from '@/styles'
+
+export const SearchInput = styled(Input)`
+  max-width: 100%;
+  ${media.md} {
+    width: 240px;
+  }
+`
+
+export const SectionSearchWrapper = styled.div<{ isMobileSearchOpen: boolean }>`
+  width: ${({ isMobileSearchOpen }) => (isMobileSearchOpen ? '100%' : 'unset')};
+  ${media.md} {
+    width: unset;
+  }
+`

+ 43 - 0
packages/atlas/src/components/Section/SectionHeader/DynamicSearch/DynamicSearch.tsx

@@ -0,0 +1,43 @@
+import { FC } from 'react'
+
+import { SvgActionClose, SvgActionSearch } from '@/assets/icons'
+import { Button } from '@/components/_buttons/Button'
+import { InputProps } from '@/components/_inputs/Input'
+
+import { SearchInput, SectionSearchWrapper } from './DynamicSearch.styles'
+
+export type SearchProps = Omit<InputProps, 'size' | 'actionButton' | 'nodeStart' | 'type' | 'placeholder'>
+
+type DynamicSearchProps = {
+  search: SearchProps
+  isOpen: boolean
+  onSearchToggle: (isOpen: boolean) => void
+}
+
+export const DynamicSearch: FC<DynamicSearchProps> = ({ isOpen, onSearchToggle, search }) => {
+  return (
+    <SectionSearchWrapper isMobileSearchOpen={isOpen}>
+      {isOpen ? (
+        <SearchInput
+          {...search}
+          actionButton={{
+            onClick: () => onSearchToggle(false),
+            icon: <SvgActionClose />,
+          }}
+          nodeStart={<Button icon={<SvgActionSearch />} variant="tertiary" />}
+          size="medium"
+          placeholder="Search"
+          type="search"
+        />
+      ) : (
+        <Button
+          icon={<SvgActionSearch />}
+          onClick={() => {
+            onSearchToggle(true)
+          }}
+          variant="tertiary"
+        />
+      )}
+    </SectionSearchWrapper>
+  )
+}

+ 58 - 0
packages/atlas/src/components/Section/SectionHeader/SectionFilters/SectionFilters.styles.ts

@@ -0,0 +1,58 @@
+import styled from '@emotion/styled'
+
+import { Button } from '@/components/_buttons/Button'
+import { cVar, sizes } from '@/styles'
+import { MaskProps, getMaskImage } from '@/utils/styles'
+
+export const VerticalDivider = styled.div`
+  width: 1px;
+  background-color: ${cVar('colorBorderMutedAlpha')};
+`
+
+export const FiltersAndSortWrapper = styled.div`
+  display: flex;
+  gap: ${sizes(4)};
+`
+
+export const SectionFiltersWrapper = styled.div`
+  display: flex;
+  gap: ${sizes(2)};
+  min-width: 0;
+`
+
+export const FiltersWrapper = styled.div<MaskProps>`
+  display: flex;
+  overflow: auto;
+  width: max-content;
+  gap: ${sizes(2)};
+  scrollbar-width: none;
+  position: relative;
+
+  ::-webkit-scrollbar {
+    display: none;
+  }
+
+  ${getMaskImage};
+`
+
+export const ChevronButtonHandler = styled.div`
+  position: relative;
+  display: flex;
+  overflow: hidden;
+
+  ::-webkit-scrollbar {
+    display: none;
+  }
+`
+
+export const ChevronButton = styled(Button)<{ direction: 'right' | 'left' }>`
+  position: absolute;
+  align-self: center;
+  left: ${({ direction }) => (direction === 'left' ? 0 : 'unset')};
+  right: ${({ direction }) => (direction === 'right' ? 0 : 'unset')};
+`
+
+export const FilterButtonWrapper = styled.div`
+  width: max-content;
+  flex-shrink: 0;
+`

+ 100 - 0
packages/atlas/src/components/Section/SectionHeader/SectionFilters/SectionFilters.tsx

@@ -0,0 +1,100 @@
+import { FC, useRef } from 'react'
+
+import { SvgActionChevronL, SvgActionChevronR, SvgActionClose } from '@/assets/icons'
+import { FilterButton, FilterButtonOption, SectionFilter } from '@/components/FilterButton'
+import { MobileFilterButton } from '@/components/MobileFilterButton'
+import { Button } from '@/components/_buttons/Button'
+import { useHorizonthalFade } from '@/hooks/useHorizonthalFade'
+import { useMediaMatch } from '@/hooks/useMediaMatch'
+
+import {
+  ChevronButton,
+  ChevronButtonHandler,
+  FilterButtonWrapper,
+  FiltersWrapper,
+  SectionFiltersWrapper,
+  VerticalDivider,
+} from './SectionFilters.styles'
+
+type SectionFiltersProps = {
+  filters: SectionFilter[]
+  onApplyFilters?: (appliedFilters: SectionFilter[]) => void
+}
+
+export const SectionFilters: FC<SectionFiltersProps> = ({ filters, onApplyFilters }) => {
+  const smMatch = useMediaMatch('sm')
+  const filterWrapperRef = useRef<HTMLDivElement>(null)
+
+  const { handleMouseDown, visibleShadows, handleArrowScroll, isOverflow } = useHorizonthalFade(filterWrapperRef)
+
+  const areThereAnyOptionsSelected = filters
+    .map((filter) => filter.options?.map((option) => option.applied))
+    .flat()
+    .some(Boolean)
+
+  const handleApply = (name: string, selectedOptions: FilterButtonOption[]) => {
+    onApplyFilters?.(
+      filters.map((filter) => {
+        if (filter.name === name) {
+          return { ...filter, options: selectedOptions }
+        }
+        return filter
+      })
+    )
+  }
+
+  const handleResetFilters = () => {
+    const newFilters = filters.map((filter) => ({
+      ...filter,
+      options: filter.options?.map((option) => ({ ...option, selected: false, applied: false })),
+    }))
+
+    onApplyFilters?.(newFilters)
+  }
+
+  if (!smMatch) {
+    return <MobileFilterButton filters={filters} onChangeFilters={onApplyFilters} />
+  }
+
+  return (
+    <SectionFiltersWrapper>
+      {areThereAnyOptionsSelected && (
+        <>
+          <Button icon={<SvgActionClose />} variant="tertiary" onClick={handleResetFilters}>
+            Clear
+          </Button>
+          <VerticalDivider />
+        </>
+      )}
+      <ChevronButtonHandler>
+        <FiltersWrapper ref={filterWrapperRef} onMouseDown={handleMouseDown} visibleShadows={visibleShadows}>
+          {filters.map((filter, idx) => {
+            return (
+              <FilterButtonWrapper key={idx}>
+                <FilterButton {...filter} onChange={(selectedOptions) => handleApply(filter.name, selectedOptions)} />
+              </FilterButtonWrapper>
+            )
+          })}
+        </FiltersWrapper>
+        {visibleShadows.left && isOverflow && (
+          <ChevronButton
+            direction="left"
+            size="small"
+            variant="tertiary"
+            icon={<SvgActionChevronL />}
+            onClick={handleArrowScroll('left')}
+          />
+        )}
+        {visibleShadows.right && isOverflow && (
+          <ChevronButton
+            direction="right"
+            size="small"
+            variant="tertiary"
+            icon={<SvgActionChevronR />}
+            onClick={handleArrowScroll('right')}
+          />
+        )}
+      </ChevronButtonHandler>
+    </SectionFiltersWrapper>
+  )
+}

+ 270 - 0
packages/atlas/src/components/Section/SectionHeader/SectionHeader.stories.tsx

@@ -0,0 +1,270 @@
+import { Meta, StoryFn } from '@storybook/react'
+import { useState } from 'react'
+
+import {
+  SvgActionAuction,
+  SvgActionCalendar,
+  SvgActionChevronR,
+  SvgActionClock,
+  SvgActionMember,
+  SvgActionSettings,
+} from '@/assets/icons'
+import { FilterButtonOption, SectionFilter } from '@/components/FilterButton'
+import { OverlayManagerProvider } from '@/providers/overlayManager'
+
+import { SectionHeader, SectionHeaderProps } from './SectionHeader'
+
+import { SelectItem } from '../../_inputs/Select'
+
+const TABS = [
+  {
+    name: 'Videos',
+  },
+  {
+    name: 'NFTs',
+  },
+  {
+    name: 'Token',
+  },
+  {
+    name: 'Information',
+  },
+  {
+    name: 'About',
+  },
+]
+
+const ORDER_ITEMS: SelectItem[] = [
+  { name: 'Newest', value: 'newest' },
+  { name: 'Oldest', value: 'oldest' },
+  { name: 'Most popular', value: 'popular' },
+]
+
+const NFT_STATUSES: FilterButtonOption[] = [
+  {
+    value: 'AuctionTypeEnglish',
+    selected: false,
+    applied: false,
+    label: 'Timed auction',
+  },
+  {
+    value: 'AuctionTypeOpen',
+    selected: false,
+    applied: false,
+    label: 'Open auction',
+  },
+  {
+    value: 'TransactionalStatusBuyNow',
+    selected: false,
+    applied: false,
+    label: 'Fixed price',
+  },
+  {
+    value: 'TransactionalStatusIdle',
+    selected: false,
+    applied: false,
+    label: 'Not for sale',
+  },
+]
+
+const DATE_UPLOADED: FilterButtonOption[] = [
+  {
+    label: 'Last 24 hours',
+    selected: false,
+    applied: false,
+    value: '1',
+  },
+  {
+    label: 'Last 7 days',
+    selected: false,
+    applied: false,
+    value: '7',
+  },
+  {
+    label: 'Last 30 days',
+    selected: false,
+    applied: false,
+    value: '30',
+  },
+  {
+    label: 'Last 365 days',
+    selected: false,
+    applied: false,
+    value: '365',
+  },
+]
+
+const LENGTHS: FilterButtonOption[] = [
+  { label: 'Less than 4 minutes', selected: false, applied: false, value: '0-to-4' },
+  { label: '4 to 10 minutes', selected: false, applied: false, value: '4-to-10' },
+  { label: 'More than 10 minutes', selected: false, applied: false, value: '4-to-9999' },
+]
+
+const OTHER: FilterButtonOption[] = [
+  { label: 'Paid promotional material', selected: false, applied: false, value: 'promotional' },
+  { label: 'Mature content rating', selected: false, applied: false, value: 'mature' },
+]
+
+const INITIAL_STATE: SectionFilter[] = [
+  {
+    name: 'date-uploaded',
+    type: 'radio',
+    options: DATE_UPLOADED,
+    label: 'Date uploaded',
+    icon: <SvgActionCalendar />,
+  },
+  { name: 'lengths', type: 'radio', options: LENGTHS, label: 'Length', icon: <SvgActionClock /> },
+  { name: 'nft-statuses', type: 'checkbox', options: NFT_STATUSES, label: 'Status', icon: <SvgActionAuction /> },
+  { name: 'other', type: 'checkbox', options: OTHER, label: 'Other', icon: <SvgActionSettings /> },
+]
+
+export default {
+  title: 'other/SectionHeader',
+  component: SectionHeader,
+  args: {
+    start: {
+      type: 'title',
+      title: 'Videos',
+    },
+  },
+  decorators: [
+    (Story: StoryFn) => (
+      <OverlayManagerProvider>
+        <Story />
+      </OverlayManagerProvider>
+    ),
+  ],
+} as Meta<SectionHeaderProps>
+
+const DefaultTemplate: StoryFn<SectionHeaderProps> = (args: SectionHeaderProps) => {
+  return <SectionHeader {...args} />
+}
+
+export const Default = DefaultTemplate.bind({})
+
+const WithTabsTemplate = () => {
+  const [filters, setFilters] = useState<SectionFilter[]>(INITIAL_STATE)
+
+  return (
+    <div style={{ display: 'grid', gap: 64 }}>
+      <SectionHeader
+        search={{}}
+        start={{
+          type: 'tabs',
+          tabsProps: {
+            tabs: TABS,
+            onSelectTab: () => null,
+            selected: 0,
+          },
+        }}
+        sort={{
+          type: 'select',
+          selectProps: {
+            value: 'oldest',
+            inlineLabel: 'Sort by',
+            items: ORDER_ITEMS,
+          },
+        }}
+        filters={filters}
+        onApplyFilters={setFilters}
+      />
+
+      <SectionHeader
+        search={{}}
+        start={{
+          type: 'tabs',
+          tabsProps: {
+            tabs: [...TABS, { name: 'More Tabs' }, { name: 'Moooar tabs' }],
+            onSelectTab: () => null,
+            selected: 0,
+          },
+        }}
+        sort={{
+          type: 'select',
+          selectProps: {
+            value: 'oldest',
+            inlineLabel: 'Sort by',
+            items: ORDER_ITEMS,
+          },
+        }}
+      />
+    </div>
+  )
+}
+
+export const WithTabs = WithTabsTemplate.bind({})
+
+const WithTitleTemplate: StoryFn<SectionHeaderProps> = () => {
+  const [filters, setFilters] = useState<SectionFilter[]>(INITIAL_STATE)
+
+  return (
+    <div style={{ display: 'grid', gap: 64 }}>
+      <SectionHeader
+        button={{ children: 'Browse', icon: <SvgActionChevronR />, iconPlacement: 'right' }}
+        sort={{
+          type: 'select',
+          selectProps: {
+            value: 'oldest',
+            inlineLabel: 'Sort by',
+            items: ORDER_ITEMS,
+          },
+        }}
+        start={{
+          type: 'title',
+          title: 'Icon',
+          nodeStart: {
+            type: 'avatar',
+            avatarProps: {
+              assetUrl: 'https://placekitten.com/g/200/300',
+            },
+          },
+        }}
+      />
+      <SectionHeader
+        sort={{
+          type: 'select',
+          selectProps: {
+            value: 'oldest',
+            inlineLabel: 'Sort by',
+            items: ORDER_ITEMS,
+          },
+        }}
+        start={{
+          type: 'title',
+          title: 'Icon',
+          nodeStart: {
+            type: 'icon',
+            iconWrapperProps: {
+              icon: <SvgActionMember />,
+            },
+          },
+        }}
+        filters={filters}
+        onApplyFilters={setFilters}
+      />
+      <SectionHeader
+        start={{
+          type: 'title',
+          title: 'Custom title',
+          nodeStart: {
+            type: 'custom',
+            node: <div style={{ width: 24, height: 24, background: 'blue', borderRadius: 5 }} />,
+          },
+        }}
+        sort={{
+          type: 'toggle-button',
+          toggleButtonOptionTypeProps: {
+            type: 'options',
+            options: ['Newest', 'Oldest'],
+            onChange: () => null,
+          },
+        }}
+        filters={filters}
+        onApplyFilters={setFilters}
+        search={{}}
+      />
+    </div>
+  )
+}
+
+export const WithTitle = WithTitleTemplate.bind({})

+ 70 - 0
packages/atlas/src/components/Section/SectionHeader/SectionHeader.styles.ts

@@ -0,0 +1,70 @@
+import styled from '@emotion/styled'
+
+import { Button } from '@/components/_buttons/Button'
+import { Select } from '@/components/_inputs/Select'
+import { cVar, media, sizes } from '@/styles'
+
+export const MobileFirstRow = styled.div`
+  display: flex;
+  width: 100%;
+  overflow: hidden;
+  gap: ${sizes(2)};
+`
+
+export const MobileSecondRow = styled.div`
+  width: 100%;
+  display: flex;
+  gap: ${sizes(2)};
+`
+
+export const FiltersAndSortWrapper = styled.div`
+  display: flex;
+  gap: ${sizes(4)};
+`
+type SectionHeaderWrapperProps = {
+  isTabs: boolean
+  isSearchInputOpen?: boolean
+}
+export const SectionHeaderWrapper = styled.header<SectionHeaderWrapperProps>`
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  gap: ${({ isSearchInputOpen, isTabs }) => sizes(isSearchInputOpen && isTabs ? 8 : 4)};
+  ${media.sm} {
+    gap: ${sizes(4)};
+    width: 100%;
+    display: flex;
+    flex-direction: row;
+    align-items: ${({ isTabs }) => (isTabs ? 'start' : 'center')};
+    box-shadow: ${({ isTabs }) => (isTabs ? `inset 0 -1px 0 ${cVar('colorBorderMutedAlpha')}` : 'unset')};
+  }
+`
+
+export const RightSide = styled.div`
+  margin-left: auto;
+  display: flex;
+  gap: ${sizes(4)};
+`
+
+export const StyledButton = styled(Button)`
+  align-self: flex-start;
+`
+
+export const OverflowHiddenWrapper = styled.div`
+  overflow: hidden;
+  margin-left: auto;
+`
+
+export const StyledSelect = styled(Select)`
+  width: unset;
+` as typeof Select
+
+export const StartWrapper = styled.div<{ enableHorizonthalScrolling: boolean }>`
+  overflow: ${({ enableHorizonthalScrolling }) => (enableHorizonthalScrolling ? 'hidden' : 'unset')};
+  display: flex;
+  gap: ${sizes(4)};
+`
+
+export const StyledArrowButton = styled(Button)`
+  border-radius: unset;
+`

+ 166 - 0
packages/atlas/src/components/Section/SectionHeader/SectionHeader.tsx

@@ -0,0 +1,166 @@
+import { FC, ReactNode, useState } from 'react'
+
+import { SvgActionChevronL, SvgActionChevronR } from '@/assets/icons'
+import { SectionFilter } from '@/components/FilterButton'
+import { ButtonProps } from '@/components/_buttons/Button'
+import { useMediaMatch } from '@/hooks/useMediaMatch'
+
+import { DynamicSearch, SearchProps } from './DynamicSearch/DynamicSearch'
+import { SectionFilters } from './SectionFilters/SectionFilters'
+import {
+  MobileFirstRow,
+  MobileSecondRow,
+  OverflowHiddenWrapper,
+  RightSide,
+  SectionHeaderWrapper,
+  StartWrapper,
+  StyledArrowButton,
+  StyledButton,
+  StyledSelect,
+} from './SectionHeader.styles'
+import { SectionTitleComponent } from './SectionTitle/SectionTitle'
+
+import { AvatarProps } from '../../Avatar'
+import { IconWrapperProps } from '../../IconWrapper'
+import { Tabs, TabsProps } from '../../Tabs'
+import { Select, SelectProps } from '../../_inputs/Select'
+import { ToggleButtonGroup, ToggleButtonOptionTypeProps } from '../../_inputs/ToggleButtonGroup'
+
+type Sort =
+  | {
+      type: 'toggle-button'
+      toggleButtonOptionTypeProps: ToggleButtonOptionTypeProps
+    }
+  | {
+      type: 'select'
+      selectProps: SelectProps
+    }
+
+type TitleNodeStart =
+  | {
+      type: 'icon'
+      iconWrapperProps: IconWrapperProps
+    }
+  | {
+      type: 'avatar'
+      avatarProps: AvatarProps
+    }
+  | {
+      type: 'custom'
+      node: ReactNode
+    }
+
+type SectionHeaderStart =
+  | {
+      type: 'title'
+      title: string
+      nodeStart?: TitleNodeStart
+    }
+  | {
+      type: 'tabs'
+      tabsProps: TabsProps
+    }
+
+type Carousel =
+  | {
+      isCarousel?: true
+      onMoveCarouselRight?: () => void
+      onMoveCarouselLeft?: () => void
+    }
+  | {
+      isCarousel?: false
+    }
+
+export type SectionHeaderProps = {
+  start: SectionHeaderStart
+  search?: SearchProps
+  sort?: Sort
+  filters?: SectionFilter[]
+  onApplyFilters?: (appliedFilters: SectionFilter[]) => void
+  button?: Omit<ButtonProps, 'size' | 'variant'>
+} & Carousel
+
+export const SectionHeader: FC<SectionHeaderProps> = (props) => {
+  const { start, sort, search, filters, onApplyFilters, button, isCarousel } = props
+  const [isSearchInputOpen, setIsSearchInputOpen] = useState(false)
+  const smMatch = useMediaMatch('sm')
+  const mdMatch = useMediaMatch('md')
+
+  // MOBILE
+  if (!smMatch) {
+    const filtersInFirstRow = !sort && start.type === 'title'
+    return (
+      <SectionHeaderWrapper isTabs={start.type === 'tabs'} isSearchInputOpen={isSearchInputOpen}>
+        <MobileFirstRow>
+          {!isSearchInputOpen && (
+            <>
+              {start.type === 'title' && <SectionTitleComponent nodeStart={start.nodeStart} title={start.title} />}
+              {start.type === 'tabs' && <Tabs {...start.tabsProps} />}
+            </>
+          )}
+          {search && <DynamicSearch search={search} isOpen={isSearchInputOpen} onSearchToggle={setIsSearchInputOpen} />}
+          {!isSearchInputOpen && (
+            <RightSide>
+              {filters && filtersInFirstRow && <SectionFilters filters={filters} onApplyFilters={onApplyFilters} />}
+              {isCarousel && (
+                <>
+                  <StyledArrowButton
+                    size="medium"
+                    icon={<SvgActionChevronL />}
+                    variant="tertiary"
+                    onClick={props.onMoveCarouselLeft}
+                  />
+                  <StyledArrowButton
+                    size="medium"
+                    icon={<SvgActionChevronR />}
+                    variant="tertiary"
+                    onClick={props.onMoveCarouselRight}
+                  />
+                </>
+              )}
+              {button && <StyledButton {...button} size="medium" variant="secondary" />}
+            </RightSide>
+          )}
+        </MobileFirstRow>
+        <MobileSecondRow>
+          {filters && !filtersInFirstRow && <SectionFilters filters={filters} onApplyFilters={onApplyFilters} />}
+          {sort?.type === 'select' && <Select {...sort.selectProps} size="medium" />}
+        </MobileSecondRow>
+      </SectionHeaderWrapper>
+    )
+  }
+
+  const shouldShowFilters = !mdMatch ? !isSearchInputOpen : true
+  // DESKTOP
+  return (
+    <SectionHeaderWrapper isTabs={start.type === 'tabs'}>
+      <StartWrapper enableHorizonthalScrolling={start.type === 'tabs'}>
+        {start.type === 'title' && <SectionTitleComponent nodeStart={start.nodeStart} title={start.title} />}
+        {start.type === 'tabs' && <Tabs {...start.tabsProps} />}
+      </StartWrapper>
+      {search && <DynamicSearch search={search} isOpen={isSearchInputOpen} onSearchToggle={setIsSearchInputOpen} />}
+      <OverflowHiddenWrapper>
+        {filters && shouldShowFilters && <SectionFilters filters={filters} onApplyFilters={onApplyFilters} />}
+      </OverflowHiddenWrapper>
+      {sort?.type === 'toggle-button' && <ToggleButtonGroup {...sort.toggleButtonOptionTypeProps} />}
+      {sort?.type === 'select' && <StyledSelect {...sort.selectProps} size="medium" />}
+      {isCarousel && (
+        <>
+          <StyledArrowButton
+            size="medium"
+            icon={<SvgActionChevronL />}
+            variant="tertiary"
+            onClick={props.onMoveCarouselLeft}
+          />
+          <StyledArrowButton
+            size="medium"
+            icon={<SvgActionChevronR />}
+            variant="tertiary"
+            onClick={props.onMoveCarouselRight}
+          />
+        </>
+      )}
+      {button && <StyledButton {...button} size="medium" variant="secondary" />}
+    </SectionHeaderWrapper>
+  )
+}

+ 16 - 0
packages/atlas/src/components/Section/SectionHeader/SectionTitle/SectionTitle.styles.ts

@@ -0,0 +1,16 @@
+import styled from '@emotion/styled'
+
+import { Text } from '@/components/Text'
+import { sizes } from '@/styles'
+
+export const HeaderTitleWrapper = styled.div`
+  display: flex;
+  flex-shrink: 0;
+  width: max-content;
+  align-items: center;
+  gap: ${sizes(3)};
+`
+
+export const HeaderTitle = styled(Text)`
+  align-self: center;
+`

+ 51 - 0
packages/atlas/src/components/Section/SectionHeader/SectionTitle/SectionTitle.tsx

@@ -0,0 +1,51 @@
+import { FC, ReactNode } from 'react'
+
+import { Avatar, AvatarProps } from '@/components/Avatar'
+import { IconWrapper, IconWrapperProps } from '@/components/IconWrapper'
+import { useMediaMatch } from '@/hooks/useMediaMatch'
+
+import { HeaderTitle, HeaderTitleWrapper } from './SectionTitle.styles'
+
+type TitleNodeStart =
+  | {
+      type: 'icon'
+      iconWrapperProps: IconWrapperProps
+    }
+  | {
+      type: 'avatar'
+      avatarProps: AvatarProps
+    }
+  | {
+      type: 'custom'
+      node: ReactNode
+    }
+
+type SectionTitleComponentProps = {
+  nodeStart?: TitleNodeStart
+  title: string
+}
+
+export const SectionTitleComponent: FC<SectionTitleComponentProps> = ({ nodeStart, title }) => {
+  const smMatch = useMediaMatch('sm')
+
+  const renderNodeStart = () => {
+    if (nodeStart?.type === 'avatar') {
+      return <Avatar {...nodeStart.avatarProps} size={smMatch ? 'default' : 'bid'} />
+    }
+    if (nodeStart?.type === 'icon') {
+      return <IconWrapper {...nodeStart?.iconWrapperProps} size="medium" />
+    }
+    if (nodeStart?.type === 'custom') {
+      return <>{nodeStart.node}</>
+    }
+  }
+
+  return (
+    <HeaderTitleWrapper>
+      {nodeStart && renderNodeStart()}
+      <HeaderTitle variant={smMatch ? 'h500' : 'h400'} as="h3">
+        {title}
+      </HeaderTitle>
+    </HeaderTitleWrapper>
+  )
+}

+ 1 - 0
packages/atlas/src/components/Section/SectionHeader/index.ts

@@ -0,0 +1 @@
+export * from './SectionHeader'

+ 5 - 1
packages/atlas/src/components/Tabs/Tabs.stories.tsx

@@ -13,6 +13,11 @@ const badgeTabs = [
   { name: 'six', badgeNumber: 6 },
   { name: 'seven', badgeNumber: 7 },
   { name: 'eight', badgeNumber: 8 },
+  { name: 'nine', badgeNumber: 8 },
+  { name: 'ten', badgeNumber: 8 },
+  { name: 'eleven', badgeNumber: 8 },
+  { name: 'twelve', badgeNumber: 8 },
+  { name: 'thirteen', badgeNumber: 8 },
 ]
 const pillTabs = [
   { name: 'one', pillText: 1 },
@@ -58,6 +63,5 @@ const Template: StoryFn<TabsProps> = (args) => {
 export const Default = Template.bind({})
 
 export const Container = styled.div`
-  width: 300px;
   gap: 64px;
 `

+ 1 - 2
packages/atlas/src/components/Tabs/Tabs.styles.ts

@@ -9,13 +9,12 @@ import { Button } from '../_buttons/Button'
 
 export const TabsWrapper = styled.div`
   position: relative;
-  width: 100%;
+  min-width: 0;
 `
 
 export const TabsGroup = styled.div<MaskProps>`
   display: flex;
   position: relative;
-  scroll-behavior: smooth;
   overflow: auto;
   ${getMaskImage}
 

+ 7 - 63
packages/atlas/src/components/Tabs/Tabs.tsx

@@ -1,9 +1,8 @@
-import { throttle } from 'lodash-es'
-import { FC, memo, useEffect, useRef, useState } from 'react'
+import { FC, memo, useRef, useState } from 'react'
 import { CSSTransition } from 'react-transition-group'
-import useDraggableScroll from 'use-draggable-scroll'
 
 import { Text } from '@/components/Text'
+import { useHorizonthalFade } from '@/hooks/useHorizonthalFade'
 import { transitions } from '@/styles'
 
 import { ButtonWrapper, StyledButton, StyledPill, Tab, TabsGroup, TabsWrapper } from './Tabs.styles'
@@ -24,58 +23,14 @@ export type TabsProps = {
   className?: string
 }
 
-const SCROLL_SHADOW_OFFSET = 10
-
 export const Tabs: FC<TabsProps> = memo(
   ({ tabs, onSelectTab, initialIndex = -1, selected: paramsSelected, underline, className }) => {
     const [_selected, setSelected] = useState(initialIndex)
     const selected = paramsSelected ?? _selected
-    const [isContentOverflown, setIsContentOverflown] = useState(false)
     const tabsGroupRef = useRef<HTMLDivElement>(null)
     const tabRef = useRef<HTMLDivElement>(null)
-    const [shadowsVisible, setShadowsVisible] = useState({
-      left: false,
-      right: false,
-    })
-    const { onMouseDown } = useDraggableScroll(tabsGroupRef, { direction: 'horizontal' })
-
-    useEffect(() => {
-      const tabsGroup = tabsGroupRef.current
-      if (!tabsGroup) {
-        return
-      }
-      setIsContentOverflown(tabsGroup.scrollWidth > tabsGroup.clientWidth)
-    }, [])
-
-    useEffect(() => {
-      const tabsGroup = tabsGroupRef.current
-      const tab = tabRef.current
-      if (!tabsGroup || !isContentOverflown || !tab) {
-        return
-      }
-      setShadowsVisible((prev) => ({ ...prev, right: true }))
-      const { clientWidth, scrollWidth } = tabsGroup
-      const tabWidth = tab.offsetWidth
-
-      const middleTabPosition = clientWidth / 2 - tabWidth / 2
-
-      tabsGroup.scrollLeft = tab.offsetLeft - middleTabPosition
 
-      const touchHandler = throttle(() => {
-        setShadowsVisible({
-          left: tabsGroup.scrollLeft > SCROLL_SHADOW_OFFSET,
-          right: tabsGroup.scrollLeft < scrollWidth - clientWidth - SCROLL_SHADOW_OFFSET,
-        })
-      }, 100)
-
-      tabsGroup.addEventListener('touchmove', touchHandler, { passive: true })
-      tabsGroup.addEventListener('scroll', touchHandler)
-      return () => {
-        touchHandler.cancel()
-        tabsGroup.removeEventListener('touchmove', touchHandler)
-        tabsGroup.removeEventListener('scroll', touchHandler)
-      }
-    }, [isContentOverflown, selected])
+    const { handleMouseDown, handleArrowScroll, isOverflow, visibleShadows } = useHorizonthalFade(tabsGroupRef)
 
     const createClickHandler = (idx?: number) => () => {
       if (idx !== undefined) {
@@ -84,21 +39,10 @@ export const Tabs: FC<TabsProps> = memo(
       }
     }
 
-    const handleArrowScroll = (direction: 'left' | 'right') => () => {
-      const tabsGroup = tabsGroupRef.current
-      const tab = tabRef.current
-      if (!tabsGroup || !isContentOverflown || !tab) {
-        return
-      }
-
-      const addition = (direction === 'left' ? -1 : 1) * (tabsGroup.clientWidth - tab.offsetWidth)
-      tabsGroup.scrollLeft = tabsGroup.scrollLeft + addition
-    }
-
     return (
       <TabsWrapper className={className}>
         <CSSTransition
-          in={shadowsVisible.left && isContentOverflown}
+          in={visibleShadows.left && isOverflow}
           timeout={100}
           classNames={transitions.names.fade}
           unmountOnExit
@@ -113,7 +57,7 @@ export const Tabs: FC<TabsProps> = memo(
           </ButtonWrapper>
         </CSSTransition>
         <CSSTransition
-          in={shadowsVisible.right && isContentOverflown}
+          in={visibleShadows.right && isOverflow}
           timeout={100}
           classNames={transitions.names.fade}
           unmountOnExit
@@ -131,8 +75,8 @@ export const Tabs: FC<TabsProps> = memo(
         <TabsGroup
           data-underline={!!underline}
           ref={tabsGroupRef}
-          onMouseDown={onMouseDown}
-          shadowsVisible={shadowsVisible}
+          onMouseDown={handleMouseDown}
+          visibleShadows={visibleShadows}
         >
           {tabs.map((tab, idx) => (
             <Tab

+ 3 - 1
packages/atlas/src/components/_content/TopTenVideos/TopTenVideos.tsx

@@ -3,6 +3,7 @@ import { FC } from 'react'
 import { useTop10VideosThisMonth, useTop10VideosThisWeek } from '@/api/hooks/video'
 import { VideoGallery } from '@/components/_video/VideoGallery'
 import { publicChannelFilter, publicVideoFilter } from '@/config/contentFilter'
+import { useMediaMatch } from '@/hooks/useMediaMatch'
 import { SentryLogger } from '@/utils/logs'
 
 type TopTenVideosProps = {
@@ -10,6 +11,7 @@ type TopTenVideosProps = {
 }
 
 export const TopTenVideos: FC<TopTenVideosProps> = ({ period }) => {
+  const smMatch = useMediaMatch('sm')
   const queryFn = period === 'week' ? useTop10VideosThisWeek : useTop10VideosThisMonth
   const { videos, loading } = queryFn(
     {
@@ -23,7 +25,7 @@ export const TopTenVideos: FC<TopTenVideosProps> = ({ period }) => {
 
   return (
     <section>
-      <VideoGallery title={`Top 10 this ${period}`} videos={videos} loading={loading} hasRanking />
+      <VideoGallery title={`Top 10 this ${period}`} videos={videos} loading={loading} hasRanking={smMatch} />
     </section>
   )
 }

+ 9 - 2
packages/atlas/src/components/_inputs/CheckboxGroup/CheckboxGroup.tsx

@@ -7,13 +7,20 @@ import { sizes } from '@/styles'
 export type CheckboxGroupProps = {
   options: CheckboxProps[]
   onChange?: (id: number, event: ChangeEvent<HTMLInputElement>) => void
-  checkedIds: number[]
+  checkedIds?: number[]
   name?: string
   disabled?: boolean
   error?: boolean
 }
 
-export const CheckboxGroup: FC<CheckboxGroupProps> = ({ options, checkedIds, onChange, name, disabled, error }) => {
+export const CheckboxGroup: FC<CheckboxGroupProps> = ({
+  options,
+  checkedIds = [],
+  onChange,
+  name,
+  disabled,
+  error,
+}) => {
   return (
     <Wrapper>
       {options.map((option, idx) => (

+ 1 - 0
packages/atlas/src/components/_inputs/Input/Input.styles.ts

@@ -78,6 +78,7 @@ export const NodeContainer = styled.div<NodeContainerProps>`
 
 export const InputContainer = styled.div<{ size: InputSize }>`
   position: relative;
+  height: max-content;
   font: ${({ size }) => (size === 'large' ? cVar('typographyDesktopT300') : cVar('typographyDesktopT200'))};
   overflow: hidden;
   border-radius: 0 0 ${cVar('radiusSmall')} ${cVar('radiusSmall')};

+ 1 - 2
packages/atlas/src/components/_inputs/Select/Select.tsx

@@ -103,7 +103,6 @@ export const _Select = <T extends unknown>(
     () => items.find((item) => isEqual(item.value, selectedItemValue)),
     [items, selectedItemValue]
   )
-
   return (
     <SelectWrapper ref={containerRef} className={className}>
       <SelectMenuWrapper ref={setWrapperRef}>
@@ -136,7 +135,7 @@ export const _Select = <T extends unknown>(
         <span />
 
         {ReactDOM.createPortal(
-          <div ref={setDropdownRef} style={{ ...styles.popper }} {...attributes.popper}>
+          <div ref={setDropdownRef} style={{ ...styles.popper }} {...attributes.popper} data-popper-escaped="false">
             <SelectMenu {...getMenuProps()}>
               {isOpen && (
                 <List

+ 14 - 6
packages/atlas/src/components/_inputs/ToggleButtonGroup/ToggleButtonGroup.stories.tsx

@@ -9,28 +9,36 @@ export default {
   title: 'inputs/ToggleButtonGroup',
   component: ToggleButtonGroup,
   args: {
+    type: 'options',
     options: ['small', 'large', 'medium', 'medium3', 'medium2'],
   },
 } as Meta<ToggleButtonGroupProps<SbOptions>>
 
-const Template: StoryFn<ToggleButtonGroupProps<SbOptions>> = (args) => {
+const OptionsTemplate: StoryFn<ToggleButtonGroupProps> = (args: ToggleButtonGroupProps<SbOptions>) => {
   const [value, setValue] = useState<SbOptions>('large')
 
-  return <ToggleButtonGroup {...args} value={value} onChange={setValue} />
+  return (
+    <ToggleButtonGroup<SbOptions>
+      {...args}
+      options={args.type === 'options' ? args.options : []}
+      type="options"
+      value={value}
+      onChange={(value: SbOptions) => setValue(value)}
+    />
+  )
 }
 
-export const WidthAuto = Template.bind({})
+export const WidthAuto = OptionsTemplate.bind({})
 WidthAuto.args = {
   width: 'auto',
 }
 
-export const WidthFixed = Template.bind({})
+export const WidthFixed = OptionsTemplate.bind({})
 WidthFixed.args = {
   width: 'fixed',
 }
 
-export const WidthFixedWithoutOverflow = Template.bind({})
+export const WidthFixedWithoutOverflow = OptionsTemplate.bind({})
 WidthFixedWithoutOverflow.args = {
   width: 'fixed',
-  options: ['small', 'large'],
 }

+ 19 - 15
packages/atlas/src/components/_inputs/ToggleButtonGroup/ToggleButtonGroup.styles.ts

@@ -3,23 +3,31 @@ import styled from '@emotion/styled'
 import { Text } from '@/components/Text'
 import { Button } from '@/components/_buttons/Button'
 import { cVar, sizes, zIndex } from '@/styles'
-import { getMaskImage } from '@/utils/styles'
+import { MaskProps, getMaskImage } from '@/utils/styles'
 
-export const Container = styled.div<{ width: 'auto' | 'fixed' }>`
+export type ContainerWidth = 'auto' | 'fixed' | 'fluid'
+
+const getContainerMaxWidth = (width: ContainerWidth) => {
+  switch (width) {
+    case 'fluid':
+      return 'unset'
+    case 'auto':
+      return 'fit-content'
+    case 'fixed':
+      return '320px'
+  }
+}
+
+export const Container = styled.div<{ width: ContainerWidth }>`
   display: flex;
   padding: ${sizes(1)};
   gap: ${sizes(1)};
-  border: 1px solid ${cVar('colorBorderMutedAlpha')};
+  box-shadow: inset 0 0 0 1px ${cVar('colorBorderMutedAlpha')};
   border-radius: ${cVar('radiusSmall')};
-  max-width: ${(props) => (props.width === 'auto' ? 'fit-content' : '320px')};
+  max-width: ${(props) => getContainerMaxWidth(props.width)};
 `
 
-export const OptionWrapper = styled.div<{
-  shadowsVisible: {
-    left: boolean
-    right: boolean
-  }
-}>`
+export const OptionWrapper = styled.div<MaskProps>`
   display: flex;
   flex: 1;
   gap: ${sizes(1)};
@@ -27,11 +35,7 @@ export const OptionWrapper = styled.div<{
   overflow: auto;
   scrollbar-width: none;
   position: relative;
-  ${(props) =>
-    getMaskImage({
-      shadowsVisible: props.shadowsVisible,
-      'data-underline': false,
-    })};
+  ${getMaskImage};
 
   ::-webkit-scrollbar {
     display: none;

+ 49 - 76
packages/atlas/src/components/_inputs/ToggleButtonGroup/ToggleButtonGroup.tsx

@@ -1,78 +1,48 @@
-import { throttle } from 'lodash-es'
-import { useEffect, useRef, useState } from 'react'
-import useDraggableScroll from 'use-draggable-scroll'
+import { useRef } from 'react'
 
 import { SvgActionChevronL, SvgActionChevronR } from '@/assets/icons'
+import { FilterButton, FilterButtonProps } from '@/components/FilterButton'
 import { Button } from '@/components/_buttons/Button'
+import { useHorizonthalFade } from '@/hooks/useHorizonthalFade'
 
-import { ButtonLeft, ButtonRight, Container, ContentWrapper, Label, OptionWrapper } from './ToggleButtonGroup.styles'
+import {
+  ButtonLeft,
+  ButtonRight,
+  Container,
+  ContainerWidth,
+  ContentWrapper,
+  Label,
+  OptionWrapper,
+} from './ToggleButtonGroup.styles'
 
-export type ToggleButtonGroupProps<T extends string> = {
-  options: T[]
-  value?: T
+type SharedToggleButtonProps = {
   label?: string
-  width?: 'auto' | 'fixed'
-  onChange: (width: T) => void
+  width?: ContainerWidth
   className?: string
 }
 
-const SCROLL_SHADOW_OFFSET = 10
-
-export const ToggleButtonGroup = <T extends string>({
-  label,
-  width = 'auto',
-  options,
-  value,
-  onChange,
-  className,
-}: ToggleButtonGroupProps<T>) => {
-  const optionWrapperRef = useRef<HTMLDivElement>(null)
-  const [isOverflowing, setIsOverflowing] = useState<boolean>(false)
-  const { onMouseDown } = useDraggableScroll(optionWrapperRef, { direction: 'horizontal' })
-  const [shadowsVisible, setShadowsVisible] = useState({
-    left: false,
-    right: false,
-  })
-
-  useEffect(() => {
-    if (optionWrapperRef.current) {
-      setIsOverflowing(optionWrapperRef.current.clientWidth < optionWrapperRef.current.scrollWidth)
-    }
-  }, [])
-
-  useEffect(() => {
-    const optionGroup = optionWrapperRef.current
-    if (!optionGroup || !isOverflowing || width !== 'fixed') {
-      return
-    }
-    setShadowsVisible((prev) => ({ ...prev, right: true }))
-    const { clientWidth, scrollWidth } = optionGroup
+export type ToggleButtonOptionTypeProps<T extends string = string> = {
+  type: 'options'
+  options: T[]
+  value?: T
+  onChange: (value: T) => void
+} & SharedToggleButtonProps
 
-    const touchHandler = throttle(() => {
-      setShadowsVisible({
-        left: optionGroup.scrollLeft > SCROLL_SHADOW_OFFSET,
-        right: optionGroup.scrollLeft < scrollWidth - clientWidth - SCROLL_SHADOW_OFFSET,
-      })
-    }, 100)
+export type ToggleButtonFilterTypeProps = {
+  type: 'filter'
+  onClearFilters?: () => void
+  filters: FilterButtonProps[]
+} & SharedToggleButtonProps
 
-    optionGroup.addEventListener('touchmove', touchHandler, { passive: true })
-    optionGroup.addEventListener('scroll', touchHandler)
-    return () => {
-      touchHandler.cancel()
-      optionGroup.removeEventListener('touchmove', touchHandler)
-      optionGroup.removeEventListener('scroll', touchHandler)
-    }
-  }, [isOverflowing, width])
+export type ToggleButtonGroupProps<T extends string = string> =
+  | ToggleButtonFilterTypeProps
+  | ToggleButtonOptionTypeProps<T>
 
-  const handleArrowScroll = (direction: 'left' | 'right') => () => {
-    const optionGroup = optionWrapperRef.current
-    if (!optionGroup || !isOverflowing) {
-      return
-    }
+export const ToggleButtonGroup = <T extends string = string>(props: ToggleButtonGroupProps<T>) => {
+  const { type, label, width = 'auto', className } = props
+  const optionWrapperRef = useRef<HTMLDivElement>(null)
 
-    const addition = (direction === 'left' ? -1 : 1) * (optionGroup.clientWidth / 2)
-    optionGroup.scrollBy({ left: addition, behavior: 'smooth' })
-  }
+  const { handleArrowScroll, handleMouseDown, isOverflow, visibleShadows } = useHorizonthalFade(optionWrapperRef)
 
   return (
     <Container className={className} width={width}>
@@ -82,7 +52,7 @@ export const ToggleButtonGroup = <T extends string>({
         </Label>
       )}
       <ContentWrapper>
-        {width === 'fixed' && isOverflowing && shadowsVisible.left && (
+        {width === 'fixed' && isOverflow && visibleShadows.left && (
           <ButtonLeft
             onClick={handleArrowScroll('left')}
             size="small"
@@ -90,20 +60,23 @@ export const ToggleButtonGroup = <T extends string>({
             icon={<SvgActionChevronL />}
           />
         )}
-        <OptionWrapper onMouseDown={onMouseDown} ref={optionWrapperRef} shadowsVisible={shadowsVisible}>
-          {options.map((option) => (
-            <Button
-              key={option}
-              fullWidth
-              variant={option !== value ? 'tertiary' : 'secondary'}
-              onClick={() => onChange(option)}
-              size="small"
-            >
-              {option}
-            </Button>
-          ))}
+        <OptionWrapper onMouseDown={handleMouseDown} ref={optionWrapperRef} visibleShadows={visibleShadows}>
+          {type === 'options' &&
+            props.options.map((option) => (
+              <Button
+                key={option}
+                fullWidth
+                variant={option !== props.value ? 'tertiary' : 'secondary'}
+                onClick={() => props.onChange(option)}
+                size="small"
+              >
+                {option}
+              </Button>
+            ))}
+          {type === 'filter' &&
+            props.filters.map((filterButtonProps, idx) => <FilterButton key={idx} {...filterButtonProps} />)}
         </OptionWrapper>
-        {width === 'fixed' && isOverflowing && shadowsVisible.right && (
+        {width === 'fixed' && isOverflow && visibleShadows.right && (
           <ButtonRight
             onClick={handleArrowScroll('right')}
             size="small"

+ 7 - 2
packages/atlas/src/components/_navigation/SidenavStudio/SidenavStudio.tsx

@@ -19,6 +19,7 @@ import { absoluteRoutes } from '@/config/routes'
 import { chanelUnseenDraftsSelector, useDraftStore } from '@/providers/drafts'
 import { useUploadsStore } from '@/providers/uploads/uploads.store'
 import { useUser } from '@/providers/user/user.hooks'
+import { useVideoWorkspace } from '@/providers/videoWorkspace'
 
 const studioNavbarItems: NavItemType[] = [
   {
@@ -72,6 +73,7 @@ type SidenavStudioProps = {
 
 export const SidenavStudio: FC<SidenavStudioProps> = ({ className }) => {
   const [expanded, setExpanded] = useState(false)
+  const { uploadVideoButtonProps } = useVideoWorkspace()
   const { channelId } = useUser()
   const unseenDrafts = useDraftStore(chanelUnseenDraftsSelector(channelId || ''))
 
@@ -95,8 +97,11 @@ export const SidenavStudio: FC<SidenavStudioProps> = ({ className }) => {
     <>
       <Button
         icon={<SvgActionAddVideo />}
-        onClick={() => setExpanded(false)}
-        to={absoluteRoutes.studio.videoWorkspace()}
+        to={uploadVideoButtonProps.to}
+        onClick={() => {
+          setExpanded(false)
+          uploadVideoButtonProps.onClick()
+        }}
       >
         Upload video
       </Button>

+ 2 - 3
packages/atlas/src/components/_navigation/TopbarStudio/TopbarStudio.tsx

@@ -27,7 +27,7 @@ export const TopbarStudio: FC<StudioTopbarProps> = ({ hideChannelInfo, isMembers
   const mdMatch = useMediaMatch('md')
   const hasAtLeastOneChannel = !!activeMembership?.channels.length && activeMembership?.channels.length >= 1
 
-  const { isWorkspaceOpen, setEditedVideo, setIsWorkspaceOpen } = useVideoWorkspace()
+  const { isWorkspaceOpen, setIsWorkspaceOpen, uploadVideoButtonProps } = useVideoWorkspace()
 
   const currentChannel = activeMembership?.channels.find((channel) => channel.id === channelId)
 
@@ -79,11 +79,10 @@ export const TopbarStudio: FC<StudioTopbarProps> = ({ hideChannelInfo, isMembers
                 classNames={transitions.names.fade}
               >
                 <Button
-                  to={absoluteRoutes.studio.videoWorkspace()}
-                  onClick={() => setEditedVideo()}
                   variant="secondary"
                   icon={<SvgActionAddVideo />}
                   iconPlacement="left"
+                  {...uploadVideoButtonProps}
                 >
                   {mdMatch && 'Upload video'}
                 </Button>

+ 5 - 0
packages/atlas/src/components/_overlays/AdminModal/AdminModal.styles.ts

@@ -1,5 +1,6 @@
 import styled from '@emotion/styled'
 
+import { Text } from '@/components/Text'
 import { sizes } from '@/styles'
 
 export const HorizontalSpacedContainer = styled.div`
@@ -32,3 +33,7 @@ export const CustomNodeUrlWrapper = styled.div`
     margin-left: ${sizes(4)};
   }
 `
+
+export const VersionText = styled(Text)`
+  padding-top: ${sizes(4)};
+`

+ 10 - 1
packages/atlas/src/components/_overlays/AdminModal/AdminModal.tsx

@@ -1,5 +1,6 @@
 import { ChangeEvent, FC, useEffect, useState } from 'react'
 
+import packageJson from '@/../package.json'
 import { useGetKillSwitch, useSetKillSwitch } from '@/api/hooks/admin'
 import { SvgActionNewTab, SvgAlertsError24, SvgAlertsWarning24 } from '@/assets/icons'
 import { Information } from '@/components/Information'
@@ -23,7 +24,12 @@ import { ActiveUserState } from '@/providers/user/user.types'
 import { useUserLocationStore } from '@/providers/userLocation'
 import { SentryLogger } from '@/utils/logs'
 
-import { CustomNodeUrlWrapper, HorizontalSpacedContainer, VerticalSpacedContainer } from './AdminModal.styles'
+import {
+  CustomNodeUrlWrapper,
+  HorizontalSpacedContainer,
+  VersionText,
+  VerticalSpacedContainer,
+} from './AdminModal.styles'
 
 const ENVIRONMENT_NAMES: Record<string, string> = {
   production: 'Joystream Mainnet',
@@ -118,6 +124,9 @@ export const AdminModal: FC = () => {
       {selectedTabIdx === 2 && <UserTab />}
       {selectedTabIdx === 3 && <LocationTab />}
       {selectedTabIdx === 4 && <KillSwitch />}
+      <VersionText variant="t200" as="p">
+        Built on Atlas v{packageJson.version}
+      </VersionText>
     </DialogModal>
   )
 }

+ 21 - 0
packages/atlas/src/components/_overlays/ContentTypeDialog/ContentTypeDialog.styles.ts

@@ -0,0 +1,21 @@
+import styled from '@emotion/styled'
+
+import { cVar, sizes } from '@/styles'
+
+export const StyledImg = styled.img`
+  object-fit: cover;
+  min-width: 100%;
+`
+
+export const HeaderWrapper = styled.header`
+  padding: ${sizes(6)};
+`
+
+export const CheckboxWrapper = styled.div`
+  box-shadow: ${cVar('effectDividersTop')}, ${cVar('effectDividersBottom')};
+  background-color: ${cVar('colorBackgroundElevated')};
+  padding: ${sizes(4)} ${sizes(6)};
+  margin-bottom: ${sizes(6)};
+  display: flex;
+  align-items: center;
+`

+ 75 - 0
packages/atlas/src/components/_overlays/ContentTypeDialog/ContentTypeDialog.tsx

@@ -0,0 +1,75 @@
+import { FC } from 'react'
+import { Controller, useForm } from 'react-hook-form'
+
+import discoverView from '@/assets/images/discover-view.webp'
+import { Text } from '@/components/Text'
+import { Checkbox } from '@/components/_inputs/Checkbox'
+import { atlasConfig } from '@/config'
+
+import { CheckboxWrapper, HeaderWrapper, StyledImg } from './ContentTypeDialog.styles'
+
+import { DialogModal } from '../DialogModal'
+
+type ContentTypeDialogProps = {
+  onClose: () => void
+  isOpen: boolean
+  onSubmit: () => void
+}
+
+export const ContentTypeDialog: FC<ContentTypeDialogProps> = ({ onClose, isOpen, onSubmit }) => {
+  const { handleSubmit, control, reset } = useForm({
+    defaultValues: {
+      isSelected: false,
+    },
+  })
+  return (
+    <DialogModal
+      show={isOpen}
+      noContentPadding
+      primaryButton={{
+        text: 'Continue',
+        onClick: () => {
+          handleSubmit(() => {
+            onSubmit()
+          })()
+        },
+      }}
+      secondaryButton={{
+        text: 'Cancel',
+        onClick: () => {
+          reset({ isSelected: false })
+          onClose()
+        },
+      }}
+    >
+      <StyledImg src={discoverView} alt="Discover subpage" width={480} height={264} />
+      <HeaderWrapper>
+        <Text variant="h500" as="p" color="colorTextStrong">
+          Upload only {atlasConfig.general.appContentFocus} related content
+        </Text>
+        <Text variant="t200" as="p" color="colorText" margin={{ top: 2 }}>
+          Uploading any other type of content will result in taking down your channel. Please make sure that your
+          content falls under one of {atlasConfig.general.appName} categories before uploading.
+        </Text>
+      </HeaderWrapper>
+      <CheckboxWrapper>
+        <Controller
+          name="isSelected"
+          rules={{
+            required: { value: true, message: 'You have to agree to continue' },
+          }}
+          control={control}
+          render={({ field: { value, onChange }, fieldState: { error } }) => (
+            <Checkbox
+              caption={error?.message}
+              value={value}
+              label={`I will upload only ${atlasConfig.general.appContentFocus} related content`}
+              onChange={onChange}
+              error={!!error?.message}
+            />
+          )}
+        />
+      </CheckboxWrapper>
+    </DialogModal>
+  )
+}

+ 1 - 0
packages/atlas/src/components/_overlays/ContentTypeDialog/index.ts

@@ -0,0 +1 @@
+export * from './ContentTypeDialog'

+ 5 - 4
packages/atlas/src/components/_video/VideoGallery/VideoGallery.tsx

@@ -35,10 +35,11 @@ export type VideoGalleryProps = {
 const PLACEHOLDERS_COUNT = 12
 
 const responsive: CarouselProps['breakpoints'] = {
-  [parseInt(breakpoints.sm)]: {
-    slidesPerView: 1,
+  [parseInt(breakpoints.xs)]: {
+    slidesPerView: 1.2,
+    slidesPerGroup: 1,
   },
-  [parseInt(breakpoints.md)]: {
+  [parseInt(breakpoints.sm)]: {
     slidesPerView: 2,
     slidesPerGroup: 2,
   },
@@ -75,7 +76,7 @@ export const VideoGallery: FC<VideoGalleryProps> = ({
 
   return (
     <Gallery
-      slidesPerView={3}
+      slidesPerView={1}
       title={title}
       breakpoints={responsive}
       dotsVisible

+ 0 - 1
packages/atlas/src/components/_video/VideoPlayer/VideoOverlays/EndingOverlay.styles.ts

@@ -151,7 +151,6 @@ export const StyledCircularProgress = styled(CircularProgress)`
 `
 
 export const CountDownButton = styled(Button)`
-  display: block;
   position: absolute;
 `
 

+ 8 - 2
packages/atlas/src/components/_ypp/BenefitCard/BenefitCard.stories.tsx

@@ -40,8 +40,14 @@ export default {
     actionButton: {
       text: 'Publish new video',
     },
-    dollarAmount: 2.56,
-    joyAmount: 1234,
+    dollarAmount: {
+      type: 'number',
+      amount: 2.56,
+    },
+    joyAmount: {
+      type: 'number',
+      amount: 12356,
+    },
   },
 } as Meta<BenefitCardProps>
 

+ 75 - 11
packages/atlas/src/components/_ypp/BenefitCard/BenefitCard.tsx

@@ -21,6 +21,26 @@ import {
   Wrapper,
 } from './BenefitCard.styles'
 
+type JoyAmountRange = {
+  type: 'range'
+  max: BN | number
+  min: BN | number
+}
+type JoyAmountNumber = {
+  type: 'number'
+  amount: BN | number
+}
+
+type DollarAmountRange = {
+  type: 'range'
+  max: number
+  min: number
+}
+type DollarAmountNumber = {
+  type: 'number'
+  amount: number
+}
+
 export type BenefitCardProps = {
   variant?: Variant
   title: string
@@ -31,8 +51,8 @@ export type BenefitCardProps = {
     onClick?: () => void
     to?: string
   }
-  joyAmount: BN | number
-  dollarAmount?: number
+  joyAmount: JoyAmountNumber | JoyAmountRange
+  dollarAmount?: DollarAmountNumber | DollarAmountRange
   className?: string
 }
 
@@ -54,15 +74,36 @@ export const BenefitCard: FC<BenefitCardProps> = ({
       (!smMatch && !isFullVariant && dollarAmount) || (!isFullVariant && dollarAmount) || isFullVariant
     return (
       <RewardWrapper isCompact={!isFullVariant}>
-        {!!dollarAmount && (
+        {!!dollarAmount && dollarAmount.type === 'number' && (
           <NumberFormat
             as="p"
             format="dollar"
             variant={!smMatch ? 'h500' : 'h600'}
-            value={dollarAmount}
+            value={dollarAmount.amount}
             margin={{ right: dollarAmount && !isFullVariant && !smMatch ? 2 : 0 }}
           />
         )}
+        {!!dollarAmount && dollarAmount.type === 'range' && (
+          <>
+            <NumberFormat
+              as="p"
+              format="dollar"
+              variant={!smMatch ? 'h500' : 'h600'}
+              value={dollarAmount.min}
+              margin={{ right: dollarAmount && !isFullVariant && !smMatch ? 2 : 0 }}
+            />
+            <Text as="span" variant={smMatch ? 'h600' : 'h500'} color="colorText" margin={{ right: 1 }}>
+              -
+            </Text>
+            <NumberFormat
+              as="p"
+              format="dollar"
+              variant={!smMatch ? 'h500' : 'h600'}
+              value={dollarAmount.max}
+              margin={{ right: dollarAmount && !isFullVariant && !smMatch ? 2 : 0 }}
+            />
+          </>
+        )}
         <TokenRewardWrapper>
           {isJoyTokenIconVisible ? (
             <StyledJoyTokenIcon variant="silver" size={smMatch && !dollarAmount ? 24 : 16} />
@@ -71,13 +112,36 @@ export const BenefitCard: FC<BenefitCardProps> = ({
               +
             </Text>
           )}
-          <NumberFormat
-            as="span"
-            format="short"
-            color={!dollarAmount ? 'colorTextStrong' : 'colorText'}
-            variant={!dollarAmount ? (!smMatch ? 'h500' : 'h600') : !smMatch ? 'h300' : 'h400'}
-            value={joyAmount}
-          />
+          {joyAmount.type === 'number' && (
+            <NumberFormat
+              as="span"
+              format="short"
+              color={!dollarAmount ? 'colorTextStrong' : 'colorText'}
+              variant={!dollarAmount ? (!smMatch ? 'h500' : 'h600') : !smMatch ? 'h300' : 'h400'}
+              value={joyAmount.amount}
+            />
+          )}
+          {joyAmount.type === 'range' && (
+            <>
+              <NumberFormat
+                as="span"
+                format="short"
+                color={!dollarAmount ? 'colorTextStrong' : 'colorText'}
+                variant={!dollarAmount ? (!smMatch ? 'h500' : 'h600') : !smMatch ? 'h300' : 'h400'}
+                value={joyAmount.min}
+              />
+              <Text as="span" variant={smMatch ? 'h600' : 'h500'} color="colorText" margin={{ right: 1 }}>
+                -
+              </Text>
+              <NumberFormat
+                as="span"
+                format="short"
+                color={!dollarAmount ? 'colorTextStrong' : 'colorText'}
+                variant={!dollarAmount ? (!smMatch ? 'h500' : 'h600') : !smMatch ? 'h300' : 'h400'}
+                value={joyAmount.max}
+              />
+            </>
+          )}
           {!dollarAmount && !isFullVariant && (
             <Text as="span" variant={smMatch ? 'h400' : 'h300'} color="colorText" margin={{ left: 1 }}>
               {atlasConfig.joystream.tokenTicker}

+ 3 - 3
packages/atlas/src/config/config.ts

@@ -23,11 +23,11 @@ let parsedConfig: RawConfig
 try {
   const configWithEnv = cloneDeepWith(rawConfig, (value) => {
     if (typeof value === 'string') {
-      const match = value.match(/^\$(.*)$/)
+      const match = value.match(/\$\w+/)
       if (!match) return
-      const envVar = match[1]
+      const envVar = match[0].split('$')[1]
       const envValue = import.meta.env[envVar]
-      return envValue ?? null
+      return match.input?.replaceAll(match[0], envValue) ?? null
     }
   })
   parsedConfig = configSchema.parse(configWithEnv)

+ 7 - 1
packages/atlas/src/config/configSchema.ts

@@ -62,7 +62,13 @@ export const configSchema = z.object({
             shortDescription: z.string(),
             stepsDescription: z.string().optional(),
             steps: z.array(z.string()).optional(),
-            baseAmount: z.number(),
+            baseAmount: z.union([
+              z.number(),
+              z.object({
+                min: z.number(),
+                max: z.number(),
+              }),
+            ]),
             actionButtonText: z.string().optional(),
             actionButtonAction: z
               .string()

+ 103 - 0
packages/atlas/src/hooks/useHorizonthalFade.ts

@@ -0,0 +1,103 @@
+import { throttle } from 'lodash-es'
+import { useEffect, useLayoutEffect, useState } from 'react'
+import useDraggableScroll from 'use-draggable-scroll'
+
+type CallbackArg = {
+  hasOverflow: boolean
+  clientWidth: number | undefined
+  scrollWidth: number | undefined
+}
+export const useIsOverflow = (ref: React.RefObject<HTMLElement>, callback?: (arg: CallbackArg) => void) => {
+  const [isOverflow, setIsOverflow] = useState<boolean>()
+  const [clientWidth, setClientWidth] = useState<number>()
+  const [scrollWidth, setScrollWidth] = useState<number>()
+
+  useLayoutEffect(() => {
+    const el = ref.current
+    if (!el) {
+      return
+    }
+
+    const trigger = () => {
+      const hasOverflow = el.scrollWidth > el.clientWidth
+      setClientWidth(el.clientWidth)
+      setIsOverflow(hasOverflow)
+      setScrollWidth(el.scrollWidth)
+
+      if (callback) callback({ hasOverflow, clientWidth: el.clientWidth, scrollWidth: el.scrollWidth })
+    }
+
+    let resizeObserver: ResizeObserver
+    if ('ResizeObserver' in window) {
+      resizeObserver = new ResizeObserver(trigger)
+      resizeObserver.observe(el)
+    }
+
+    trigger()
+    return () => {
+      if ('ResizeObserver' in window) {
+        resizeObserver.unobserve(el)
+        resizeObserver.disconnect()
+      }
+    }
+  }, [callback, ref])
+
+  return { isOverflow, clientWidth, scrollWidth }
+}
+
+const SCROLL_SHADOW_OFFSET = 10
+
+export const useHorizonthalFade = (ref: React.RefObject<HTMLElement>) => {
+  const { onMouseDown } = useDraggableScroll(ref, { direction: 'horizontal' })
+  const { isOverflow } = useIsOverflow(ref)
+
+  const [visibleShadows, setVisibleShadows] = useState({
+    left: false,
+    right: false,
+  })
+
+  useEffect(() => {
+    if (!isOverflow) {
+      setVisibleShadows({ right: false, left: false })
+    }
+    setVisibleShadows((prev) => ({ ...prev, right: !!isOverflow }))
+  }, [isOverflow])
+
+  useEffect(() => {
+    const filterWrapper = ref.current
+    if (!filterWrapper) {
+      return
+    }
+
+    const touchHandler = throttle((event: Event | TouchEvent) => {
+      const scrollLeft = (event.target as HTMLDivElement)?.scrollLeft
+      const scrollWidth = (event.target as HTMLDivElement).scrollWidth
+      const clientWidth = (event.target as HTMLDivElement).clientWidth
+
+      setVisibleShadows({
+        left: scrollLeft > SCROLL_SHADOW_OFFSET,
+        right: scrollLeft < scrollWidth - clientWidth - SCROLL_SHADOW_OFFSET,
+      })
+    }, 100)
+
+    filterWrapper.addEventListener('touchmove', touchHandler)
+    filterWrapper.addEventListener('scroll', touchHandler)
+    return () => {
+      touchHandler.cancel()
+      filterWrapper.removeEventListener('touchmove', touchHandler)
+      filterWrapper.removeEventListener('scroll', touchHandler)
+    }
+  }, [ref])
+
+  const handleArrowScroll = (direction: 'left' | 'right') => () => {
+    const filterWrapper = ref.current
+    if (!filterWrapper || !isOverflow) {
+      return
+    }
+
+    const addition = (direction === 'left' ? -1 : 1) * (filterWrapper.clientWidth / 2)
+    filterWrapper.scrollBy({ left: addition, behavior: 'smooth' })
+  }
+
+  return { handleMouseDown: onMouseDown, visibleShadows, handleArrowScroll, isOverflow }
+}

+ 25 - 0
packages/atlas/src/joystream-lib/lib.ts

@@ -3,7 +3,9 @@ import '@joystream/types'
 import { ApiPromise, WsProvider } from '@polkadot/api'
 import { QueryableStorageMultiArg } from '@polkadot/api-base/types/storage'
 import { Signer } from '@polkadot/api/types'
+import { getSpecTypes } from '@polkadot/types-known'
 import { Codec, SignerPayloadRawBase } from '@polkadot/types/types'
+import { base64Encode } from '@polkadot/util-crypto'
 import BN from 'bn.js'
 import { proxy } from 'comlink'
 
@@ -227,4 +229,27 @@ export class JoystreamLib {
       distributionBucketsCountPerFamily: transformedFamilies,
     }
   }
+
+  async getChainMetadata() {
+    await this.ensureApi()
+    const systemChain = await this.api.rpc.system.chain()
+
+    return {
+      icon: 'substrate',
+      chainType: 'substrate',
+      chain: systemChain.toString(),
+      metaCalls: base64Encode(this.api.runtimeMetadata.asCallsOnly.toU8a()),
+      types: getSpecTypes(
+        this.api.registry,
+        systemChain.toString(),
+        this.api.runtimeVersion.specName.toString(),
+        this.api.runtimeVersion.specVersion
+      ),
+      specVersion: this.api.runtimeVersion.specVersion.toNumber(),
+      ss58Format: this.api.registry.chainSS58 ?? 0,
+      tokenDecimals: this.api.registry.chainDecimals[0],
+      tokenSymbol: this.api.registry.chainTokens[0],
+      genesisHash: this.api.genesisHash.toHex(),
+    }
+  }
 }

+ 33 - 0
packages/atlas/src/providers/transactions/transactions.hooks.ts

@@ -13,6 +13,7 @@ import { absoluteRoutes } from '@/config/routes'
 import { ErrorCode, JoystreamLibError, JoystreamLibErrorType } from '@/joystream-lib/errors'
 import { ExtrinsicResult, ExtrinsicStatus, ExtrinsicStatusCallbackFn } from '@/joystream-lib/types'
 import { useSubscribeAccountBalance } from '@/providers/joystream/joystream.hooks'
+import { useUser } from '@/providers/user/user.hooks'
 import { useUserStore } from '@/providers/user/user.store'
 import { createId } from '@/utils/createId'
 import { ConsoleLogger, SentryLogger } from '@/utils/logs'
@@ -62,6 +63,8 @@ export const useTransaction = (): HandleTransactionFn => {
   const { displaySnackbar } = useSnackbar()
   const getMetaprotocolTxStatus = useMetaprotocolTransactionStatus()
   const { totalBalance } = useSubscribeAccountBalance()
+  const { isSignerMetadataOutdated, updateSignerMetadata, skipSignerMetadataUpdate } = useUser()
+  const { wallet } = useUserStore()
 
   return useCallback(
     async ({
@@ -84,6 +87,32 @@ export const useTransaction = (): HandleTransactionFn => {
         return false
       }
 
+      if (isSignerMetadataOutdated) {
+        await new Promise((resolve) => {
+          openOngoingTransactionModal({
+            title: 'Update Wallet Metadata',
+            type: 'informative',
+            description: `Updated metadata in ${wallet?.title} wallet will allow to view all transactions details before signing. If you choose to ignore it, you will not be prompted until next version of node update is released.`,
+            primaryButton: {
+              text: 'Update',
+              onClick: () => {
+                updateSignerMetadata().then(() => {
+                  resolve(null)
+                })
+              },
+            },
+            secondaryButton: {
+              text: 'Skip',
+              onClick: () => {
+                resolve(null)
+                skipSignerMetadataUpdate()
+              },
+            },
+          })
+        })
+        closeOngoingTransactionModal()
+      }
+
       if (fee && totalBalance?.lt(fee)) {
         displaySnackbar({
           title: 'Not enough funds',
@@ -306,13 +335,17 @@ export const useTransaction = (): HandleTransactionFn => {
       closeOngoingTransactionModal,
       displaySnackbar,
       getMetaprotocolTxStatus,
+      isSignerMetadataOutdated,
       navigate,
       nodeConnectionStatus,
       openOngoingTransactionModal,
       removeTransaction,
+      skipSignerMetadataUpdate,
       totalBalance,
+      updateSignerMetadata,
       updateTransaction,
       userWalletName,
+      wallet?.title,
     ]
   )
 }

+ 77 - 5
packages/atlas/src/providers/user/user.provider.tsx

@@ -2,6 +2,7 @@ import { FC, PropsWithChildren, createContext, useCallback, useContext, useEffec
 
 import { useMemberships } from '@/api/hooks/membership'
 import { ViewErrorFallback } from '@/components/ViewErrorFallback'
+import { JoystreamContext, JoystreamContextValue } from '@/providers/joystream/joystream.provider'
 import { isMobile } from '@/utils/browser'
 import { AssetLogger, SentryLogger } from '@/utils/logs'
 import { retryPromise } from '@/utils/misc'
@@ -16,13 +17,22 @@ UserContext.displayName = 'UserContext'
 const isMobileDevice = isMobile()
 
 export const UserProvider: FC<PropsWithChildren> = ({ children }) => {
-  const { accountId, memberId, channelId, walletAccounts, walletStatus, lastUsedWalletName } = useUserStore(
-    (state) => state
-  )
-  const { setActiveUser, setSignInModalOpen } = useUserStore((state) => state.actions)
+  const {
+    accountId,
+    memberId,
+    channelId,
+    walletAccounts,
+    walletStatus,
+    lastUsedWalletName,
+    wallet,
+    lastChainMetadataVersion,
+  } = useUserStore((state) => state)
+  const { setActiveUser, setSignInModalOpen, setLastChainMetadataVersion } = useUserStore((state) => state.actions)
   const { initSignerWallet } = useSignerWallet()
+  const joystreamCtx = useContext<JoystreamContextValue | undefined>(JoystreamContext)
 
   const [isAuthLoading, setIsAuthLoading] = useState(true)
+  const [isSignerMetadataOutdated, setIsSignerMetadataOutdated] = useState(false)
 
   const accountsIds = walletAccounts.map((a) => a.address)
 
@@ -150,6 +160,53 @@ export const UserProvider: FC<PropsWithChildren> = ({ children }) => {
   }, [accountId, lastUsedWalletName, memberId, signIn, walletStatus])
 
   const activeMembership = (memberId && memberships?.find((membership) => membership.id === memberId)) || null
+  const activeChannel =
+    (activeMembership && activeMembership?.channels.find((channel) => channel.id === channelId)) || null
+
+  const checkSignerStatus = useCallback(async () => {
+    const chainMetadata = await joystreamCtx?.joystream?.getChainMetadata()
+
+    if (wallet?.extension.metadata && chainMetadata) {
+      const [localGenesisHash, localSpecVersion] = lastChainMetadataVersion ?? ['', 0]
+
+      // update was skipped
+      if (localGenesisHash === chainMetadata.genesisHash && localSpecVersion === chainMetadata.specVersion) {
+        return setIsSignerMetadataOutdated(false)
+      }
+
+      const extensionMetadata = await wallet.extension.metadata.get()
+      const currentChain = extensionMetadata.find(
+        (infoEntry: { specVersion: number; genesisHash: string }) =>
+          infoEntry.genesisHash === chainMetadata?.genesisHash
+      )
+
+      // if there isn't even a metadata entry for node with specific genesis hash then update
+      if (!currentChain) {
+        return setIsSignerMetadataOutdated(true)
+      }
+
+      // if there is metadata for this node then verify specVersion
+      const isOutdated = currentChain.specVersion < chainMetadata.specVersion
+      setIsSignerMetadataOutdated(isOutdated)
+    }
+  }, [joystreamCtx?.joystream, lastChainMetadataVersion, wallet?.extension.metadata])
+
+  const updateSignerMetadata = useCallback(async () => {
+    const chainMetadata = await joystreamCtx?.joystream?.getChainMetadata()
+    return wallet?.extension.metadata.provide(chainMetadata)
+  }, [joystreamCtx?.joystream, wallet?.extension.metadata])
+
+  const skipSignerMetadataUpdate = useCallback(async () => {
+    const chainMetadata = await joystreamCtx?.joystream?.getChainMetadata()
+    if (chainMetadata) {
+      setLastChainMetadataVersion(chainMetadata.genesisHash, chainMetadata.specVersion)
+      setIsSignerMetadataOutdated(false)
+    }
+  }, [joystreamCtx?.joystream, setLastChainMetadataVersion])
+
+  useEffect(() => {
+    checkSignerStatus()
+  }, [checkSignerStatus])
 
   const isChannelBelongsToTheUserOrExists = activeMembership?.channels.length
     ? activeMembership.channels.some((channel) => channel.id === channelId)
@@ -166,11 +223,26 @@ export const UserProvider: FC<PropsWithChildren> = ({ children }) => {
       memberships: memberships || [],
       membershipsLoading,
       activeMembership,
+      activeChannel,
       isAuthLoading,
       signIn,
       refetchUserMemberships,
+      isSignerMetadataOutdated,
+      updateSignerMetadata,
+      skipSignerMetadataUpdate,
     }),
-    [memberships, activeMembership, isAuthLoading, signIn, refetchUserMemberships, membershipsLoading]
+    [
+      activeChannel,
+      memberships,
+      membershipsLoading,
+      activeMembership,
+      isAuthLoading,
+      signIn,
+      refetchUserMemberships,
+      isSignerMetadataOutdated,
+      updateSignerMetadata,
+      skipSignerMetadataUpdate,
+    ]
   )
 
   if (error) {

+ 9 - 3
packages/atlas/src/providers/user/user.store.ts

@@ -7,7 +7,7 @@ export type UserStoreState = ActiveUserState & {
   walletAccounts: SignerWalletAccount[]
   walletStatus: SignerWalletStatus
   lastUsedWalletName: string | null
-
+  lastChainMetadataVersion: [string, number] | null // [genesisHash, number]
   signInModalOpen: boolean
 }
 
@@ -21,6 +21,7 @@ export type UserStoreActions = {
   setWalletStatus: (status: SignerWalletStatus) => void
 
   setSignInModalOpen: (isOpen: boolean) => void
+  setLastChainMetadataVersion: (genesisHash: string, version: number) => void
 }
 
 export const useUserStore = createStore<UserStoreState, UserStoreActions>(
@@ -34,7 +35,7 @@ export const useUserStore = createStore<UserStoreState, UserStoreActions>(
       walletAccounts: [],
       walletStatus: 'unknown',
       lastUsedWalletName: null,
-
+      lastChainMetadataVersion: null,
       signInModalOpen: false,
     },
     actionsFactory: (set) => ({
@@ -85,13 +86,18 @@ export const useUserStore = createStore<UserStoreState, UserStoreActions>(
           state.signInModalOpen = isOpen
         })
       },
+      setLastChainMetadataVersion: (genesisHash, version) => {
+        set((state) => {
+          state.lastChainMetadataVersion = [genesisHash, version]
+        })
+      },
     }),
   },
   {
     persist: {
       key: 'activeUser',
       version: 0,
-      whitelist: ['accountId', 'memberId', 'channelId', 'lastUsedWalletName'],
+      whitelist: ['accountId', 'memberId', 'channelId', 'lastUsedWalletName', 'lastChainMetadataVersion'],
       migrate: (oldState) => {
         return oldState
       },

+ 4 - 2
packages/atlas/src/providers/user/user.types.ts

@@ -17,9 +17,11 @@ export type UserContextValue = {
   memberships: Membership[]
   membershipsLoading: boolean
   activeMembership: Membership | null
-
+  isSignerMetadataOutdated: boolean
+  activeChannel: Membership['channels'][number] | null
   isAuthLoading: boolean
-
   signIn: (walletName?: string, mobileCallback?: ({ onConfirm }: { onConfirm: () => void }) => void) => Promise<boolean>
+  updateSignerMetadata: () => Promise<boolean>
+  skipSignerMetadataUpdate: () => Promise<void>
   refetchUserMemberships: ReturnType<typeof useMemberships>['refetch']
 }

+ 49 - 2
packages/atlas/src/providers/videoWorkspace/provider.tsx

@@ -1,9 +1,16 @@
 import { FC, PropsWithChildren, createContext, useCallback, useMemo, useState } from 'react'
+import { useNavigate } from 'react-router'
 
+import { ContentTypeDialog } from '@/components/_overlays/ContentTypeDialog'
+import { atlasConfig } from '@/config'
+import { absoluteRoutes } from '@/config/routes'
 import { createId } from '@/utils/createId'
 
 import { ContextValue, VideoWorkspace } from './types'
 
+import { usePersonalDataStore } from '../personalData'
+import { useUser } from '../user/user.hooks'
+
 export const VideoWorkspaceContext = createContext<ContextValue | undefined>(undefined)
 VideoWorkspaceContext.displayName = 'VideoWorkspaceContext'
 
@@ -14,12 +21,35 @@ const generateVideo = () => ({
   mintNft: false,
 })
 
+const CONTENT_TYPE_INFO = 'content-type'
+
 export const VideoWorkspaceProvider: FC<PropsWithChildren> = ({ children }) => {
   const [editedVideoInfo, setEditedVideoInfo] = useState<VideoWorkspace>(generateVideo())
   const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false)
   const setEditedVideo = useCallback((video?: VideoWorkspace) => {
     setEditedVideoInfo(!video ? generateVideo() : video)
   }, [])
+  const navigate = useNavigate()
+
+  const { activeChannel } = useUser()
+
+  const isContentTypeInfoDismissed = usePersonalDataStore((state) =>
+    atlasConfig.general.appContentFocus
+      ? state.dismissedMessages.some((message) => message.id === CONTENT_TYPE_INFO) ||
+        (activeChannel?.totalVideosCreated && activeChannel?.totalVideosCreated > 0)
+      : true
+  )
+  const updateDismissedMessages = usePersonalDataStore((state) => state.actions.updateDismissedMessages)
+
+  const [isContentTypeDialogOpen, setIsContentDialogOpen] = useState(false)
+
+  const handleOpenVideoWorkspace = useCallback(() => {
+    if (!isContentTypeInfoDismissed) {
+      setIsContentDialogOpen(true)
+      return
+    }
+    setEditedVideo()
+  }, [isContentTypeInfoDismissed, setEditedVideo])
 
   const value = useMemo(
     () => ({
@@ -27,9 +57,26 @@ export const VideoWorkspaceProvider: FC<PropsWithChildren> = ({ children }) => {
       setEditedVideo,
       isWorkspaceOpen,
       setIsWorkspaceOpen,
+      uploadVideoButtonProps: {
+        to: isContentTypeInfoDismissed ? absoluteRoutes.studio.videoWorkspace() : undefined,
+        onClick: handleOpenVideoWorkspace,
+      },
     }),
-    [editedVideoInfo, setEditedVideo, isWorkspaceOpen]
+    [editedVideoInfo, setEditedVideo, isWorkspaceOpen, isContentTypeInfoDismissed, handleOpenVideoWorkspace]
   )
 
-  return <VideoWorkspaceContext.Provider value={value}>{children}</VideoWorkspaceContext.Provider>
+  return (
+    <VideoWorkspaceContext.Provider value={value}>
+      <ContentTypeDialog
+        isOpen={isContentTypeDialogOpen}
+        onClose={() => setIsContentDialogOpen(false)}
+        onSubmit={() => {
+          updateDismissedMessages(CONTENT_TYPE_INFO)
+          setIsContentDialogOpen(false)
+          navigate(absoluteRoutes.studio.videoWorkspace())
+        }}
+      />
+      {children}
+    </VideoWorkspaceContext.Provider>
+  )
 }

+ 4 - 0
packages/atlas/src/providers/videoWorkspace/types.ts

@@ -32,6 +32,10 @@ export type ContextValue = {
   setEditedVideo: (video?: VideoWorkspace) => void
   isWorkspaceOpen: boolean
   setIsWorkspaceOpen: (open: boolean) => void
+  uploadVideoButtonProps: {
+    to?: string
+    onClick: () => void
+  }
 }
 
 export type VideoWorkspaceVideoFormFields = {

+ 5 - 6
packages/atlas/src/utils/styles.ts

@@ -3,25 +3,24 @@ import { css } from '@emotion/react'
 export const toPx = (n: number | string) => (typeof n === 'number' ? `${n}px` : n)
 
 export type MaskProps = {
-  'data-underline': boolean
-  shadowsVisible: {
+  visibleShadows: {
     left: boolean
     right: boolean
   }
 }
 
-export const getMaskImage = ({ shadowsVisible }: MaskProps) => {
-  if (shadowsVisible.left && shadowsVisible.right) {
+export const getMaskImage = ({ visibleShadows }: MaskProps) => {
+  if (visibleShadows.left && visibleShadows.right) {
     return css`
       mask-image: linear-gradient(to left, transparent 5%, black 25%, black 75%, transparent 95%);
     `
   }
-  if (shadowsVisible.left) {
+  if (visibleShadows.left) {
     return css`
       mask-image: linear-gradient(90deg, rgb(0 0 0 / 0) 5%, rgb(0 0 0 / 1) 25%);
     `
   }
-  if (shadowsVisible.right) {
+  if (visibleShadows.right) {
     return css`
       mask-image: linear-gradient(270deg, rgb(0 0 0 / 0) 5%, rgb(0 0 0 / 1) 25%);
     `

+ 15 - 9
packages/atlas/src/views/global/YppLandingView/YppRewardSection.tsx

@@ -136,15 +136,21 @@ export const YppRewardSection: FC = () => {
         )}
         <LayoutGrid data-aos="fade-up" data-aos-delay="200" data-aos-offset="80" data-aos-easing="atlas-easing">
           <BenefitsCardsContainerGridItem colStart={{ lg: 2 }} colSpan={{ base: 12, lg: 10 }}>
-            {rewards.map((reward) => (
-              <BenefitCard
-                key={reward.title}
-                title={reward.title}
-                joyAmount={reward.baseAmount * rewardMultiplier}
-                variant="compact"
-                description={reward.shortDescription}
-              />
-            ))}
+            {rewards.map((reward) => {
+              const joyAmount =
+                typeof reward.baseAmount === 'number'
+                  ? { type: 'number' as const, amount: reward.baseAmount * rewardMultiplier }
+                  : { type: 'range' as const, min: reward.baseAmount.min, max: reward.baseAmount.max }
+              return (
+                <BenefitCard
+                  key={reward.title}
+                  title={reward.title}
+                  joyAmount={joyAmount}
+                  variant="compact"
+                  description={reward.shortDescription}
+                />
+              )
+            })}
           </BenefitsCardsContainerGridItem>
         </LayoutGrid>
       </StyledLimitedWidthContainer>

+ 3 - 7
packages/atlas/src/views/studio/MyUploadsView/MyUploadsView.tsx

@@ -5,11 +5,11 @@ import { SvgActionUpload } from '@/assets/icons'
 import { EmptyFallback } from '@/components/EmptyFallback'
 import { Text } from '@/components/Text'
 import { Button } from '@/components/_buttons/Button'
-import { absoluteRoutes } from '@/config/routes'
 import { useHeadTags } from '@/hooks/useHeadTags'
 import { useUploadsStore } from '@/providers/uploads/uploads.store'
 import { AssetUpload } from '@/providers/uploads/uploads.types'
 import { useUser } from '@/providers/user/user.hooks'
+import { useVideoWorkspace } from '@/providers/videoWorkspace'
 import { arrayFrom } from '@/utils/data'
 
 import { UploadsContainer } from './MyUploadsView.styles'
@@ -24,6 +24,7 @@ export const MyUploadsView: FC = () => {
   const { channelId } = useUser()
 
   const headTags = useHeadTags('My uploads')
+  const { uploadVideoButtonProps } = useVideoWorkspace()
 
   const channelUploads = useUploadsStore((state) => state.uploads.filter((asset) => asset.owner === channelId), shallow)
   const isSyncing = useUploadsStore((state) => state.isSyncing)
@@ -62,12 +63,7 @@ export const MyUploadsView: FC = () => {
           title="No ongoing uploads"
           subtitle="You will see status of each ongoing upload here."
           button={
-            <Button
-              icon={<SvgActionUpload />}
-              variant="secondary"
-              size="large"
-              to={absoluteRoutes.studio.videoWorkspace()}
-            >
+            <Button icon={<SvgActionUpload />} variant="secondary" size="large" {...uploadVideoButtonProps}>
               Upload video
             </Button>
           }

+ 10 - 32
packages/atlas/src/views/studio/MyVideosView/MyVideosView.tsx

@@ -1,5 +1,5 @@
 import axios from 'axios'
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
 import { useQuery } from 'react-query'
 import { useNavigate } from 'react-router-dom'
 
@@ -58,7 +58,7 @@ export const MyVideosView = () => {
   const headTags = useHeadTags('My videos')
   const navigate = useNavigate()
   const { channelId } = useAuthorizedUser()
-  const { editedVideoInfo, setEditedVideo } = useVideoWorkspace()
+  const { editedVideoInfo, setEditedVideo, uploadVideoButtonProps } = useVideoWorkspace()
   const { displaySnackbar, updateSnackbar } = useSnackbar()
   const [videosPerRow, setVideosPerRow] = useState(INITIAL_VIDEOS_PER_ROW)
   const [sortVideosBy, setSortVideosBy] = useState<VideoOrderByInput>(VideoOrderByInput.CreatedAtDesc)
@@ -181,8 +181,6 @@ export const MyVideosView = () => {
     }
   }
 
-  const handleAddVideoTab = useCallback(() => setEditedVideo(), [setEditedVideo])
-
   type HandleVideoClickOpts = {
     draft?: boolean
     minimized?: boolean
@@ -265,7 +263,9 @@ export const MyVideosView = () => {
         .slice(videosPerPage * currentPage, currentPage * videosPerPage + videosPerPage)
         .map((draft, idx) => {
           if (draft === 'new-video-tile') {
-            return <NewVideoTile loading={areTilesLoading} key={`$draft-${idx}`} onClick={handleAddVideoTab} />
+            return (
+              <NewVideoTile loading={areTilesLoading} key={`$draft-${idx}`} onClick={uploadVideoButtonProps.onClick} />
+            )
           }
           return (
             <VideoTileDraft
@@ -282,7 +282,7 @@ export const MyVideosView = () => {
             <NewVideoTile
               loading={video === 'new-video-tile' ? areTilesLoading : true}
               key={idx}
-              onClick={video === 'new-video-tile' ? handleAddVideoTab : undefined}
+              onClick={video === 'new-video-tile' ? uploadVideoButtonProps.onClick : undefined}
             />
           )
         }
@@ -333,13 +333,7 @@ export const MyVideosView = () => {
         My videos
       </Text>
       {!smMatch && sortVisibleAndUploadButtonVisible && (
-        <MobileButton
-          size="large"
-          to={absoluteRoutes.studio.videoWorkspace()}
-          icon={<SvgActionAddVideo />}
-          onClick={handleAddVideoTab}
-          fullWidth
-        >
+        <MobileButton size="large" icon={<SvgActionAddVideo />} fullWidth {...uploadVideoButtonProps}>
           Upload video
         </MobileButton>
       )}
@@ -349,13 +343,7 @@ export const MyVideosView = () => {
           title="Add your first video"
           subtitle="No videos uploaded yet. Start publishing by adding your first video to Joystream."
           button={
-            <Button
-              icon={<SvgActionUpload />}
-              to={absoluteRoutes.studio.videoWorkspace()}
-              variant="secondary"
-              size="large"
-              onClick={handleAddVideoTab}
-            >
+            <Button icon={<SvgActionUpload />} variant="secondary" size="large" {...uploadVideoButtonProps}>
               Upload video
             </Button>
           }
@@ -366,11 +354,7 @@ export const MyVideosView = () => {
             <Tabs initialIndex={0} tabs={mappedTabs} onSelectTab={handleSetCurrentTab} />
             {mdMatch && sortVisibleAndUploadButtonVisible && sortSelectNode}
             {smMatch && sortVisibleAndUploadButtonVisible && (
-              <Button
-                to={absoluteRoutes.studio.videoWorkspace()}
-                icon={<SvgActionAddVideo />}
-                onClick={handleAddVideoTab}
-              >
+              <Button {...uploadVideoButtonProps} icon={<SvgActionAddVideo />}>
                 Upload video
               </Button>
             )}
@@ -434,13 +418,7 @@ export const MyVideosView = () => {
                   : 'Videos published with "Unlisted" privacy setting will show up here.'
               }
               button={
-                <Button
-                  icon={<SvgActionUpload />}
-                  to={absoluteRoutes.studio.videoWorkspace()}
-                  variant="secondary"
-                  size="large"
-                  onClick={handleAddVideoTab}
-                >
+                <Button icon={<SvgActionUpload />} variant="secondary" size="large" {...uploadVideoButtonProps}>
                   Upload video
                 </Button>
               }

+ 34 - 28
packages/atlas/src/views/studio/YppDashboard/tabs/YppDashboardMainTab.tsx

@@ -86,34 +86,40 @@ export const YppDashboardMainTab: FC<YppDashboardMainTabProps> = ({ currentTier
         </WidgetsWrapper>
       )}
       <RewardsWrapper>
-        {REWARDS?.map((reward) => (
-          <BenefitCard
-            key={reward.title}
-            title={reward.title}
-            description={reward.description}
-            steps={reward.steps}
-            actionButton={
-              reward.actionButton !== undefined
-                ? {
-                    ...reward.actionButton,
-                    onClick: () => {
-                      if (
-                        reward.actionButton &&
-                        'copyReferral' in reward.actionButton &&
-                        reward.actionButton.copyReferral
-                      ) {
-                        copyToClipboard(
-                          `${window.location.host}/ypp?referrerId=${channelId}`,
-                          'Referral link copied to clipboard'
-                        )
-                      }
-                    },
-                  }
-                : undefined
-            }
-            joyAmount={reward.joyAmount * multiplier}
-          />
-        ))}
+        {REWARDS?.map((reward) => {
+          const joyAmount =
+            typeof reward.joyAmount === 'number'
+              ? { type: 'number' as const, amount: reward.joyAmount * multiplier }
+              : { type: 'range' as const, min: reward.joyAmount.min, max: reward.joyAmount.max }
+          return (
+            <BenefitCard
+              key={reward.title}
+              title={reward.title}
+              description={reward.description}
+              steps={reward.steps}
+              actionButton={
+                reward.actionButton !== undefined
+                  ? {
+                      ...reward.actionButton,
+                      onClick: () => {
+                        if (
+                          reward.actionButton &&
+                          'copyReferral' in reward.actionButton &&
+                          reward.actionButton.copyReferral
+                        ) {
+                          copyToClipboard(
+                            `${window.location.host}/ypp?referrerId=${channelId}`,
+                            'Referral link copied to clipboard'
+                          )
+                        }
+                      },
+                    }
+                  : undefined
+              }
+              joyAmount={joyAmount}
+            />
+          )
+        })}
       </RewardsWrapper>
       <Banner
         icon={<StyledSvgAlertsInformative24 />}

+ 1 - 0
yarn.lock

@@ -18473,6 +18473,7 @@ __metadata:
   resolution: "root-workspace-0b6124@workspace:."
   dependencies:
     "@emotion/eslint-plugin": ^11.10.0
+    "@joystream/prettier-config": ^1.0.0
     "@stylelint/postcss-css-in-js": ^0.38.0
     "@trivago/prettier-plugin-sort-imports": ^4.0.0
     "@types/node": ^16.18.8