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
This commit is contained in:
schroda
2024-01-02 23:32:49 +01:00
committed by GitHub
parent 9caca6e753
commit db65b9df44
15 changed files with 226 additions and 145 deletions

View File

@@ -148,13 +148,16 @@ export function ExtensionCard(props: IProps) {
}
return (
<Card sx={{ margin: '10px' }}>
<Card>
<CardContent
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: 2,
'&:last-child': {
paddingBottom: 2,
},
}}
>
<Box sx={{ display: 'flex' }}>
@@ -173,10 +176,10 @@ export function ExtensionCard(props: IProps) {
<Typography variant="h5" component="h2">
{name}
</Typography>
<Typography variant="caption" display="block" gutterBottom>
<Typography variant="caption" display="block">
{langPress} {versionName}
{isNsfw && (
<Typography variant="caption" display="inline" gutterBottom color="red">
<Typography variant="caption" display="inline" color="red">
{' 18+'}
</Typography>
)}

View File

@@ -53,6 +53,7 @@ export const SourceCard: React.FC<IProps> = (props: IProps) => {
<Card
sx={{
margin: '10px',
marginTop: 0,
}}
>
<CardActionArea

View File

@@ -8,7 +8,7 @@
import { Stack, Tab, Tabs } from '@mui/material';
import React, { useState } from 'react';
import { TabPanel } from '@/components/util/TabPanel';
import { TabPanel } from '@/components/tabs/TabPanel.tsx';
import { OptionsPanel } from '@/components/molecules/OptionsPanel';
interface IProps<T = string> {

View File

@@ -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<NavbarItem> = [
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',

View File

@@ -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 (
<StyledTabsMenu
{...props}
indicatorColor="primary"
textColor="primary"
centered={!scrollableTabs}
variant={scrollableTabs ? 'scrollable' : 'fullWidth'}
scrollButtons
allowScrollButtonsMobile
>
{children}
</StyledTabsMenu>
);
};

View File

@@ -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)',
},
}));

View File

@@ -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',
},
}));

View File

@@ -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',
}));

View File

@@ -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)`,
},
}));

View File

@@ -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<number>(0);
return (
<>
<Tabs
value={tabNum}
onChange={(e, newTab) => setTabNum(newTab)}
indicatorColor="primary"
textColor="primary"
centered
variant="fullWidth"
scrollButtons
allowScrollButtonsMobile
>
<TabsWrapper>
<TabsMenu value={tabNum} tabsCount={2} onChange={(e, newTab) => setTabNum(newTab)}>
<Tab sx={{ textTransform: 'none' }} label={t('source.title')} />
<Tab sx={{ textTransform: 'none' }} label={t('extension.title')} />
</Tabs>
</TabsMenu>
<TabPanel index={0} currentIndex={tabNum}>
<Sources />
</TabPanel>
<TabPanel index={1} currentIndex={tabNum}>
<Extensions />
</TabPanel>
</>
</TabsWrapper>
);
}

View File

@@ -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<HTMLInputElement>(null);
const { setTitle, setAction } = useContext(NavBarContext);
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownExtensionLangs', extensionDefaultLangs());
const [showNsfw] = useLocalStorage<boolean>('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<React.ReactElement[]>([]));
@@ -206,33 +217,47 @@ export function Extensions() {
}
}}
/>
<Virtuoso
<StyledGroupedVirtuoso
style={{
height: isMobile ? 'calc(100vh - 64px - 64px)' : 'calc(100vh - 64px)',
// override Virtuoso default values and set them with class
height: 'undefined',
}}
totalCount={flatRenderItems.length}
itemContent={(index) => {
if (typeof flatRenderItems[index] === 'string') {
const item = flatRenderItems[index] as string;
return (
<Typography
key={item}
variant="h2"
style={{
paddingLeft: 25,
paddingBottom: '0.83em',
paddingTop: '0.83em',
fontSize: '2em',
fontWeight: 'bold',
}}
>
{translateExtensionLanguage(item)}
</Typography>
);
}
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 <ExtensionCard key={item.apkName} extension={item} handleUpdate={handleExtensionUpdate} />;
return (
<StyledGroupHeader
key={groupName}
variant="h4"
style={{
paddingLeft: '24px',
paddingTop: '6px',
paddingBottom: '16px',
fontWeight: 'bold',
}}
isFirstItem={index === 0}
>
{translateExtensionLanguage(groupName)}
</StyledGroupHeader>
);
}}
itemContent={(index) => {
const item = visibleExtensions[index];
return (
<StyledGroupItemWrapper key={item.apkName} isLastItem={index === visibleExtensions.length - 1}>
<ExtensionCard extension={item} handleUpdate={handleExtensionUpdate} />
</StyledGroupItemWrapper>
);
}}
/>
</>

View File

@@ -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 (
<>
<StyledGridWrapper>
<TabsWrapper>
<TabsMenu
sx={{ borderBottom: 2, borderColor: 'divider' }}
value={activeTab.order}
onChange={(e, newTab) => handleTabChange(newTab)}
indicatorColor="primary"
textColor="primary"
centered={!scrollableTabs}
variant={scrollableTabs ? 'scrollable' : 'fullWidth'}
scrollButtons
allowScrollButtonsMobile
tabsCount={tabs.length}
>
{tabs.map((tab) => (
<Tab
@@ -261,7 +227,7 @@ export function Library() {
))}
</TabPanel>
))}
</StyledGridWrapper>
</TabsWrapper>
{isSelectModeActive && (
<SelectionFAB selectedItemsCount={selectedItemIds.length} title="manga.title">
{(handleClose, setHideMenu) => (

View File

@@ -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 && (
<Fragment key={lang}>
<h1 key={lang} style={{ marginLeft: 25 }}>
<Typography
key={lang}
variant="h4"
style={{
paddingLeft: '24px',
paddingTop: '6px',
paddingBottom: '16px',
fontWeight: 'bold',
}}
>
{translateExtensionLanguage(lang)}
</h1>
</Typography>
{(list as ISource[])
.filter((source) => showNsfw || !source.isNsfw)
.map((source) => (

View File

@@ -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