From db65b9df44521cb78e76c64328a308e071b65aac Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Tue, 2 Jan 2024 23:32:49 +0100 Subject: [PATCH] Feature/merge source and extensions screen on desktop (#535) * Merge "source" and "extension" screen for desktop * Move "tab" related components into new folder * Reuse "tab" components for "browse" screen * Prevent extension list from being taller than 100% of the parents height - remove margin - virtuoso has issues calculating the height otherwise - change group title styling, fontSize caused issues * Align "source" list group header with "extension" * Remove bottom padding from "ExtensionCard" * Move "GroupedVirtuoso" related components into folder * Use "GroupedVirtuoso" for "extensions" There was an issue with the height calculation of the virtuoso list, which apparently was caused by the conditional rendering of the group title and item as list items. Due to this, while scrolling down, the virtuoso list caused an overflow, which rendered the scrollbar, which in turn caused the list items to shrink in width. The end result of this was, that the list items "jumped" slightly on the initial render of the items, due to the scrollbar appearing and disappearing --- src/components/ExtensionCard.tsx | 9 +- src/components/SourceCard.tsx | 1 + src/components/molecules/OptionsTabs.tsx | 2 +- src/components/navbar/DefaultNavBar.tsx | 18 +--- src/components/{util => tabs}/TabPanel.tsx | 0 src/components/tabs/TabsMenu.tsx | 45 ++++++++++ src/components/tabs/TabsWrapper.tsx | 23 +++++ src/components/virtuoso/StyledGroupHeader.tsx | 26 ++++++ .../virtuoso/StyledGroupItemWrapper.tsx | 16 ++++ .../virtuoso/StyledGroupedVirtuoso.tsx | 23 +++++ src/screens/Browse.tsx | 22 ++--- src/screens/Extensions.tsx | 83 ++++++++++++------- src/screens/Library.tsx | 48 ++--------- src/screens/Sources.tsx | 15 +++- src/screens/Updates.tsx | 40 +-------- 15 files changed, 226 insertions(+), 145 deletions(-) rename src/components/{util => tabs}/TabPanel.tsx (100%) create mode 100644 src/components/tabs/TabsMenu.tsx create mode 100644 src/components/tabs/TabsWrapper.tsx create mode 100644 src/components/virtuoso/StyledGroupHeader.tsx create mode 100644 src/components/virtuoso/StyledGroupItemWrapper.tsx create mode 100644 src/components/virtuoso/StyledGroupedVirtuoso.tsx diff --git a/src/components/ExtensionCard.tsx b/src/components/ExtensionCard.tsx index 26f7c48e..3257dd42 100644 --- a/src/components/ExtensionCard.tsx +++ b/src/components/ExtensionCard.tsx @@ -148,13 +148,16 @@ export function ExtensionCard(props: IProps) { } return ( - + @@ -173,10 +176,10 @@ export function ExtensionCard(props: IProps) { {name} - + {langPress} {versionName} {isNsfw && ( - + {' 18+'} )} diff --git a/src/components/SourceCard.tsx b/src/components/SourceCard.tsx index a34bbd10..8c22291b 100644 --- a/src/components/SourceCard.tsx +++ b/src/components/SourceCard.tsx @@ -53,6 +53,7 @@ export const SourceCard: React.FC = (props: IProps) => { { diff --git a/src/components/navbar/DefaultNavBar.tsx b/src/components/navbar/DefaultNavBar.tsx index 570e49db..edd1e4b3 100644 --- a/src/components/navbar/DefaultNavBar.tsx +++ b/src/components/navbar/DefaultNavBar.tsx @@ -19,7 +19,6 @@ import NewReleasesIcon from '@mui/icons-material/NewReleases'; import NewReleasesOutlinedIcon from '@mui/icons-material/NewReleasesOutlined'; import ExploreIcon from '@mui/icons-material/Explore'; import ExploreOutlinedIcon from '@mui/icons-material/ExploreOutlined'; -import ExtensionIcon from '@mui/icons-material/Extension'; import GetAppIcon from '@mui/icons-material/GetApp'; import GetAppOutlinedIcon from '@mui/icons-material/GetAppOutlined'; import SettingsIcon from '@mui/icons-material/Settings'; @@ -28,7 +27,6 @@ import { useLocation } from 'react-router-dom'; import { createPortal } from 'react-dom'; import { NavbarItem } from '@/typings'; import { NavBarContext } from '@/components/context/NavbarContext'; -import { ExtensionOutlinedIcon } from '@/components/util/CustomExtensionOutlinedIcon'; import { DesktopSideBar } from '@/components/navbar/navigation/DesktopSideBar'; import { MobileBottomBar } from '@/components/navbar/navigation/MobileBottomBar'; import { useBackButton } from '@/util/useBackButton.ts'; @@ -48,26 +46,12 @@ const navbarItems: Array = [ IconComponent: NewReleasesOutlinedIcon, show: 'both', }, - { - path: '/extensions', - title: 'extension.title', - SelectedIconComponent: ExtensionIcon, - IconComponent: ExtensionOutlinedIcon, - show: 'desktop', - }, - { - path: '/sources', - title: 'source.title', - SelectedIconComponent: ExploreIcon, - IconComponent: ExploreOutlinedIcon, - show: 'desktop', - }, { path: '/browse', title: 'global.label.browse', SelectedIconComponent: ExploreIcon, IconComponent: ExploreOutlinedIcon, - show: 'mobile', + show: 'both', }, { path: '/downloads', diff --git a/src/components/util/TabPanel.tsx b/src/components/tabs/TabPanel.tsx similarity index 100% rename from src/components/util/TabPanel.tsx rename to src/components/tabs/TabPanel.tsx diff --git a/src/components/tabs/TabsMenu.tsx b/src/components/tabs/TabsMenu.tsx new file mode 100644 index 00000000..2391e276 --- /dev/null +++ b/src/components/tabs/TabsMenu.tsx @@ -0,0 +1,45 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { styled, Tabs, TabsProps } from '@mui/material'; + +const StyledTabsMenu = styled(Tabs)(({ theme }) => ({ + display: 'flex', + position: 'fixed', + top: '64px', + width: 'calc(100% - 64px)', + zIndex: 1, + backgroundColor: theme.palette.background.default, + border: 0, + borderBottomWidth: 2, + borderStyle: 'solid', + borderColor: theme.palette.divider, + [theme.breakpoints.down('sm')]: { + top: '56px', // header height + width: '100%', + }, +})); + +export const TabsMenu = ({ children, tabsCount, ...props }: TabsProps & { tabsCount: number }) => { + // Visual Hack: 160px is min-width for viewport width of >600 + const scrollableTabs = window.innerWidth < tabsCount * 160; + + return ( + + {children} + + ); +}; diff --git a/src/components/tabs/TabsWrapper.tsx b/src/components/tabs/TabsWrapper.tsx new file mode 100644 index 00000000..6b98a10e --- /dev/null +++ b/src/components/tabs/TabsWrapper.tsx @@ -0,0 +1,23 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { Box, styled } from '@mui/material'; + +export const TabsWrapper = styled(Box)(({ theme }) => ({ + // TabsMenu height + TabsMenu bottom padding - grid item top padding + marginTop: `calc(48px + 13px - 8px)`, + // header height - TabsMenu height - TabsMenu bottom padding + grid item top padding + minHeight: 'calc(100vh - 64px - 48px - 13px + 8px)', + position: 'relative', + [theme.breakpoints.down('sm')]: { + // TabsMenu - 8px margin diff header height (56px) + TabsMenu bottom padding - grid item top padding + marginTop: `calc(48px - 8px + 13px - 8px)`, + // header height (+ 8px margin) - footer height - TabsMenu height + minHeight: 'calc(100vh - 64px - 64px - 48px)', + }, +})); diff --git a/src/components/virtuoso/StyledGroupHeader.tsx b/src/components/virtuoso/StyledGroupHeader.tsx new file mode 100644 index 00000000..b68e547c --- /dev/null +++ b/src/components/virtuoso/StyledGroupHeader.tsx @@ -0,0 +1,26 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { styled } from '@mui/material'; +import Typography from '@mui/material/Typography'; + +export const StyledGroupHeader = styled(Typography, { shouldForwardProp: (prop) => prop !== 'isFirstItem' })<{ + isFirstItem: boolean; +}>(({ theme, isFirstItem }) => ({ + paddingLeft: '24px', + // 16px - 10px (bottom padding of the group items) + paddingTop: '6px', + paddingBottom: '16px', + fontWeight: 700, + textTransform: 'uppercase', + backgroundColor: theme.palette.background.default, + [theme.breakpoints.down('sm')]: { + // 16px - 8px (margin of header) + paddingTop: isFirstItem ? '8px' : '6px', + }, +})); diff --git a/src/components/virtuoso/StyledGroupItemWrapper.tsx b/src/components/virtuoso/StyledGroupItemWrapper.tsx new file mode 100644 index 00000000..b144d1a9 --- /dev/null +++ b/src/components/virtuoso/StyledGroupItemWrapper.tsx @@ -0,0 +1,16 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { Box, styled } from '@mui/material'; + +export const StyledGroupItemWrapper = styled(Box, { shouldForwardProp: (prop) => prop !== 'isLastItem' })<{ + isLastItem: boolean; +}>(({ isLastItem }) => ({ + padding: '0 10px', + paddingBottom: isLastItem ? '0' : '10px', +})); diff --git a/src/components/virtuoso/StyledGroupedVirtuoso.tsx b/src/components/virtuoso/StyledGroupedVirtuoso.tsx new file mode 100644 index 00000000..6f1aada3 --- /dev/null +++ b/src/components/virtuoso/StyledGroupedVirtuoso.tsx @@ -0,0 +1,23 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { styled } from '@mui/material'; +import { GroupedVirtuoso } from 'react-virtuoso'; + +export const StyledGroupedVirtuoso = styled(GroupedVirtuoso, { + shouldForwardProp: (prop) => prop !== 'heightToSubtract', +})<{ + heightToSubtract?: number; +}>(({ theme, heightToSubtract = 0 }) => ({ + // 64px header + height: `calc(100vh - 64px - ${heightToSubtract}px)`, + [theme.breakpoints.down('sm')]: { + // 64px header (margin); 64px menu (margin); + height: `calc(100vh - 64px - 64px - ${heightToSubtract}px)`, + }, +})); diff --git a/src/screens/Browse.tsx b/src/screens/Browse.tsx index 78937e40..f1bf5cb5 100644 --- a/src/screens/Browse.tsx +++ b/src/screens/Browse.tsx @@ -7,12 +7,13 @@ */ import { useState } from 'react'; -import Tabs from '@mui/material/Tabs'; import Tab from '@mui/material/Tab'; import { useTranslation } from 'react-i18next'; import { Sources } from '@/screens/Sources'; import { Extensions } from '@/screens/Extensions'; -import { TabPanel } from '@/components/util/TabPanel'; +import { TabPanel } from '@/components/tabs/TabPanel.tsx'; +import { TabsWrapper } from '@/components/tabs/TabsWrapper.tsx'; +import { TabsMenu } from '@/components/tabs/TabsMenu.tsx'; export function Browse() { const { t } = useTranslation(); @@ -20,26 +21,17 @@ export function Browse() { const [tabNum, setTabNum] = useState(0); return ( - <> - setTabNum(newTab)} - indicatorColor="primary" - textColor="primary" - centered - variant="fullWidth" - scrollButtons - allowScrollButtonsMobile - > + + setTabNum(newTab)}> - + - + ); } diff --git a/src/screens/Extensions.tsx b/src/screens/Extensions.tsx index 598748b3..7fa84355 100644 --- a/src/screens/Extensions.tsx +++ b/src/screens/Extensions.tsx @@ -11,9 +11,9 @@ import { fromEvent } from 'file-selector'; import IconButton from '@mui/material/IconButton'; import AddIcon from '@mui/icons-material/Add'; import { StringParam, useQueryParam } from 'use-query-params'; -import { Virtuoso } from 'react-virtuoso'; -import { Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'; +import { Tooltip, useMediaQuery } from '@mui/material'; import { useTranslation } from 'react-i18next'; +import { useTheme } from '@mui/material/styles'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { extensionDefaultLangs, DefaultLanguage, langSortCmp } from '@/util/language'; import { useLocalStorage } from '@/util/useLocalStorage'; @@ -31,6 +31,9 @@ import { LangSelect } from '@/components/navbar/action/LangSelect'; import { ExtensionCard } from '@/components/ExtensionCard'; import { PartialExtension } from '@/typings.ts'; import { NavBarContext } from '@/components/context/NavbarContext.tsx'; +import { StyledGroupedVirtuoso } from '@/components/virtuoso/StyledGroupedVirtuoso.tsx'; +import { StyledGroupHeader } from '@/components/virtuoso/StyledGroupHeader.tsx'; +import { StyledGroupItemWrapper } from '@/components/virtuoso/StyledGroupItemWrapper.tsx'; const LANGUAGE = 0; const EXTENSIONS = 1; @@ -92,12 +95,13 @@ function getExtensionsInfo(extensions: PartialExtension[]): { export function Extensions() { const { t } = useTranslation(); + const theme = useTheme(); + const isMobileWidth = useMediaQuery(theme.breakpoints.down('sm')); + const inputRef = useRef(null); const { setTitle, setAction } = useContext(NavBarContext); const [shownLangs, setShownLangs] = useLocalStorage('shownExtensionLangs', extensionDefaultLangs()); const [showNsfw] = useLocalStorage('showNsfw', true); - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const [query] = useQueryParam('query', StringParam); const [refetchExtensions, setRefetchExtensions] = useState({}); @@ -130,7 +134,14 @@ export function Extensions() { [shownLangs, groupedExtensions], ); - const flatRenderItems: (PartialExtension | string)[] = filteredGroupedExtensions.flat(2); + const groupCounts = useMemo( + () => filteredGroupedExtensions.map((extensionGroup) => extensionGroup[EXTENSIONS].length - 1), + [filteredGroupedExtensions], + ); + const visibleExtensions = useMemo( + () => filteredGroupedExtensions.map(([, extensions]) => extensions).flat(1), + [filteredGroupedExtensions], + ); const [toasts, makeToast] = makeToaster(useState([])); @@ -206,33 +217,47 @@ export function Extensions() { } }} /> - { - if (typeof flatRenderItems[index] === 'string') { - const item = flatRenderItems[index] as string; - return ( - - {translateExtensionLanguage(item)} - - ); - } - const item = flatRenderItems[index] as PartialExtension; + heightToSubtract={ + isMobileWidth + ? // desktop: TabsMenu height + 48 + : // desktop: TabsMenu height - TabsMenu bottom padding + grid item top padding + 48 + 13 - 8 + } + overscan={window.innerHeight * 0.5} + groupCounts={groupCounts} + groupContent={(index) => { + const [groupName] = filteredGroupedExtensions[index]; - return ; + return ( + + {translateExtensionLanguage(groupName)} + + ); + }} + itemContent={(index) => { + const item = visibleExtensions[index]; + + return ( + + + + ); }} /> diff --git a/src/screens/Library.tsx b/src/screens/Library.tsx index 82ddba2f..1283d190 100644 --- a/src/screens/Library.tsx +++ b/src/screens/Library.tsx @@ -6,14 +6,14 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { Chip, Tab, Tabs, styled, Box } from '@mui/material'; +import { Chip, Tab, styled } from '@mui/material'; import React, { useContext, useEffect, useMemo, useState } from 'react'; import { useQueryParam, NumberParam } from 'use-query-params'; import { useTranslation } from 'react-i18next'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { EmptyView } from '@/components/util/EmptyView'; import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder'; -import { TabPanel } from '@/components/util/TabPanel'; +import { TabPanel } from '@/components/tabs/TabPanel.tsx'; import { LibraryToolbarMenu } from '@/components/library/LibraryToolbarMenu'; import { LibraryMangaGrid } from '@/components/library/LibraryMangaGrid'; import { AppbarSearch } from '@/components/util/AppbarSearch'; @@ -27,33 +27,8 @@ import { useGetVisibleLibraryMangas } from '@/components/library/useGetVisibleLi import { SelectionFAB } from '@/components/collection/SelectionFAB.tsx'; import { PARTIAL_MANGA_FIELDS } from '@/lib/graphql/Fragments.ts'; import { MangaActionMenuItems } from '@/components/manga/MangaActionMenuItems.tsx'; - -const StyledGridWrapper = styled(Box)(({ theme }) => ({ - // TabsMenu height + TabsMenu bottom padding - grid item top padding - marginTop: `calc(48px + 13px - 8px)`, - // header height - TabsMenu height - TabsMenu bottom padding + grid item top padding - minHeight: 'calc(100vh - 64px - 48px - 13px + 8px)', - position: 'relative', - [theme.breakpoints.down('sm')]: { - // TabsMenu - 8px margin diff header height (56px) + TabsMenu bottom padding - grid item top padding - marginTop: `calc(48px - 8px + 13px - 8px)`, - // header height (+ 8px margin) - footer height - TabsMenu height - minHeight: 'calc(100vh - 64px - 64px - 48px)', - }, -})); - -const TabsMenu = styled(Tabs)(({ theme }) => ({ - display: 'flex', - position: 'fixed', - top: '64px', - width: 'calc(100% - 64px)', - zIndex: 1, - backgroundColor: theme.palette.background.default, - [theme.breakpoints.down('sm')]: { - top: '56px', // header height - width: '100%', - }, -})); +import { TabsMenu } from '@/components/tabs/TabsMenu.tsx'; +import { TabsWrapper } from '@/components/tabs/TabsWrapper.tsx'; const TitleWithSizeTag = styled('span')({ display: 'flex', @@ -209,22 +184,13 @@ export function Library() { ); } - // Visual Hack: 160px is min-width for viewport width of >600 - const scrollableTabs = window.innerWidth < tabs.length * 160; - return ( <> - + handleTabChange(newTab)} - indicatorColor="primary" - textColor="primary" - centered={!scrollableTabs} - variant={scrollableTabs ? 'scrollable' : 'fullWidth'} - scrollButtons - allowScrollButtonsMobile + tabsCount={tabs.length} > {tabs.map((tab) => ( ))} - + {isSelectModeActive && ( {(handleClose, setHideMenu) => ( diff --git a/src/screens/Sources.tsx b/src/screens/Sources.tsx index 04b92ec8..682dba9f 100644 --- a/src/screens/Sources.tsx +++ b/src/screens/Sources.tsx @@ -7,7 +7,7 @@ */ import { Fragment, useContext, useEffect } from 'react'; -import { IconButton, Tooltip } from '@mui/material'; +import { IconButton, Tooltip, Typography } from '@mui/material'; import TravelExploreIcon from '@mui/icons-material/TravelExplore'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -107,9 +107,18 @@ export function Sources() { ([lang, list]) => shownLangs.indexOf(lang) !== -1 && ( -

+ {translateExtensionLanguage(lang)} -

+
{(list as ISource[]) .filter((source) => showNsfw || !source.isNsfw) .map((source) => ( diff --git a/src/screens/Updates.tsx b/src/screens/Updates.tsx index 3fbe58aa..2840ea0f 100644 --- a/src/screens/Updates.tsx +++ b/src/screens/Updates.tsx @@ -7,7 +7,7 @@ */ import DownloadIcon from '@mui/icons-material/Download'; -import { Box, CardActionArea, styled, Tooltip } from '@mui/material'; +import { Box, CardActionArea, Tooltip } from '@mui/material'; import Avatar from '@mui/material/Avatar'; import Card from '@mui/material/Card'; import CardContent from '@mui/material/CardContent'; @@ -17,7 +17,6 @@ import React, { useCallback, useContext, useEffect, useLayoutEffect, useMemo, us import { Link, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { t as translate } from 'i18next'; -import { GroupedVirtuoso } from 'react-virtuoso'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder'; import { EmptyView } from '@/components/util/EmptyView'; @@ -26,40 +25,9 @@ import { DownloadType } from '@/lib/graphql/generated/graphql.ts'; import { TChapter } from '@/typings.ts'; import { NavBarContext } from '@/components/context/NavbarContext.tsx'; import { UpdateChecker } from '@/components/library/UpdateChecker.tsx'; - -const StyledGroupedVirtuoso = styled(GroupedVirtuoso, { shouldForwardProp: (prop) => prop !== 'heightToSubtract' })<{ - heightToSubtract: number; -}>(({ theme, heightToSubtract }) => ({ - // 64px header - height: `calc(100vh - 64px - ${heightToSubtract}px)`, - [theme.breakpoints.down('sm')]: { - // 64px header (margin); 64px menu (margin); - height: `calc(100vh - 64px - 64px - ${heightToSubtract}px)`, - }, -})); - -const StyledGroupHeader = styled(Typography, { shouldForwardProp: (prop) => prop !== 'isFirstItem' })<{ - isFirstItem: boolean; -}>(({ theme, isFirstItem }) => ({ - paddingLeft: '24px', - // 16px - 10px (bottom padding of the group items) - paddingTop: '6px', - paddingBottom: '16px', - fontWeight: 700, - textTransform: 'uppercase', - backgroundColor: theme.palette.background.default, - [theme.breakpoints.down('sm')]: { - // 16px - 8px (margin of header) - paddingTop: isFirstItem ? '8px' : '6px', - }, -})); - -const StyledGroupItemWrapper = styled(Box, { shouldForwardProp: (prop) => prop !== 'isLastItem' })<{ - isLastItem: boolean; -}>(({ isLastItem }) => ({ - padding: '0 10px', - paddingBottom: isLastItem ? '0' : '10px', -})); +import { StyledGroupedVirtuoso } from '@/components/virtuoso/StyledGroupedVirtuoso.tsx'; +import { StyledGroupHeader } from '@/components/virtuoso/StyledGroupHeader.tsx'; +import { StyledGroupItemWrapper } from '@/components/virtuoso/StyledGroupItemWrapper.tsx'; function epochToDate(epoch: number) { const date = new Date(0); // The 0 there is the key, which sets the date to the epoch