diff --git a/package.json b/package.json index 8f198ac25d..a874614826 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "@internxt/css-config": "1.1.0", "@internxt/lib": "1.4.1", "@internxt/sdk": "=1.13.2", - "@internxt/ui": "=0.1.4", + "@internxt/ui": "=0.1.7", "@phosphor-icons/react": "^2.1.7", "@popperjs/core": "^2.11.6", "@reduxjs/toolkit": "^1.6.0", diff --git a/src/assets/icons/small-logo.svg b/src/assets/icons/small-logo.svg index 8186515d1f..f946ac4645 100644 --- a/src/assets/icons/small-logo.svg +++ b/src/assets/icons/small-logo.svg @@ -1,5 +1,15 @@ - - - - + + + + + + + + + + + + + + diff --git a/src/components/Sidenav.tsx b/src/components/Sidenav.tsx deleted file mode 100644 index 1400cb45cb..0000000000 --- a/src/components/Sidenav.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import { Clock, ClockCounterClockwise, Desktop, FolderSimple, Icon, Trash, Users } from '@phosphor-icons/react'; -import { connect, useSelector } from 'react-redux'; -import { matchPath } from 'react-router-dom'; - -import desktopService from 'services/desktop.service'; -import PlanUsage from 'views/Home/components/PlanUsage'; -import { RootState } from 'app/store'; -import { planSelectors } from 'app/store/slices/plan'; -import navigationService from 'services/navigation.service'; -import { AppView } from 'app/core/types'; - -import { UserSubscription } from '@internxt/sdk/dist/drive/payments/types/types'; -import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; -import { Loader } from '@internxt/ui'; -import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; -import { useAppDispatch, useAppSelector } from 'app/store/hooks'; -import InternxtLogo from 'assets/icons/big-logo.svg?react'; -import localStorageService from 'services/local-storage.service'; -import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; -import { SidenavItem } from 'components'; -import WorkspaceSelectorContainer from 'views/Home/components/WorkspaceSelectorContainer'; -import { STORAGE_KEYS } from 'services/storage-keys'; -import { HUNDRED_TB } from 'app/core/constants'; -import { useEffect } from 'react'; -import { sharedThunks } from 'app/store/slices/sharedLinks'; -import { Translate } from 'app/i18n/types'; - -interface SidenavProps { - user: UserSettings | undefined; - subscription: UserSubscription | null; - planUsage: number; - planLimit: number; - isLoadingPlanLimit: boolean; - isLoadingPlanUsage: boolean; -} - -interface SideNavItemsProps { - label: string; - icon: Icon; - iconDataCy: string; - isVisible: boolean; - to?: string; - isActive?: boolean; - notifications?: number; - onClick?: () => void; -} - -const resetAccessTokenFileFolder = () => { - localStorageService.set(STORAGE_KEYS.FOLDER_ACCESS_TOKEN, ''); - localStorageService.set(STORAGE_KEYS.FILE_ACCESS_TOKEN, ''); -}; - -const isActiveButton = (path: string) => { - return !!matchPath(globalThis.location.pathname, { path, exact: true }); -}; - -const handleDownloadApp = (translate: Translate): void => { - resetAccessTokenFileFolder(); - desktopService.openDownloadAppUrl(translate); -}; - -const LoadingSpinner = ({ text }: { text: string }) => ( -
- -

{text}

-
-); - -const SideNavItems = ({ sideNavItems }: { sideNavItems: SideNavItemsProps[] }) => ( - <> - {sideNavItems.map((item) => - item.isVisible ? ( - - ) : null, - )} - -); - -const getItemNavigationPath = (path: string, workspaceUuid?: string) => { - return workspaceUuid ? `${path}?workspaceid=${workspaceUuid}` : `${path}`; -}; - -const Sidenav = ({ - user, - subscription, - planUsage, - planLimit, - isLoadingPlanLimit, - isLoadingPlanUsage, -}: SidenavProps) => { - const { translate } = useTranslationContext(); - const dispatch = useAppDispatch(); - const isB2BWorkspace = !!useSelector(workspacesSelectors.getSelectedWorkspace); - const isLoadingCredentials = useAppSelector((state: RootState) => state.workspaces.isLoadingCredentials); - const isLoadingBusinessLimitAndUsage = useAppSelector( - (state: RootState) => state.plan.isLoadingBusinessLimitAndUsage, - ); - const pendingInvitations = useAppSelector((state: RootState) => state.shared.pendingInvitations); - const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace); - const workspaceUuid = selectedWorkspace?.workspaceUser.workspaceId; - - useEffect(() => { - dispatch(sharedThunks.getPendingInvitations()); - }, []); - - const itemsNavigation: SideNavItemsProps[] = [ - { - to: getItemNavigationPath('/', workspaceUuid), - isActive: isActiveButton('/') || isActiveButton('/file/:uuid') || isActiveButton('/folder/:uuid'), - label: translate('sideNav.drive'), - icon: FolderSimple, - iconDataCy: 'sideNavDriveIcon', - isVisible: true, - onClick: resetAccessTokenFileFolder, - }, - { - to: getItemNavigationPath('/backups'), - isActive: isActiveButton('/backups'), - label: translate('sideNav.backups'), - icon: ClockCounterClockwise, - iconDataCy: 'sideNavBackupsIcon', - isVisible: !isB2BWorkspace, - }, - { - to: getItemNavigationPath('/shared', workspaceUuid), - isActive: isActiveButton('/shared'), - label: translate('sideNav.shared'), - icon: Users, - notifications: pendingInvitations.length, - iconDataCy: 'sideNavSharedIcon', - isVisible: true, - onClick: resetAccessTokenFileFolder, - }, - { - to: getItemNavigationPath('/recents'), - isActive: isActiveButton('/recents'), - label: translate('sideNav.recents'), - icon: Clock, - iconDataCy: 'sideNavRecentsIcon', - isVisible: !isB2BWorkspace, - }, - { - to: getItemNavigationPath('/trash', workspaceUuid), - isActive: isActiveButton('/trash'), - label: translate('sideNav.trash'), - icon: Trash, - iconDataCy: 'sideNavTrashIcon', - isVisible: true, - onClick: resetAccessTokenFileFolder, - }, - { - label: translate('sideNav.desktop'), - icon: Desktop, - iconDataCy: 'sideNavDesktopIcon', - onClick: () => handleDownloadApp(translate), - isVisible: !isB2BWorkspace, - }, - ]; - - const onLogoClicked = () => { - navigationService.push(AppView.Drive, {}, workspaceUuid); - }; - - const isUpgradeAvailable = () => { - const isLifetimeAvailable = subscription?.type === 'lifetime' && planLimit < HUNDRED_TB; - - return subscription?.type === 'free' || isLifetimeAvailable; - }; - - return ( -
- {isLoadingCredentials && } - - -
-
- {user && ( - - )} - -
- -
- -
- -
-
-
- ); -}; - -export default connect((state: RootState) => ({ - user: state.user.user, - subscription: planSelectors.subscriptionToShow(state), - planUsage: planSelectors.planUsageToShow(state), - planLimit: planSelectors.planLimitToShow(state), - isLoadingPlanLimit: state.plan.isLoadingPlanLimit, - isLoadingPlanUsage: state.plan.isLoadingPlanUsage, -}))(Sidenav); diff --git a/src/components/SidenavItem.scss b/src/components/SidenavItem.scss deleted file mode 100644 index b3ff9f20bb..0000000000 --- a/src/components/SidenavItem.scss +++ /dev/null @@ -1,31 +0,0 @@ -@layer components { - a.nav-link.active { - @apply text-primary; - } - - .side-navigator-item { - @apply relative cursor-pointer hover:text-primary mb-1; - - &.collapsed { - @apply hover:shadow-b; - - a.nav-link.active { - @apply bg-gray-5; - - &::after { - content: ""; - width: 1px; - height: 16px; - bottom: 12; - right: 0%; - - @apply absolute bg-primary; - } - } - } - } - - a.nav-link { - @apply text-gray-100 no-underline hover:text-primary w-full; - } -} diff --git a/src/components/SidenavItem.tsx b/src/components/SidenavItem.tsx deleted file mode 100644 index 4a51667d2c..0000000000 --- a/src/components/SidenavItem.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { IconProps } from '@phosphor-icons/react'; -import { ReactNode } from 'react'; -import { NavLink } from 'react-router-dom'; -import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; - -interface SidenavItemProps { - label: string; - showNew?: boolean; - notifications?: number; - to?: string; - Icon: React.ForwardRefExoticComponent>; - onClick?: () => void; - iconDataCy?: string; - isB2BWorkspace?: boolean; - isActive?: boolean; -} - -const SidenavItem = ({ - label, - to, - Icon, - onClick, - showNew, - notifications, - iconDataCy, - isB2BWorkspace, - isActive, -}: SidenavItemProps): JSX.Element => { - const { translate } = useTranslationContext(); - - const content: ReactNode = ( -
-
- - - {label} - -
- {showNew && ( -
-

{translate('general.new')}

-
- )} - {!isB2BWorkspace && !!notifications && ( -
- {notifications} -
- )} -
- ); - - onClick = onClick || (() => undefined); - - return ( - - ); -}; - -export default SidenavItem; diff --git a/src/components/SidenavWrapper.tsx b/src/components/SidenavWrapper.tsx new file mode 100644 index 0000000000..7665075b46 --- /dev/null +++ b/src/components/SidenavWrapper.tsx @@ -0,0 +1,134 @@ +import { connect } from 'react-redux'; +import { useEffect, useState } from 'react'; + +import { RootState } from 'app/store'; +import { planSelectors } from 'app/store/slices/plan'; +import navigationService from 'services/navigation.service'; +import { AppView } from 'app/core/types'; +import { UserSubscription } from '@internxt/sdk/dist/drive/payments/types/types'; +import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; +import { Sidenav } from '@internxt/ui'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import { useAppDispatch, useAppSelector } from 'app/store/hooks'; +import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; +import { HUNDRED_TB } from 'app/core/constants'; +import { sharedThunks } from 'app/store/slices/sharedLinks'; +import logo from 'assets/icons/small-logo.svg'; +import { bytesToString } from 'app/drive/services/size.service'; +import WorkspaceSelectorContainer from 'views/Home/components/WorkspaceSelectorContainer'; +import WorkspaceSelectorSkeleton from 'views/Home/components/WorkspaceSelectorSkeleton'; +import { useSuiteLauncher } from 'hooks/useSuiteLauncher'; +import { useSidenavNavigation } from 'hooks/useSidenavNavigation'; +import { uiActions } from 'app/store/slices/ui'; + +interface SidenavWrapperProps { + user: UserSettings | undefined; + subscription: UserSubscription | null; + planUsage: number; + planLimit: number; + isLoadingPlanLimit: boolean; + isLoadingPlanUsage: boolean; +} + +const SidenavWrapper = ({ + user, + subscription, + planUsage, + planLimit, + isLoadingPlanLimit, + isLoadingPlanUsage, +}: SidenavWrapperProps) => { + const { translate } = useTranslationContext(); + const dispatch = useAppDispatch(); + const isLoadingCredentials = useAppSelector((state: RootState) => state.workspaces.isLoadingCredentials); + const isLoadingBusinessLimitAndUsage = useAppSelector( + (state: RootState) => state.plan.isLoadingBusinessLimitAndUsage, + ); + const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace); + const workspaceUuid = selectedWorkspace?.workspaceUser.workspaceId; + const { itemsNavigation } = useSidenavNavigation(); + const { suiteArray } = useSuiteLauncher(); + + const [isCollapsed, setIsCollapsed] = useState(() => { + const savedState = sessionStorage.getItem('sidenav-collapsed'); + return savedState === 'true'; + }); + + useEffect(() => { + dispatch(sharedThunks.getPendingInvitations()); + }, []); + + const onLogoClicked = () => { + navigationService.push(AppView.Drive, {}, workspaceUuid); + }; + + const isUpgradeAvailable = () => { + const isLifetimeAvailable = subscription?.type === 'lifetime' && planLimit < HUNDRED_TB; + + return subscription?.type === 'free' || isLifetimeAvailable; + }; + + const handleUpgradeClick = () => { + navigationService.openPreferencesDialog({ + section: 'account', + subsection: 'plans', + workspaceUuid: selectedWorkspace?.workspaceUser.workspaceId, + }); + dispatch(uiActions.setIsPreferencesDialogOpen(true)); + }; + + const handleToggleCollapse = () => { + setIsCollapsed((prev) => { + const newValue = !prev; + sessionStorage.setItem('sidenav-collapsed', String(newValue)); + return newValue; + }); + }; + + return ( +
+ : + } + suiteLauncher={{ + suiteArray: suiteArray, + soonText: translate('modals.upgradePlanDialog.soonBadge'), + }} + options={itemsNavigation} + isCollapsed={isCollapsed} + onToggleCollapse={handleToggleCollapse} + collapsedPrimaryAction={ + user && !isLoadingCredentials ? ( + + ) : ( + + ) + } + storage={{ + usage: bytesToString(planUsage), + limit: bytesToString(planLimit), + percentage: Math.min((planUsage / planLimit) * 100, 100), + onUpgradeClick: handleUpgradeClick, + upgradeLabel: isUpgradeAvailable() ? translate('preferences.account.plans.upgrade') : undefined, + isLoading: isLoadingPlanUsage && isLoadingPlanLimit && isLoadingBusinessLimitAndUsage, + }} + /> +
+ ); +}; + +export default connect((state: RootState) => ({ + user: state.user.user, + subscription: planSelectors.subscriptionToShow(state), + planUsage: planSelectors.planUsageToShow(state), + planLimit: planSelectors.planLimitToShow(state), + isLoadingPlanLimit: state.plan.isLoadingPlanLimit, + isLoadingPlanUsage: state.plan.isLoadingPlanUsage, +}))(SidenavWrapper); diff --git a/src/components/index.ts b/src/components/index.ts index 89f0cff19e..1f88e84495 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -17,8 +17,7 @@ export { default as PasswordStrengthIndicator } from './PasswordStrengthIndicato export { default as Popover } from './Popover'; export { default as PreparingWorkspaceAnimation } from './PreparingWorkspaceAnimation'; export { ScrollableTable } from './ScrollableTable'; -export { default as Sidenav } from './Sidenav'; -export { default as SidenavItem } from './SidenavItem'; +export { default as Sidenav } from './SidenavWrapper'; export { skinSkeleton } from './Skeleton'; export { default as TextInput } from './TextInput'; export { default as Tooltip } from './Tooltip'; diff --git a/src/hooks/useSidenavNavigation.tsx b/src/hooks/useSidenavNavigation.tsx new file mode 100644 index 0000000000..ab6c6849f8 --- /dev/null +++ b/src/hooks/useSidenavNavigation.tsx @@ -0,0 +1,101 @@ +import { useCallback, useMemo } from 'react'; +import { matchPath } from 'react-router-dom'; +import { Clock, ClockCounterClockwise, Desktop, FolderSimple, Trash, Users } from '@phosphor-icons/react'; +import { useSelector } from 'react-redux'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import { useAppSelector } from 'app/store/hooks'; +import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; +import { SidenavOption } from '@internxt/ui/dist/components/sidenav/SidenavOptions'; +import { AppView } from 'app/core/types'; +import { RootState } from 'app/store'; +import localStorageService from 'services/local-storage.service'; +import { STORAGE_KEYS } from 'services/storage-keys'; +import desktopService from 'services/desktop.service'; +import { Translate } from 'app/i18n/types'; +import { navigationService } from 'services'; + +const resetAccessTokenFileFolder = () => { + localStorageService.set(STORAGE_KEYS.FOLDER_ACCESS_TOKEN, ''); + localStorageService.set(STORAGE_KEYS.FILE_ACCESS_TOKEN, ''); +}; + +const isActiveButton = (path: string) => { + return !!matchPath(globalThis.location.pathname, { path, exact: true }); +}; + +const handleDownloadApp = (translate: Translate): void => { + resetAccessTokenFileFolder(); + desktopService.openDownloadAppUrl(translate); +}; + +export const useSidenavNavigation = () => { + const { translate } = useTranslationContext(); + const isB2BWorkspace = !!useSelector(workspacesSelectors.getSelectedWorkspace); + const pendingInvitations = useAppSelector((state: RootState) => state.shared.pendingInvitations); + const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace); + const workspaceUuid = selectedWorkspace?.workspaceUser.workspaceId; + + const onSidenavItemClick = useCallback( + (path: AppView, workspaceUuid?: string, cb?: () => void) => { + cb?.(); + navigationService.push(path, {}, workspaceUuid); + }, + [navigationService, workspaceUuid], + ); + + const itemsNavigation: SidenavOption[] = useMemo( + () => [ + { + isActive: isActiveButton('/') || isActiveButton('/file/:uuid') || isActiveButton('/folder/:uuid'), + label: translate('sideNav.drive'), + icon: FolderSimple, + iconDataCy: 'sideNavDriveIcon', + isVisible: true, + onClick: () => onSidenavItemClick(AppView.Drive, workspaceUuid, resetAccessTokenFileFolder), + }, + { + isActive: isActiveButton('/backups'), + label: translate('sideNav.backups'), + icon: ClockCounterClockwise, + iconDataCy: 'sideNavBackupsIcon', + isVisible: !isB2BWorkspace, + onClick: () => onSidenavItemClick(AppView.Backups, workspaceUuid, resetAccessTokenFileFolder), + }, + { + isActive: isActiveButton('/shared'), + label: translate('sideNav.shared'), + icon: Users, + notifications: pendingInvitations.length > 0 ? pendingInvitations.length : undefined, + iconDataCy: 'sideNavSharedIcon', + isVisible: true, + onClick: () => onSidenavItemClick(AppView.Shared, workspaceUuid, resetAccessTokenFileFolder), + }, + { + isActive: isActiveButton('/recents'), + label: translate('sideNav.recents'), + icon: Clock, + iconDataCy: 'sideNavRecentsIcon', + isVisible: !isB2BWorkspace, + onClick: () => onSidenavItemClick(AppView.Recents, workspaceUuid, resetAccessTokenFileFolder), + }, + { + isActive: isActiveButton('/trash'), + label: translate('sideNav.trash'), + icon: Trash, + iconDataCy: 'sideNavTrashIcon', + isVisible: true, + onClick: () => onSidenavItemClick(AppView.Trash, workspaceUuid, resetAccessTokenFileFolder), + }, + { + label: translate('sideNav.desktop'), + icon: Desktop, + iconDataCy: 'sideNavDesktopIcon', + onClick: () => handleDownloadApp(translate), + isVisible: !isB2BWorkspace, + }, + ], + [workspaceUuid, pendingInvitations.length, isB2BWorkspace, translate, onSidenavItemClick], + ); + + return { itemsNavigation }; +}; diff --git a/src/views/Home/components/SuitePopover.tsx b/src/hooks/useSuiteLauncher.tsx similarity index 80% rename from src/views/Home/components/SuitePopover.tsx rename to src/hooks/useSuiteLauncher.tsx index 3cf97aaf47..5c354c361a 100644 --- a/src/views/Home/components/SuitePopover.tsx +++ b/src/hooks/useSuiteLauncher.tsx @@ -1,26 +1,26 @@ -import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import { - FolderSimple, - VideoCamera, EnvelopeSimple, - PaperPlaneTilt, + FolderSimple, Gauge, + PaperPlaneTilt, Shield, Sparkle, + VideoCamera, } from '@phosphor-icons/react'; -import { SuiteLauncher, SuiteLauncherProps } from '@internxt/ui'; -import desktopService from 'services/desktop.service'; -import { useAppDispatch, useAppSelector } from 'app/store/hooks'; +import { SuiteLauncherProps } from '@internxt/ui'; import { Service } from '@internxt/sdk/dist/drive/payments/types/tiers'; +import { useAppSelector } from 'app/store/hooks'; import { uiActions } from 'app/store/slices/ui'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import desktopService from 'services/desktop.service'; +import { store } from 'app/store'; +import { envService } from 'services'; -interface SuitePopoverProps { - className?: string; -} +const MEET_URL = 'https://meet.internxt.com'; +const SEND_URL = 'https://send.internxt.com'; -export default function SuitePopover({ className = '' }: Readonly): JSX.Element { +export const useSuiteLauncher = () => { const { translate } = useTranslationContext(); - const dispatch = useAppDispatch(); const userFeatures = useAppSelector((state) => state.user.userTierFeatures); const openSuite = (suite: { @@ -32,13 +32,13 @@ export default function SuitePopover({ className = '' }: Readonly, title: 'Drive', onClick: () => { - window.open('https://drive.internxt.com', '_self', 'noopener'); + window.open(envService.getVariable('hostname'), '_self', 'noopener'); }, isMain: true, }, @@ -57,7 +57,7 @@ export default function SuitePopover({ className = '' }: Readonly openSuite({ enabled: userFeatures?.[Service.Meet].enabled ?? false, - onOpenSuite: () => window.open('https://meet.internxt.com', '_blank', 'noopener'), + onOpenSuite: () => window.open(MEET_URL, '_blank', 'noopener'), upgradeTitle: translate('modals.upgradePlanDialog.meet.title'), upgradeDescription: translate('modals.upgradePlanDialog.meet.description'), }), @@ -74,7 +74,7 @@ export default function SuitePopover({ className = '' }: Readonly, title: 'Send', onClick: () => { - window.open('https://send.internxt.com', '_blank', 'noopener'); + window.open(SEND_URL, '_blank', 'noopener'); }, }, { @@ -124,11 +124,7 @@ export default function SuitePopover({ className = '' }: Readonly - ); -} + return { + suiteArray, + }; +}; diff --git a/src/index.scss b/src/index.scss index ac63ca8fd0..22e562309d 100644 --- a/src/index.scss +++ b/src/index.scss @@ -13,6 +13,7 @@ height: 100%; -webkit-touch-callout: none; /* iOS Safari */ /* Safari */ /* Konqueror HTML */ /* Old versions of Firefox */ /* Internet Explorer/Edge */ user-select: none; /* Non-prefixed version, currently supported by Chrome, Edge, Opera and Firefox */ + overscroll-behavior: none; --footer-height: 1rem; } diff --git a/src/views/Backups/BackupsView.tsx b/src/views/Backups/BackupsView.tsx index 4677856c9d..6724efd49e 100644 --- a/src/views/Backups/BackupsView.tsx +++ b/src/views/Backups/BackupsView.tsx @@ -251,7 +251,7 @@ export default function BackupsView(): JSX.Element { contextMenu={contextMenuForFileViewer} /> )} -
+
{currentDevice ? ( { {hideSearch ? (
) : ( -
+