From e49fe6c452467abd3e506765e603df8acc7f6e1d Mon Sep 17 00:00:00 2001 From: jeffvli Date: Wed, 10 May 2023 18:20:04 -0700 Subject: [PATCH] Add collapsible sidebar (#68) - Sidebar can collapse by menu option or dragging --- .../player/components/left-controls.tsx | 40 +++--- .../sidebar/components/action-bar.tsx | 4 +- .../components/collapsed-sidebar-item.tsx | 100 ++++++++++++++ .../sidebar/components/collapsed-sidebar.tsx | 123 ++++++++++++++++++ .../features/sidebar/components/sidebar.tsx | 11 +- .../features/titlebar/components/app-menu.tsx | 62 ++++++++- .../layouts/default-layout/main-content.tsx | 43 ++++-- src/renderer/router/routes.ts | 1 + src/renderer/store/app.store.ts | 12 +- src/renderer/themes/default.scss | 1 + src/renderer/themes/light.scss | 1 + 11 files changed, 348 insertions(+), 50 deletions(-) create mode 100644 src/renderer/features/sidebar/components/collapsed-sidebar-item.tsx create mode 100644 src/renderer/features/sidebar/components/collapsed-sidebar.tsx diff --git a/src/renderer/features/player/components/left-controls.tsx b/src/renderer/features/player/components/left-controls.tsx index e54f8db0..e1b7f422 100644 --- a/src/renderer/features/player/components/left-controls.tsx +++ b/src/renderer/features/player/components/left-controls.tsx @@ -8,10 +8,10 @@ import { Button, Text } from '/@/renderer/components'; import { AppRoute } from '/@/renderer/router/routes'; import { useAppStoreActions, - useAppStore, useCurrentSong, useSetFullScreenPlayerStore, useFullScreenPlayerStore, + useSidebarStore, } from '/@/renderer/store'; import { fadeIn } from '/@/renderer/styles'; import { LibraryItem } from '/@/renderer/api/types'; @@ -45,6 +45,7 @@ const Image = styled(motion.div)` width: 60px; height: 60px; background-color: var(--placeholder-bg); + cursor: pointer; filter: drop-shadow(0 5px 6px rgb(0, 0, 0, 50%)); ${fadeIn}; @@ -84,7 +85,9 @@ export const LeftControls = () => { const { setSideBar } = useAppStoreActions(); const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore(); const setFullScreenPlayerStore = useSetFullScreenPlayerStore(); - const hideImage = useAppStore((state) => state.sidebar.image); + // const hideImage = useAppStore((state) => state.sidebar.image); + const { image, collapsed } = useSidebarStore(); + const hideImage = image && !collapsed; const currentSong = useCurrentSong(); const title = currentSong?.name; const artists = currentSong?.artists; @@ -144,22 +147,23 @@ export const LeftControls = () => { )} - - + {!collapsed && ( + + )} )} diff --git a/src/renderer/features/sidebar/components/action-bar.tsx b/src/renderer/features/sidebar/components/action-bar.tsx index 097ef4e2..2cd7e03c 100644 --- a/src/renderer/features/sidebar/components/action-bar.tsx +++ b/src/renderer/features/sidebar/components/action-bar.tsx @@ -27,7 +27,7 @@ export const ActionBar = () => { ref={cq.ref} gutter="sm" > - + { size="md" /> - + ` + position: relative; + width: 100%; + padding: 0.9rem 0.3rem; + border-right: var(--sidebar-border); + cursor: ${(props) => (props.$disabled ? 'default' : 'pointer')}; + opacity: ${(props) => props.$disabled && 0.6}; + + svg { + fill: ${(props) => (props.$active ? 'var(--primary-color)' : 'var(--sidebar-fg)')}; + } + + &:focus-visible { + background-color: var(--sidebar-bg-hover); + outline: none; + } + + ${(props) => + !props.$disabled && + ` + &:hover { + background-color: var(--sidebar-bg-hover); + + div { + color: var(--main-fg) !important; + } + + svg { + fill: var(--primary-color); + } + } + `} +`; + +const TextWrapper = styled.div` + width: 100%; + overflow: hidden; + white-space: nowrap; + text-align: center; + text-overflow: ellipsis; +`; + +const ActiveTabIndicator = styled(motion.div)` + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 3px; + width: 2px; + height: 80%; + margin-top: auto; + margin-bottom: auto; + background: var(--primary-color); +`; + +interface CollapsedSidebarItemProps { + active?: boolean; + activeIcon: React.ReactNode; + disabled?: boolean; + icon: React.ReactNode; + label: string; +} + +const _CollapsedSidebarItem = forwardRef( + ({ active, activeIcon, icon, label, disabled, ...props }: CollapsedSidebarItemProps, ref) => { + return ( + + {active && } + {active ? activeIcon : icon} + + + {label} + + + + ); + }, +); + +export const CollapsedSidebarItem = createPolymorphicComponent<'button', CollapsedSidebarItemProps>( + _CollapsedSidebarItem, +); diff --git a/src/renderer/features/sidebar/components/collapsed-sidebar.tsx b/src/renderer/features/sidebar/components/collapsed-sidebar.tsx new file mode 100644 index 00000000..2f7d6462 --- /dev/null +++ b/src/renderer/features/sidebar/components/collapsed-sidebar.tsx @@ -0,0 +1,123 @@ +import { UnstyledButton } from '@mantine/core'; +import { motion } from 'framer-motion'; +import { + RiUserVoiceLine, + RiMenuFill, + RiFlag2Line, + RiFolder3Line, + RiPlayListLine, + RiAlbumLine, + RiHome6Line, + RiMusic2Line, + RiHome6Fill, + RiAlbumFill, + RiMusic2Fill, + RiUserVoiceFill, + RiFlag2Fill, + RiFolder3Fill, + RiPlayListFill, +} from 'react-icons/ri'; +import { useLocation } from 'react-router'; +import { Link } from 'react-router-dom'; +import styled from 'styled-components'; +import { DropdownMenu, ScrollArea } from '/@/renderer/components'; +import { CollapsedSidebarItem } from '/@/renderer/features/sidebar/components/collapsed-sidebar-item'; +import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu'; +import { AppRoute } from '/@/renderer/router/routes'; +import { useWindowSettings } from '/@/renderer/store'; +import { Platform } from '/@/renderer/types'; + +const SidebarContainer = styled(motion.div)<{ windowBarStyle: Platform }>` + display: flex; + flex-direction: column; + height: 100%; + max-height: ${(props) => + props.windowBarStyle === Platform.WEB + ? 'calc(100vh - 149px)' + : 'calc(100vh - 119px)'}; // Playerbar (90px), titlebar (65px), windowbar (30px) + user-select: none; +`; + +export const CollapsedSidebar = () => { + const location = useLocation(); + const { windowBarStyle } = useWindowSettings(); + + return ( + + + + + } + component={UnstyledButton} + icon={} + label="Menu" + /> + + + + + + } + component={Link} + icon={} + label="Home" + to={AppRoute.HOME} + /> + } + component={Link} + icon={} + label="Albums" + to={AppRoute.LIBRARY_ALBUMS} + /> + } + component={Link} + icon={} + label="Tracks" + to={AppRoute.LIBRARY_SONGS} + /> + } + component={Link} + icon={} + label="Artists" + to={AppRoute.LIBRARY_ALBUM_ARTISTS} + /> + } + component={Link} + icon={} + label="Genres" + to={AppRoute.LIBRARY_GENRES} + /> + } + component={Link} + icon={} + label="Folders" + to={AppRoute.LIBRARY_FOLDERS} + /> + } + component={Link} + icon={} + label="Playlists" + to={AppRoute.PLAYLISTS} + /> + + + ); +}; diff --git a/src/renderer/features/sidebar/components/sidebar.tsx b/src/renderer/features/sidebar/components/sidebar.tsx index db6cf1a6..d3d1632a 100644 --- a/src/renderer/features/sidebar/components/sidebar.tsx +++ b/src/renderer/features/sidebar/components/sidebar.tsx @@ -13,8 +13,8 @@ import { RiDiscLine, RiFlag2Line, RiFolder3Line, - RiHome5Fill, - RiHome5Line, + RiHome6Fill, + RiHome6Line, RiListUnordered, RiMusic2Fill, RiMusic2Line, @@ -54,6 +54,7 @@ const SidebarContainer = styled.div<{ windowBarStyle: Platform }>` const ImageContainer = styled(motion.div)<{ height: string }>` position: relative; height: ${(props) => props.height}; + cursor: pointer; ${fadeIn}; animation: fadein 0.2s ease-in-out; @@ -141,9 +142,9 @@ export const Sidebar = () => { > {location.pathname === AppRoute.HOME ? ( - + ) : ( - + )} Home @@ -305,7 +306,7 @@ export const Sidebar = () => { opacity={0.8} radius={100} size="md" - sx={{ position: 'absolute', right: 5, top: 5 }} + sx={{ cursor: 'default', position: 'absolute', right: 5, top: 5 }} tooltip={{ label: 'Collapse', openDelay: 500 }} variant="default" onClick={(e) => { diff --git a/src/renderer/features/titlebar/components/app-menu.tsx b/src/renderer/features/titlebar/components/app-menu.tsx index b908c4f5..30caa58d 100644 --- a/src/renderer/features/titlebar/components/app-menu.tsx +++ b/src/renderer/features/titlebar/components/app-menu.tsx @@ -3,10 +3,14 @@ import { openModal, closeAllModals } from '@mantine/modals'; import isElectron from 'is-electron'; import { RiLockLine, - RiServerFill, - RiEdit2Fill, - RiSettings3Fill, RiWindowFill, + RiArrowLeftSLine, + RiArrowRightSLine, + RiLayoutRightLine, + RiLayoutLeftLine, + RiEdit2Line, + RiSettings3Line, + RiServerLine, } from 'react-icons/ri'; import { useNavigate } from 'react-router'; import { Link } from 'react-router-dom'; @@ -14,7 +18,13 @@ import { DropdownMenu } from '/@/renderer/components'; import { ServerList } from '/@/renderer/features/servers'; import { EditServerForm } from '/@/renderer/features/servers/components/edit-server-form'; import { AppRoute } from '/@/renderer/router/routes'; -import { useCurrentServer, useServerList, useAuthStoreActions } from '/@/renderer/store'; +import { + useCurrentServer, + useServerList, + useAuthStoreActions, + useSidebarStore, + useAppStoreActions, +} from '/@/renderer/store'; import { ServerListItem, ServerType } from '/@/renderer/types'; const browser = isElectron() ? window.electron.browser : null; @@ -24,6 +34,8 @@ export const AppMenu = () => { const currentServer = useCurrentServer(); const serverList = useServerList(); const { setCurrentServer } = useAuthStoreActions(); + const { collapsed } = useSidebarStore(); + const { setSideBar } = useAppStoreActions(); const handleSetCurrentServer = (server: ServerListItem) => { navigate(AppRoute.HOME); @@ -55,6 +67,14 @@ export const AppMenu = () => { browser?.devtools(); }; + const handleCollapseSidebar = () => { + setSideBar({ collapsed: true }); + }; + + const handleExpandSidebar = () => { + setSideBar({ collapsed: false }); + }; + const showBrowserDevToolsButton = isElectron(); return ( @@ -70,7 +90,7 @@ export const AppMenu = () => { : } + icon={isSessionExpired ? : } onClick={() => { if (!isSessionExpired) return handleSetCurrentServer(server); return handleCredentialsModal(server); @@ -82,18 +102,46 @@ export const AppMenu = () => { })} } + icon={} onClick={handleManageServersModal} > Manage servers } + icon={} to={AppRoute.SETTINGS} > Settings + + } + onClick={() => navigate(-1)} + > + Go back + + } + onClick={() => navigate(1)} + > + Go forward + + {collapsed ? ( + } + onClick={handleExpandSidebar} + > + Expand sidebar + + ) : ( + } + onClick={handleCollapseSidebar} + > + Collapse sidebar + + )} {showBrowserDevToolsButton && ( <> diff --git a/src/renderer/layouts/default-layout/main-content.tsx b/src/renderer/layouts/default-layout/main-content.tsx index 073b8723..8952bf89 100644 --- a/src/renderer/layouts/default-layout/main-content.tsx +++ b/src/renderer/layouts/default-layout/main-content.tsx @@ -8,25 +8,31 @@ import styled from 'styled-components'; import { DrawerPlayQueue, SidebarPlayQueue } from '/@/renderer/features/now-playing'; import { FullScreenPlayer } from '/@/renderer/features/player/components/full-screen-player'; import { Sidebar } from '/@/renderer/features/sidebar/components/sidebar'; +import { CollapsedSidebar } from '../../features/sidebar/components/collapsed-sidebar'; import { AppRoute } from '/@/renderer/router/routes'; import { useAppStore, useAppStoreActions, useFullScreenPlayerStore } from '/@/renderer/store'; import { useWindowSettings, useGeneralSettings } from '/@/renderer/store/settings.store'; import { Platform } from '/@/renderer/types'; import { constrainSidebarWidth, constrainRightSidebarWidth } from '/@/renderer/utils'; +const MINIMUM_SIDEBAR_WIDTH = 260; + const MainContentContainer = styled.div<{ leftSidebarWidth: string; rightExpanded?: boolean; rightSidebarWidth?: string; shell?: boolean; + sidebarCollapsed?: boolean; }>` position: relative; display: ${(props) => (props.shell ? 'flex' : 'grid')}; grid-area: main-content; grid-template-areas: 'sidebar . right-sidebar'; grid-template-rows: 1fr; - grid-template-columns: ${(props) => props.leftSidebarWidth} 1fr ${(props) => - props.rightExpanded && props.rightSidebarWidth}; + grid-template-columns: ${(props) => (props.sidebarCollapsed ? '80px' : props.leftSidebarWidth)} 1fr ${( + props, + ) => props.rightExpanded && props.rightSidebarWidth}; + gap: 0; background: var(--main-bg); `; @@ -56,14 +62,25 @@ const ResizeHandle = styled.div<{ bottom: ${(props) => props.placement === 'bottom' && 0}; left: ${(props) => props.placement === 'left' && 0}; z-index: 90; - width: 2px; + width: 4px; height: 100%; - background-color: var(--sidebar-handle-bg); cursor: ew-resize; opacity: ${(props) => (props.isResizing ? 1 : 0)}; &:hover { - opacity: 0.5; + opacity: 0.7; + } + + &::before { + position: absolute; + top: ${(props) => props.placement === 'top' && 0}; + right: ${(props) => props.placement === 'right' && 0}; + bottom: ${(props) => props.placement === 'bottom' && 0}; + left: ${(props) => props.placement === 'left' && 0}; + width: 1px; + height: 100%; + background-color: var(--sidebar-handle-bg); + content: ''; } `; @@ -193,10 +210,15 @@ export const MainContent = ({ shell }: { shell?: boolean }) => { const resize = useCallback( (mouseMoveEvent: any) => { if (isResizing) { - const width = `${constrainSidebarWidth(mouseMoveEvent.clientX)}px`; - setSideBar({ leftWidth: width }); - } - if (isResizingRight) { + const width = mouseMoveEvent.clientX; + const constrainedWidth = `${constrainSidebarWidth(width)}px`; + + if (width < MINIMUM_SIDEBAR_WIDTH - 100) { + setSideBar({ collapsed: true }); + } else { + setSideBar({ collapsed: false, leftWidth: constrainedWidth }); + } + } else if (isResizingRight) { const start = Number(sidebar.rightWidth.split('px')[0]); const { left } = rightSidebarRef!.current!.getBoundingClientRect(); const width = `${constrainRightSidebarWidth(start + left - mouseMoveEvent.clientX)}px`; @@ -224,6 +246,7 @@ export const MainContent = ({ shell }: { shell?: boolean }) => { rightExpanded={showSideQueue && sideQueueType === 'sideQueue'} rightSidebarWidth={sidebar.rightWidth} shell={shell} + sidebarCollapsed={sidebar.collapsed} > {!shell && ( <> @@ -243,7 +266,7 @@ export const MainContent = ({ shell }: { shell?: boolean }) => { startResizing('left'); }} /> - + {sidebar.collapsed ? : } ()( isReorderingQueue: false, platform: Platform.WINDOWS, sidebar: { + collapsed: false, expanded: [], image: false, leftWidth: '400px', @@ -78,7 +74,7 @@ export const useAppStore = create()( return merge(currentState, persistedState); }, name: 'store_app', - version: 1, + version: 2, }, ), ); diff --git a/src/renderer/themes/default.scss b/src/renderer/themes/default.scss index 18987894..4c479b6d 100644 --- a/src/renderer/themes/default.scss +++ b/src/renderer/themes/default.scss @@ -21,6 +21,7 @@ --titlebar-controls-bg: rgba(0, 0, 0, 0); --sidebar-bg: rgb(0, 0, 0); + --sidebar-bg-hover: rgb(50, 50, 50); --sidebar-fg: rgb(210, 210, 210); --sidebar-fg-hover: rgb(255, 255, 255); --sidebar-handle-bg: #4d4d4d; diff --git a/src/renderer/themes/light.scss b/src/renderer/themes/light.scss index f4f94d20..79009359 100644 --- a/src/renderer/themes/light.scss +++ b/src/renderer/themes/light.scss @@ -10,6 +10,7 @@ body[data-theme='defaultLight'] { --titlebar-controls-bg: rgba(0, 0, 0, 0); --sidebar-bg: rgb(240, 241, 242); + --sidebar-bg-hover: rgb(200, 200, 200); --sidebar-fg: rgb(0, 0, 0); --sidebar-fg-hover: rgb(85, 85, 85); --sidebar-handle-bg: rgb(220, 220, 220);