Merge pull request #499 from ente-io/collections-redesign

Collections UI redesign
This commit is contained in:
Abhinav Kumar 2022-05-11 20:46:25 +05:30 committed by GitHub
commit ab96dd8a3b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
63 changed files with 2078 additions and 1586 deletions

View file

@ -9,6 +9,7 @@ const { PHASE_DEVELOPMENT_SERVER } = require('next/constants');
const withTM = require('next-transpile-modules')([
'@mui/material',
'@mui/system',
'@mui/icons-material',
]);
const {

View file

@ -14,6 +14,7 @@
},
"dependencies": {
"@ente-io/next-with-workbox": "^1.0.3",
"@mui/icons-material": "^5.6.2",
"@mui/material": "^5.6.2",
"@mui/styled-engine": "npm:@mui/styled-engine-sc@latest",
"@mui/styled-engine-sc": "^5.6.1",
@ -80,7 +81,7 @@
"@types/react-select": "^4.0.15",
"@types/react-window": "^1.8.2",
"@types/react-window-infinite-loader": "^1.0.3",
"@types/styled-components": "^5.1.3",
"@types/styled-components": "^5.1.25",
"@types/yup": "^0.29.7",
"babel-plugin-styled-components": "^1.11.1",
"eslint": "^7.27.0",

BIN
public/fonts/Inter-Bold.ttf Normal file

Binary file not shown.

Binary file not shown.

93
public/fonts/OFL.txt Normal file
View file

@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -1,96 +0,0 @@
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.

View file

@ -3,7 +3,7 @@ import { FreeFlowText, IconButton } from './Container';
import CopyIcon from './icons/CopyIcon';
import React, { useState } from 'react';
import { Tooltip, OverlayTrigger } from 'react-bootstrap';
import TickIcon from './icons/TickIcon';
import TickIcon from '@mui/icons-material/Done';
import EnteSpinner from './EnteSpinner';
const Wrapper = styled.div`

View file

@ -0,0 +1,37 @@
import { Typography } from '@mui/material';
import constants from 'utils/strings/constants';
import React from 'react';
import CollectionCard from '../CollectionCard';
export default function AllCollectionCard({
onCollectionClick,
collectionAttributes,
latestFile,
fileCount,
}) {
return (
<CollectionCard
large
latestFile={latestFile}
onClick={() => onCollectionClick(collectionAttributes.id)}>
<div>
<Typography
css={`
font-size: 14px;
font-weight: 600;
line-height: 20px;
`}>
{collectionAttributes.name}
</Typography>
<Typography
css={`
font-size: 14px;
font-weight: 400;
line-height: 20px;
`}>
{fileCount} {constants.PHOTOS}
</Typography>
</div>
</CollectionCard>
);
}

View file

@ -0,0 +1,44 @@
import React, { useState } from 'react';
import { COLLECTION_SORT_BY } from 'constants/collection';
import Menu from '@mui/material/Menu';
import { IconButton, styled } from '@mui/material';
import SortIcon from '@mui/icons-material/Sort';
import CollectionSortOptions from './options';
export interface CollectionSortProps {
setCollectionSortBy: (sortBy: COLLECTION_SORT_BY) => void;
activeSortBy: COLLECTION_SORT_BY;
}
const StyledMenu = styled(Menu)`
& .MuiPaper-root {
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.16);
}
`;
export default function CollectionSort(props: CollectionSortProps) {
const [sortByEl, setSortByEl] = useState(null);
const handleClose = () => setSortByEl(null);
return (
<>
<IconButton
onClick={(event) => setSortByEl(event.currentTarget)}
aria-controls={sortByEl ? 'collection-sort' : undefined}
aria-haspopup="true"
aria-expanded={sortByEl ? 'true' : undefined}>
<SortIcon />
</IconButton>
<StyledMenu
id="collection-sort"
anchorEl={sortByEl}
open={Boolean(sortByEl)}
onClose={handleClose}
MenuListProps={{
disablePadding: true,
'aria-labelledby': 'collection-sort',
}}>
<CollectionSortOptions {...props} close={handleClose} />
</StyledMenu>
</>
);
}

View file

@ -0,0 +1,35 @@
import React from 'react';
import { MenuItem, ListItemIcon, ListItemText } from '@mui/material';
import { COLLECTION_SORT_BY } from 'constants/collection';
import TickIcon from '@mui/icons-material/Done';
import { CollectionSortProps } from '.';
export interface SortOptionProps extends CollectionSortProps {
close: () => void;
}
const SortByOptionCreator =
({ setCollectionSortBy, activeSortBy, close }: SortOptionProps) =>
(props: { sortBy: COLLECTION_SORT_BY; children: any }) => {
const handleClick = () => {
setCollectionSortBy(props.sortBy);
close();
};
return (
<MenuItem onClick={handleClick} style={{ paddingLeft: '5px' }}>
<ListItemIcon style={{ minWidth: '25px' }}>
{activeSortBy === props.sortBy && (
<TickIcon
css={`
height: 16px;
width: 16px;
`}
/>
)}
</ListItemIcon>
<ListItemText>{props.children}</ListItemText>
</MenuItem>
);
};
export default SortByOptionCreator;

View file

@ -0,0 +1,26 @@
import React from 'react';
import { MenuList } from '@mui/material';
import { COLLECTION_SORT_BY } from 'constants/collection';
import constants from 'utils/strings/constants';
import SortByOptionCreator, { SortOptionProps } from './optionCreator';
export default function CollectionSortOptions(props: SortOptionProps) {
const SortByOption = SortByOptionCreator(props);
return (
<MenuList>
<SortByOption sortBy={COLLECTION_SORT_BY.NAME}>
{constants.SORT_BY_NAME}
</SortByOption>
<SortByOption sortBy={COLLECTION_SORT_BY.CREATION_TIME_DESCENDING}>
{constants.SORT_BY_CREATION_TIME_DESCENDING}
</SortByOption>
<SortByOption sortBy={COLLECTION_SORT_BY.CREATION_TIME_ASCENDING}>
{constants.SORT_BY_CREATION_TIME_ASCENDING}
</SortByOption>
<SortByOption sortBy={COLLECTION_SORT_BY.UPDATION_TIME_DESCENDING}>
{constants.SORT_BY_UPDATION_TIME_DESCENDING}
</SortByOption>
</MenuList>
);
}

View file

@ -0,0 +1,30 @@
import React from 'react';
import { DialogContent } from '@mui/material';
import { FlexWrapper } from 'components/Container';
import AllCollectionCard from './CollectionCard';
export default function AllCollectionContent({
sortedCollectionSummaries,
onCollectionClick,
}) {
return (
<DialogContent>
<FlexWrapper
style={{
flexWrap: 'wrap',
}}>
{sortedCollectionSummaries.map(
({ latestFile, collectionAttributes, fileCount }) => (
<AllCollectionCard
onCollectionClick={onCollectionClick}
collectionAttributes={collectionAttributes}
key={collectionAttributes.id}
latestFile={latestFile}
fileCount={fileCount}
/>
)
)}
</FlexWrapper>
</DialogContent>
);
}

View file

@ -0,0 +1,46 @@
import React from 'react';
import { DialogTitle, IconButton, Typography } from '@mui/material';
import { TwoScreenSpacedOptions } from 'components/Container';
import CollectionSort from 'components/Collections/AllCollections/CollectionSort';
import constants from 'utils/strings/constants';
import Close from '@mui/icons-material/Close';
export default function AllCollectionsHeader({
onClose,
collectionCount,
collectionSortBy,
setCollectionSortBy,
}) {
return (
<DialogTitle>
<TwoScreenSpacedOptions>
<Typography
css={`
font-size: 24px;
font-weight: 600;
line-height: 36px;
`}>
{constants.ALL_ALBUMS}
</Typography>
<IconButton onClick={onClose}>
<Close />
</IconButton>
</TwoScreenSpacedOptions>
<TwoScreenSpacedOptions>
<Typography
css={`
font-size: 24px;
font-weight: 600;
line-height: 36px;
`}
color={'text.secondary'}>
{`${collectionCount} ${constants.ALBUMS}`}
</Typography>
<CollectionSort
activeSortBy={collectionSortBy}
setCollectionSortBy={setCollectionSortBy}
/>
</TwoScreenSpacedOptions>
</DialogTitle>
);
}

View file

@ -0,0 +1,62 @@
import React, { useMemo } from 'react';
import Divider from '@mui/material/Divider';
import { COLLECTION_SORT_BY } from 'constants/collection';
import { sortCollectionSummaries } from 'services/collectionService';
import { Transition, FloatingSidebar } from 'components/FloatingSidebar';
import { useLocalState } from 'hooks/useLocalState';
import { LS_KEYS } from 'utils/storage/localStorage';
import AllCollectionsHeader from './header';
import { CollectionSummaries } from 'types/collection';
import AllCollectionContent from './content';
interface Iprops {
isOpen: boolean;
close: () => void;
collectionSummaries: CollectionSummaries;
setActiveCollection: (id?: number) => void;
}
const LeftSlideTransition = Transition('up');
export default function AllCollections(props: Iprops) {
const { collectionSummaries, isOpen, close, setActiveCollection } = props;
const [collectionSortBy, setCollectionSortBy] =
useLocalState<COLLECTION_SORT_BY>(
LS_KEYS.COLLECTION_SORT_BY,
COLLECTION_SORT_BY.UPDATION_TIME_DESCENDING
);
const sortedCollectionSummaries = useMemo(
() =>
sortCollectionSummaries(
[...collectionSummaries.values()],
collectionSortBy
),
[collectionSortBy, collectionSummaries]
);
const onCollectionClick = (collectionID: number) => {
setActiveCollection(collectionID);
close();
};
return (
<FloatingSidebar
TransitionComponent={LeftSlideTransition}
onClose={close}
open={isOpen}>
<AllCollectionsHeader
onClose={close}
collectionCount={props.collectionSummaries.size}
collectionSortBy={collectionSortBy}
setCollectionSortBy={setCollectionSortBy}
/>
<Divider />
<AllCollectionContent
sortedCollectionSummaries={sortedCollectionSummaries}
onCollectionClick={onCollectionClick}
/>
</FloatingSidebar>
);
}

View file

@ -0,0 +1,27 @@
import React from 'react';
import { EnteFile } from 'types/file';
import { CollectionTileWrapper, ActiveIndicator } from '../styledComponents';
import CollectionCard from '../CollectionCard';
const CollectionCardWithActiveIndicator = React.forwardRef(
(
props: {
children;
active: boolean;
latestFile: EnteFile;
onClick: () => void;
},
ref: any
) => {
const { active, ...others } = props;
return (
<CollectionTileWrapper ref={ref}>
<CollectionCard {...others} />
{active && <ActiveIndicator />}
</CollectionTileWrapper>
);
}
);
export default CollectionCardWithActiveIndicator;

View file

@ -0,0 +1,12 @@
import React from 'react';
import constants from 'utils/strings/englishConstants';
import { CollectionTitleWithDashedBorder } from '../styledComponents';
export const CreateNewCollectionTile = (props) => {
return (
<CollectionTitleWithDashedBorder {...props}>
<div>{constants.NEW} </div>
<div>{'+'}</div>
</CollectionTitleWithDashedBorder>
);
};

View file

@ -0,0 +1,56 @@
import React from 'react';
import styled, { css } from 'styled-components';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
export enum SCROLL_DIRECTION {
LEFT = -1,
RIGHT = +1,
}
const Wrapper = styled.button<{ direction: SCROLL_DIRECTION }>`
position: absolute;
top: 7px;
height: 50px;
width: 50px;
border: none;
padding: 0;
margin: 0;
border-radius: 50%;
background-color: ${({ theme }) => theme.palette.background.paper};
color: ${({ theme }) => theme.palette.text.primary};
${(props) =>
props.direction === SCROLL_DIRECTION.LEFT
? css`
left: 0;
text-align: right;
transform: translate(-50%, 0%);
`
: css`
right: 0;
text-align: left;
transform: translate(50%, 0%);
`}
& > svg {
${(props) =>
props.direction === SCROLL_DIRECTION.LEFT &&
'transform:rotate(180deg);'}
border-radius: 50%;
height: 30px;
width: 30px;
}
&:hover {
color: #fff;
}
`;
const NavigationButton = ({ scrollDirection, ...rest }) => (
<Wrapper direction={scrollDirection} {...rest}>
<NavigateNextIcon />
</Wrapper>
);
export default NavigationButton;

View file

@ -0,0 +1,112 @@
import NavigationButton, {
SCROLL_DIRECTION,
} from 'components/Collections/CollectionBar/NavigationButton';
import React, { useEffect } from 'react';
import { Collection, CollectionSummaries } from 'types/collection';
import constants from 'utils/strings/constants';
import { ALL_SECTION } from 'constants/collection';
import { Link, Typography } from '@mui/material';
import {
Hider,
CollectionBarWrapper,
ScrollContainer,
TwoScreenSpacedOptionsWithBodyPadding,
} from 'components/Collections/styledComponents';
import CollectionCardWithActiveIndicator from 'components/Collections/CollectionBar/CollectionCardWithActiveIndicator';
import useComponentScroll from 'hooks/useComponentScroll';
import useWindowSize from 'hooks/useWindowSize';
interface IProps {
collections: Collection[];
activeCollection?: number;
setActiveCollection: (id?: number) => void;
isInSearchMode: boolean;
collectionSummaries: CollectionSummaries;
showAllCollections: () => void;
}
export default function CollectionBar(props: IProps) {
const {
activeCollection,
collections,
setActiveCollection,
collectionSummaries,
showAllCollections,
} = props;
const windowSize = useWindowSize();
const {
componentRef,
scrollComponent,
hasScrollBar,
onFarLeft,
onFarRight,
} = useComponentScroll({
dependencies: [windowSize, collections],
});
const collectionChipsRef = props.collections.reduce(
(refMap, collection) => {
refMap[collection.id] = React.createRef();
return refMap;
},
{}
);
useEffect(() => {
collectionChipsRef[activeCollection]?.current.scrollIntoView({
inline: 'center',
});
}, [activeCollection]);
const clickHandler = (collectionID?: number) => () => {
setActiveCollection(collectionID ?? ALL_SECTION);
};
return (
<Hider hide={props.isInSearchMode}>
<TwoScreenSpacedOptionsWithBodyPadding>
<Typography>{constants.ALBUMS}</Typography>
{hasScrollBar && (
<Link component="button" onClick={showAllCollections}>
{constants.VIEW_ALL_ALBUMS}
</Link>
)}
</TwoScreenSpacedOptionsWithBodyPadding>
<CollectionBarWrapper>
{!onFarLeft && (
<NavigationButton
scrollDirection={SCROLL_DIRECTION.LEFT}
onClick={scrollComponent(SCROLL_DIRECTION.LEFT)}
/>
)}
<ScrollContainer ref={componentRef}>
<CollectionCardWithActiveIndicator
latestFile={null}
active={activeCollection === ALL_SECTION}
onClick={clickHandler(ALL_SECTION)}>
{constants.ALL_SECTION_NAME}
</CollectionCardWithActiveIndicator>
{collections.map((item) => (
<CollectionCardWithActiveIndicator
key={item.id}
latestFile={
collectionSummaries.get(item.id)?.latestFile
}
ref={collectionChipsRef[item.id]}
active={activeCollection === item.id}
onClick={clickHandler(item.id)}>
{item.name}
</CollectionCardWithActiveIndicator>
))}
</ScrollContainer>
{!onFarRight && (
<NavigationButton
scrollDirection={SCROLL_DIRECTION.RIGHT}
onClick={scrollComponent(SCROLL_DIRECTION.RIGHT)}
/>
)}
</CollectionBarWrapper>
</Hider>
);
}

View file

@ -0,0 +1,37 @@
import React from 'react';
import { GalleryContext } from 'pages/gallery';
import { useState, useContext, useEffect } from 'react';
import downloadManager from 'services/downloadManager';
import { EnteFile } from 'types/file';
import { CollectionTile, LargerCollectionTile } from './styledComponents';
export default function CollectionCard(props: {
children?: any;
latestFile: EnteFile;
onClick: () => void;
large?: boolean;
}) {
const { latestFile: file, onClick, children, large } = props;
const [coverImageURL, setCoverImageURL] = useState(null);
const galleryContext = useContext(GalleryContext);
useEffect(() => {
const main = async () => {
if (!file) {
return;
}
if (!galleryContext.thumbs.has(file.id)) {
const url = await downloadManager.getThumbnail(file);
galleryContext.thumbs.set(file.id, url);
}
setCoverImageURL(galleryContext.thumbs.get(file.id));
};
main();
}, [file]);
const UsedCollectionTile = large ? LargerCollectionTile : CollectionTile;
return (
<UsedCollectionTile coverImgURL={coverImageURL} onClick={onClick}>
{children}
</UsedCollectionTile>
);
}

View file

@ -0,0 +1,46 @@
import React from 'react';
import { Typography } from '@mui/material';
import constants from 'utils/strings/constants';
import { Collection, CollectionSummary } from 'types/collection';
import { TwoScreenSpacedOptionsWithBodyPadding } from 'components/Collections/styledComponents';
import CollectionOptions from 'components/Collections/CollectionOptions';
import { SetCollectionNamerAttributes } from 'components/Collections/CollectionNamer';
interface Iprops {
activeCollection: Collection;
collectionSummary: CollectionSummary;
setCollectionNamerAttributes: SetCollectionNamerAttributes;
showCollectionShareModal: () => void;
redirectToAll: () => void;
}
export default function collectionInfo(props: Iprops) {
if (!props.collectionSummary) {
return <></>;
}
const {
collectionSummary: { collectionAttributes, fileCount },
} = props;
return (
<TwoScreenSpacedOptionsWithBodyPadding>
<div>
<Typography
css={`
font-size: 24px;
font-weight: 600;
line-height: 36px;
`}>
{collectionAttributes.name}
</Typography>
<Typography
css={`
font-size: 14px;
line-height: 20px;
`}>
{fileCount} {constants.PHOTOS}
</Typography>
</div>
<CollectionOptions {...props} />
</TwoScreenSpacedOptionsWithBodyPadding>
);
}

View file

@ -0,0 +1,101 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogTitle,
IconButton,
TextField,
} from '@mui/material';
import constants from 'utils/strings/constants';
import SubmitButton from 'components/SubmitButton';
import { Formik } from 'formik';
import * as Yup from 'yup';
import { TwoScreenSpacedOptions } from 'components/Container';
import Close from '@mui/icons-material/Close';
export interface CollectionNamerAttributes {
callback: (name) => void;
title: string;
autoFilledName: string;
buttonText: string;
}
export type SetCollectionNamerAttributes = React.Dispatch<
React.SetStateAction<CollectionNamerAttributes>
>;
interface Props {
show: boolean;
onHide: () => void;
attributes: CollectionNamerAttributes;
}
interface formValues {
albumName: string;
}
export default function CollectionNamer({ attributes, ...props }: Props) {
if (!attributes) {
return <></>;
}
const onSubmit = ({ albumName }: formValues) => {
attributes.callback(albumName);
props.onHide();
};
return (
<Dialog open={props.show} onClose={props.onHide} maxWidth="xs">
<DialogTitle>
<TwoScreenSpacedOptions>
{attributes?.title}
<IconButton onClick={props.onHide}>
<Close />
</IconButton>
</TwoScreenSpacedOptions>
</DialogTitle>
<DialogContent>
<Formik<formValues>
initialValues={{
albumName: attributes.autoFilledName ?? '',
}}
validationSchema={Yup.object().shape({
albumName: Yup.string().required(constants.REQUIRED),
})}
validateOnChange={false}
validateOnBlur={false}
onSubmit={onSubmit}>
{({
values,
touched,
errors,
handleChange,
handleSubmit,
}) => (
<form noValidate onSubmit={handleSubmit}>
<TextField
margin="normal"
fullWidth
type="text"
label={constants.ENTER_ALBUM_NAME}
value={values.albumName}
onChange={handleChange('albumName')}
autoFocus
required
error={
touched.albumName &&
Boolean(errors.albumName)
}
helperText={
touched.albumName && errors.albumName
}
/>
<SubmitButton
buttonText={attributes.buttonText}
loading={false}
/>
</form>
)}
</Formik>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,231 @@
import React, { useContext, useState } from 'react';
import * as CollectionAPI from 'services/collectionService';
import {
changeCollectionVisibility,
downloadAllCollectionFiles,
} from 'utils/collection';
import constants from 'utils/strings/constants';
import { SetCollectionNamerAttributes } from './CollectionNamer';
import { Collection } from 'types/collection';
import { IsArchived } from 'utils/magicMetadata';
import { InvertedIconButton } from 'components/Container';
import OptionIcon from 'components/icons/OptionIcon-2';
import Paper from '@mui/material/Paper';
import MenuList from '@mui/material/MenuList';
import { ListItem, Menu, MenuItem } from '@mui/material';
import { GalleryContext } from 'pages/gallery';
import { logError } from 'utils/sentry';
import { VISIBILITY_STATE } from 'types/magicMetadata';
interface CollectionOptionsProps {
setCollectionNamerAttributes: SetCollectionNamerAttributes;
activeCollection: Collection;
showCollectionShareModal: () => void;
redirectToAll: () => void;
}
enum CollectionActions {
RENAME,
DOWNLOAD,
ARCHIVE,
UNARCHIVE,
DELETE,
}
const CollectionOptions = (props: CollectionOptionsProps) => {
const {
activeCollection,
redirectToAll,
setCollectionNamerAttributes,
showCollectionShareModal,
} = props;
const { startLoading, finishLoading, setDialogMessage, syncWithRemote } =
useContext(GalleryContext);
const [optionEl, setOptionEl] = useState(null);
const handleClose = () => setOptionEl(null);
const handleCollectionAction = (action: CollectionActions) => {
let callback;
switch (action) {
case CollectionActions.RENAME:
callback = renameCollection;
break;
case CollectionActions.DOWNLOAD:
callback = downloadCollection;
break;
case CollectionActions.ARCHIVE:
callback = archiveCollection;
break;
case CollectionActions.UNARCHIVE:
callback = unArchiveCollection;
break;
case CollectionActions.DELETE:
callback = deleteCollection;
break;
default:
logError(
Error('invalid collection action '),
'handleCollectionAction failed'
);
{
action;
}
}
return async (...args) => {
startLoading();
try {
await callback(...args);
handleClose();
} catch (e) {
setDialogMessage({
title: constants.ERROR,
content: constants.UNKNOWN_ERROR,
close: { variant: 'danger' },
});
}
syncWithRemote();
finishLoading();
};
};
const renameCollection = (newName: string) => {
if (activeCollection.name !== newName) {
CollectionAPI.renameCollection(activeCollection, newName);
}
};
const deleteCollection = async () => {
await CollectionAPI.deleteCollection(activeCollection.id);
redirectToAll();
};
const archiveCollection = () => {
changeCollectionVisibility(activeCollection, VISIBILITY_STATE.ARCHIVED);
};
const unArchiveCollection = () => {
changeCollectionVisibility(activeCollection, VISIBILITY_STATE.VISIBLE);
};
const downloadCollection = () => {
downloadAllCollectionFiles(activeCollection.id);
};
const showRenameCollectionModal = () => {
setCollectionNamerAttributes({
title: constants.RENAME_COLLECTION,
buttonText: constants.RENAME,
autoFilledName: activeCollection.name,
callback: handleCollectionAction(CollectionActions.RENAME),
});
};
const confirmDeleteCollection = () => {
setDialogMessage({
title: constants.CONFIRM_DELETE_COLLECTION,
content: constants.DELETE_COLLECTION_MESSAGE(),
staticBackdrop: true,
proceed: {
text: constants.DELETE_COLLECTION,
action: handleCollectionAction(CollectionActions.DELETE),
variant: 'danger',
},
close: {
text: constants.CANCEL,
},
});
};
const confirmDownloadCollection = () => {
setDialogMessage({
title: constants.CONFIRM_DOWNLOAD_COLLECTION,
content: constants.DOWNLOAD_COLLECTION_MESSAGE(),
staticBackdrop: true,
proceed: {
text: constants.DOWNLOAD,
action: handleCollectionAction(CollectionActions.DOWNLOAD),
variant: 'success',
},
close: {
text: constants.CANCEL,
},
});
};
return (
<>
<InvertedIconButton
style={{
transform: 'rotate(90deg)',
}}
onClick={(event) => setOptionEl(event.currentTarget)}
aria-controls={optionEl ? 'collection-options' : undefined}
aria-haspopup="true"
aria-expanded={optionEl ? 'true' : undefined}>
<OptionIcon />
</InvertedIconButton>
<Menu
id="collection-options"
anchorEl={optionEl}
open={Boolean(optionEl)}
onClose={handleClose}
MenuListProps={{
disablePadding: true,
'aria-labelledby': 'collection-options',
}}>
<Paper sx={{ borderRadius: '10px' }}>
<MenuList
sx={{
padding: 0,
border: 'none',
borderRadius: '8px',
}}>
<MenuItem>
<ListItem onClick={showRenameCollectionModal}>
{constants.RENAME}
</ListItem>
</MenuItem>
<MenuItem>
<ListItem onClick={showCollectionShareModal}>
{constants.SHARE}
</ListItem>
</MenuItem>
<MenuItem>
<ListItem onClick={confirmDownloadCollection}>
{constants.DOWNLOAD}
</ListItem>
</MenuItem>
<MenuItem>
{IsArchived(activeCollection) ? (
<ListItem
onClick={handleCollectionAction(
CollectionActions.UNARCHIVE
)}>
{constants.UNARCHIVE}
</ListItem>
) : (
<ListItem
onClick={handleCollectionAction(
CollectionActions.ARCHIVE
)}>
{constants.ARCHIVE}
</ListItem>
)}
</MenuItem>
<MenuItem>
<ListItem
color="danger"
onClick={confirmDeleteCollection}>
{constants.DELETE}
</ListItem>
</MenuItem>
</MenuList>
</Paper>
</Menu>
</>
);
};
export default CollectionOptions;

View file

@ -16,31 +16,30 @@ import {
updateShareableURL,
} from 'services/collectionService';
import { getData, LS_KEYS } from 'utils/storage/localStorage';
import SubmitButton from './SubmitButton';
import MessageDialog from './MessageDialog';
import SubmitButton from '../SubmitButton';
import MessageDialog from '../MessageDialog';
import { Collection, PublicURL, UpdatePublicURL } from 'types/collection';
import {
appendCollectionKeyToShareURL,
selectIntOptions,
shareExpiryOptions,
} from 'utils/collection';
import { FlexWrapper, Label, Row, Value } from './Container';
import { CodeBlock } from './CodeBlock';
import { ButtonVariant, getVariantColor } from './pages/gallery/LinkButton';
import { FlexWrapper, Label, Row, Value } from '../Container';
import { CodeBlock } from '../CodeBlock';
import { ButtonVariant, getVariantColor } from '../pages/gallery/LinkButton';
import { handleSharingErrors } from 'utils/error';
import { sleep } from 'utils/common';
import { SelectStyles } from './Search/styles';
import { SelectStyles } from '../Search/styles';
import CryptoWorker from 'utils/crypto';
import { dateStringWithMMH } from 'utils/time';
import styled from 'styled-components';
import SingleInputForm from './SingleInputForm';
import SingleInputForm from '../SingleInputForm';
import { AppContext } from 'pages/_app';
interface Props {
show: boolean;
onHide: () => void;
collection: Collection;
syncWithRemote: () => Promise<void>;
}
interface formValues {
email: string;

View file

@ -0,0 +1,79 @@
import { Collection, CollectionSummaries } from 'types/collection';
import CollectionBar from 'components/Collections/CollectionBar';
import React, { useEffect, useRef, useState } from 'react';
import AllCollections from 'components/Collections/AllCollections';
import CollectionInfo from 'components/Collections/CollectionInfo';
import { ALL_SECTION } from 'constants/collection';
import CollectionShare from 'components/Collections/CollectionShare';
import { SetCollectionNamerAttributes } from 'components/Collections/CollectionNamer';
interface Iprops {
collections: Collection[];
activeCollectionID?: number;
setActiveCollectionID: (id?: number) => void;
isInSearchMode: boolean;
collectionSummaries: CollectionSummaries;
setCollectionNamerAttributes: SetCollectionNamerAttributes;
}
export default function Collections(props: Iprops) {
const {
collections,
isInSearchMode,
activeCollectionID,
setActiveCollectionID,
collectionSummaries,
setCollectionNamerAttributes,
} = props;
const [allCollectionView, setAllCollectionView] = useState(false);
const [collectionShareModalView, setCollectionShareModalView] =
useState(false);
const collectionsMap = useRef<Map<number, Collection>>(new Map());
const activeCollection = useRef<Collection>(null);
useEffect(() => {
collectionsMap.current = new Map(
props.collections.map((collection) => [collection.id, collection])
);
}, [collections]);
useEffect(() => {
activeCollection.current =
collectionsMap.current.get(activeCollectionID);
}, [activeCollectionID, collections]);
return (
<>
<CollectionBar
collections={collections}
isInSearchMode={isInSearchMode}
activeCollection={activeCollectionID}
setActiveCollection={setActiveCollectionID}
collectionSummaries={collectionSummaries}
showAllCollections={() => setAllCollectionView(true)}
/>
<AllCollections
isOpen={allCollectionView}
close={() => setAllCollectionView(false)}
collectionSummaries={collectionSummaries}
setActiveCollection={setActiveCollectionID}
/>
<CollectionInfo
collectionSummary={collectionSummaries.get(activeCollectionID)}
activeCollection={activeCollection.current}
setCollectionNamerAttributes={setCollectionNamerAttributes}
redirectToAll={() => setActiveCollectionID(ALL_SECTION)}
showCollectionShareModal={() =>
setCollectionShareModalView(true)
}
/>
<CollectionShare
show={collectionShareModalView}
onHide={() => setCollectionShareModalView(false)}
collection={activeCollection.current}
/>
</>
);
}

View file

@ -0,0 +1,83 @@
import { TwoScreenSpacedOptions } from 'components/Container';
import { IMAGE_CONTAINER_MAX_WIDTH } from 'constants/gallery';
import styled from 'styled-components';
export const CollectionBarWrapper = styled.div`
display: flex;
position: relative;
overflow: hidden;
height: 86px;
width: 100%;
margin: 10px auto;
padding: 0 24px;
@media (max-width: ${IMAGE_CONTAINER_MAX_WIDTH * 4}px) {
padding: 0 4px;
}
border-bottom: 1px solid ${({ theme }) => theme.palette.grey.A200};
`;
export const TwoScreenSpacedOptionsWithBodyPadding = styled(
TwoScreenSpacedOptions
)`
margin-bottom: 8px;
margin-top: 16px;
padding: 0 24px;
@media (max-width: ${IMAGE_CONTAINER_MAX_WIDTH * 4}px) {
padding: 0 4px;
}
`;
export const ScrollContainer = styled.div`
width: 100%;
height: 100px;
overflow: auto;
max-width: 100%;
scroll-behavior: smooth;
display: flex;
`;
export const CollectionTile = styled.div<{
coverImgURL?: string;
}>`
display: flex;
width: 80px;
height: 64px;
border-radius: 4px;
padding: 4px 6px;
align-items: flex-end;
justify-content: space-between;
user-select: none;
cursor: pointer;
background-image: url(${({ coverImgURL }) => coverImgURL});
background-size: cover;
border: 1px solid ${({ theme }) => theme.palette.grey.A200};
font-size: 14px;
line-height: 20px;
`;
export const CollectionTileWrapper = styled.div`
margin-right: 4px;
`;
export const ActiveIndicator = styled.div`
height: 3px;
background-color: ${({ theme }) => theme.palette.text.primary};
margin-top: 18px;
border-radius: 2px;
`;
export const Hider = styled.div<{ hide: boolean }>`
opacity: ${(props) => (props.hide ? '0' : '100')};
height: ${(props) => (props.hide ? '0' : 'auto')};
`;
export const LargerCollectionTile = styled(CollectionTile)`
width: 150px;
height: 150px;
align-items: flex-start;
margin: 2px;
`;
export const CollectionTitleWithDashedBorder = styled(CollectionTile)`
border: 1px dashed ${({ theme }) => theme.palette.grey.A200};
`;

View file

@ -55,10 +55,8 @@ export const Value = styled.div<{ width?: string }>`
`;
export const FlexWrapper = styled.div`
width: 100%;
display: flex;
text-align: center;
justify-content: center;
align-item: center;
`;
export const FreeFlowText = styled.div`
@ -66,3 +64,20 @@ export const FreeFlowText = styled.div`
min-width: 30%;
text-align: left;
`;
export const TwoScreenSpacedOptions = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
export const InvertedIconButton = styled(IconButton)`
background-color: ${({ theme }) => theme.palette.primary.main};
color: ${({ theme }) => theme.palette.background.default};
&:hover {
background-color: ${({ theme }) => theme.palette.grey.A100};
}
&:focus {
background-color: ${({ theme }) => theme.palette.primary.main};
}
`;

View file

@ -0,0 +1,30 @@
import { Dialog, Slide, styled } from '@mui/material';
import React from 'react';
import PropTypes from 'prop-types';
export const FloatingSidebar = styled(Dialog)(({ theme }) => ({
'& .MuiDialog-container': {
justifyContent: 'flex-end',
},
'& .MuiPaper-root': {
maxWidth: '498px',
},
'& .MuiDialogTitle-root': {
padding: theme.spacing(3, 2),
},
'& .MuiDialogContent-root': {
padding: theme.spacing(2),
},
}));
FloatingSidebar.propTypes = {
children: PropTypes.node,
onClose: PropTypes.func.isRequired,
};
export const Transition = (direction: 'left' | 'right' | 'up') =>
React.forwardRef(
(props: { children: React.ReactElement<any, any> }, ref) => {
return <Slide direction={direction} ref={ref} {...props} />;
}
);

View file

@ -0,0 +1,26 @@
import React from 'react';
import { DialogTitle, IconButton } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import { TwoScreenSpacedOptions } from 'components/Container';
const DialogTitleWithCloseButton = (props) => {
const { children, onClose, ...other } = props;
return (
<DialogTitle {...other}>
<TwoScreenSpacedOptions>
{children}
{onClose && (
<IconButton
aria-label="close"
onClick={onClose}
sx={{ float: 'right' }}>
<CloseIcon />
</IconButton>
)}
</TwoScreenSpacedOptions>
</DialogTitle>
);
};
export default DialogTitleWithCloseButton;

View file

@ -25,6 +25,7 @@ type Props = React.PropsWithChildren<{
attributes: MessageAttributes;
size?: 'sm' | 'lg' | 'xl';
}>;
export default function MessageDialog({
attributes,
children,

View file

@ -1,69 +0,0 @@
import React from 'react';
import styled from 'styled-components';
import NavigateNext from './icons/NavigateNext';
export enum SCROLL_DIRECTION {
LEFT = -1,
RIGHT = +1,
}
const Wrapper = styled.button<{ direction: SCROLL_DIRECTION }>`
height: 40px;
width: 40px;
background-color: #191919;
border: none;
color: #eee;
z-index: 1;
position: absolute;
${(props) =>
props.direction === SCROLL_DIRECTION.LEFT
? 'margin-right: 10px;'
: 'margin-left: 10px;'}
${(props) =>
props.direction === SCROLL_DIRECTION.LEFT ? 'left: 0;' : 'right: 0;'}
& > svg {
${(props) =>
props.direction === SCROLL_DIRECTION.LEFT &&
'transform:rotate(180deg);'}
border-radius: 50%;
height: 30px;
width: 30px;
}
&:hover > svg {
background-color: #555;
}
&:hover {
color: #fff;
}
&::after {
content: ' ';
background: linear-gradient(
to
${(props) =>
props.direction === SCROLL_DIRECTION.LEFT
? 'right'
: 'left'},
#191919 5%,
rgba(255, 255, 255, 0) 80%
);
position: absolute;
top: 0;
width: 40px;
height: 40px;
${(props) =>
props.direction === SCROLL_DIRECTION.LEFT
? 'left: 40px;'
: 'right: 40px;'}
}
`;
const NavigationButton = ({ scrollDirection, ...rest }) => (
<Wrapper direction={scrollDirection} {...rest}>
<NavigateNext />
</Wrapper>
);
export default NavigationButton;

View file

@ -38,7 +38,7 @@ import { livePhotoBtnHTML } from 'components/LivePhotoBtn';
import { logError } from 'utils/sentry';
import CloseIcon from 'components/icons/CloseIcon';
import TickIcon from 'components/icons/TickIcon';
import TickIcon from '@mui/icons-material/Done';
import { Formik } from 'formik';
import * as Yup from 'yup';
import EnteSpinner from 'components/EnteSpinner';

View file

@ -1,22 +0,0 @@
import React from 'react';
export default function NavigateNext(props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="40"
viewBox="0 0 24 24"
width="24px"
fill="currentColor"
{...props}>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg>
);
}
NavigateNext.defaultProps = {
height: 24,
width: 24,
viewBox: '0 0 24 24',
};

View file

@ -1,6 +1,6 @@
import React from 'react';
export default function TickIcon(props) {
export default function OptionIcon(props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@ -8,12 +8,13 @@ export default function TickIcon(props) {
viewBox={props.viewBox}
width={props.width}
fill="currentColor">
<path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z" />
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />{' '}
</svg>
);
}
TickIcon.defaultProps = {
OptionIcon.defaultProps = {
height: 20,
width: 20,
viewBox: '0 0 24 24',

View file

@ -1,20 +0,0 @@
import React from 'react';
export default function SortIcon(props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height={props.height}
viewBox={props.viewBox}
width={props.width}>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z" />
</svg>
);
}
SortIcon.defaultProps = {
height: 24,
width: 24,
viewBox: '0 0 24 24',
};

View file

@ -1,94 +0,0 @@
import React, { useEffect, useRef } from 'react';
import { Form } from 'react-bootstrap';
import constants from 'utils/strings/constants';
import { Formik } from 'formik';
import * as Yup from 'yup';
import SubmitButton from 'components/SubmitButton';
import MessageDialog from 'components/MessageDialog';
export interface CollectionNamerAttributes {
callback: (name) => void;
title: string;
autoFilledName: string;
buttonText: string;
}
export type SetCollectionNamerAttributes = React.Dispatch<
React.SetStateAction<CollectionNamerAttributes>
>;
interface Props {
show: boolean;
onHide: () => void;
attributes: CollectionNamerAttributes;
}
interface formValues {
albumName: string;
}
export default function CollectionNamer({ attributes, ...props }: Props) {
const collectionNameInputRef = useRef(null);
useEffect(() => {
if (attributes) {
setTimeout(() => {
collectionNameInputRef.current?.focus();
}, 200);
}
}, [attributes]);
if (!attributes) {
return (
<MessageDialog show={false} onHide={() => null} attributes={{}} />
);
}
const onSubmit = ({ albumName }: formValues) => {
attributes.callback(albumName);
props.onHide();
};
return (
<MessageDialog
show={props.show}
onHide={props.onHide}
size="sm"
attributes={{
title: attributes?.title,
}}>
<Formik<formValues>
initialValues={{ albumName: attributes.autoFilledName ?? '' }}
validationSchema={Yup.object().shape({
albumName: Yup.string().required(constants.REQUIRED),
})}
validateOnChange={false}
validateOnBlur={false}
onSubmit={onSubmit}>
{({ values, touched, errors, handleChange, handleSubmit }) => (
<Form noValidate onSubmit={handleSubmit}>
<Form.Group>
<Form.Control
className="text-center"
type="text"
value={values.albumName}
onChange={handleChange('albumName')}
isInvalid={Boolean(
touched.albumName && errors.albumName
)}
placeholder={constants.ENTER_ALBUM_NAME}
ref={collectionNameInputRef}
autoFocus
/>
<Form.Control.Feedback
type="invalid"
className="text-center">
{errors.albumName}
</Form.Control.Feedback>
</Form.Group>
<SubmitButton
buttonText={attributes.buttonText}
loading={false}
/>
</Form>
)}
</Formik>
</MessageDialog>
);
}

View file

@ -1,187 +0,0 @@
import React from 'react';
import { SetDialogMessage } from 'components/MessageDialog';
import { ListGroup, Popover } from 'react-bootstrap';
import { deleteCollection, renameCollection } from 'services/collectionService';
import {
changeCollectionVisibilityHelper,
downloadCollection,
getSelectedCollection,
} from 'utils/collection';
import constants from 'utils/strings/constants';
import { SetCollectionNamerAttributes } from './CollectionNamer';
import LinkButton, { ButtonVariant, LinkButtonProps } from './LinkButton';
import { sleep } from 'utils/common';
import { Collection } from 'types/collection';
import { IsArchived } from 'utils/magicMetadata';
interface CollectionOptionsProps {
syncWithRemote: () => Promise<void>;
setCollectionNamerAttributes: SetCollectionNamerAttributes;
collections: Collection[];
selectedCollectionID: number;
setDialogMessage: SetDialogMessage;
startLoading: () => void;
finishLoading: () => void;
showCollectionShareModal: () => void;
redirectToAll: () => void;
}
export const MenuLink = ({ children, ...props }: LinkButtonProps) => (
<LinkButton
style={{ fontSize: '14px', fontWeight: 700, padding: '8px 1em' }}
{...props}>
{children}
</LinkButton>
);
export const MenuItem = (props: { children: any }) => (
<ListGroup.Item
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#282828',
padding: 0,
}}>
{props.children}
</ListGroup.Item>
);
const CollectionOptions = (props: CollectionOptionsProps) => {
const collectionRename = async (
selectedCollection: Collection,
newName: string
) => {
if (selectedCollection.name !== newName) {
await renameCollection(selectedCollection, newName);
props.syncWithRemote();
}
};
const showRenameCollectionModal = () => {
props.setCollectionNamerAttributes({
title: constants.RENAME_COLLECTION,
buttonText: constants.RENAME,
autoFilledName: getSelectedCollection(
props.selectedCollectionID,
props.collections
)?.name,
callback: (newName) => {
props.startLoading();
collectionRename(
getSelectedCollection(
props.selectedCollectionID,
props.collections
),
newName
);
},
});
};
const confirmDeleteCollection = () => {
props.setDialogMessage({
title: constants.CONFIRM_DELETE_COLLECTION,
content: constants.DELETE_COLLECTION_MESSAGE(),
staticBackdrop: true,
proceed: {
text: constants.DELETE_COLLECTION,
action: () => {
props.startLoading();
deleteCollection(
props.selectedCollectionID,
props.syncWithRemote,
props.redirectToAll,
props.setDialogMessage
);
},
variant: 'danger',
},
close: {
text: constants.CANCEL,
},
});
};
const archiveCollectionHelper = () => {
changeCollectionVisibilityHelper(
getSelectedCollection(
props.selectedCollectionID,
props.collections
),
props.startLoading,
props.finishLoading,
props.setDialogMessage,
props.syncWithRemote
);
};
const confirmDownloadCollection = () => {
props.setDialogMessage({
title: constants.CONFIRM_DOWNLOAD_COLLECTION,
content: constants.DOWNLOAD_COLLECTION_MESSAGE(),
staticBackdrop: true,
proceed: {
text: constants.DOWNLOAD,
action: downloadCollectionHelper,
variant: 'success',
},
close: {
text: constants.CANCEL,
},
});
};
const downloadCollectionHelper = async () => {
props.startLoading();
await downloadCollection(
props.selectedCollectionID,
props.setDialogMessage
);
await sleep(1000);
props.finishLoading();
};
return (
<Popover id="collection-options" style={{ borderRadius: '10px' }}>
<Popover.Content style={{ padding: 0, border: 'none' }}>
<ListGroup style={{ borderRadius: '8px' }}>
<MenuItem>
<MenuLink onClick={showRenameCollectionModal}>
{constants.RENAME}
</MenuLink>
</MenuItem>
<MenuItem>
<MenuLink onClick={props.showCollectionShareModal}>
{constants.SHARE}
</MenuLink>
</MenuItem>
<MenuItem>
<MenuLink onClick={confirmDownloadCollection}>
{constants.DOWNLOAD}
</MenuLink>
</MenuItem>
<MenuItem>
<MenuLink onClick={archiveCollectionHelper}>
{IsArchived(
getSelectedCollection(
props.selectedCollectionID,
props.collections
)
)
? constants.UNARCHIVE
: constants.ARCHIVE}
</MenuLink>
</MenuItem>
<MenuItem>
<MenuLink
variant={ButtonVariant.danger}
onClick={confirmDeleteCollection}>
{constants.DELETE}
</MenuLink>
</MenuItem>
</ListGroup>
</Popover.Content>
</Popover>
);
};
export default CollectionOptions;

View file

@ -1,31 +0,0 @@
import { IconButton } from 'components/Container';
import SortIcon from 'components/icons/SortIcon';
import React from 'react';
import { OverlayTrigger } from 'react-bootstrap';
import { COLLECTION_SORT_BY } from 'constants/collection';
import constants from 'utils/strings/constants';
import CollectionSortOptions from './CollectionSortOptions';
import { IconWithMessage } from 'components/IconWithMessage';
interface Props {
setCollectionSortBy: (sortBy: COLLECTION_SORT_BY) => void;
activeSortBy: COLLECTION_SORT_BY;
}
export default function CollectionSort(props: Props) {
const collectionSortOptions = CollectionSortOptions(props);
return (
<OverlayTrigger
rootClose
trigger="click"
placement="bottom"
overlay={collectionSortOptions}>
<div>
<IconWithMessage message={constants.SORT}>
<IconButton style={{ color: '#fff' }}>
<SortIcon />
</IconButton>
</IconWithMessage>
</div>
</OverlayTrigger>
);
}

View file

@ -1,69 +0,0 @@
import { Value } from 'components/Container';
import TickIcon from 'components/icons/TickIcon';
import React from 'react';
import { ListGroup, Popover, Row } from 'react-bootstrap';
import { COLLECTION_SORT_BY } from 'constants/collection';
import styled from 'styled-components';
import constants from 'utils/strings/constants';
import { MenuItem, MenuLink } from './CollectionOptions';
interface OptionProps {
activeSortBy: COLLECTION_SORT_BY;
setCollectionSortBy: (sortBy: COLLECTION_SORT_BY) => void;
}
const TickWrapper = styled.span`
color: #aaa;
margin-left: 5px;
`;
const SortByOptionCreator =
({ setCollectionSortBy, activeSortBy }: OptionProps) =>
(props: { sortBy: COLLECTION_SORT_BY; children: any }) =>
(
<MenuItem>
<Row>
<Value width="20px">
{activeSortBy === props.sortBy && (
<TickWrapper>
<TickIcon />
</TickWrapper>
)}
</Value>
<Value width="165px">
<MenuLink
onClick={() => setCollectionSortBy(props.sortBy)}
variant={
activeSortBy === props.sortBy && 'success'
}>
{props.children}
</MenuLink>
</Value>
</Row>
</MenuItem>
);
const CollectionSortOptions = (props: OptionProps) => {
const SortByOption = SortByOptionCreator(props);
return (
<Popover id="collection-sort-options" style={{ borderRadius: '10px' }}>
<Popover.Content
style={{ padding: 0, border: 'none', width: '185px' }}>
<ListGroup style={{ borderRadius: '8px' }}>
<SortByOption sortBy={COLLECTION_SORT_BY.LATEST_FILE}>
{constants.SORT_BY_LATEST_PHOTO}
</SortByOption>
<SortByOption sortBy={COLLECTION_SORT_BY.MODIFICATION_TIME}>
{constants.SORT_BY_MODIFICATION_TIME}
</SortByOption>
<SortByOption sortBy={COLLECTION_SORT_BY.NAME}>
{constants.SORT_BY_COLLECTION_NAME}
</SortByOption>
</ListGroup>
</Popover.Content>
</Popover>
);
};
export default CollectionSortOptions;

View file

@ -1,299 +0,0 @@
import CollectionShare from 'components/CollectionShare';
import { SetDialogMessage } from 'components/MessageDialog';
import NavigationButton, {
SCROLL_DIRECTION,
} from 'components/NavigationButton';
import React, { useEffect, useRef, useState } from 'react';
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
import { sortCollections } from 'services/collectionService';
import { User } from 'types/user';
import styled from 'styled-components';
import { IMAGE_CONTAINER_MAX_WIDTH } from 'constants/gallery';
import { Collection, CollectionAndItsLatestFile } from 'types/collection';
import { getSelectedCollection } from 'utils/collection';
import { getData, LS_KEYS } from 'utils/storage/localStorage';
import constants from 'utils/strings/constants';
import { SetCollectionNamerAttributes } from './CollectionNamer';
import CollectionOptions from './CollectionOptions';
import CollectionSort from './CollectionSort';
import OptionIcon, { OptionIconWrapper } from './OptionIcon';
import {
ALL_SECTION,
ARCHIVE_SECTION,
CollectionType,
COLLECTION_SORT_BY,
TRASH_SECTION,
} from 'constants/collection';
import { IsArchived } from 'utils/magicMetadata';
import Archive from 'components/icons/Archive';
import { IconWithMessage } from 'components/IconWithMessage';
interface CollectionProps {
collections: Collection[];
collectionAndTheirLatestFile: CollectionAndItsLatestFile[];
activeCollection?: number;
setActiveCollection: (id?: number) => void;
setDialogMessage: SetDialogMessage;
syncWithRemote: () => Promise<void>;
setCollectionNamerAttributes: SetCollectionNamerAttributes;
startLoading: () => void;
finishLoading: () => void;
isInSearchMode: boolean;
collectionFilesCount: Map<number, number>;
}
const CollectionContainer = styled.div`
overflow-y: hidden;
height: 40px;
display: flex;
width: calc(100% - 80px);
position: relative;
padding: 0 24px;
@media (max-width: ${IMAGE_CONTAINER_MAX_WIDTH * 4}px) {
padding: 0 4px;
}
`;
const Wrapper = styled.div`
height: 70px;
flex: 1;
white-space: nowrap;
overflow: auto;
max-width: 100%;
scroll-behavior: smooth;
`;
const CollectionBar = styled.div`
width: 100%;
margin: 10px auto;
display: flex;
align-items: center;
justify-content: flex-start;
`;
const Chip = styled.button<{ active: boolean; archived?: boolean }>`
border-radius: 8px;
padding: 4px;
padding-left: 15px;
${({ archived }) => !archived && 'padding-left: 24px;'}
margin: 3px;
border: none;
background-color: ${(props) =>
props.active ? '#fff' : 'rgba(255, 255, 255, 0.3)'};
outline: none !important;
&:hover {
background-color: ${(props) => !props.active && '#bbbbbb'};
}
&:hover ${OptionIconWrapper} {
opacity: 1;
color: #000000;
}
`;
const SectionChipCreater =
({ activeCollection, clickHandler }) =>
({ section, label }) =>
(
<Chip
active={activeCollection === section}
onClick={clickHandler(section)}>
{label}
<div
style={{
display: 'inline-block',
width: '24px',
}}
/>
</Chip>
);
const Hider = styled.div<{ hide: boolean }>`
opacity: ${(props) => (props.hide ? '0' : '100')};
height: ${(props) => (props.hide ? '0' : 'auto')};
`;
export default function Collections(props: CollectionProps) {
const { activeCollection, collections, setActiveCollection } = props;
const [selectedCollectionID, setSelectedCollectionID] =
useState<number>(null);
const collectionWrapperRef = useRef<HTMLDivElement>(null);
const collectionChipsRef = props.collections.reduce(
(refMap, collection) => {
refMap[collection.id] = React.createRef();
return refMap;
},
{}
);
const [collectionShareModalView, setCollectionShareModalView] =
useState(false);
const [scrollObj, setScrollObj] = useState<{
scrollLeft?: number;
scrollWidth?: number;
clientWidth?: number;
}>({});
const [collectionSortBy, setCollectionSortBy] =
useState<COLLECTION_SORT_BY>(COLLECTION_SORT_BY.LATEST_FILE);
const updateScrollObj = () => {
if (collectionWrapperRef.current) {
const { scrollLeft, scrollWidth, clientWidth } =
collectionWrapperRef.current;
setScrollObj({ scrollLeft, scrollWidth, clientWidth });
}
};
useEffect(() => {
updateScrollObj();
}, [collectionWrapperRef.current, props.isInSearchMode, collections]);
useEffect(() => {
if (!collectionWrapperRef?.current) {
return;
}
collectionWrapperRef.current.scrollLeft = 0;
}, [collections]);
useEffect(() => {
collectionChipsRef[activeCollection]?.current.scrollIntoView({
inline: 'center',
});
}, [activeCollection]);
const clickHandler = (collectionID?: number) => () => {
setSelectedCollectionID(collectionID);
setActiveCollection(collectionID ?? ALL_SECTION);
};
const user: User = getData(LS_KEYS.USER);
const collectionOptions = CollectionOptions({
syncWithRemote: props.syncWithRemote,
setCollectionNamerAttributes: props.setCollectionNamerAttributes,
collections: props.collections,
selectedCollectionID,
setDialogMessage: props.setDialogMessage,
startLoading: props.startLoading,
finishLoading: props.finishLoading,
showCollectionShareModal: setCollectionShareModalView.bind(null, true),
redirectToAll: setActiveCollection.bind(null, ALL_SECTION),
});
const scrollCollection = (direction: SCROLL_DIRECTION) => () => {
collectionWrapperRef.current.scrollBy(250 * direction, 0);
};
const renderTooltip = (collectionID: number) => {
const fileCount = props.collectionFilesCount?.get(collectionID) ?? 0;
return (
<Tooltip id="button-tooltip">
{fileCount} {fileCount > 1 ? 'items' : 'item'}
</Tooltip>
);
};
const SectionChip = SectionChipCreater({ activeCollection, clickHandler });
return (
<Hider hide={props.isInSearchMode}>
<CollectionShare
show={collectionShareModalView}
onHide={() => setCollectionShareModalView(false)}
collection={getSelectedCollection(
selectedCollectionID,
props.collections
)}
syncWithRemote={props.syncWithRemote}
/>
<CollectionBar>
<CollectionContainer>
{scrollObj.scrollLeft > 0 && (
<NavigationButton
scrollDirection={SCROLL_DIRECTION.LEFT}
onClick={scrollCollection(SCROLL_DIRECTION.LEFT)}
/>
)}
<Wrapper
ref={collectionWrapperRef}
onScroll={updateScrollObj}>
<SectionChip
section={ALL_SECTION}
label={constants.ALL}
/>
{sortCollections(
collections,
props.collectionAndTheirLatestFile,
collectionSortBy
).map((item) => (
<OverlayTrigger
key={item.id}
placement="top"
delay={{ show: 250, hide: 400 }}
overlay={renderTooltip(item.id)}>
<Chip
ref={collectionChipsRef[item.id]}
active={activeCollection === item.id}
onClick={clickHandler(item.id)}
archived={IsArchived(item)}>
{IsArchived(item) && (
<IconWithMessage
message={constants.ARCHIVED_ALBUM}>
<div
style={{
display: 'inline-block',
marginRight: '5px',
}}>
<Archive />
</div>
</IconWithMessage>
)}
{item.name}
{item.type !== CollectionType.favorites &&
item.owner.id === user?.id ? (
<OverlayTrigger
rootClose
trigger="click"
placement="bottom"
overlay={collectionOptions}>
<OptionIcon
onClick={() =>
setSelectedCollectionID(
item.id
)
}
/>
</OverlayTrigger>
) : (
<div
style={{
display: 'inline-block',
width: '24px',
}}
/>
)}
</Chip>
</OverlayTrigger>
))}
<SectionChip
section={ARCHIVE_SECTION}
label={constants.ARCHIVE}
/>
<SectionChip
section={TRASH_SECTION}
label={constants.TRASH}
/>
</Wrapper>
{scrollObj.scrollLeft <
scrollObj.scrollWidth - scrollObj.clientWidth && (
<NavigationButton
scrollDirection={SCROLL_DIRECTION.RIGHT}
onClick={scrollCollection(SCROLL_DIRECTION.RIGHT)}
/>
)}
</CollectionContainer>
<CollectionSort
setCollectionSortBy={setCollectionSortBy}
activeSortBy={collectionSortBy}
/>
</CollectionBar>
</Hider>
);
}

View file

@ -6,7 +6,7 @@ import { SetDialogMessage } from 'components/MessageDialog';
import UploadProgress from './UploadProgress';
import UploadStrategyChoiceModal from './UploadStrategyChoiceModal';
import { SetCollectionNamerAttributes } from './CollectionNamer';
import { SetCollectionNamerAttributes } from '../../Collections/CollectionNamer';
import { SetCollectionSelectorAttributes } from './CollectionSelector';
import { GalleryContext } from 'pages/gallery';
import { AppContext } from 'pages/_app';

View file

@ -1,18 +0,0 @@
import React from 'react';
import styled from 'styled-components';
import { Collection } from 'types/collection';
interface Iprops {
collection: Collection;
}
const Info = styled.h5`
margin: 20px;
`;
export function CollectionInfo(props: Iprops) {
if (!props.collection) {
return <></>;
}
return <Info>{props.collection.name}</Info>;
}

View file

@ -9,9 +9,10 @@ export enum CollectionType {
}
export enum COLLECTION_SORT_BY {
LATEST_FILE,
MODIFICATION_TIME,
NAME,
CREATION_TIME_ASCENDING,
CREATION_TIME_DESCENDING,
UPDATION_TIME_DESCENDING,
}
export const COLLECTION_SHARE_DEFAULT_VALID_DURATION =

View file

@ -2,8 +2,32 @@ import { createTheme } from '@mui/material/styles';
// Create a theme instance.
const darkThemeOptions = createTheme({
components: {
MuiPaper: {
styleOverrides: { root: { backgroundImage: 'none' } },
},
MuiMenu: {
styleOverrides: { paper: { margin: '10px' } },
},
},
palette: {
mode: 'dark',
primary: {
main: '#fff',
},
text: {
primary: '#fff',
secondary: '#808080',
},
background: { default: '#191919', paper: '#303030' },
grey: {
A100: '#ccc',
A200: 'rgba(256, 256, 256, 0.24)',
},
divider: 'rgba(255, 255, 255, 0.24)',
},
shape: {
borderRadius: 8,
},
});

View file

@ -0,0 +1,59 @@
import { SCROLL_DIRECTION } from 'components/Collections/CollectionBar/NavigationButton';
import { useRef, useState, useEffect } from 'react';
export default function useComponentScroll({
dependencies,
}: {
dependencies: any[];
}) {
const componentRef = useRef<HTMLDivElement>(null);
const [scrollObj, setScrollObj] = useState<{
scrollLeft?: number;
scrollWidth?: number;
clientWidth?: number;
}>({});
const updateScrollObj = () => {
if (!componentRef.current) {
return;
}
const { scrollLeft, scrollWidth, clientWidth } = componentRef.current;
setScrollObj({ scrollLeft, scrollWidth, clientWidth });
};
useEffect(() => {
if (!componentRef.current) {
return;
}
// Add event listener
componentRef.current?.addEventListener('scroll', updateScrollObj);
// Call handler right away so state gets updated with initial window size
updateScrollObj();
// Remove event listener on cleanup
return () =>
componentRef.current.removeEventListener('resize', updateScrollObj);
}, [componentRef.current]);
useEffect(() => {
updateScrollObj();
}, [...dependencies]);
const scrollComponent = (direction: SCROLL_DIRECTION) => () => {
componentRef.current.scrollBy(250 * direction, 0);
};
const hasScrollBar = scrollObj.scrollWidth > scrollObj.clientWidth;
const onFarLeft = scrollObj.scrollLeft === 0;
const onFarRight =
scrollObj.scrollLeft + scrollObj.clientWidth === scrollObj.scrollWidth;
return {
hasScrollBar,
onFarLeft,
onFarRight,
scrollComponent,
componentRef: componentRef,
};
}

View file

@ -0,0 +1,20 @@
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { getData, LS_KEYS, setData } from 'utils/storage/localStorage';
export function useLocalState<T>(
key: LS_KEYS,
initialValue?: T
): [T, Dispatch<SetStateAction<T>>] {
const [value, setValue] = useState<T>(null);
useEffect(() => {
const { value } = getData(key) ?? {};
setValue(value ?? initialValue);
}, []);
useEffect(() => {
setData(key, { value });
}, [value]);
return [value, setValue];
}

View file

@ -0,0 +1,37 @@
// https://usehooks.com/useWindowSize/
import { useState, useEffect } from 'react';
// Hook
export default function useWindowSize() {
// Initialize state with undefined width/height so server and client renders match
// Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/
const [windowSize, setWindowSize] = useState<{
width: number;
height: number;
}>({
width: undefined,
height: undefined,
});
useEffect(() => {
// Handler to call on window resize
function handleResize() {
// Set window width/height to state
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
// Add event listener
window.addEventListener('resize', handleResize);
// Call handler right away so state gets updated with initial window size
handleResize();
// Remove event listener on cleanup
return () => window.removeEventListener('resize', handleResize);
}, []); // Empty array ensures that effect is only run on mount
return windowSize;
}

View file

@ -1,14 +1,12 @@
import React, { createContext, useEffect, useRef, useState } from 'react';
import styled, {
createGlobalStyle,
ThemeProvider as SThemeProvider,
} from 'styled-components';
import styled, { ThemeProvider as SThemeProvider } from 'styled-components';
import Navbar from 'components/Navbar';
import constants from 'utils/strings/constants';
import { useRouter } from 'next/router';
import Container from 'components/Container';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'photoswipe/dist/photoswipe.css';
import 'styles/global.css';
import EnteSpinner from 'components/EnteSpinner';
import { logError } from '../utils/sentry';
// import { Workbox } from 'workbox-window';
@ -27,496 +25,9 @@ import MessageDialog, {
} from 'components/MessageDialog';
import { ThemeProvider as MThemeProvider } from '@mui/material/styles';
import darkThemeOptions from 'darkThemeOptions';
const GlobalStyles = createGlobalStyle`
/* ubuntu-regular - latin */
@font-face {
font-family: 'Ubuntu';
font-style: normal;
font-weight: 400;
src: local(''),
url('/fonts/ubuntu-v15-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
url('/fonts/ubuntu-v15-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
}
/* ubuntu-700 - latin */
@font-face {
font-family: 'Ubuntu';
font-style: normal;
font-weight: 700;
src: local(''),
url('/fonts/ubuntu-v15-latin-700.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
url('/fonts/ubuntu-v15-latin-700.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
}
html, body {
padding: 0;
margin: 0;
font-family: Arial, Helvetica, sans-serif;
height: 100%;
flex: 1;
display: flex;
flex-direction: column;
background-color: #191919;
color: #aaa;
font-family:Ubuntu, Arial, sans-serif !important;
}
:is(h1, h2, h3, h4, h5, h6) {
color: #d7d7d7;
}
#__next {
flex: 1;
display: flex;
flex-direction: column;
}
.material-icons {
vertical-align: middle;
margin: 8px;
}
.pswp__item video {
width: 100%;
height: 100%;
}
.pswp-item-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
object-fit: contain;
}
.pswp-item-container > * {
position: absolute;
transition: opacity 1s ease;
max-width: 100%;
max-height: 100%;
}
.pswp-item-container > img{
opacity: 1;
}
.pswp-item-container > video{
opacity: 0;
}
.pswp-item-container > div.download-banner {
width:100%;
height: 16vh;
padding:2vh 0;
background-color: #151414;
color:#ddd;
display: flex;
flex-direction:column;
align-items: center;
justify-content: space-around;
opacity: 0.8;
font-size:20px;
}
.download-banner > a{
width: 130px;
}
:root {
--primary: #e26f99,
};
svg {
fill: currentColor;
}
.pswp__img {
object-fit: contain;
}
.pswp__caption{
font-size:20px;
height:10%;
padding-left:5%;
color:#eee;
}
.modal {
z-index: 2000;
}
.modal-dialog-centered {
min-height: -webkit-calc(80% - 3.5rem);
min-height: -moz-calc(80% - 3.5rem);
min-height: calc(80% - 3.5rem);
}
.modal .modal-header, .modal .modal-footer , .toast-header{
border-color: #444 !important;
}
.modal .modal-header .close, .toast-header .close {
color: #aaa;
text-shadow: none;
}
.modal-backdrop {
z-index:2000;
opacity:0.8 !important;
}
.toast-header{
border-radius:0px !important;
}
.modal .card , .table , .toast {
background-color: #202020;
border: none;
}
.modal .card > div {
border-radius: 30px;
overflow: hidden;
margin: 0 0 5px 0;
}
.modal-content ,.toast-header{
border-radius:15px;
background-color:#202020 !important;
}
.modal-dialog{
margin:5vh auto;
width:90%;
}
.modal-body{
max-height:74vh;
overflow-y:auto;
}
.modal-xl{
max-width:90% !important;
}
.modal-lg {
max-width: 720px !important;
}
.plan-selector-modal-content {
width:auto;
margin:auto;
}
.pswp-custom {
opacity: 0.75;
transition: opacity .2s;
display: inline-block;
float: right;
cursor: pointer;
border: none;
height: 44px;
width: 44px;
}
.pswp-custom:hover {
opacity: 1;
}
.download-btn{
background: url('/download_icon.png') no-repeat;
background-size: 20px 20px;
background-position: center;
}
.info-btn{
background: url('/info_icon.png') no-repeat;
background-size: 20px 20px;
background-position: center;
}
.share-btn{
background: url('/share_icon.png') no-repeat;
background-size: 20px 20px;
background-position: center;
}
.btn.focus , .btn:focus{
box-shadow: none;
}
.btn-success {
background: #51cd7c;
color: #fff;
border-color: #51cd7c;
}
.btn-success:hover .btn-success:focus .btn-success:active {
background-color: #29a354;
border-color: #51cd7c;
color: #242424;
}
.btn-success:disabled {
background-color: #69b383;
}
.btn-outline-success {
background: #51cd7c;
color: #fff;
border-color: #51cd7c;
}
.btn-outline-success:hover:enabled {
background: #4db76c;
color: #fff;
}
.btn-outline-danger, .btn-outline-secondary, .btn-outline-primary{
border-width: 2px;
}
#go-to-ente{
background:none;
border-color: #3dbb69;
color:#51cd7c;
}
#go-to-ente:hover, #go-to-ente:focus, #go-to-ente:active {
color:#fff;
background-color: #44774d;
}
a {
color: #fff;
}
a:hover {
color: #51cd7c;
}
.btn-link {
color: #fff;
}
.btn-link:hover {
color: #51cd7c;
}
.btn-link-danger {
color: #dc3545;
}
.btn-link-danger:hover {
color: #ff495a;
}
.card {
background-color: #242424;
color: #d1d1d1;
border-radius: 12px;
}
.jumbotron{
background-color: #191919;
color: #d1d1d1;
text-align: center;
margin-top: 50px;
}
.alert-primary {
background-color: rgb(235, 255, 243);
color: #000000;
}
.alert-success {
background-color: #c4ffd6;
}
.bm-burger-button {
position: fixed;
width: 24px;
height: 16px;
top:27px;
left: 32px;
z-index:100 !important;
}
.bm-burger-bars {
background: #bdbdbd;
}
.bm-menu-wrap {
top:0px;
}
.bm-menu {
background: #131313;
font-size: 1.15em;
color:#d1d1d1;
display: flex;
}
.bm-cross {
background: #d1d1d1;
}
.bm-cross-button {
top: 20px !important;
}
.bm-item-list {
display: flex !important;
flex-direction: column;
max-height: 100%;
flex: 1;
}
.bm-item {
padding: 20px;
}
.bm-overlay {
top: 0;
background: rgba(0, 0, 0, 0.8) !important;
}
.bg-upload-progress-bar {
background-color: #51cd7c;
}
.custom-switch.custom-switch-md{
z-index:0;
}
.custom-switch.custom-switch-md .custom-control-label {
padding-left: 2rem;
padding-bottom: 1.5rem;
}
.custom-switch.custom-switch-md .custom-control-label::before {
height: 1.5rem;
background-color: #303030;
border: none;
width: calc(2.5rem + 0.75rem);
border-radius: 3rem;
}
.custom-switch.custom-switch-md:active .custom-control-label::before {
background-color: #303030;
}
.custom-switch.custom-switch-md .custom-control-label::after {
top:2px;
background:#c4c4c4;
width: calc(2.0rem - 4px);
height: calc(2.0rem - 4px);
border-radius: calc(2rem - (2.0rem / 2));
left: -38px;
}
.custom-switch.custom-switch-md .custom-control-input:checked ~ .custom-control-label::after {
transform: translateX(calc(2.0rem - 0.25rem));
background:#c4c4c4;
}
.custom-control-input:checked ~ .custom-control-label::before {
background-color: #29a354;
}
.bold-text{
color: #ECECEC;
line-height: 24px;
font-size: 24px;
}
.dropdown-item:active{
color: #16181b;
text-decoration: none;
background-color: #e9ecef;
}
hr{
border-top: 1rem solid #444 !important;
}
.list-group-item:hover{
background-color:#343434 !important;
}
.list-group-item:active , list-group-item:focus{
background-color:#000 !important;
}
#button-tooltip > .arrow::before{
border-bottom-color:#282828 !important;
}
#button-tooltip > .arrow::after{
border-bottom-color:#282828 !important;
border-top-color:#282828 !important;
}
.arrow::before{
border-bottom-color:#282828 !important;
border-top-color:#282828 !important;
}
.arrow::after{
border-bottom-color:#282828 !important;
border-top-color:#282828 !important;
}
.carousel-inner {
padding-bottom: 50px !important;
}
.carousel-indicators li {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 12px;
}
.carousel-indicators .active {
background-color: #51cd7c;
}
div.otp-input input {
width: 36px !important;
height: 36px;
margin: 0 10px;
}
div.otp-input input::placeholder {
opacity:0;
}
div.otp-input input:not(:placeholder-shown) , div.otp-input input:focus{
border: 2px solid #51cd7c;
border-radius:1px;
-webkit-transition: 0.5s;
transition: 0.5s;
outline: none;
}
.flash-message{
padding:16px;
display:flex;
align-items:center;
}
@-webkit-keyframes rotation
{
from {
-webkit-transform: rotate(0deg);
}
to {
-webkit-transform: rotate(359deg);
}
}
#button-tooltip{
color: #ddd;
border-radius: 5px;
font-size: 12px;
padding:0px
}
.tooltip-inner{
background-color: #282828;
}
.react-datepicker__input-container > input {
width:100%;
}
.react-datepicker__navigation{
top:14px;
}
.react-datepicker, .react-datepicker__header,.react-datepicker__time-container .react-datepicker__time,.react-datepicker-time__header{
background-color: #202020;
color:#fff;
border-color: #444;
}
.react-datepicker__current-month,.react-datepicker__day-name, .react-datepicker__day, .react-datepicker__time-name{
color:#fff;
}
.react-datepicker__day--disabled{
color:#5b5656;
}
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item:hover{
background-color:#686868
}
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--disabled :hover{
background-color: #202020;
}
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--disabled{
color:#5b5656;
}
.react-datepicker{
padding-bottom:10px;
}
.react-datepicker__day:hover {
background-color:#686868
}
.react-datepicker__day--disabled:hover {
background-color: #202020;
}
.ente-form-group{
margin:0;
}
.form-check-input:hover, .form-check-label :hover{
cursor:pointer;
}
@media (min-width: 450px) {
.file-type-choice-modal{
width: 25em;
}
}
.manageLinkHeader:hover{
color:#bbb;
}
`;
import { CssBaseline } from '@mui/material';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import * as types from 'styled-components/cssprop';
export const LogoImage = styled.img`
max-height: 28px;
@ -720,9 +231,10 @@ export default function App({ Component, err }) {
content="initial-scale=1, width=device-width"
/>
</Head>
<GlobalStyles />
<MThemeProvider theme={darkThemeOptions}>
<SThemeProvider theme={darkThemeOptions}>
<CssBaseline />
{showNavbar && (
<Navbar>
<FlexContainer shouldJustifyLeft={isAlbumsDomain}>

View file

@ -1,16 +0,0 @@
import { createProxyMiddleware } from 'http-proxy-middleware';
export const config = {
api: {
bodyParser: false,
},
};
const API_ENDPOINT =
process.env.NEXT_PUBLIC_ENTE_ENDPOINT || 'https://api.staging.ente.io';
export default createProxyMiddleware({
target: API_ENDPOINT,
changeOrigin: true,
pathRewrite: { '^/api': '/' },
});

View file

@ -22,6 +22,7 @@ import {
getLocalCollections,
getNonEmptyCollections,
createCollection,
getCollectionSummaries,
} from 'services/collectionService';
import constants from 'utils/strings/constants';
import billingService from 'services/billingService';
@ -48,7 +49,6 @@ import {
getSelectedFiles,
mergeMetadata,
sortFiles,
sortFilesIntoCollections,
} from 'utils/file';
import SearchBar from 'components/Search';
import SelectedFileOptions from 'components/pages/gallery/SelectedFileOptions/GalleryOptions';
@ -57,7 +57,7 @@ import CollectionSelector, {
} from 'components/pages/gallery/CollectionSelector';
import CollectionNamer, {
CollectionNamerAttributes,
} from 'components/pages/gallery/CollectionNamer';
} from 'components/Collections/CollectionNamer';
import AlertBanner from 'components/pages/gallery/AlertBanner';
import UploadButton from 'components/pages/gallery/UploadButton';
import PlanSelector from 'components/pages/gallery/PlanSelector';
@ -93,7 +93,11 @@ import DeleteBtn from 'components/DeleteBtn';
import FixCreationTime, {
FixCreationTimeAttributes,
} from 'components/FixCreationTime';
import { Collection, CollectionAndItsLatestFile } from 'types/collection';
import {
Collection,
CollectionAndItsLatestFile,
CollectionSummaries,
} from 'types/collection';
import { EnteFile } from 'types/file';
import {
GalleryContextType,
@ -101,11 +105,11 @@ import {
Search,
NotificationAttributes,
} from 'types/gallery';
import Collections from 'components/pages/gallery/Collections';
import { VISIBILITY_STATE } from 'types/magicMetadata';
import ToastNotification from 'components/ToastNotification';
import { ElectronFile } from 'types/upload';
import importService from 'services/importService';
import Collections from 'components/Collections';
export const DeadCenter = styled.div`
flex: 1;
@ -130,6 +134,9 @@ const defaultGalleryContext: GalleryContextType = {
syncWithRemote: () => null,
setNotificationAttributes: () => null,
setBlockingLoad: () => null,
startLoading: () => null,
finishLoading: () => null,
setDialogMessage: () => null,
};
export const GalleryContext = createContext<GalleryContextType>(
@ -185,8 +192,8 @@ export default function Gallery() {
const [deleted, setDeleted] = useState<number[]>([]);
const { startLoading, finishLoading, setDialogMessage, ...appContext } =
useContext(AppContext);
const [collectionFilesCount, setCollectionFilesCount] =
useState<Map<number, number>>();
const [collectionSummaries, setCollectionSummaries] =
useState<CollectionSummaries>(new Map());
const [activeCollection, setActiveCollection] = useState<number>(undefined);
const [trash, setTrash] = useState<Trash>([]);
const [fixCreationTimeView, setFixCreationTimeView] = useState(false);
@ -303,6 +310,7 @@ export default function Gallery() {
const trash = await syncTrash(collections, setFiles, files);
setTrash(trash);
} catch (e) {
console.log(e);
switch (e.message) {
case ServerErrorCodes.SESSION_EXPIRED:
setBannerMessage(constants.SESSION_EXPIRED_MESSAGE);
@ -340,20 +348,21 @@ export default function Gallery() {
const favItemIds = await getFavItemIds(files);
setFavItemIds(favItemIds);
const nonEmptyCollections = getNonEmptyCollections(collections, files);
setCollections(nonEmptyCollections);
const collectionsAndTheirLatestFile = getCollectionsAndTheirLatestFile(
nonEmptyCollections,
files
);
setCollectionsAndTheirLatestFile(collectionsAndTheirLatestFile);
const collectionWiseFiles = sortFilesIntoCollections(files);
const collectionFilesCount = new Map<number, number>();
for (const [id, files] of collectionWiseFiles) {
collectionFilesCount.set(id, files.length);
}
setCollectionFilesCount(collectionFilesCount);
const collectionSummaries = getCollectionSummaries(
nonEmptyCollections,
files
);
const archivedCollections = getArchivedCollections(collections);
setCollectionSummaries(collectionSummaries);
const archivedCollections = getArchivedCollections(nonEmptyCollections);
setArchivedCollections(new Set(archivedCollections));
};
@ -427,24 +436,28 @@ export default function Gallery() {
}
};
const showCreateCollectionModal = (ops: COLLECTION_OPS_TYPE) => {
const showCreateCollectionModal = (ops?: COLLECTION_OPS_TYPE) => {
const callback = async (collectionName: string) => {
try {
startLoading();
const collection = await createCollection(
collectionName,
CollectionType.album,
collections
);
if (ops) {
await collectionOpsHelper(ops)(collection);
}
} catch (e) {
logError(e, 'create and collection ops failed');
logError(e, 'create and collection ops failed', { ops });
setDialogMessage({
title: constants.ERROR,
staticBackdrop: true,
close: { variant: 'danger' },
content: constants.UNKNOWN_ERROR,
});
} finally {
finishLoading();
}
};
return () =>
@ -570,6 +583,9 @@ export default function Gallery() {
syncWithRemote,
setNotificationAttributes,
setBlockingLoad,
startLoading,
finishLoading,
setDialogMessage,
}}>
<FullScreenDropZone
getRootProps={getRootProps}
@ -607,16 +623,11 @@ export default function Gallery() {
/>
<Collections
collections={collections}
collectionAndTheirLatestFile={collectionsAndTheirLatestFile}
isInSearchMode={isInSearchMode}
activeCollection={activeCollection}
setActiveCollection={setActiveCollection}
syncWithRemote={syncWithRemote}
setDialogMessage={setDialogMessage}
activeCollectionID={activeCollection}
setActiveCollectionID={setActiveCollection}
collectionSummaries={collectionSummaries}
setCollectionNamerAttributes={setCollectionNamerAttributes}
startLoading={startLoading}
finishLoading={finishLoading}
collectionFilesCount={collectionFilesCount}
/>
<CollectionNamer
show={collectionNamerView}

View file

@ -13,11 +13,10 @@ import {
syncPublicFiles,
verifyPublicCollectionPassword,
} from 'services/publicCollectionService';
import { Collection } from 'types/collection';
import { Collection, CollectionSummaries } from 'types/collection';
import { EnteFile } from 'types/file';
import { mergeMetadata, sortFiles } from 'utils/file';
import { AppContext } from 'pages/_app';
import { CollectionInfo } from 'components/pages/sharedAlbum/CollectionInfo';
import { AbuseReportForm } from 'components/pages/sharedAlbum/AbuseReportForm';
import {
defaultPublicCollectionGalleryContext,
@ -59,6 +58,8 @@ export default function PublicCollectionGallery() {
const router = useRouter();
const [isPasswordProtected, setIsPasswordProtected] =
useState<boolean>(false);
const [collectionSummaries, setCollectionSummaries] =
useState<CollectionSummaries>(new Map());
useEffect(() => {
appContext.showNavBar(true);
@ -132,7 +133,7 @@ export default function PublicCollectionGallery() {
collection?.publicURLs?.[0]?.passwordEnabled;
setIsPasswordProtected(isPasswordProtected);
setErrorMessage(null);
let files = [];
// remove outdated password, sharer has disabled the password
if (!isPasswordProtected && passwordJWTToken.current) {
passwordJWTToken.current = null;
@ -143,7 +144,7 @@ export default function PublicCollectionGallery() {
(isPasswordProtected && passwordJWTToken.current)
) {
try {
await syncPublicFiles(
files = await syncPublicFiles(
token.current,
passwordJWTToken.current,
collection,
@ -161,6 +162,17 @@ export default function PublicCollectionGallery() {
if (isPasswordProtected && !passwordJWTToken.current) {
await removePublicFiles(collectionUID);
}
collectionSummaries.set(collection.id, {
collectionAttributes: {
name: collection.name,
type: collection.type,
id: collection.id,
updationTime: collection.updationTime,
},
latestFile: files[0],
fileCount: files.length,
});
setCollectionSummaries(collectionSummaries);
} catch (e) {
const parsedError = parseSharingErrorCodes(e);
if (
@ -277,7 +289,6 @@ export default function PublicCollectionGallery() {
accessedThroughSharedURL: true,
openReportForm,
}}>
<CollectionInfo collection={publicCollection} />
<PhotoFrame
files={publicFiles}
setFiles={setPublicFiles}

View file

@ -4,15 +4,13 @@ import localForage from 'utils/storage/localForage';
import { getActualKey, getToken } from 'utils/common/key';
import CryptoWorker from 'utils/crypto';
import { SetDialogMessage } from 'components/MessageDialog';
import constants from 'utils/strings/constants';
import { getPublicKey } from './userService';
import { B64EncryptionResult } from 'utils/crypto';
import HTTPService from './HTTPService';
import { EnteFile } from 'types/file';
import { logError } from 'utils/sentry';
import { CustomError } from 'utils/error';
import { sortFiles } from 'utils/file';
import { sortFiles, sortFilesIntoCollections } from 'utils/file';
import {
Collection,
CollectionAndItsLatestFile,
@ -23,6 +21,8 @@ import {
CreatePublicAccessTokenRequest,
PublicURL,
UpdatePublicURL,
CollectionSummaries,
CollectionSummary,
} from 'types/collection';
import { COLLECTION_SORT_BY, CollectionType } from 'constants/collection';
import { UpdateMagicMetadataRequest } from 'types/magicMetadata';
@ -156,7 +156,7 @@ export const syncCollections = async () => {
}
});
let collections: Collection[] = [];
const collections: Collection[] = [];
let updationTime = await localForage.getItem<number>(
COLLECTION_UPDATION_TIME
);
@ -167,11 +167,7 @@ export const syncCollections = async () => {
updationTime = Math.max(updationTime, collection.updationTime);
}
}
collections = sortCollections(
collections,
[],
COLLECTION_SORT_BY.MODIFICATION_TIME
);
await localForage.setItem(COLLECTION_TABLE, collections);
await localForage.setItem(COLLECTION_UPDATION_TIME, updationTime);
return collections;
@ -478,12 +474,7 @@ export const removeFromCollection = async (
}
};
export const deleteCollection = async (
collectionID: number,
syncWithRemote: () => Promise<void>,
redirectToAll: () => void,
setDialogMessage: SetDialogMessage
) => {
export const deleteCollection = async (collectionID: number) => {
try {
const token = getToken();
@ -493,15 +484,9 @@ export const deleteCollection = async (
null,
{ 'X-Auth-Token': token }
);
await syncWithRemote();
redirectToAll();
} catch (e) {
logError(e, 'delete collection failed ');
setDialogMessage({
title: constants.ERROR,
content: constants.DELETE_COLLECTION_FAILED,
close: { variant: 'danger' },
});
throw e;
}
};
@ -719,79 +704,95 @@ export const getNonEmptyCollections = (
);
};
export function sortCollections(
collections: Collection[],
collectionAndTheirLatestFile: CollectionAndItsLatestFile[],
export function sortCollectionSummaries(
collectionSummaries: CollectionSummary[],
sortBy: COLLECTION_SORT_BY
) {
return moveFavCollectionToFront(
collections.sort((collectionA, collectionB) => {
collectionSummaries.sort((a, b) => {
switch (sortBy) {
case COLLECTION_SORT_BY.LATEST_FILE:
case COLLECTION_SORT_BY.CREATION_TIME_DESCENDING:
return compareCollectionsLatestFile(
collectionAndTheirLatestFile,
collectionA,
collectionB
b.latestFile,
a.latestFile
);
case COLLECTION_SORT_BY.CREATION_TIME_ASCENDING:
return (
-1 *
compareCollectionsLatestFile(b.latestFile, a.latestFile)
);
case COLLECTION_SORT_BY.UPDATION_TIME_DESCENDING:
return (
b.collectionAttributes.updationTime -
a.collectionAttributes.updationTime
);
case COLLECTION_SORT_BY.MODIFICATION_TIME:
return collectionB.updationTime - collectionA.updationTime;
case COLLECTION_SORT_BY.NAME:
return collectionA.name.localeCompare(collectionB.name);
return a.collectionAttributes.name.localeCompare(
b.collectionAttributes.name
);
}
})
);
}
function compareCollectionsLatestFile(
collectionAndTheirLatestFile: CollectionAndItsLatestFile[],
collectionA: Collection,
collectionB: Collection
) {
if (!collectionAndTheirLatestFile?.length) {
return 0;
}
const CollectionALatestFile = getCollectionLatestFile(
collectionAndTheirLatestFile,
collectionA
);
const CollectionBLatestFile = getCollectionLatestFile(
collectionAndTheirLatestFile,
collectionB
);
if (!CollectionALatestFile || !CollectionBLatestFile) {
return 0;
} else {
const sortedFiles = sortFiles([
CollectionALatestFile,
CollectionBLatestFile,
]);
if (sortedFiles[0].id !== CollectionALatestFile.id) {
function compareCollectionsLatestFile(first: EnteFile, second: EnteFile) {
const sortedFiles = sortFiles([first, second]);
if (sortedFiles[0].id !== first.id) {
return 1;
} else {
return -1;
}
}
}
function getCollectionLatestFile(
collectionAndTheirLatestFile: CollectionAndItsLatestFile[],
collection: Collection
) {
const collectionAndItsLatestFile = collectionAndTheirLatestFile.filter(
(collectionAndItsLatestFile) =>
collectionAndItsLatestFile.collection.id === collection.id
);
if (collectionAndItsLatestFile.length === 1) {
return collectionAndItsLatestFile[0].file;
}
}
function moveFavCollectionToFront(collections: Collection[]) {
return collections.sort((collectionA, collectionB) =>
collectionA.type === CollectionType.favorites
function moveFavCollectionToFront(collectionSummaries: CollectionSummary[]) {
return collectionSummaries.sort((a, b) =>
a.collectionAttributes.type === CollectionType.favorites
? -1
: collectionB.type === CollectionType.favorites
: b.collectionAttributes.type === CollectionType.favorites
? 1
: 0
);
}
export function getCollectionSummaries(
collections: Collection[],
files: EnteFile[]
): CollectionSummaries {
const CollectionSummaries: CollectionSummaries = new Map();
const collectionAndTheirLatestFile = getCollectionsAndTheirLatestFile(
collections,
files
);
const collectionAndTheirLatestFileMap = new Map();
for (const collectionAndItsLatestFile of collectionAndTheirLatestFile) {
collectionAndTheirLatestFileMap.set(
collectionAndItsLatestFile.collection.id,
collectionAndItsLatestFile.file
);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const collectionFilesCount = getCollectionsFileCount(files);
for (const collection of collections) {
CollectionSummaries.set(collection.id, {
collectionAttributes: {
id: collection.id,
name: collection.name,
type: collection.type,
updationTime: collection.updationTime,
},
latestFile: collectionAndTheirLatestFileMap.get(collection.id),
fileCount: collectionFilesCount.get(collection.id),
});
}
return CollectionSummaries;
}
function getCollectionsFileCount(files: EnteFile[]) {
const collectionWiseFiles = sortFilesIntoCollections(files);
const collectionFilesCount = new Map<number, number>();
for (const [id, files] of collectionWiseFiles) {
collectionFilesCount.set(id, files.length);
}
return collectionFilesCount;
}

499
src/styles/global.css Normal file
View file

@ -0,0 +1,499 @@
/* Inter-regular - latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
src: local(''),
url('/fonts/Inter-Regular.ttf') format('truetype');
}
/* Inter-700 - latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
src: local(''),
url('/fonts/Inter-Bold.ttf') format('truetype');
}
html, body {
font-family: Arial, Helvetica, sans-serif;
height: 100%;
flex: 1;
display: flex;
flex-direction: column;
font-family:Inter, Arial, sans-serif !important;
}
:is(h1, h2, h3, h4, h5, h6) {
color: #d7d7d7;
}
#__next {
flex: 1;
display: flex;
flex-direction: column;
}
.download-btn{
background: url('/download_icon.png') no-repeat;
background-size: 20px 20px;
background-position: center;
}
.info-btn{
background: url('/info_icon.png') no-repeat;
background-size: 20px 20px;
background-position: center;
}
.pswp__item video {
width: 100%;
height: 100%;
}
.pswp-item-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
object-fit: contain;
}
.pswp-item-container > * {
position: absolute;
transition: opacity 1s ease;
max-width: 100%;
max-height: 100%;
}
.pswp-item-container > img{
opacity: 1;
}
.pswp-item-container > video{
opacity: 0;
}
.pswp-item-container > div.download-banner {
width:100%;
height: 16vh;
padding:2vh 0;
background-color: #151414;
color:#ddd;
display: flex;
flex-direction:column;
align-items: center;
justify-content: space-around;
opacity: 0.8;
font-size:20px;
}
.download-banner > a{
width: 130px;
}
:root {
--primary: #e26f99,
};
svg {
fill: currentColor;
}
.pswp__img {
object-fit: contain;
}
.pswp__caption{
font-size:20px;
height:10%;
padding-left:5%;
color:#eee;
}
.modal {
z-index: 2000;
}
.modal-dialog-centered {
min-height: -webkit-calc(80% - 3.5rem);
min-height: -moz-calc(80% - 3.5rem);
min-height: calc(80% - 3.5rem);
}
.modal .modal-header, .modal .modal-footer , .toast-header{
border-color: #444 !important;
}
.modal .modal-header .close, .toast-header .close {
color: #aaa;
text-shadow: none;
}
.modal-backdrop {
z-index:2000;
opacity:0.8 !important;
}
.toast-header{
border-radius:0px !important;
}
.modal .card , .table , .toast {
background-color: #202020;
border: none;
}
.modal .card > div {
border-radius: 30px;
overflow: hidden;
margin: 0 0 5px 0;
}
.modal-content ,.toast-header{
border-radius:15px;
background-color:#202020 !important;
}
.modal-dialog{
margin:5vh auto;
width:90%;
}
.modal-body{
max-height:74vh;
overflow-y:auto;
}
.modal-xl{
max-width:90% !important;
}
.modal-lg {
max-width: 720px !important;
}
.plan-selector-modal-content {
width:auto;
margin:auto;
}
.pswp-custom {
display: flex;
justify-content: center;
align-items: center;
opacity: 0.75;
transition: opacity .2s;
padding-top: 4px;
float: right;
cursor: pointer;
border: none;
height: 44px;
width: 44px;
}
.pswp-custom:hover {
opacity: 1;
}
.pswp__button--arrow--left::before, .pswp__button--arrow--right::before {
background-color: #333333;
border-radius: 50%;
width: 56px;
height: 56px;
top:0;
}
.pswp__button--arrow--left::before {
background-position: -128px -32px;
}
.pswp__button--arrow--left{
margin-left: 20px;
}
.pswp__button--arrow--right{
margin-right: 20px;
}
.pswp__button--arrow--right::before {
background-position: -83px -30px;
}
.btn.focus , .btn:focus{
box-shadow: none;
}
.btn-success {
background: #51cd7c;
color: #fff;
border-color: #51cd7c;
}
.btn-success:hover .btn-success:focus .btn-success:active {
background-color: #29a354;
border-color: #51cd7c;
color: #242424;
}
.btn-success:disabled {
background-color: #69b383;
}
.btn-outline-success {
background: #51cd7c;
color: #fff;
border-color: #51cd7c;
}
.btn-outline-success:hover:enabled {
background: #4db76c;
color: #fff;
}
.btn-outline-danger, .btn-outline-secondary, .btn-outline-primary{
border-width: 2px;
}
#go-to-ente{
background:none;
border-color: #3dbb69;
color:#51cd7c;
}
#go-to-ente:hover, #go-to-ente:focus, #go-to-ente:active {
color:#fff;
background-color: #44774d;
}
a {
color: #fff;
}
a:hover {
color: #51cd7c;
}
.btn-link {
color: #fff;
}
.btn-link:hover {
color: #51cd7c;
}
.btn-link-danger {
color: #dc3545;
}
.btn-link-danger:hover {
color: #ff495a;
}
.card {
background-color: #242424;
color: #d1d1d1;
border-radius: 12px;
}
.jumbotron{
background-color: #191919;
color: #d1d1d1;
text-align: center;
margin-top: 50px;
}
.alert-primary {
background-color: rgb(235, 255, 243);
color: #000000;
}
.alert-success {
background-color: #c4ffd6;
}
.bm-burger-button {
position: fixed;
width: 24px;
height: 16px;
top:27px;
left: 32px;
z-index:100 !important;
}
.bm-burger-bars {
background: #bdbdbd;
}
.bm-menu-wrap {
top:0px;
}
.bm-menu {
background: #131313;
font-size: 1.15em;
color:#d1d1d1;
display: flex;
}
.bm-cross {
background: #d1d1d1;
}
.bm-cross-button {
top: 20px !important;
}
.bm-item-list {
display: flex !important;
flex-direction: column;
max-height: 100%;
flex: 1;
}
.bm-item {
padding: 20px;
}
.bm-overlay {
top: 0;
background: rgba(0, 0, 0, 0.8) !important;
}
.bg-upload-progress-bar {
background-color: #51cd7c;
}
.custom-switch.custom-switch-md{
z-index:0;
}
.custom-switch.custom-switch-md .custom-control-label {
padding-left: 2rem;
padding-bottom: 1.5rem;
}
.custom-switch.custom-switch-md .custom-control-label::before {
height: 1.5rem;
background-color: #303030;
border: none;
width: calc(2.5rem + 0.75rem);
border-radius: 3rem;
}
.custom-switch.custom-switch-md:active .custom-control-label::before {
background-color: #303030;
}
.custom-switch.custom-switch-md .custom-control-label::after {
top:2px;
background:#c4c4c4;
width: calc(2.0rem - 4px);
height: calc(2.0rem - 4px);
border-radius: calc(2rem - (2.0rem / 2));
left: -38px;
}
.custom-switch.custom-switch-md .custom-control-input:checked ~ .custom-control-label::after {
transform: translateX(calc(2.0rem - 0.25rem));
background:#c4c4c4;
}
.custom-control-input:checked ~ .custom-control-label::before {
background-color: #29a354;
}
.bold-text{
color: #ECECEC;
line-height: 24px;
font-size: 24px;
}
.dropdown-item:active{
color: #16181b;
text-decoration: none;
background-color: #e9ecef;
}
.list-group-item:hover{
background-color:#343434 !important;
}
.list-group-item:active , list-group-item:focus{
background-color:#000 !important;
}
#button-tooltip > .arrow::before{
border-bottom-color:#282828 !important;
}
#button-tooltip > .arrow::after{
border-bottom-color:#282828 !important;
border-top-color:#282828 !important;
}
.arrow::before{
border-bottom-color:#282828 !important;
border-top-color:#282828 !important;
}
.arrow::after{
border-bottom-color:#282828 !important;
border-top-color:#282828 !important;
}
.carousel-inner {
padding-bottom: 50px !important;
}
.carousel-indicators li {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 12px;
}
.carousel-indicators .active {
background-color: #51cd7c;
}
div.otp-input input {
width: 36px !important;
height: 36px;
margin: 0 10px;
}
div.otp-input input::placeholder {
opacity:0;
}
div.otp-input input:not(:placeholder-shown) , div.otp-input input:focus{
border: 2px solid #51cd7c;
border-radius:1px;
-webkit-transition: 0.5s;
transition: 0.5s;
outline: none;
}
.flash-message{
padding:16px;
display:flex;
align-items:center;
}
@-webkit-keyframes rotation
{
from {
-webkit-transform: rotate(0deg);
}
to {
-webkit-transform: rotate(359deg);
}
}
#button-tooltip{
color: #ddd;
border-radius: 5px;
font-size: 12px;
padding:0px
}
.tooltip-inner{
background-color: #282828;
}
.react-datepicker__input-container > input {
width:100%;
}
.react-datepicker__navigation{
top:14px;
}
.react-datepicker, .react-datepicker__header,.react-datepicker__time-container .react-datepicker__time,.react-datepicker-time__header{
background-color: #202020;
color:#fff;
border-color: #444;
}
.react-datepicker__current-month,.react-datepicker__day-name, .react-datepicker__day, .react-datepicker__time-name{
color:#fff;
}
.react-datepicker__day--disabled{
color:#5b5656;
}
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item:hover{
background-color:#686868
}
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--disabled :hover{
background-color: #202020;
}
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--disabled{
color:#5b5656;
}
.react-datepicker{
padding-bottom:10px;
}
.react-datepicker__day:hover {
background-color:#686868
}
.react-datepicker__day--disabled:hover {
background-color: #202020;
}
.ente-form-group{
margin:0;
}
.form-check-input:hover, .form-check-label :hover{
cursor:pointer;
}
@media (min-width: 450px) {
.file-type-choice-modal{
width: 25em;
}
}
.manageLinkHeader:hover{
color:#bbb;
}

View file

@ -91,3 +91,15 @@ export interface CollectionMagicMetadata
extends Omit<MagicMetadataCore, 'data'> {
data: CollectionMagicMetadataProps;
}
export interface CollectionSummary {
collectionAttributes: {
id: number;
name: string;
type: CollectionType;
updationTime: number;
};
latestFile: EnteFile;
fileCount: number;
}
export type CollectionSummaries = Map<number, CollectionSummary>;

View file

@ -1,3 +1,4 @@
import { SetDialogMessage } from 'components/MessageDialog';
import { Collection } from 'types/collection';
import { EnteFile } from 'types/file';
import { DateValue, Bbox } from 'types/search';
@ -30,6 +31,9 @@ export type GalleryContextType = {
syncWithRemote: (force?: boolean, silent?: boolean) => Promise<void>;
setNotificationAttributes: (attributes: NotificationAttributes) => void;
setBlockingLoad: (value: boolean) => void;
startLoading: () => void;
finishLoading: () => void;
setDialogMessage: SetDialogMessage;
};
export interface NotificationAttributes {

View file

@ -41,6 +41,9 @@ export interface User {
encryptedToken: string;
isTwoFactorEnabled: boolean;
twoFactorSessionID: string;
usage: number;
fileCount: number;
sharedCollectionCount: number;
}
export interface EmailVerificationResponse {
id: number;

View file

@ -12,7 +12,6 @@ import { CustomError, ServerErrorCodes } from 'utils/error';
import { SelectedState } from 'types/gallery';
import { User } from 'types/user';
import { getData, LS_KEYS } from 'utils/storage/localStorage';
import { SetDialogMessage } from 'components/MessageDialog';
import { logError } from 'utils/sentry';
import constants from 'utils/strings/constants';
import { Collection, CollectionMagicMetadataProps } from 'types/collection';
@ -96,10 +95,7 @@ export function isFavoriteCollection(
}
}
export async function downloadCollection(
collectionID: number,
setDialogMessage: SetDialogMessage
) {
export async function downloadAllCollectionFiles(collectionID: number) {
try {
const allFiles = await getLocalFiles();
const collectionFiles = allFiles.filter(
@ -108,11 +104,6 @@ export async function downloadCollection(
await downloadFiles(collectionFiles);
} catch (e) {
logError(e, 'download collection failed ');
setDialogMessage({
title: constants.ERROR,
content: constants.DELETE_COLLECTION_FAILED,
close: { variant: 'danger' },
});
}
}
@ -168,19 +159,13 @@ export const shareExpiryOptions = [
},
];
export const changeCollectionVisibilityHelper = async (
export const changeCollectionVisibility = async (
collection: Collection,
startLoading: () => void,
finishLoading: () => void,
setDialogMessage: SetDialogMessage,
syncWithRemote: () => Promise<void>
visibility: VISIBILITY_STATE
) => {
startLoading();
try {
const updatedMagicMetadataProps: CollectionMagicMetadataProps = {
visibility: collection.magicMetadata?.data.visibility
? VISIBILITY_STATE.VISIBLE
: VISIBILITY_STATE.ARCHIVED,
visibility,
};
const updatedCollection = {
@ -197,23 +182,9 @@ export const changeCollectionVisibilityHelper = async (
logError(e, 'change file visibility failed');
switch (e.status?.toString()) {
case ServerErrorCodes.FORBIDDEN:
setDialogMessage({
title: constants.ERROR,
staticBackdrop: true,
close: { variant: 'danger' },
content: constants.NOT_FILE_OWNER,
});
return;
throw Error(constants.NOT_FILE_OWNER);
}
setDialogMessage({
title: constants.ERROR,
staticBackdrop: true,
close: { variant: 'danger' },
content: constants.UNKNOWN_ERROR,
});
} finally {
await syncWithRemote();
finishLoading();
throw e;
}
};

View file

@ -16,6 +16,7 @@ export enum LS_KEYS {
LIVE_PHOTO_INFO_SHOWN_COUNT = 'livePhotoInfoShownCount',
FAILED_UPLOADS = 'failedUploads',
LOGS = 'logs',
COLLECTION_SORT_BY = 'collectionSortBy',
}
export const setData = (key: LS_KEYS, value: object) => {

View file

@ -554,7 +554,7 @@ const englishConstants = {
'these files were not uploaded as they exceed our maximum file size limit',
UPLOAD_TO_COLLECTION: 'upload to album',
ARCHIVE: 'archive',
ALL: 'all',
ALL_SECTION_NAME: 'All Photos',
MOVE_TO_COLLECTION: 'move to album',
UNARCHIVE: 'un-archive',
MOVE: 'move',
@ -586,9 +586,11 @@ const englishConstants = {
</p>
</>
),
SORT_BY_LATEST_PHOTO: 'recent photo',
SORT_BY_MODIFICATION_TIME: 'last updated',
SORT_BY_COLLECTION_NAME: 'album name',
SORT_BY_CREATION_TIME_ASCENDING: 'Oldest',
SORT_BY_CREATION_TIME_DESCENDING: 'Newest',
SORT_BY_UPDATION_TIME_DESCENDING: 'Last updated',
SORT_BY_NAME: 'Name',
FIX_LARGE_THUMBNAILS: 'compress thumbnails',
THUMBNAIL_REPLACED: 'thumbnails compressed',
FIX_THUMBNAIL: 'compress',
@ -712,6 +714,12 @@ const englishConstants = {
'are you sure that you want to stop all the uploads in progress?',
STOP_UPLOADS_HEADER: 'stop uploads?',
YES_STOP_UPLOADS: 'yes, stop uploads',
ALBUMS: 'Albums',
NEW: 'New',
VIEW_ALL_ALBUMS: 'View all Albums',
ALL_ALBUMS: 'All Albums',
PHOTOS: 'Photos',
ENTE: 'ente',
};
export default englishConstants;

View file

@ -1065,6 +1065,13 @@
prop-types "^15.7.2"
react-is "^17.0.2"
"@mui/icons-material@^5.6.2":
version "5.6.2"
resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-5.6.2.tgz#239c40fc5841dc7c6af7d00e4e988550de170fcd"
integrity sha512-9QdI7axKuBAyaGz4mtdi7Uy1j73/thqFmEuxpJHxNC7O8ADEK1Da3t2veK2tgmsXsUlAHcAG63gg+GvWWeQNqQ==
dependencies:
"@babel/runtime" "^7.17.2"
"@mui/material@^5.6.2":
version "5.6.2"
resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.6.2.tgz#bcd0ecdb265aca1bfee11773f6561aeedaa25b45"
@ -1093,6 +1100,7 @@
prop-types "^15.7.2"
"@mui/styled-engine-sc@^5.6.1", "@mui/styled-engine@^5.6.1", "@mui/styled-engine@npm:@mui/styled-engine-sc@latest":
name "@mui/styled-engine"
version "5.6.1"
resolved "https://registry.yarnpkg.com/@mui/styled-engine-sc/-/styled-engine-sc-5.6.1.tgz#d9505c005eeefa5c7d7ef9c03e63324db889ee09"
integrity sha512-BMY5Pb8YgOxvvwg9s6mPeDgzha4244/KgRmHvTVPBmvqypbiLPApR6SNyYON+1/3kLHt3TpmxrlRY3jPiZF2QQ==
@ -1626,10 +1634,10 @@
resolved "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz"
integrity sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==
"@types/styled-components@^5.1.3":
version "5.1.14"
resolved "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.14.tgz"
integrity sha512-d6P1/tyNytqKwam3cQXq7a9uPtovc/mdAs7dBiz1YbDdNIT3X4WmuFU78YdSYh84TXVuhOwezZ3EeKuNBhwsHQ==
"@types/styled-components@^5.1.25":
version "5.1.25"
resolved "https://registry.yarnpkg.com/@types/styled-components/-/styled-components-5.1.25.tgz#0177c4ab5fa7c6ed0565d36f597393dae3f380ad"
integrity sha512-fgwl+0Pa8pdkwXRoVPP9JbqF0Ivo9llnmsm+7TCI330kbPIFd9qv1Lrhr37shf4tnxCOSu+/IgqM7uJXLWZZNQ==
dependencies:
"@types/hoist-non-react-statics" "*"
"@types/react" "*"