Merge branch 'ui-redesign' into watch

This commit is contained in:
Rushikesh Tote 2022-06-10 19:31:31 +05:30
commit 02ff9aeaa7
119 changed files with 3553 additions and 2960 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View file

@ -1,8 +1,9 @@
import { Typography } from '@mui/material';
import { Box } from '@mui/material';
import constants from 'utils/strings/constants';
import React from 'react';
import CollectionCard from '../CollectionCard';
import { CollectionSummary } from 'types/collection';
import { AllCollectionTileText } from '../styledComponents';
interface Iprops {
collectionTile: any;
@ -20,24 +21,10 @@ export default function AllCollectionCard({
collectionTile={collectionTile}
latestFile={collectionSummary.latestFile}
onClick={() => onCollectionClick(collectionSummary.id)}>
<div>
<Typography
css={`
font-size: 14px;
font-weight: 600;
line-height: 20px;
`}>
{collectionSummary.name}
</Typography>
<Typography
css={`
font-size: 14px;
font-weight: 400;
line-height: 20px;
`}>
{constants.PHOTO_COUNT(collectionSummary.fileCount)}
</Typography>
</div>
<AllCollectionTileText>
<Box fontWeight={'bold'}>{collectionSummary.name}</Box>
<Box>{constants.PHOTO_COUNT(collectionSummary.fileCount)}</Box>
</AllCollectionTileText>
</CollectionCard>
);
}

View file

@ -2,7 +2,7 @@ import { Dialog, Slide, styled } from '@mui/material';
import React from 'react';
import PropTypes from 'prop-types';
export const FloatingDrawer = styled(Dialog)(({ theme }) => ({
export const AllCollectionContainer = styled(Dialog)(({ theme }) => ({
'& .MuiDialog-container': {
justifyContent: 'flex-end',
},
@ -17,7 +17,7 @@ export const FloatingDrawer = styled(Dialog)(({ theme }) => ({
},
}));
FloatingDrawer.propTypes = {
AllCollectionContainer.propTypes = {
children: PropTypes.node,
onClose: PropTypes.func.isRequired,
};

View file

@ -14,12 +14,7 @@ export default function AllCollectionsHeader({
return (
<DialogTitle>
<SpaceBetweenFlex>
<Typography
css={`
font-size: 24px;
font-weight: 600;
line-height: 36px;
`}>
<Typography variant="subtitle">
{constants.ALL_ALBUMS}
</Typography>
<IconButton onClick={onClose}>
@ -27,13 +22,7 @@ export default function AllCollectionsHeader({
</IconButton>
</SpaceBetweenFlex>
<SpaceBetweenFlex>
<Typography
css={`
font-size: 24px;
font-weight: 600;
line-height: 36px;
`}
color={'text.secondary'}>
<Typography variant="subtitle" color={'text.secondary'}>
{`${collectionCount} ${constants.ALBUMS}`}
</Typography>
<CollectionSort

View file

@ -1,11 +1,14 @@
import React, { useMemo } from 'react';
import Divider from '@mui/material/Divider';
import { CollectionType, COLLECTION_SORT_BY } from 'constants/collection';
import {
COLLECTION_SORT_BY,
SPECIAL_COLLECTION_TYPES,
} from 'constants/collection';
import { sortCollectionSummaries } from 'services/collectionService';
import {
Transition,
FloatingDrawer,
} from 'components/Collections/FloatingDrawer';
AllCollectionContainer,
} from 'components/Collections/AllCollections/Container';
import { useLocalState } from 'hooks/useLocalState';
import { LS_KEYS } from 'utils/storage/localStorage';
import AllCollectionsHeader from './header';
@ -35,7 +38,7 @@ export default function AllCollections(props: Iprops) {
() =>
sortCollectionSummaries(
[...collectionSummaries.values()].filter(
(x) => x.type !== CollectionType.system
(x) => !SPECIAL_COLLECTION_TYPES.has(x.type)
),
collectionSortBy
),
@ -48,7 +51,7 @@ export default function AllCollections(props: Iprops) {
};
return (
<FloatingDrawer
<AllCollectionContainer
TransitionComponent={LeftSlideTransition}
onClose={close}
open={isOpen}>
@ -64,6 +67,6 @@ export default function AllCollections(props: Iprops) {
collectionSummaries={sortedCollectionSummaries}
onCollectionClick={onCollectionClick}
/>
</FloatingDrawer>
</AllCollectionContainer>
);
}

View file

@ -3,25 +3,30 @@ import { EnteFile } from 'types/file';
import {
CollectionTileWrapper,
ActiveIndicator,
CollectionTile,
CollectionBarTile,
CollectionBarTileText,
} from '../styledComponents';
import CollectionCard from '../CollectionCard';
import TruncateText from 'components/TruncateText';
interface Iprops {
active: boolean;
latestFile: EnteFile;
collectionName: string;
onClick: () => void;
}
const CollectionCardWithActiveIndicator = React.forwardRef(
(
props: {
children;
active: boolean;
latestFile: EnteFile;
onClick: () => void;
},
ref: any
) => {
const { active, ...others } = props;
(props: Iprops, ref: any) => {
const { active, collectionName, ...others } = props;
return (
<CollectionTileWrapper ref={ref}>
<CollectionCard collectionTile={CollectionTile} {...others} />
<CollectionCard collectionTile={CollectionBarTile} {...others}>
<CollectionBarTileText>
<TruncateText text={collectionName} />
</CollectionBarTileText>
</CollectionCard>
{active && <ActiveIndicator />}
</CollectionTileWrapper>
);

View file

@ -96,21 +96,15 @@ export default function CollectionBar(props: IProps) {
/>
)}
<ScrollContainer ref={componentRef}>
<CollectionCardWithActiveIndicator
latestFile={null}
active={activeCollection === ALL_SECTION}
onClick={clickHandler(ALL_SECTION)}>
{constants.ALL_SECTION_NAME}
</CollectionCardWithActiveIndicator>
{sortedCollectionSummary.map((item) => (
<CollectionCardWithActiveIndicator
key={item.id}
latestFile={item.latestFile}
ref={collectionChipsRef[item.id]}
active={activeCollection === item.id}
onClick={clickHandler(item.id)}>
{item.name}
</CollectionCardWithActiveIndicator>
onClick={clickHandler(item.id)}
collectionName={item.name}
/>
))}
</ScrollContainer>
{!onFarRight && (

View file

@ -34,7 +34,8 @@ export default function CollectionCard(props: {
}, [file]);
return (
<CustomCollectionTile coverImgURL={coverImageURL} onClick={onClick}>
<CustomCollectionTile onClick={onClick}>
{coverImageURL && <img src={coverImageURL} />}
{children}
</CustomCollectionTile>
);

View file

@ -4,7 +4,7 @@ import { Collection, CollectionSummary } from 'types/collection';
import { CollectionSectionWrapper } from 'components/Collections/styledComponents';
import CollectionOptions from 'components/Collections/CollectionOptions';
import { SetCollectionNamerAttributes } from 'components/Collections/CollectionNamer';
import { CollectionType } from 'constants/collection';
import { SPECIAL_COLLECTION_TYPES } from 'constants/collection';
import { SpaceBetweenFlex } from 'components/Container';
interface Iprops {
@ -28,10 +28,9 @@ export default function collectionInfoWithOptions({
<CollectionSectionWrapper>
<SpaceBetweenFlex>
<CollectionInfo name={name} fileCount={fileCount} />
{type !== CollectionType.system &&
type !== CollectionType.favorites && (
<CollectionOptions {...props} />
)}
{!SPECIAL_COLLECTION_TYPES.has(type) && (
<CollectionOptions {...props} />
)}
</SpaceBetweenFlex>
</CollectionSectionWrapper>
);

View file

@ -1,20 +1,12 @@
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 { SpaceBetweenFlex } from 'components/Container';
import Close from '@mui/icons-material/Close';
import DialogBox from 'components/DialogBox';
import SingleInputForm, {
SingleInputFormProps,
} from 'components/SingleInputForm';
export interface CollectionNamerAttributes {
callback: (name) => void;
callback: (name: string) => void;
title: string;
autoFilledName: string;
buttonText: string;
@ -29,73 +21,36 @@ interface Props {
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();
const onSubmit: SingleInputFormProps['callback'] = async (
albumName,
setFieldError
) => {
try {
attributes.callback(albumName);
props.onHide();
} catch (e) {
setFieldError(constants.UNKNOWN_ERROR);
}
};
return (
<Dialog open={props.show} onClose={props.onHide} maxWidth="xs">
<DialogTitle>
<SpaceBetweenFlex>
{attributes?.title}
<IconButton onClick={props.onHide}>
<Close />
</IconButton>
</SpaceBetweenFlex>
</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>
<DialogBox
open={props.show}
attributes={{ title: attributes.title }}
onClose={props.onHide}
titleCloseButton
maxWidth="xs">
<SingleInputForm
callback={onSubmit}
fieldType="text"
buttonText={attributes.buttonText}
placeholder={constants.ENTER_ALBUM_NAME}
/>
</DialogBox>
);
}

View file

@ -128,7 +128,6 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
setDialogMessage({
title: constants.CONFIRM_DELETE_COLLECTION,
content: constants.DELETE_COLLECTION_MESSAGE(),
staticBackdrop: true,
proceed: {
text: constants.DELETE_COLLECTION,
action: handleCollectionAction(CollectionActions.DELETE),
@ -144,7 +143,6 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
setDialogMessage({
title: constants.CONFIRM_DOWNLOAD_COLLECTION,
content: constants.DOWNLOAD_COLLECTION_MESSAGE(),
staticBackdrop: true,
proceed: {
text: constants.DOWNLOAD,
action: handleCollectionAction(CollectionActions.DOWNLOAD),

View file

@ -1,607 +0,0 @@
import React, { useContext, useEffect, useState } from 'react';
import Select from 'react-select';
import constants from 'utils/strings/constants';
import { Formik, FormikHelpers } from 'formik';
import * as Yup from 'yup';
import Form from 'react-bootstrap/Form';
import FormControl from 'react-bootstrap/FormControl';
import { Button, Col, Table } from 'react-bootstrap';
import { DeadCenter, GalleryContext } from 'pages/gallery';
import { User } from 'types/user';
import {
shareCollection,
unshareCollection,
createShareableURL,
deleteShareableURL,
updateShareableURL,
} from 'services/collectionService';
import { getData, LS_KEYS } from 'utils/storage/localStorage';
import SubmitButton from '../SubmitButton';
import DialogBox from '../DialogBox';
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 { handleSharingErrors } from 'utils/error';
import { sleep } from 'utils/common';
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 { AppContext } from 'pages/_app';
interface Props {
show: boolean;
onHide: () => void;
collection: Collection;
}
interface formValues {
email: string;
}
interface ShareeProps {
sharee: User;
collectionUnshare: (sharee: User) => void;
}
const DropdownStyle = {
...SelectStyles,
dropdownIndicator: (style) => ({
...style,
margin: '0px',
}),
singleValue: (style) => ({
...style,
color: '#d1d1d1',
width: '240px',
}),
control: (style, { isFocused }) => ({
...style,
...SelectStyles.control(style, { isFocused }),
minWidth: '240px',
}),
};
const linkExpiryStyle = {
...DropdownStyle,
placeholder: (style) => ({
...style,
color: '#d1d1d1',
width: '100%',
textAlign: 'center',
}),
};
const OptionRow = styled(Row)`
flex-wrap: wrap;
justify-content: center;
`;
const OptionLabel = styled(Label)`
flex: 1 1 103px;
@media (min-width: 513px) {
text-align: left;
}
margin: 5px;
`;
const OptionValue = styled(Value)`
flex: 0 0 240px;
justify-content: center;
margin: 5px;
`;
function CollectionShare(props: Props) {
const [loading, setLoading] = useState(false);
const appContext = useContext(AppContext);
const galleryContext = useContext(GalleryContext);
const [sharableLinkError, setSharableLinkError] = useState(null);
const [publicShareUrl, setPublicShareUrl] = useState<string>(null);
const [publicShareProp, setPublicShareProp] = useState<PublicURL>(null);
const [configurePassword, setConfigurePassword] = useState(false);
const deviceLimitOptions = selectIntOptions(50);
const expiryOptions = shareExpiryOptions;
useEffect(() => {
const main = async () => {
if (props.collection?.publicURLs?.[0]?.url) {
const t = await appendCollectionKeyToShareURL(
props.collection?.publicURLs?.[0]?.url,
props.collection.key
);
setPublicShareUrl(t);
setPublicShareProp(
props.collection?.publicURLs?.[0] as PublicURL
);
} else {
setPublicShareUrl(null);
setPublicShareProp(null);
}
};
main();
}, [props.collection]);
const collectionShare = async (
{ email }: formValues,
{ resetForm, setFieldError }: FormikHelpers<formValues>
) => {
try {
setLoading(true);
appContext.startLoading();
const user: User = getData(LS_KEYS.USER);
if (email === user.email) {
setFieldError('email', constants.SHARE_WITH_SELF);
} else if (
props.collection?.sharees?.find(
(value) => value.email === email
)
) {
setFieldError('email', constants.ALREADY_SHARED(email));
} else {
await shareCollection(props.collection, email);
await sleep(2000);
await galleryContext.syncWithRemote(false, true);
resetForm();
}
} catch (e) {
const errorMessage = handleSharingErrors(e);
setFieldError('email', errorMessage);
} finally {
setLoading(false);
appContext.finishLoading();
}
};
const collectionUnshare = async (sharee) => {
try {
appContext.startLoading();
await unshareCollection(props.collection, sharee.email);
await sleep(2000);
await galleryContext.syncWithRemote(false, true);
} finally {
appContext.finishLoading();
}
};
const createSharableURLHelper = async () => {
try {
appContext.startLoading();
const publicURL = await createShareableURL(props.collection);
const sharableURL = await appendCollectionKeyToShareURL(
publicURL.url,
props.collection.key
);
setPublicShareUrl(sharableURL);
galleryContext.syncWithRemote(false, true);
} catch (e) {
const errorMessage = handleSharingErrors(e);
setSharableLinkError(errorMessage);
} finally {
appContext.finishLoading();
}
};
const disablePublicSharingHelper = async () => {
try {
appContext.startLoading();
await deleteShareableURL(props.collection);
setPublicShareUrl(null);
galleryContext.syncWithRemote(false, true);
} catch (e) {
const errorMessage = handleSharingErrors(e);
setSharableLinkError(errorMessage);
} finally {
appContext.finishLoading();
}
};
const savePassword = async (passphrase, setFieldError) => {
if (passphrase && passphrase.trim().length >= 1) {
await enablePublicUrlPassword(passphrase);
setConfigurePassword(false);
publicShareProp.passwordEnabled = true;
} else {
setFieldError('linkPassword', 'can not be empty');
}
};
const handlePasswordChangeSetting = async () => {
if (publicShareProp.passwordEnabled) {
await disablePublicUrlPassword();
} else {
setConfigurePassword(true);
}
};
const disablePublicUrlPassword = async () => {
appContext.setDialogMessage({
title: constants.DISABLE_PASSWORD,
content: constants.DISABLE_PASSWORD_MESSAGE,
close: { text: constants.CANCEL },
proceed: {
text: constants.DISABLE,
action: () =>
updatePublicShareURLHelper({
collectionID: props.collection.id,
disablePassword: true,
}),
variant: ButtonVariant.danger,
},
});
};
const enablePublicUrlPassword = async (password: string) => {
const cryptoWorker = await new CryptoWorker();
const kekSalt: string = await cryptoWorker.generateSaltToDeriveKey();
const kek = await cryptoWorker.deriveInteractiveKey(password, kekSalt);
return updatePublicShareURLHelper({
collectionID: props.collection.id,
passHash: kek.key,
nonce: kekSalt,
opsLimit: kek.opsLimit,
memLimit: kek.memLimit,
});
};
const disablePublicSharing = () => {
appContext.setDialogMessage({
title: constants.DISABLE_PUBLIC_SHARING,
content: constants.DISABLE_PUBLIC_SHARING_MESSAGE,
close: { text: constants.CANCEL },
proceed: {
text: constants.DISABLE,
action: disablePublicSharingHelper,
variant: ButtonVariant.danger,
},
});
};
const disableFileDownload = () => {
appContext.setDialogMessage({
title: constants.DISABLE_FILE_DOWNLOAD,
content: constants.DISABLE_FILE_DOWNLOAD_MESSAGE,
close: { text: constants.CANCEL },
proceed: {
text: constants.DISABLE,
action: () =>
updatePublicShareURLHelper({
collectionID: props.collection.id,
enableDownload: false,
}),
variant: ButtonVariant.danger,
},
});
};
const updatePublicShareURLHelper = async (req: UpdatePublicURL) => {
try {
galleryContext.setBlockingLoad(true);
const response = await updateShareableURL(req);
setPublicShareProp(response);
galleryContext.syncWithRemote(false, true);
} catch (e) {
const errorMessage = handleSharingErrors(e);
setSharableLinkError(errorMessage);
} finally {
galleryContext.setBlockingLoad(false);
}
};
const updateDeviceLimit = async (newLimit: number) => {
return updatePublicShareURLHelper({
collectionID: props.collection.id,
deviceLimit: newLimit,
});
};
const updateDeviceExpiry = async (optionFn) => {
return updatePublicShareURLHelper({
collectionID: props.collection.id,
validTill: optionFn(),
});
};
const handleCollectionPublicSharing = () => {
setSharableLinkError(null);
if (publicShareUrl) {
disablePublicSharing();
} else {
createSharableURLHelper();
}
};
const handleFileDownloadSetting = () => {
if (publicShareProp.enableDownload) {
disableFileDownload();
} else {
updatePublicShareURLHelper({
collectionID: props.collection.id,
enableDownload: true,
});
}
};
const ShareeRow = ({ sharee, collectionUnshare }: ShareeProps) => (
<tr>
<td>{sharee.email}</td>
<td>
<Button
variant="outline-danger"
style={{
height: '25px',
lineHeight: 0,
padding: 0,
width: '25px',
fontSize: '1.2em',
fontWeight: 900,
}}
onClick={() => collectionUnshare(sharee)}>
-
</Button>
</td>
</tr>
);
if (!props.collection) {
return <></>;
}
return (
<DialogBox
open={props.show}
onClose={props.onHide}
attributes={{
title: constants.SHARE_COLLECTION,
staticBackdrop: true,
}}>
<DeadCenter style={{ width: '85%', margin: 'auto' }}>
<h6 style={{ marginTop: '8px' }}>
{constants.SHARE_WITH_PEOPLE}
</h6>
<p />
<Formik<formValues>
initialValues={{ email: '' }}
validationSchema={Yup.object().shape({
email: Yup.string()
.email(constants.EMAIL_ERROR)
.required(constants.REQUIRED),
})}
validateOnChange={false}
validateOnBlur={false}
onSubmit={collectionShare}>
{({
values,
errors,
touched,
handleChange,
handleSubmit,
}) => (
<Form noValidate onSubmit={handleSubmit}>
<Form.Row>
<Form.Group
as={Col}
xs={10}
controlId="formHorizontalEmail">
<Form.Control
type="email"
placeholder={constants.ENTER_EMAIL}
value={values.email}
onChange={handleChange('email')}
isInvalid={Boolean(
touched.email && errors.email
)}
autoFocus
disabled={loading}
/>
<FormControl.Feedback type="invalid">
{errors.email}
</FormControl.Feedback>
</Form.Group>
<Form.Group
as={Col}
xs={2}
controlId="formHorizontalEmail">
<SubmitButton
loading={loading}
inline
buttonText="+"
/>
</Form.Group>
</Form.Row>
</Form>
)}
</Formik>
{props.collection.sharees?.length > 0 && (
<>
<p>{constants.SHAREES}</p>
<Table striped bordered hover variant="dark" size="sm">
<tbody>
{props.collection.sharees?.map((sharee) => (
<ShareeRow
key={sharee.email}
sharee={sharee}
collectionUnshare={collectionUnshare}
/>
))}
</tbody>
</Table>
</>
)}
<div
style={{
height: '1px',
marginTop: '10px',
marginBottom: '18px',
background: '#444',
width: '100%',
}}
/>
<div>
<FlexWrapper>
<FlexWrapper
style={{ paddingTop: '5px', color: '#fff' }}>
{constants.PUBLIC_SHARING}
</FlexWrapper>
<Form.Switch
style={{ marginLeft: '20px' }}
checked={!!publicShareUrl}
id="collection-public-sharing-toggler"
className="custom-switch-md"
onChange={handleCollectionPublicSharing}
/>
</FlexWrapper>
{sharableLinkError && (
<FlexWrapper
style={{
marginTop: '10px',
color: getVariantColor(ButtonVariant.danger),
}}>
{sharableLinkError}
</FlexWrapper>
)}
</div>
{publicShareUrl ? (
<>
<CodeBlock
wordBreak={'break-all'}
code={publicShareUrl}
/>
<details style={{ width: '100%' }}>
<summary
onClick={(e) => {
const lastOptionRow: Element =
e.currentTarget.nextElementSibling
.lastElementChild;
const main = async (
lastOptionRow: Element
) => {
await sleep(0);
lastOptionRow.scrollIntoView(true);
};
main(lastOptionRow);
}}
className="manageLinkHeader"
style={{ marginBottom: '20px' }}>
{constants.MANAGE_LINK}
</summary>
<section>
<OptionRow>
<OptionLabel>
{constants.LINK_DEVICE_LIMIT}
</OptionLabel>
<OptionValue>
<Select
menuPosition="fixed"
options={deviceLimitOptions}
isSearchable={false}
value={{
label: publicShareProp?.deviceLimit.toString(),
value: publicShareProp?.deviceLimit,
}}
onChange={(e) =>
updateDeviceLimit(e.value)
}
styles={DropdownStyle}
/>
</OptionValue>
</OptionRow>
<OptionRow>
<OptionLabel
style={{ alignItems: 'center' }}>
{constants.LINK_EXPIRY}
</OptionLabel>
<OptionValue>
<Select
menuPosition="fixed"
options={expiryOptions}
isSearchable={false}
value={null}
placeholder={
publicShareProp?.validTill
? dateStringWithMMH(
publicShareProp?.validTill
)
: 'never'
}
onChange={(e) => {
updateDeviceExpiry(e.value);
}}
styles={linkExpiryStyle}
/>
</OptionValue>
</OptionRow>
<OptionRow>
<OptionLabel>
{constants.FILE_DOWNLOAD}
</OptionLabel>
<OptionValue>
<Form.Switch
style={{ marginLeft: '10px' }}
checked={
publicShareProp?.enableDownload ??
false
}
id="public-sharing-file-download-toggler"
className="custom-switch-md"
onChange={handleFileDownloadSetting}
/>
</OptionValue>
</OptionRow>
<OptionRow>
<OptionLabel>
{constants.LINK_PASSWORD_LOCK}{' '}
</OptionLabel>
<OptionValue>
<Form.Switch
style={{ marginLeft: '10px' }}
checked={
publicShareProp?.passwordEnabled
}
id="public-sharing-file-password-toggler"
className="custom-switch-md"
onChange={
handlePasswordChangeSetting
}
/>
</OptionValue>
</OptionRow>
</section>
<DialogBox
open={configurePassword}
onClose={() => setConfigurePassword(false)}
size="sm"
attributes={{
title: constants.PASSWORD_LOCK,
}}>
<SingleInputForm
callback={savePassword}
placeholder={
constants.RETURN_PASSPHRASE_HINT
}
buttonText={constants.LOCK}
fieldType="password"
/>
</DialogBox>
</details>
</>
) : (
<div
style={{
height: '1px',
marginTop: '28px',
width: '100%',
}}
/>
)}
</DeadCenter>
</DialogBox>
);
}
export default CollectionShare;

View file

@ -0,0 +1,22 @@
import { Dialog, styled } from '@mui/material';
import PropTypes from 'prop-types';
export const CollectionShareContainer = styled(Dialog)(({ theme }) => ({
'& .MuiDialog-container': {
justifyContent: 'flex-end',
},
'& .MuiPaper-root': {
maxWidth: '414px',
},
'& .MuiDialogTitle-root': {
padding: theme.spacing(4, 3, 3, 4),
},
'& .MuiDialogContent-root': {
padding: theme.spacing(3, 4),
},
}));
CollectionShareContainer.propTypes = {
children: PropTypes.node,
onClose: PropTypes.func.isRequired,
};

View file

@ -0,0 +1,46 @@
import SingleInputForm from 'components/SingleInputForm';
import { GalleryContext } from 'pages/gallery';
import React, { useContext } from 'react';
import { shareCollection } from 'services/collectionService';
import { User } from 'types/user';
import { sleep } from 'utils/common';
import { handleSharingErrors } from 'utils/error';
import { getData, LS_KEYS } from 'utils/storage/localStorage';
import constants from 'utils/strings/constants';
import { CollectionShareSharees } from './sharees';
import CollectionShareSubmitButton from './submitButton';
export default function EmailShare({ collection }) {
const galleryContext = useContext(GalleryContext);
const collectionShare = async (email, setFieldError) => {
try {
const user: User = getData(LS_KEYS.USER);
if (email === user.email) {
setFieldError('email', constants.SHARE_WITH_SELF);
} else if (
collection?.sharees?.find((value) => value.email === email)
) {
setFieldError('email', constants.ALREADY_SHARED(email));
} else {
await shareCollection(collection, email);
await sleep(2000);
await galleryContext.syncWithRemote(false, true);
}
} catch (e) {
const errorMessage = handleSharingErrors(e);
setFieldError('email', errorMessage);
}
};
return (
<>
<SingleInputForm
callback={collectionShare}
placeholder={constants.ENTER_EMAIL}
fieldType="email"
buttonText={constants.SHARE}
customSubmitButton={CollectionShareSubmitButton}
/>
<CollectionShareSharees collection={collection} />
</>
);
}

View file

@ -0,0 +1,43 @@
import EmailShare from './emailShare';
import React from 'react';
import constants from 'utils/strings/constants';
import { Collection } from 'types/collection';
import { dialogCloseHandler } from 'components/DialogBox/base';
import DialogTitleWithCloseButton from 'components/DialogBox/titleWithCloseButton';
import DialogContent from '@mui/material/DialogContent';
import { Divider } from '@mui/material';
import { CollectionShareContainer } from './container';
import PublicShare from './publicShare';
interface Props {
show: boolean;
onHide: () => void;
collection: Collection;
}
function CollectionShare(props: Props) {
const handleClose = dialogCloseHandler({
onClose: props.onHide,
});
if (!props.collection) {
return <></>;
}
return (
<>
<CollectionShareContainer open={props.show} onClose={handleClose}>
<DialogTitleWithCloseButton onClose={handleClose}>
{constants.SHARE_COLLECTION}
</DialogTitleWithCloseButton>
<DialogContent>
<EmailShare collection={props.collection} />
<Divider />
<PublicShare collection={props.collection} />
</DialogContent>
</CollectionShareContainer>
</>
);
}
export default CollectionShare;

View file

@ -0,0 +1,105 @@
import { Box, Typography } from '@mui/material';
import { FlexWrapper } from 'components/Container';
import { ButtonVariant } from 'components/pages/gallery/LinkButton';
import { GalleryContext } from 'pages/gallery';
import { AppContext } from 'pages/_app';
import React, { useContext } from 'react';
import {
createShareableURL,
deleteShareableURL,
} from 'services/collectionService';
import { appendCollectionKeyToShareURL } from 'utils/collection';
import { handleSharingErrors } from 'utils/error';
import constants from 'utils/strings/constants';
import PublicShareSwitch from './switch';
export default function PublicShareControl({
publicShareUrl,
sharableLinkError,
collection,
setPublicShareUrl,
setSharableLinkError,
}) {
const appContext = useContext(AppContext);
const galleryContext = useContext(GalleryContext);
const createSharableURLHelper = async () => {
try {
appContext.startLoading();
const publicURL = await createShareableURL(collection);
const sharableURL = await appendCollectionKeyToShareURL(
publicURL.url,
collection.key
);
setPublicShareUrl(sharableURL);
galleryContext.syncWithRemote(false, true);
} catch (e) {
const errorMessage = handleSharingErrors(e);
setSharableLinkError(errorMessage);
} finally {
appContext.finishLoading();
}
};
const disablePublicSharing = async () => {
try {
appContext.startLoading();
await deleteShareableURL(collection);
setPublicShareUrl(null);
galleryContext.syncWithRemote(false, true);
} catch (e) {
const errorMessage = handleSharingErrors(e);
setSharableLinkError(errorMessage);
} finally {
appContext.finishLoading();
}
};
const confirmDisablePublicSharing = () => {
appContext.setDialogMessage({
title: constants.DISABLE_PUBLIC_SHARING,
content: constants.DISABLE_PUBLIC_SHARING_MESSAGE,
close: { text: constants.CANCEL },
proceed: {
text: constants.DISABLE,
action: disablePublicSharing,
variant: ButtonVariant.danger,
},
});
};
const handleCollectionPublicSharing = () => {
setSharableLinkError(null);
if (publicShareUrl) {
confirmDisablePublicSharing();
} else {
createSharableURLHelper();
}
};
return (
<Box mt={3}>
<FlexWrapper>
<FlexWrapper>{constants.PUBLIC_SHARING}</FlexWrapper>
<PublicShareSwitch
color="accent"
sx={{
ml: 2,
}}
checked={!!publicShareUrl}
onChange={handleCollectionPublicSharing}
/>
</FlexWrapper>
{sharableLinkError && (
<Typography
variant="body2"
sx={{
color: (theme) => theme.palette.danger.main,
mt: 0.5,
}}>
{sharableLinkError}
</Typography>
)}
</Box>
);
}

View file

@ -0,0 +1,19 @@
import React from 'react';
import { Box, Typography, Divider } from '@mui/material';
import { components } from 'react-select';
const { Option } = components;
export const OptionWithDivider = (props) => (
<Option {...props}>
<LabelWithDivider data={props.data} />
</Option>
);
export const LabelWithDivider = ({ data }) => (
<>
<Box className="main" px={3} py={1}>
<Typography>{data.label}</Typography>
</Box>
<Divider />
</>
);

View file

@ -0,0 +1,52 @@
import React, { useEffect, useState } from 'react';
import { PublicURL } from 'types/collection';
import { appendCollectionKeyToShareURL } from 'utils/collection';
import PublicShareControl from './control';
import PublicShareLink from './link';
import PublicShareManage from './manage';
export default function PublicShare({ collection }) {
const [sharableLinkError, setSharableLinkError] = useState(null);
const [publicShareUrl, setPublicShareUrl] = useState<string>(null);
const [publicShareProp, setPublicShareProp] = useState<PublicURL>(null);
useEffect(() => {
const main = async () => {
if (collection?.publicURLs?.[0]?.url) {
const t = await appendCollectionKeyToShareURL(
collection?.publicURLs?.[0]?.url,
collection.key
);
setPublicShareUrl(t);
setPublicShareProp(collection?.publicURLs?.[0] as PublicURL);
} else {
setPublicShareUrl(null);
setPublicShareProp(null);
}
};
main();
}, [collection]);
return (
<>
<PublicShareControl
setPublicShareUrl={setPublicShareUrl}
collection={collection}
publicShareUrl={publicShareUrl}
sharableLinkError={sharableLinkError}
setSharableLinkError={setSharableLinkError}
/>
{publicShareUrl && (
<PublicShareLink publicShareUrl={publicShareUrl} />
)}
{publicShareProp && (
<PublicShareManage
publicShareProp={publicShareProp}
collection={collection}
setPublicShareProp={setPublicShareProp}
setSharableLinkError={setSharableLinkError}
/>
)}
</>
);
}

View file

@ -0,0 +1,11 @@
import { Box } from '@mui/material';
import CodeBlock from 'components/CodeBlock';
import React from 'react';
export default function PublicShareLink({ publicShareUrl }) {
return (
<Box mt={2} mb={3}>
<CodeBlock wordBreak={'break-all'} code={publicShareUrl} />
</Box>
);
}

View file

@ -0,0 +1,39 @@
import { Box, Typography } from '@mui/material';
import React from 'react';
import Select from 'react-select';
import { getDeviceLimitOptions } from 'utils/collection';
import constants from 'utils/strings/constants';
import { DropdownStyle } from '../../styles';
import { OptionWithDivider } from '../customSelectComponents';
export function ManageDeviceLimit({
publicShareProp,
collection,
updatePublicShareURLHelper,
}) {
const updateDeviceLimit = async (newLimit: number) => {
return updatePublicShareURLHelper({
collectionID: collection.id,
deviceLimit: newLimit,
});
};
return (
<Box>
<Typography>{constants.LINK_DEVICE_LIMIT}</Typography>
<Select
menuPosition="fixed"
options={getDeviceLimitOptions()}
components={{
Option: OptionWithDivider,
}}
isSearchable={false}
value={{
label: publicShareProp?.deviceLimit.toString(),
value: publicShareProp?.deviceLimit,
}}
onChange={(e) => updateDeviceLimit(e.value)}
styles={DropdownStyle}
/>
</Box>
);
}

View file

@ -0,0 +1,50 @@
import { Box, Typography } from '@mui/material';
import { ButtonVariant } from 'components/pages/gallery/LinkButton';
import { AppContext } from 'pages/_app';
import React, { useContext } from 'react';
import constants from 'utils/strings/constants';
import PublicShareSwitch from '../switch';
export function ManageDownloadAccess({
publicShareProp,
updatePublicShareURLHelper,
collection,
}) {
const appContext = useContext(AppContext);
const handleFileDownloadSetting = () => {
if (publicShareProp.enableDownload) {
disableFileDownload();
} else {
updatePublicShareURLHelper({
collectionID: collection.id,
enableDownload: true,
});
}
};
const disableFileDownload = () => {
appContext.setDialogMessage({
title: constants.DISABLE_FILE_DOWNLOAD,
content: constants.DISABLE_FILE_DOWNLOAD_MESSAGE,
close: { text: constants.CANCEL },
proceed: {
text: constants.DISABLE,
action: () =>
updatePublicShareURLHelper({
collectionID: collection.id,
enableDownload: false,
}),
variant: ButtonVariant.danger,
},
});
};
return (
<Box>
<Typography>{constants.FILE_DOWNLOAD}</Typography>
<PublicShareSwitch
checked={publicShareProp?.enableDownload ?? false}
onChange={handleFileDownloadSetting}
/>
</Box>
);
}

View file

@ -0,0 +1,105 @@
import { ManageLinkPassword } from './linkPassword';
import { ManageDeviceLimit } from './deviceLimit';
import { ManageLinkExpiry } from './linkExpiry';
import { PublicLinkSetPassword } from '../setPassword';
import { Stack } from '@mui/material';
import { GalleryContext } from 'pages/gallery';
import React, { useContext, useState } from 'react';
import { updateShareableURL } from 'services/collectionService';
import { UpdatePublicURL } from 'types/collection';
import { sleep } from 'utils/common';
import { handleSharingErrors } from 'utils/error';
import constants from 'utils/strings/constants';
import {
ManageSectionLabel,
ManageSectionOptions,
} from '../../styledComponents';
import { ManageDownloadAccess } from './downloadAcess';
export default function PublicShareManage({
publicShareProp,
collection,
setPublicShareProp,
setSharableLinkError,
}) {
const galleryContext = useContext(GalleryContext);
const [changePasswordView, setChangePasswordView] = useState(false);
const closeConfigurePassword = () => setChangePasswordView(false);
const updatePublicShareURLHelper = async (req: UpdatePublicURL) => {
try {
galleryContext.setBlockingLoad(true);
const response = await updateShareableURL(req);
setPublicShareProp(response);
galleryContext.syncWithRemote(false, true);
} catch (e) {
const errorMessage = handleSharingErrors(e);
setSharableLinkError(errorMessage);
} finally {
galleryContext.setBlockingLoad(false);
}
};
const scrollToEnd = (e) => {
const lastOptionRow: Element =
e.currentTarget.nextElementSibling.lastElementChild;
const main = async (lastOptionRow: Element) => {
await sleep(0);
lastOptionRow.scrollIntoView(true);
};
main(lastOptionRow);
};
return (
<>
<details>
<ManageSectionLabel onClick={scrollToEnd}>
{constants.MANAGE_LINK}
</ManageSectionLabel>
<ManageSectionOptions>
<Stack spacing={1}>
<ManageLinkExpiry
collection={collection}
publicShareProp={publicShareProp}
updatePublicShareURLHelper={
updatePublicShareURLHelper
}
/>
<ManageDeviceLimit
collection={collection}
publicShareProp={publicShareProp}
updatePublicShareURLHelper={
updatePublicShareURLHelper
}
/>
<ManageDownloadAccess
collection={collection}
publicShareProp={publicShareProp}
updatePublicShareURLHelper={
updatePublicShareURLHelper
}
/>
<ManageLinkPassword
setChangePasswordView={setChangePasswordView}
collection={collection}
publicShareProp={publicShareProp}
updatePublicShareURLHelper={
updatePublicShareURLHelper
}
/>
</Stack>
</ManageSectionOptions>
</details>
<PublicLinkSetPassword
open={changePasswordView}
onClose={closeConfigurePassword}
collection={collection}
publicShareProp={publicShareProp}
updatePublicShareURLHelper={updatePublicShareURLHelper}
setChangePasswordView={setChangePasswordView}
/>
</>
);
}

View file

@ -0,0 +1,54 @@
import { Box, Typography } from '@mui/material';
import { DropdownStyle } from 'components/Collections/CollectionShare/styles';
import React from 'react';
import Select from 'react-select';
import { shareExpiryOptions } from 'utils/collection';
import constants from 'utils/strings/constants';
import { dateStringWithMMH } from 'utils/time';
import { OptionWithDivider } from '../customSelectComponents';
const linkExpiryStyle = {
...DropdownStyle,
placeholder: (style) => ({
...style,
color: '#d1d1d1',
width: '100%',
textAlign: 'center',
}),
};
export function ManageLinkExpiry({
publicShareProp,
collection,
updatePublicShareURLHelper,
}) {
const updateDeviceExpiry = async (optionFn) => {
return updatePublicShareURLHelper({
collectionID: collection.id,
validTill: optionFn(),
});
};
return (
<Box>
<Typography>{constants.LINK_EXPIRY}</Typography>
<Select
menuPosition="fixed"
options={shareExpiryOptions}
isSearchable={false}
value={null}
components={{
Option: OptionWithDivider,
}}
placeholder={
publicShareProp?.validTill
? dateStringWithMMH(publicShareProp?.validTill)
: 'never'
}
onChange={(e) => {
updateDeviceExpiry(e.value);
}}
styles={linkExpiryStyle}
/>
</Box>
);
}

View file

@ -0,0 +1,49 @@
import { Box, Typography } from '@mui/material';
import { ButtonVariant } from 'components/pages/gallery/LinkButton';
import { AppContext } from 'pages/_app';
import React, { useContext } from 'react';
import constants from 'utils/strings/constants';
import PublicShareSwitch from '../switch';
export function ManageLinkPassword({
collection,
publicShareProp,
updatePublicShareURLHelper,
setChangePasswordView,
}) {
const appContext = useContext(AppContext);
const handlePasswordChangeSetting = async () => {
if (publicShareProp.passwordEnabled) {
await confirmDisablePublicUrlPassword();
} else {
setChangePasswordView(true);
}
};
const confirmDisablePublicUrlPassword = async () => {
appContext.setDialogMessage({
title: constants.DISABLE_PASSWORD,
content: constants.DISABLE_PASSWORD_MESSAGE,
close: { text: constants.CANCEL },
proceed: {
text: constants.DISABLE,
action: () =>
updatePublicShareURLHelper({
collectionID: collection.id,
disablePassword: true,
}),
variant: ButtonVariant.danger,
},
});
};
return (
<Box>
<Typography> {constants.LINK_PASSWORD_LOCK}</Typography>
<PublicShareSwitch
checked={!!publicShareProp?.passwordEnabled}
onChange={handlePasswordChangeSetting}
/>
</Box>
);
}

View file

@ -0,0 +1,54 @@
import DialogBox from 'components/DialogBox';
import SingleInputForm from 'components/SingleInputForm';
import React from 'react';
import CryptoWorker from 'utils/crypto';
import constants from 'utils/strings/constants';
export function PublicLinkSetPassword({
open,
onClose,
collection,
publicShareProp,
updatePublicShareURLHelper,
setChangePasswordView,
}) {
const savePassword = async (passphrase, setFieldError) => {
if (passphrase && passphrase.trim().length >= 1) {
await enablePublicUrlPassword(passphrase);
setChangePasswordView(false);
publicShareProp.passwordEnabled = true;
} else {
setFieldError('linkPassword', 'can not be empty');
}
};
const enablePublicUrlPassword = async (password: string) => {
const cryptoWorker = await new CryptoWorker();
const kekSalt: string = await cryptoWorker.generateSaltToDeriveKey();
const kek = await cryptoWorker.deriveInteractiveKey(password, kekSalt);
return updatePublicShareURLHelper({
collectionID: collection.id,
passHash: kek.key,
nonce: kekSalt,
opsLimit: kek.opsLimit,
memLimit: kek.memLimit,
});
};
return (
<DialogBox
open={open}
onClose={onClose}
PaperProps={{ sx: { maxWidth: '350px' } }}
titleCloseButton
attributes={{
title: constants.PASSWORD_LOCK,
}}>
<SingleInputForm
callback={savePassword}
placeholder={constants.RETURN_PASSPHRASE_HINT}
buttonText={constants.LOCK}
fieldType="password"
/>
</DialogBox>
);
}

View file

@ -0,0 +1,61 @@
import React from 'react';
import { SwitchProps, Switch } from '@mui/material';
import styled from 'styled-components';
const PublicShareSwitch = styled((props: SwitchProps) => (
<Switch
focusVisibleClassName=".Mui-focusVisible"
disableRipple
{...props}
/>
))(({ theme }) => ({
width: 40,
height: 24,
padding: 0,
'& .MuiSwitch-switchBase': {
padding: 0,
margin: 2,
transitionDuration: '300ms',
'&.Mui-checked': {
transform: 'translateX(16px)',
color: '#fff',
'& + .MuiSwitch-track': {
backgroundColor:
theme.palette.mode === 'dark' ? '#2ECA45' : '#65C466',
opacity: 1,
border: 0,
},
'&.Mui-disabled + .MuiSwitch-track': {
opacity: 0.5,
},
},
'&.Mui-focusVisible .MuiSwitch-thumb': {
color: '#33cf4d',
border: '6px solid #fff',
},
'&.Mui-disabled .MuiSwitch-thumb': {
color:
theme.palette.mode === 'light'
? theme.palette.grey[100]
: theme.palette.grey[600],
},
'&.Mui-disabled + .MuiSwitch-track': {
opacity: theme.palette.mode === 'light' ? 0.7 : 0.3,
},
},
'& .MuiSwitch-thumb': {
boxSizing: 'border-box',
width: 20,
height: 20,
},
'& .MuiSwitch-track': {
borderRadius: 22 / 2,
backgroundColor: theme.palette.mode === 'light' ? '#E9E9EA' : '#39393D',
opacity: 1,
transition: theme.transitions.create(['background-color'], {
duration: 500,
}),
},
}));
export default PublicShareSwitch;

View file

@ -0,0 +1,48 @@
import { Box, Typography } from '@mui/material';
import { GalleryContext } from 'pages/gallery';
import { AppContext } from 'pages/_app';
import React, { useContext } from 'react';
import { unshareCollection } from 'services/collectionService';
import { Collection } from 'types/collection';
import { sleep } from 'utils/common';
import constants from 'utils/strings/constants';
import ShareeRow from './row';
interface Iprops {
collection: Collection;
}
export function CollectionShareSharees({ collection }: Iprops) {
const appContext = useContext(AppContext);
const galleryContext = useContext(GalleryContext);
const collectionUnshare = async (sharee) => {
try {
appContext.startLoading();
await unshareCollection(collection, sharee.email);
await sleep(2000);
await galleryContext.syncWithRemote(false, true);
} finally {
appContext.finishLoading();
}
};
if (!collection.sharees?.length) {
return <></>;
}
return (
<Box mb={3}>
<Typography variant="body2" color="text.secondary">
{constants.SHAREES}
</Typography>
{collection.sharees?.map((sharee) => (
<ShareeRow
key={sharee.email}
sharee={sharee}
collectionUnshare={collectionUnshare}
/>
))}
</Box>
);
}

View file

@ -0,0 +1,22 @@
import React from 'react';
import { IconButton } from '@mui/material';
import { SpaceBetweenFlex } from 'components/Container';
import { User } from 'types/user';
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
interface IProps {
sharee: User;
collectionUnshare: (sharee: User) => void;
}
const ShareeRow = ({ sharee, collectionUnshare }: IProps) => (
<SpaceBetweenFlex>
{sharee.email}
<IconButton
sx={{ ml: 2, color: 'text.secondary' }}
onClick={() => collectionUnshare(sharee)}>
<MoreHorizIcon />
</IconButton>
</SpaceBetweenFlex>
);
export default ShareeRow;

View file

@ -0,0 +1,14 @@
import styled from 'styled-components';
export const ManageSectionLabel = styled.summary(
({ theme }) => `
text-align: center;
margin-bottom:${theme.spacing(1)};
`
);
export const ManageSectionOptions = styled.section(
({ theme }) => `
margin-bottom:${theme.spacing(4)};
`
);

View file

@ -0,0 +1,20 @@
import { SelectStyles } from 'components/Search/styles';
export const DropdownStyle = {
...SelectStyles,
dropdownIndicator: (style) => ({
...style,
margin: '0px',
}),
singleValue: (style) => ({
...style,
color: '#d1d1d1',
width: '240px',
}),
control: (style, { isFocused }) => ({
...style,
...SelectStyles.control(style, { isFocused }),
minWidth: '240px',
paddingLeft: '8px',
}),
};

View file

@ -0,0 +1,10 @@
import { FlexWrapper } from 'components/Container';
import SubmitButton, { SubmitButtonProps } from 'components/SubmitButton';
import React from 'react';
export default function CollectionShareSubmitButton(props: SubmitButtonProps) {
return (
<FlexWrapper style={{ justifyContent: 'flex-end' }}>
<SubmitButton {...props} size="medium" inline sx={{ my: 2 }} />
</FlexWrapper>
);
}

View file

@ -25,24 +25,20 @@ export const ScrollContainer = styled.div`
display: flex;
`;
export const CollectionTile = styled.div<{
coverImgURL?: string;
}>`
export const CollectionTile = styled.div`
display: flex;
position: relative;
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;
& > img {
object-fit: cover;
max-width: 100%;
min-height: 100%;
flex: 1;
pointer-events: none;
}
`;
export const CollectionTileWrapper = styled.div`
@ -60,6 +56,11 @@ export const Hider = styled.div<{ hide: boolean }>`
display: ${(props) => (props.hide ? 'none' : 'block')};
`;
export const CollectionBarTile = styled(CollectionTile)`
width: 80px;
height: 64px;
`;
export const AllCollectionTile = styled(CollectionTile)`
width: 150px;
height: 150px;
@ -82,3 +83,30 @@ export const ResultPreviewTile = styled(AllCollectionTile)`
height: 48px;
border-radius: 4px;
`;
export const CollectionTileTextOverlay = styled.div`
height: 100%;
width: 100%;
position: absolute;
font-size: 14px;
line-height: 20px;
padding: 4px 6px;
`;
export const CollectionBarTileText = styled(CollectionTileTextOverlay)`
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.1) 0%,
rgba(0, 0, 0, 0.5) 86.46%
);
display: flex;
align-items: flex-end;
`;
export const AllCollectionTileText = styled(CollectionTileTextOverlay)`
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0.1) 0%,
rgba(0, 0, 0, 0.5) 86.46%
);
`;

View file

@ -41,20 +41,19 @@ export const IconButton = styled.button`
export const Row = styled.div`
display: flex;
align-items: center;
margin-bottom: 20px;
margin-bottom: ${({ theme }) => theme.spacing(2)};
flex: 1;
`;
export const Label = styled.div<{ width?: string }>`
width: ${(props) => props.width ?? '70%'};
color: ${(props) => props.theme.palette.text.secondary};
`;
export const Value = styled.div<{ width?: string }>`
display: flex;
justify-content: flex-start;
align-items: center;
width: ${(props) => props.width ?? '30%'};
color: #ddd;
`;
export const FlexWrapper = styled(Box)`

View file

@ -30,23 +30,21 @@ DialogBoxBase.defaultProps = {
export const dialogCloseHandler =
({
staticBackdrop,
closeOnBackdropClick,
nonClosable,
onClose,
}: {
staticBackdrop?: boolean;
closeOnBackdropClick?: boolean;
nonClosable?: boolean;
onClose?: () => void;
onClose: () => void;
}): DialogProps['onClose'] =>
(_, reason) => {
if (nonClosable) {
// no-op
} else if (staticBackdrop && reason === 'backdropClick') {
} else if (!closeOnBackdropClick && reason === 'backdropClick') {
// no-op
} else {
if (onClose && typeof onClose === 'function') {
onClose();
}
onClose();
}
};

View file

@ -3,7 +3,6 @@ import constants from 'utils/strings/constants';
import {
Breakpoint,
Button,
Dialog,
DialogActions,
DialogContent,
DialogProps,
@ -22,26 +21,34 @@ type IProps = React.PropsWithChildren<
}
>;
export default function DialogBox({ attributes, children, ...props }: IProps) {
export default function DialogBox({
attributes,
children,
open,
size,
onClose,
titleCloseButton,
...props
}: IProps) {
if (!attributes) {
return <Dialog open={false} />;
return <></>;
}
const handleClose = dialogCloseHandler({
staticBackdrop: attributes.staticBackdrop,
closeOnBackdropClick: attributes.closeOnBackdropClick,
nonClosable: attributes.nonClosable,
onClose: props.onClose,
onClose: onClose,
});
return (
<DialogBoxBase
open={props.open}
maxWidth={props.size}
open={open}
maxWidth={size}
onClose={handleClose}
{...props}>
{attributes.title && (
<DialogTitleWithCloseButton
onClose={props.titleCloseButton && handleClose}>
onClose={titleCloseButton && handleClose}>
{attributes.title}
</DialogTitleWithCloseButton>
)}
@ -61,7 +68,7 @@ export default function DialogBox({ attributes, children, ...props }: IProps) {
onClick={() => {
attributes.close.action &&
attributes.close?.action();
props.onClose();
onClose();
}}>
{attributes.close?.text ?? constants.OK}
</Button>
@ -71,7 +78,7 @@ export default function DialogBox({ attributes, children, ...props }: IProps) {
color={attributes.proceed?.variant}
onClick={() => {
attributes.proceed.action();
props.onClose();
onClose();
}}
disabled={attributes.proceed.disabled}>
{attributes.proceed.text}

View file

@ -9,10 +9,16 @@ interface Iprops {
export default function FileList(props: Iprops) {
const Row = ({ index, style }) => (
<Tooltip
PopperProps={{
sx: {
'.MuiTooltip-tooltip.MuiTooltip-tooltip.MuiTooltip-tooltip':
{ marginTop: 0 },
},
}}
title={props.fileList[index]}
placement="bottom-start"
enterDelay={300}
enterNextDelay={300}>
enterNextDelay={100}>
<div style={style}>{props.fileList[index]}</div>
</Tooltip>
);

View file

@ -101,7 +101,6 @@ export default function FixCreationTime(props: Props) {
fixState === FIX_STATE.RUNNING
? constants.FIX_CREATION_TIME_IN_PROGRESS
: constants.FIX_CREATION_TIME,
staticBackdrop: true,
nonClosable: true,
}}>
<div

View file

@ -141,7 +141,6 @@ export default function FixLargeThumbnails(props: Props) {
onClose={props.hide}
attributes={{
title: constants.COMPRESS_THUMBNAILS,
staticBackdrop: true,
}}>
<div
style={{

View file

@ -1,12 +1,5 @@
import { styled } from '@mui/material/styles';
import VerticallyCentered from 'components/Container';
const FormContainer = styled(VerticallyCentered)(({ theme }) => ({
alignItems: 'flex-end',
paddingRight: theme.spacing(10),
[theme.breakpoints.down('md')]: {
paddingRight: theme.spacing(5),
},
}));
const FormContainer = VerticallyCentered;
export default FormContainer;

View file

@ -0,0 +1,93 @@
import CloseIcon from '@mui/icons-material/Close';
import {
Box,
Button,
ButtonProps,
IconButton,
Paper,
Snackbar,
Stack,
Typography,
} from '@mui/material';
import React from 'react';
import { NotificationAttributes } from 'types/Notification';
import InfoIcon from '@mui/icons-material/Info';
interface Iprops {
open: boolean;
onClose: () => void;
attributes: NotificationAttributes;
}
export default function Notification({ open, onClose, attributes }: Iprops) {
if (!attributes) {
return <></>;
}
const handleClose: ButtonProps['onClick'] = (event) => {
onClose();
event.stopPropagation();
};
const handleClick = () => {
attributes.action?.callback();
onClose();
};
return (
<Snackbar
open={open}
anchorOrigin={{
horizontal: 'right',
vertical: 'bottom',
}}>
<Paper
component={Button}
color={attributes.variant}
onClick={handleClick}
css={`
width: 320px;
padding: 12px 16px;
`}
sx={{ textAlign: 'left' }}>
<Stack
flex={'1'}
spacing={2}
direction="row"
alignItems={'center'}>
<Box>
{attributes?.icon ?? <InfoIcon fontSize="large" />}
</Box>
<Box sx={{ flex: 1 }}>
<Typography
variant="body2"
color="rgba(255, 255, 255, 0.7)"
mb={0.5}>
{attributes.message}{' '}
</Typography>
{attributes?.action && (
<Typography
mb={0.5}
css={`
font-size: 16px;
font-weight: 600;
line-height: 19px;
`}>
{attributes?.action.text}
</Typography>
)}
</Box>
<Box>
<IconButton
onClick={handleClose}
sx={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
}}>
<CloseIcon />
</IconButton>
</Box>
</Stack>
</Paper>
</Snackbar>
);
}

View file

@ -6,7 +6,7 @@ import styled from 'styled-components';
import DownloadManager from 'services/downloadManager';
import constants from 'utils/strings/constants';
import AutoSizer from 'react-virtualized-auto-sizer';
import PhotoSwipe from 'components/PhotoSwipe/PhotoSwipe';
import PhotoSwipe from 'components/PhotoSwipe';
import { formatDateRelative } from 'utils/file';
import {
ALL_SECTION,

View file

@ -0,0 +1,63 @@
import React, { useState } from 'react';
import constants from 'utils/strings/constants';
import { FormCheck } from 'react-bootstrap';
import { RenderInfoItem } from './RenderInfoItem';
import { LegendContainer } from '../styledComponents/LegendContainer';
import { Pre } from '../styledComponents/Pre';
import { Typography } from '@mui/material';
export function ExifData(props: { exif: any }) {
const { exif } = props;
const [showAll, setShowAll] = useState(false);
const changeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
setShowAll(e.target.checked);
};
const renderAllValues = () => <Pre>{exif.raw}</Pre>;
const renderSelectedValues = () => (
<>
{exif?.Make &&
exif?.Model &&
RenderInfoItem(constants.DEVICE, `${exif.Make} ${exif.Model}`)}
{exif?.ImageWidth &&
exif?.ImageHeight &&
RenderInfoItem(
constants.IMAGE_SIZE,
`${exif.ImageWidth} x ${exif.ImageHeight}`
)}
{exif?.Flash && RenderInfoItem(constants.FLASH, exif.Flash)}
{exif?.FocalLength &&
RenderInfoItem(
constants.FOCAL_LENGTH,
exif.FocalLength.toString()
)}
{exif?.ApertureValue &&
RenderInfoItem(
constants.APERTURE,
exif.ApertureValue.toString()
)}
{exif?.ISOSpeedRatings &&
RenderInfoItem(constants.ISO, exif.ISOSpeedRatings.toString())}
</>
);
return (
<>
<LegendContainer>
<Typography variant="subtitle" mb={1}>
{constants.EXIF}
</Typography>
<FormCheck>
<FormCheck.Label>
<FormCheck.Input onChange={changeHandler} />
{constants.SHOW_ALL}
</FormCheck.Label>
</FormCheck>
</LegendContainer>
{showAll ? renderAllValues() : renderSelectedValues()}
</>
);
}

View file

@ -0,0 +1,99 @@
import React, { useState } from 'react';
import constants from 'utils/strings/constants';
import { Col, Form, FormControl } from 'react-bootstrap';
import { FlexWrapper, IconButton, Value } from 'components/Container';
import CloseIcon from '@mui/icons-material/Close';
import TickIcon from '@mui/icons-material/Done';
import { Formik } from 'formik';
import * as Yup from 'yup';
import { MAX_EDITED_FILE_NAME_LENGTH } from 'constants/file';
import { SmallLoadingSpinner } from '../styledComponents/SmallLoadingSpinner';
export interface formValues {
filename: string;
}
export const FileNameEditForm = ({
filename,
saveEdits,
discardEdits,
extension,
}) => {
const [loading, setLoading] = useState(false);
const onSubmit = async (values: formValues) => {
try {
setLoading(true);
await saveEdits(values.filename);
} finally {
setLoading(false);
}
};
return (
<Formik<formValues>
initialValues={{ filename }}
validationSchema={Yup.object().shape({
filename: Yup.string()
.required(constants.REQUIRED)
.max(
MAX_EDITED_FILE_NAME_LENGTH,
constants.FILE_NAME_CHARACTER_LIMIT
),
})}
validateOnBlur={false}
onSubmit={onSubmit}>
{({ values, errors, handleChange, handleSubmit }) => (
<Form noValidate onSubmit={handleSubmit}>
<Form.Row>
<Form.Group
bsPrefix="ente-form-group"
as={Col}
xs={extension ? 7 : 8}>
<Form.Control
as="textarea"
placeholder={constants.FILE_NAME}
value={values.filename}
onChange={handleChange('filename')}
isInvalid={Boolean(errors.filename)}
autoFocus
disabled={loading}
/>
<FormControl.Feedback
type="invalid"
style={{ textAlign: 'center' }}>
{errors.filename}
</FormControl.Feedback>
</Form.Group>
{extension && (
<Form.Group
bsPrefix="ente-form-group"
as={Col}
xs={1}
controlId="formHorizontalFileName">
<FlexWrapper style={{ padding: '5px' }}>
{`.${extension}`}
</FlexWrapper>
</Form.Group>
)}
<Form.Group bsPrefix="ente-form-group" as={Col} xs={2}>
<Value width={'16.67%'}>
<IconButton type="submit" disabled={loading}>
{loading ? (
<SmallLoadingSpinner />
) : (
<TickIcon />
)}
</IconButton>
<IconButton
onClick={discardEdits}
disabled={loading}>
<CloseIcon />
</IconButton>
</Value>
</Form.Group>
</Form.Row>
</Form>
)}
</Formik>
);
};

View file

@ -0,0 +1,121 @@
import React, { useState } from 'react';
import { updateFilePublicMagicMetadata } from 'services/fileService';
import { EnteFile } from 'types/file';
import constants from 'utils/strings/constants';
import {
changeFileCreationTime,
formatDateTime,
updateExistingFilePubMetadata,
} from 'utils/file';
import EditIcon from 'components/icons/EditIcon';
import { IconButton, Label, Row, Value } from 'components/Container';
import { logError } from 'utils/sentry';
import CloseIcon from '@mui/icons-material/Close';
import TickIcon from '@mui/icons-material/Done';
import EnteDateTimePicker from 'components/EnteDateTimePicker';
import { SmallLoadingSpinner } from '../styledComponents/SmallLoadingSpinner';
export function RenderCreationTime({
shouldDisableEdits,
file,
scheduleUpdate,
}: {
shouldDisableEdits: boolean;
file: EnteFile;
scheduleUpdate: () => void;
}) {
const [loading, setLoading] = useState(false);
const originalCreationTime = new Date(file?.metadata.creationTime / 1000);
const [isInEditMode, setIsInEditMode] = useState(false);
const [pickedTime, setPickedTime] = useState(originalCreationTime);
const openEditMode = () => setIsInEditMode(true);
const closeEditMode = () => setIsInEditMode(false);
const saveEdits = async () => {
try {
setLoading(true);
if (isInEditMode && file) {
const unixTimeInMicroSec = pickedTime.getTime() * 1000;
if (unixTimeInMicroSec === file?.metadata.creationTime) {
closeEditMode();
return;
}
let updatedFile = await changeFileCreationTime(
file,
unixTimeInMicroSec
);
updatedFile = (
await updateFilePublicMagicMetadata([updatedFile])
)[0];
updateExistingFilePubMetadata(file, updatedFile);
scheduleUpdate();
}
} catch (e) {
logError(e, 'failed to update creationTime');
} finally {
closeEditMode();
setLoading(false);
}
};
const discardEdits = () => {
setPickedTime(originalCreationTime);
closeEditMode();
};
const handleChange = (newDate: Date) => {
if (newDate instanceof Date) {
setPickedTime(newDate);
}
};
return (
<>
<Row>
<Label width="30%">{constants.CREATION_TIME}</Label>
<Value
width={
!shouldDisableEdits
? isInEditMode
? '50%'
: '60%'
: '70%'
}>
{isInEditMode ? (
<EnteDateTimePicker
loading={loading}
isInEditMode={isInEditMode}
pickedTime={pickedTime}
handleChange={handleChange}
/>
) : (
formatDateTime(pickedTime)
)}
</Value>
{!shouldDisableEdits && (
<Value
width={isInEditMode ? '20%' : '10%'}
style={{ cursor: 'pointer', marginLeft: '10px' }}>
{!isInEditMode ? (
<IconButton onClick={openEditMode}>
<EditIcon />
</IconButton>
) : (
<>
<IconButton onClick={saveEdits}>
{loading ? (
<SmallLoadingSpinner />
) : (
<TickIcon />
)}
</IconButton>
<IconButton onClick={discardEdits}>
<CloseIcon />
</IconButton>
</>
)}
</Value>
)}
</Row>
</>
);
}

View file

@ -0,0 +1,103 @@
import React, { useState } from 'react';
import { updateFilePublicMagicMetadata } from 'services/fileService';
import { EnteFile } from 'types/file';
import constants from 'utils/strings/constants';
import {
changeFileName,
splitFilenameAndExtension,
updateExistingFilePubMetadata,
} from 'utils/file';
import EditIcon from 'components/icons/EditIcon';
import {
FreeFlowText,
IconButton,
Label,
Row,
Value,
} from 'components/Container';
import { logError } from 'utils/sentry';
import { FileNameEditForm } from './FileNameEditForm';
export const getFileTitle = (filename, extension) => {
if (extension) {
return filename + '.' + extension;
} else {
return filename;
}
};
export function RenderFileName({
shouldDisableEdits,
file,
scheduleUpdate,
}: {
shouldDisableEdits: boolean;
file: EnteFile;
scheduleUpdate: () => void;
}) {
const originalTitle = file?.metadata.title;
const [isInEditMode, setIsInEditMode] = useState(false);
const [originalFileName, extension] =
splitFilenameAndExtension(originalTitle);
const [filename, setFilename] = useState(originalFileName);
const openEditMode = () => setIsInEditMode(true);
const closeEditMode = () => setIsInEditMode(false);
const saveEdits = async (newFilename: string) => {
try {
if (file) {
if (filename === newFilename) {
closeEditMode();
return;
}
setFilename(newFilename);
const newTitle = getFileTitle(newFilename, extension);
let updatedFile = await changeFileName(file, newTitle);
updatedFile = (
await updateFilePublicMagicMetadata([updatedFile])
)[0];
updateExistingFilePubMetadata(file, updatedFile);
scheduleUpdate();
}
} catch (e) {
logError(e, 'failed to update file name');
} finally {
closeEditMode();
}
};
return (
<>
<Row>
<Label width="30%">{constants.FILE_NAME}</Label>
{!isInEditMode ? (
<>
<Value width={!shouldDisableEdits ? '60%' : '70%'}>
<FreeFlowText>
{getFileTitle(filename, extension)}
</FreeFlowText>
</Value>
{!shouldDisableEdits && (
<Value
width="10%"
style={{
cursor: 'pointer',
marginLeft: '10px',
}}>
<IconButton onClick={openEditMode}>
<EditIcon />
</IconButton>
</Value>
)}
</>
) : (
<FileNameEditForm
extension={extension}
filename={filename}
saveEdits={saveEdits}
discardEdits={closeEditMode}
/>
)}
</Row>
</>
);
}

View file

@ -0,0 +1,9 @@
import React from 'react';
import { Label, Row, Value } from 'components/Container';
export const RenderInfoItem = (label: string, value: string | JSX.Element) => (
<Row>
<Label width="30%">{label}</Label>
<Value width="70%">{value}</Value>
</Row>
);

View file

@ -0,0 +1,82 @@
import React from 'react';
import constants from 'utils/strings/constants';
import { formatDateTime } from 'utils/file';
import { RenderFileName } from './RenderFileName';
import { ExifData } from './ExifData';
import { RenderCreationTime } from './RenderCreationTime';
import { RenderInfoItem } from './RenderInfoItem';
import DialogBoxBase from 'components/DialogBox/base';
import DialogTitleWithCloseButton from 'components/DialogBox/titleWithCloseButton';
import { DialogContent, Link, Typography } from '@mui/material';
export function InfoModal({
shouldDisableEdits,
showInfo,
handleCloseInfo,
items,
photoSwipe,
metadata,
exif,
scheduleUpdate,
}) {
return (
<DialogBoxBase
sx={{
zIndex: '1501',
'& .MuiDialog-container': {
alignItems: 'flex-start',
},
}}
open={showInfo}
onClose={handleCloseInfo}>
<DialogTitleWithCloseButton onClose={handleCloseInfo}>
{constants.INFO}
</DialogTitleWithCloseButton>
<DialogContent>
<Typography variant="subtitle" mb={1}>
{constants.METADATA}
</Typography>
{RenderInfoItem(
constants.FILE_ID,
items[photoSwipe?.getCurrentIndex()]?.id
)}
{metadata?.title && (
<RenderFileName
shouldDisableEdits={shouldDisableEdits}
file={items[photoSwipe?.getCurrentIndex()]}
scheduleUpdate={scheduleUpdate}
/>
)}
{metadata?.creationTime && (
<RenderCreationTime
shouldDisableEdits={shouldDisableEdits}
file={items[photoSwipe?.getCurrentIndex()]}
scheduleUpdate={scheduleUpdate}
/>
)}
{metadata?.modificationTime &&
RenderInfoItem(
constants.UPDATED_ON,
formatDateTime(metadata.modificationTime / 1000)
)}
{metadata?.longitude > 0 &&
metadata?.longitude > 0 &&
RenderInfoItem(
constants.LOCATION,
<Link
href={`https://www.openstreetmap.org/?mlat=${metadata.latitude}&mlon=${metadata.longitude}#map=15/${metadata.latitude}/${metadata.longitude}`}
target="_blank"
rel="noopener noreferrer">
{constants.SHOW_MAP}
</Link>
)}
{exif && (
<>
<ExifData exif={exif} />
</>
)}
</DialogContent>
</DialogBoxBase>
);
}

View file

@ -1,908 +0,0 @@
import React, { useContext, useEffect, useRef, useState } from 'react';
import Photoswipe from 'photoswipe';
import PhotoswipeUIDefault from 'photoswipe/dist/photoswipe-ui-default';
import classnames from 'classnames';
import FavButton from 'components/FavButton';
import {
addToFavorites,
removeFromFavorites,
} from 'services/collectionService';
import { updateFilePublicMagicMetadata } from 'services/fileService';
import { EnteFile } from 'types/file';
import constants from 'utils/strings/constants';
import exifr from 'exifr';
import Modal from 'react-bootstrap/Modal';
import Button from 'react-bootstrap/Button';
import styled from 'styled-components';
import events from './events';
import {
changeFileCreationTime,
changeFileName,
downloadFile,
formatDateTime,
splitFilenameAndExtension,
updateExistingFilePubMetadata,
} from 'utils/file';
import { Col, Form, FormCheck, FormControl } from 'react-bootstrap';
import { prettyPrintExif } from 'utils/exif';
import EditIcon from 'components/icons/EditIcon';
import {
FlexWrapper,
FreeFlowText,
IconButton,
Label,
Row,
Value,
} from 'components/Container';
import { livePhotoBtnHTML } from 'components/LivePhotoBtn';
import { logError } from 'utils/sentry';
import CloseIcon from '@mui/icons-material/Close';
import TickIcon from '@mui/icons-material/Done';
import { Formik } from 'formik';
import * as Yup from 'yup';
import EnteSpinner from 'components/EnteSpinner';
import EnteDateTimePicker from 'components/EnteDateTimePicker';
import { MAX_EDITED_FILE_NAME_LENGTH, FILE_TYPE } from 'constants/file';
import { sleep } from 'utils/common';
import { playVideo, pauseVideo } from 'utils/photoFrame';
import { PublicCollectionGalleryContext } from 'utils/publicCollectionGallery';
import { GalleryContext } from 'pages/gallery';
import { AppContext } from 'pages/_app';
const SmallLoadingSpinner = () => (
<EnteSpinner
style={{
width: '20px',
height: '20px',
}}
/>
);
interface Iprops {
isOpen: boolean;
items: any[];
currentIndex?: number;
onClose?: (needUpdate: boolean) => void;
gettingData: (instance: any, index: number, item: EnteFile) => void;
id?: string;
className?: string;
favItemIds: Set<number>;
isSharedCollection: boolean;
isTrashCollection: boolean;
enableDownload: boolean;
isSourceLoaded: boolean;
}
const LegendContainer = styled.div`
display: flex;
justify-content: space-between;
`;
const Legend = styled.span`
font-size: 20px;
color: #ddd;
display: inline;
`;
const Pre = styled.pre`
color: #aaa;
padding: 7px 15px;
`;
const LivePhotoBtn = styled.button`
position: absolute;
bottom: 6vh;
right: 6vh;
height: 40px;
width: 80px;
background: #d7d7d7;
outline: none;
border: none;
border-radius: 10%;
z-index: 10;
cursor: ${(props) => (props.disabled ? 'not-allowed' : 'pointer')};
}
`;
const livePhotoDefaultOptions = {
click: () => {},
hide: () => {},
show: () => {},
loading: false,
visible: false,
};
const renderInfoItem = (label: string, value: string | JSX.Element) => (
<Row>
<Label width="30%">{label}</Label>
<Value width="70%">{value}</Value>
</Row>
);
function RenderCreationTime({
shouldDisableEdits,
file,
scheduleUpdate,
}: {
shouldDisableEdits: boolean;
file: EnteFile;
scheduleUpdate: () => void;
}) {
const [loading, setLoading] = useState(false);
const originalCreationTime = new Date(file?.metadata.creationTime / 1000);
const [isInEditMode, setIsInEditMode] = useState(false);
const [pickedTime, setPickedTime] = useState(originalCreationTime);
const openEditMode = () => setIsInEditMode(true);
const closeEditMode = () => setIsInEditMode(false);
const saveEdits = async () => {
try {
setLoading(true);
if (isInEditMode && file) {
const unixTimeInMicroSec = pickedTime.getTime() * 1000;
if (unixTimeInMicroSec === file?.metadata.creationTime) {
closeEditMode();
return;
}
let updatedFile = await changeFileCreationTime(
file,
unixTimeInMicroSec
);
updatedFile = (
await updateFilePublicMagicMetadata([updatedFile])
)[0];
updateExistingFilePubMetadata(file, updatedFile);
scheduleUpdate();
}
} catch (e) {
logError(e, 'failed to update creationTime');
} finally {
closeEditMode();
setLoading(false);
}
};
const discardEdits = () => {
setPickedTime(originalCreationTime);
closeEditMode();
};
const handleChange = (newDate: Date) => {
if (newDate instanceof Date) {
setPickedTime(newDate);
}
};
return (
<>
<Row>
<Label width="30%">{constants.CREATION_TIME}</Label>
<Value
width={
!shouldDisableEdits
? isInEditMode
? '50%'
: '60%'
: '70%'
}>
{isInEditMode ? (
<EnteDateTimePicker
loading={loading}
isInEditMode={isInEditMode}
pickedTime={pickedTime}
handleChange={handleChange}
/>
) : (
formatDateTime(pickedTime)
)}
</Value>
{!shouldDisableEdits && (
<Value
width={isInEditMode ? '20%' : '10%'}
style={{ cursor: 'pointer', marginLeft: '10px' }}>
{!isInEditMode ? (
<IconButton onClick={openEditMode}>
<EditIcon />
</IconButton>
) : (
<>
<IconButton onClick={saveEdits}>
{loading ? (
<SmallLoadingSpinner />
) : (
<TickIcon />
)}
</IconButton>
<IconButton onClick={discardEdits}>
<CloseIcon />
</IconButton>
</>
)}
</Value>
)}
</Row>
</>
);
}
const getFileTitle = (filename, extension) => {
if (extension) {
return filename + '.' + extension;
} else {
return filename;
}
};
interface formValues {
filename: string;
}
const FileNameEditForm = ({ filename, saveEdits, discardEdits, extension }) => {
const [loading, setLoading] = useState(false);
const onSubmit = async (values: formValues) => {
try {
setLoading(true);
await saveEdits(values.filename);
} finally {
setLoading(false);
}
};
return (
<Formik<formValues>
initialValues={{ filename }}
validationSchema={Yup.object().shape({
filename: Yup.string()
.required(constants.REQUIRED)
.max(
MAX_EDITED_FILE_NAME_LENGTH,
constants.FILE_NAME_CHARACTER_LIMIT
),
})}
validateOnBlur={false}
onSubmit={onSubmit}>
{({ values, errors, handleChange, handleSubmit }) => (
<Form noValidate onSubmit={handleSubmit}>
<Form.Row>
<Form.Group
bsPrefix="ente-form-group"
as={Col}
xs={extension ? 7 : 8}>
<Form.Control
as="textarea"
placeholder={constants.FILE_NAME}
value={values.filename}
onChange={handleChange('filename')}
isInvalid={Boolean(errors.filename)}
autoFocus
disabled={loading}
/>
<FormControl.Feedback
type="invalid"
style={{ textAlign: 'center' }}>
{errors.filename}
</FormControl.Feedback>
</Form.Group>
{extension && (
<Form.Group
bsPrefix="ente-form-group"
as={Col}
xs={1}
controlId="formHorizontalFileName">
<FlexWrapper style={{ padding: '5px' }}>
{`.${extension}`}
</FlexWrapper>
</Form.Group>
)}
<Form.Group bsPrefix="ente-form-group" as={Col} xs={2}>
<Value width={'16.67%'}>
<IconButton type="submit" disabled={loading}>
{loading ? (
<SmallLoadingSpinner />
) : (
<TickIcon />
)}
</IconButton>
<IconButton
onClick={discardEdits}
disabled={loading}>
<CloseIcon />
</IconButton>
</Value>
</Form.Group>
</Form.Row>
</Form>
)}
</Formik>
);
};
function RenderFileName({
shouldDisableEdits,
file,
scheduleUpdate,
}: {
shouldDisableEdits: boolean;
file: EnteFile;
scheduleUpdate: () => void;
}) {
const originalTitle = file?.metadata.title;
const [isInEditMode, setIsInEditMode] = useState(false);
const [originalFileName, extension] =
splitFilenameAndExtension(originalTitle);
const [filename, setFilename] = useState(originalFileName);
const openEditMode = () => setIsInEditMode(true);
const closeEditMode = () => setIsInEditMode(false);
const saveEdits = async (newFilename: string) => {
try {
if (file) {
if (filename === newFilename) {
closeEditMode();
return;
}
setFilename(newFilename);
const newTitle = getFileTitle(newFilename, extension);
let updatedFile = await changeFileName(file, newTitle);
updatedFile = (
await updateFilePublicMagicMetadata([updatedFile])
)[0];
updateExistingFilePubMetadata(file, updatedFile);
scheduleUpdate();
}
} catch (e) {
logError(e, 'failed to update file name');
} finally {
closeEditMode();
}
};
return (
<>
<Row>
<Label width="30%">{constants.FILE_NAME}</Label>
{!isInEditMode ? (
<>
<Value width={!shouldDisableEdits ? '60%' : '70%'}>
<FreeFlowText>
{getFileTitle(filename, extension)}
</FreeFlowText>
</Value>
{!shouldDisableEdits && (
<Value
width="10%"
style={{
cursor: 'pointer',
marginLeft: '10px',
}}>
<IconButton onClick={openEditMode}>
<EditIcon />
</IconButton>
</Value>
)}
</>
) : (
<FileNameEditForm
extension={extension}
filename={filename}
saveEdits={saveEdits}
discardEdits={closeEditMode}
/>
)}
</Row>
</>
);
}
function ExifData(props: { exif: any }) {
const { exif } = props;
const [showAll, setShowAll] = useState(false);
const changeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
setShowAll(e.target.checked);
};
const renderAllValues = () => <Pre>{exif.raw}</Pre>;
const renderSelectedValues = () => (
<>
{exif?.Make &&
exif?.Model &&
renderInfoItem(constants.DEVICE, `${exif.Make} ${exif.Model}`)}
{exif?.ImageWidth &&
exif?.ImageHeight &&
renderInfoItem(
constants.IMAGE_SIZE,
`${exif.ImageWidth} x ${exif.ImageHeight}`
)}
{exif?.Flash && renderInfoItem(constants.FLASH, exif.Flash)}
{exif?.FocalLength &&
renderInfoItem(
constants.FOCAL_LENGTH,
exif.FocalLength.toString()
)}
{exif?.ApertureValue &&
renderInfoItem(
constants.APERTURE,
exif.ApertureValue.toString()
)}
{exif?.ISOSpeedRatings &&
renderInfoItem(constants.ISO, exif.ISOSpeedRatings.toString())}
</>
);
return (
<>
<LegendContainer>
<Legend>{constants.EXIF}</Legend>
<FormCheck>
<FormCheck.Label>
<FormCheck.Input onChange={changeHandler} />
{constants.SHOW_ALL}
</FormCheck.Label>
</FormCheck>
</LegendContainer>
{showAll ? renderAllValues() : renderSelectedValues()}
</>
);
}
function InfoModal({
shouldDisableEdits,
showInfo,
handleCloseInfo,
items,
photoSwipe,
metadata,
exif,
scheduleUpdate,
}) {
return (
<Modal show={showInfo} onHide={handleCloseInfo}>
<Modal.Header closeButton>
<Modal.Title>{constants.INFO}</Modal.Title>
</Modal.Header>
<Modal.Body>
<div>
<Legend>{constants.METADATA}</Legend>
</div>
{renderInfoItem(
constants.FILE_ID,
items[photoSwipe?.getCurrentIndex()]?.id
)}
{metadata?.title && (
<RenderFileName
shouldDisableEdits={shouldDisableEdits}
file={items[photoSwipe?.getCurrentIndex()]}
scheduleUpdate={scheduleUpdate}
/>
)}
{metadata?.creationTime && (
<RenderCreationTime
shouldDisableEdits={shouldDisableEdits}
file={items[photoSwipe?.getCurrentIndex()]}
scheduleUpdate={scheduleUpdate}
/>
)}
{metadata?.modificationTime &&
renderInfoItem(
constants.UPDATED_ON,
formatDateTime(metadata.modificationTime / 1000)
)}
{metadata?.longitude > 0 &&
metadata?.longitude > 0 &&
renderInfoItem(
constants.LOCATION,
<a
href={`https://www.openstreetmap.org/?mlat=${metadata.latitude}&mlon=${metadata.longitude}#map=15/${metadata.latitude}/${metadata.longitude}`}
target="_blank"
rel="noopener noreferrer">
{constants.SHOW_MAP}
</a>
)}
{exif && (
<>
<ExifData exif={exif} />
</>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="outline-secondary" onClick={handleCloseInfo}>
{constants.CLOSE}
</Button>
</Modal.Footer>
</Modal>
);
}
function PhotoSwipe(props: Iprops) {
const pswpElement = useRef<HTMLDivElement>();
const [photoSwipe, setPhotoSwipe] =
useState<Photoswipe<Photoswipe.Options>>();
const { isOpen, items, isSourceLoaded } = props;
const [isFav, setIsFav] = useState(false);
const [showInfo, setShowInfo] = useState(false);
const [metadata, setMetaData] = useState<EnteFile['metadata']>(null);
const [exif, setExif] = useState<any>(null);
const [livePhotoBtnOptions, setLivePhotoBtnOptions] = useState(
livePhotoDefaultOptions
);
const needUpdate = useRef(false);
const publicCollectionGalleryContext = useContext(
PublicCollectionGalleryContext
);
const galleryContext = useContext(GalleryContext);
const appContext = useContext(AppContext);
useEffect(() => {
if (!pswpElement) return;
if (isOpen) {
openPhotoSwipe();
}
if (!isOpen) {
closePhotoSwipe();
}
return () => {
closePhotoSwipe();
};
}, [isOpen]);
useEffect(() => {
updateItems(items);
}, [items]);
useEffect(() => {
if (photoSwipe) {
photoSwipe.options.arrowKeys = !showInfo;
photoSwipe.options.escKey = !showInfo;
}
}, [showInfo]);
useEffect(() => {
if (!isOpen) return;
const item = items[photoSwipe?.getCurrentIndex()];
if (item && item.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
const getVideoAndImage = () => {
const video = document.getElementById(
`live-photo-video-${item.id}`
);
const image = document.getElementById(
`live-photo-image-${item.id}`
);
return { video, image };
};
const { video, image } = getVideoAndImage();
if (video && image) {
setLivePhotoBtnOptions({
click: async () => {
await playVideo(video, image);
},
hide: async () => {
await pauseVideo(video, image);
},
show: async () => {
await playVideo(video, image);
},
visible: true,
loading: false,
});
} else {
setLivePhotoBtnOptions({
...livePhotoDefaultOptions,
visible: true,
loading: true,
});
}
const downloadLivePhotoBtn = document.getElementById(
`download-btn-${item.id}`
) as HTMLButtonElement;
if (downloadLivePhotoBtn) {
const downloadLivePhoto = () => {
downloadFileHelper(photoSwipe.currItem);
};
downloadLivePhotoBtn.addEventListener(
'click',
downloadLivePhoto
);
return () => {
downloadLivePhotoBtn.removeEventListener(
'click',
downloadLivePhoto
);
setLivePhotoBtnOptions(livePhotoDefaultOptions);
};
}
return () => {
setLivePhotoBtnOptions(livePhotoDefaultOptions);
};
}
}, [photoSwipe?.currItem, isOpen, isSourceLoaded]);
function updateFavButton() {
setIsFav(isInFav(this?.currItem));
}
const openPhotoSwipe = () => {
const { items, currentIndex } = props;
const options = {
history: false,
maxSpreadZoom: 5,
index: currentIndex,
showHideOpacity: true,
getDoubleTapZoom(isMouseClick, item) {
if (isMouseClick) {
return 2.5;
}
// zoom to original if initial zoom is less than 0.7x,
// otherwise to 1.5x, to make sure that double-tap gesture always zooms image
return item.initialZoomLevel < 0.7 ? 1 : 1.5;
},
getThumbBoundsFn: (index) => {
try {
const file = items[index];
const ele = document.getElementById(`thumb-${file.id}`);
if (ele) {
const rect = ele.getBoundingClientRect();
const pageYScroll =
window.pageYOffset ||
document.documentElement.scrollTop;
return {
x: rect.left,
y: rect.top + pageYScroll,
w: rect.width,
};
}
return null;
} catch (e) {
return null;
}
},
};
const photoSwipe = new Photoswipe(
pswpElement.current,
PhotoswipeUIDefault,
items,
options
);
events.forEach((event) => {
const callback = props[event];
if (callback || event === 'destroy') {
photoSwipe.listen(event, function (...args) {
if (callback) {
args.unshift(this);
callback(...args);
}
if (event === 'destroy') {
handleClose();
}
if (event === 'close') {
handleClose();
}
});
}
});
photoSwipe.listen('beforeChange', function () {
updateInfo.call(this);
updateFavButton.call(this);
});
photoSwipe.listen('resize', checkExifAvailable);
photoSwipe.init();
needUpdate.current = false;
setPhotoSwipe(photoSwipe);
};
const closePhotoSwipe = () => {
if (photoSwipe) photoSwipe.close();
};
const handleClose = () => {
const { onClose } = props;
if (typeof onClose === 'function') {
onClose(needUpdate.current);
}
const videoTags = document.getElementsByTagName('video');
for (const videoTag of videoTags) {
videoTag.pause();
}
handleCloseInfo();
// BE_AWARE: this will clear any notification set, even if they were not set in/by the photoswipe component
galleryContext.setNotificationAttributes(null);
};
const isInFav = (file) => {
const { favItemIds } = props;
if (favItemIds && file) {
return favItemIds.has(file.id);
}
return false;
};
const onFavClick = async (file) => {
const { favItemIds } = props;
if (!isInFav(file)) {
favItemIds.add(file.id);
addToFavorites(file);
setIsFav(true);
} else {
favItemIds.delete(file.id);
removeFromFavorites(file);
setIsFav(false);
}
needUpdate.current = true;
};
const updateItems = (items = []) => {
if (photoSwipe) {
photoSwipe.items.length = 0;
items.forEach((item) => {
photoSwipe.items.push(item);
});
photoSwipe.invalidateCurrItems();
// photoSwipe.updateSize(true);
}
};
const checkExifAvailable = async () => {
setExif(null);
await sleep(100);
try {
const img: HTMLImageElement = document.querySelector(
'.pswp__img:not(.pswp__img--placeholder)'
);
if (img) {
const exifData = await exifr.parse(img);
if (!exifData) {
return;
}
exifData.raw = prettyPrintExif(exifData);
setExif(exifData);
}
} catch (e) {
logError(e, 'exifr parsing failed');
}
};
function updateInfo() {
const file: EnteFile = this?.currItem;
if (file?.metadata) {
setMetaData(file.metadata);
setExif(null);
checkExifAvailable();
}
}
const handleCloseInfo = () => {
setShowInfo(false);
};
const handleOpenInfo = () => {
setShowInfo(true);
};
const downloadFileHelper = async (file) => {
appContext.startLoading();
await downloadFile(
file,
publicCollectionGalleryContext.accessedThroughSharedURL,
publicCollectionGalleryContext.token,
publicCollectionGalleryContext.passwordToken
);
appContext.finishLoading();
};
const scheduleUpdate = () => (needUpdate.current = true);
const { id } = props;
let { className } = props;
className = classnames(['pswp', className]).trim();
return (
<>
<div
id={id}
className={className}
tabIndex={Number('-1')}
role="dialog"
aria-hidden="true"
ref={pswpElement}>
<div className="pswp__bg" />
<div className="pswp__scroll-wrap">
<LivePhotoBtn
onClick={livePhotoBtnOptions.click}
onMouseEnter={livePhotoBtnOptions.show}
onMouseLeave={livePhotoBtnOptions.hide}
disabled={livePhotoBtnOptions.loading}
style={{
display: livePhotoBtnOptions.visible
? 'block'
: 'none',
}}>
{livePhotoBtnHTML} {constants.LIVE}
</LivePhotoBtn>
<div className="pswp__container">
<div className="pswp__item" />
<div className="pswp__item" />
<div className="pswp__item" />
</div>
<div className="pswp__ui pswp__ui--hidden">
<div className="pswp__top-bar">
<div className="pswp__counter" />
<button
className="pswp__button pswp__button--close"
title={constants.CLOSE}
/>
{props.enableDownload && (
<button
className="pswp-custom download-btn"
title={constants.DOWNLOAD}
onClick={() =>
downloadFileHelper(photoSwipe.currItem)
}
/>
)}
<button
className="pswp__button pswp__button--fs"
title={constants.TOGGLE_FULLSCREEN}
/>
<button
className="pswp__button pswp__button--zoom"
title={constants.ZOOM_IN_OUT}
/>
{!props.isSharedCollection &&
!props.isTrashCollection && (
<FavButton
size={44}
isClick={isFav}
onClick={() => {
onFavClick(photoSwipe?.currItem);
}}
/>
)}
{!props.isSharedCollection && (
<button
className="pswp-custom info-btn"
title={constants.INFO}
onClick={handleOpenInfo}
/>
)}
<div className="pswp__preloader">
<div className="pswp__preloader__icn">
<div className="pswp__preloader__cut">
<div className="pswp__preloader__donut" />
</div>
</div>
</div>
</div>
<div className="pswp__share-modal pswp__share-modal--hidden pswp__single-tap">
<div className="pswp__share-tooltip" />
</div>
<button
className="pswp__button pswp__button--arrow--left"
title={constants.PREVIOUS}
/>
<button
className="pswp__button pswp__button--arrow--right"
title={constants.NEXT}
/>
<div className="pswp__caption">
<div />
</div>
</div>
</div>
</div>
{!props.isSharedCollection && (
<InfoModal
shouldDisableEdits={props.isSharedCollection}
showInfo={showInfo}
handleCloseInfo={handleCloseInfo}
items={items}
photoSwipe={photoSwipe}
metadata={metadata}
exif={exif}
scheduleUpdate={scheduleUpdate}
/>
)}
</>
);
}
export default PhotoSwipe;

View file

@ -0,0 +1,433 @@
import React, { useContext, useEffect, useRef, useState } from 'react';
import Photoswipe from 'photoswipe';
import PhotoswipeUIDefault from 'photoswipe/dist/photoswipe-ui-default';
import classnames from 'classnames';
import FavButton from 'components/FavButton';
import {
addToFavorites,
removeFromFavorites,
} from 'services/collectionService';
import { EnteFile } from 'types/file';
import constants from 'utils/strings/constants';
import exifr from 'exifr';
import events from './events';
import { downloadFile } from 'utils/file';
import { prettyPrintExif } from 'utils/exif';
import { livePhotoBtnHTML } from 'components/LivePhotoBtn';
import { logError } from 'utils/sentry';
import { FILE_TYPE } from 'constants/file';
import { sleep } from 'utils/common';
import { playVideo, pauseVideo } from 'utils/photoFrame';
import { PublicCollectionGalleryContext } from 'utils/publicCollectionGallery';
import { AppContext } from 'pages/_app';
import { InfoModal } from './InfoDialog';
import { defaultLivePhotoDefaultOptions } from 'constants/photoswipe';
import { LivePhotoBtn } from './styledComponents/LivePhotoBtn';
interface Iprops {
isOpen: boolean;
items: any[];
currentIndex?: number;
onClose?: (needUpdate: boolean) => void;
gettingData: (instance: any, index: number, item: EnteFile) => void;
id?: string;
className?: string;
favItemIds: Set<number>;
isSharedCollection: boolean;
isTrashCollection: boolean;
enableDownload: boolean;
isSourceLoaded: boolean;
}
function PhotoSwipe(props: Iprops) {
const pswpElement = useRef<HTMLDivElement>();
const [photoSwipe, setPhotoSwipe] =
useState<Photoswipe<Photoswipe.Options>>();
const { isOpen, items, isSourceLoaded } = props;
const [isFav, setIsFav] = useState(false);
const [showInfo, setShowInfo] = useState(false);
const [metadata, setMetaData] = useState<EnteFile['metadata']>(null);
const [exif, setExif] = useState<any>(null);
const [livePhotoBtnOptions, setLivePhotoBtnOptions] = useState(
defaultLivePhotoDefaultOptions
);
const needUpdate = useRef(false);
const publicCollectionGalleryContext = useContext(
PublicCollectionGalleryContext
);
const appContext = useContext(AppContext);
useEffect(() => {
if (!pswpElement) return;
if (isOpen) {
openPhotoSwipe();
}
if (!isOpen) {
closePhotoSwipe();
}
return () => {
closePhotoSwipe();
};
}, [isOpen]);
useEffect(() => {
updateItems(items);
}, [items]);
useEffect(() => {
if (photoSwipe) {
photoSwipe.options.arrowKeys = !showInfo;
photoSwipe.options.escKey = !showInfo;
}
}, [showInfo]);
useEffect(() => {
if (!isOpen) return;
const item = items[photoSwipe?.getCurrentIndex()];
if (item && item.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
const getVideoAndImage = () => {
const video = document.getElementById(
`live-photo-video-${item.id}`
);
const image = document.getElementById(
`live-photo-image-${item.id}`
);
return { video, image };
};
const { video, image } = getVideoAndImage();
if (video && image) {
setLivePhotoBtnOptions({
click: async () => {
await playVideo(video, image);
},
hide: async () => {
await pauseVideo(video, image);
},
show: async () => {
await playVideo(video, image);
},
visible: true,
loading: false,
});
} else {
setLivePhotoBtnOptions({
...defaultLivePhotoDefaultOptions,
visible: true,
loading: true,
});
}
const downloadLivePhotoBtn = document.getElementById(
`download-btn-${item.id}`
) as HTMLButtonElement;
if (downloadLivePhotoBtn) {
const downloadLivePhoto = () => {
downloadFileHelper(photoSwipe.currItem);
};
downloadLivePhotoBtn.addEventListener(
'click',
downloadLivePhoto
);
return () => {
downloadLivePhotoBtn.removeEventListener(
'click',
downloadLivePhoto
);
setLivePhotoBtnOptions(defaultLivePhotoDefaultOptions);
};
}
return () => {
setLivePhotoBtnOptions(defaultLivePhotoDefaultOptions);
};
}
}, [photoSwipe?.currItem, isOpen, isSourceLoaded]);
function updateFavButton() {
setIsFav(isInFav(this?.currItem));
}
const openPhotoSwipe = () => {
const { items, currentIndex } = props;
const options = {
history: false,
maxSpreadZoom: 5,
index: currentIndex,
showHideOpacity: true,
getDoubleTapZoom(isMouseClick, item) {
if (isMouseClick) {
return 2.5;
}
// zoom to original if initial zoom is less than 0.7x,
// otherwise to 1.5x, to make sure that double-tap gesture always zooms image
return item.initialZoomLevel < 0.7 ? 1 : 1.5;
},
getThumbBoundsFn: (index) => {
try {
const file = items[index];
const ele = document.getElementById(`thumb-${file.id}`);
if (ele) {
const rect = ele.getBoundingClientRect();
const pageYScroll =
window.pageYOffset ||
document.documentElement.scrollTop;
return {
x: rect.left,
y: rect.top + pageYScroll,
w: rect.width,
};
}
return null;
} catch (e) {
return null;
}
},
};
const photoSwipe = new Photoswipe(
pswpElement.current,
PhotoswipeUIDefault,
items,
options
);
events.forEach((event) => {
const callback = props[event];
if (callback || event === 'destroy') {
photoSwipe.listen(event, function (...args) {
if (callback) {
args.unshift(this);
callback(...args);
}
if (event === 'destroy') {
handleClose();
}
if (event === 'close') {
handleClose();
}
});
}
});
photoSwipe.listen('beforeChange', function () {
updateInfo.call(this);
updateFavButton.call(this);
});
photoSwipe.listen('resize', checkExifAvailable);
photoSwipe.init();
needUpdate.current = false;
setPhotoSwipe(photoSwipe);
};
const closePhotoSwipe = () => {
if (photoSwipe) photoSwipe.close();
};
const handleClose = () => {
const { onClose } = props;
if (typeof onClose === 'function') {
onClose(needUpdate.current);
}
const videoTags = document.getElementsByTagName('video');
for (const videoTag of videoTags) {
videoTag.pause();
}
handleCloseInfo();
};
const isInFav = (file) => {
const { favItemIds } = props;
if (favItemIds && file) {
return favItemIds.has(file.id);
}
return false;
};
const onFavClick = async (file) => {
const { favItemIds } = props;
if (!isInFav(file)) {
favItemIds.add(file.id);
addToFavorites(file);
setIsFav(true);
} else {
favItemIds.delete(file.id);
removeFromFavorites(file);
setIsFav(false);
}
needUpdate.current = true;
};
const updateItems = (items = []) => {
if (photoSwipe) {
photoSwipe.items.length = 0;
items.forEach((item) => {
photoSwipe.items.push(item);
});
photoSwipe.invalidateCurrItems();
// photoSwipe.updateSize(true);
}
};
const checkExifAvailable = async () => {
setExif(null);
await sleep(100);
try {
const img: HTMLImageElement = document.querySelector(
'.pswp__img:not(.pswp__img--placeholder)'
);
if (img) {
const exifData = await exifr.parse(img);
if (!exifData) {
return;
}
exifData.raw = prettyPrintExif(exifData);
setExif(exifData);
}
} catch (e) {
logError(e, 'exifr parsing failed');
}
};
function updateInfo() {
const file: EnteFile = this?.currItem;
if (file?.metadata) {
setMetaData(file.metadata);
setExif(null);
checkExifAvailable();
}
}
const handleCloseInfo = () => {
setShowInfo(false);
};
const handleOpenInfo = () => {
setShowInfo(true);
};
const downloadFileHelper = async (file) => {
appContext.startLoading();
await downloadFile(
file,
publicCollectionGalleryContext.accessedThroughSharedURL,
publicCollectionGalleryContext.token,
publicCollectionGalleryContext.passwordToken
);
appContext.finishLoading();
};
const scheduleUpdate = () => (needUpdate.current = true);
const { id } = props;
let { className } = props;
className = classnames(['pswp', className]).trim();
return (
<>
<div
id={id}
className={className}
tabIndex={Number('-1')}
role="dialog"
aria-hidden="true"
ref={pswpElement}>
<div className="pswp__bg" />
<div className="pswp__scroll-wrap">
<LivePhotoBtn
onClick={livePhotoBtnOptions.click}
onMouseEnter={livePhotoBtnOptions.show}
onMouseLeave={livePhotoBtnOptions.hide}
disabled={livePhotoBtnOptions.loading}
style={{
display: livePhotoBtnOptions.visible
? 'block'
: 'none',
}}>
{livePhotoBtnHTML} {constants.LIVE}
</LivePhotoBtn>
<div className="pswp__container">
<div className="pswp__item" />
<div className="pswp__item" />
<div className="pswp__item" />
</div>
<div className="pswp__ui pswp__ui--hidden">
<div className="pswp__top-bar">
<div className="pswp__counter" />
<button
className="pswp__button pswp__button--close"
title={constants.CLOSE}
/>
{props.enableDownload && (
<button
className="pswp-custom download-btn"
title={constants.DOWNLOAD}
onClick={() =>
downloadFileHelper(photoSwipe.currItem)
}
/>
)}
<button
className="pswp__button pswp__button--fs"
title={constants.TOGGLE_FULLSCREEN}
/>
<button
className="pswp__button pswp__button--zoom"
title={constants.ZOOM_IN_OUT}
/>
{!props.isSharedCollection &&
!props.isTrashCollection && (
<FavButton
size={44}
isClick={isFav}
onClick={() => {
onFavClick(photoSwipe?.currItem);
}}
/>
)}
{!props.isSharedCollection && (
<button
className="pswp-custom info-btn"
title={constants.INFO}
onClick={handleOpenInfo}
/>
)}
<div className="pswp__preloader">
<div className="pswp__preloader__icn">
<div className="pswp__preloader__cut">
<div className="pswp__preloader__donut" />
</div>
</div>
</div>
</div>
<div className="pswp__share-modal pswp__share-modal--hidden pswp__single-tap">
<div className="pswp__share-tooltip" />
</div>
<button
className="pswp__button pswp__button--arrow--left"
title={constants.PREVIOUS}
/>
<button
className="pswp__button pswp__button--arrow--right"
title={constants.NEXT}
/>
<div className="pswp__caption">
<div />
</div>
</div>
</div>
</div>
<InfoModal
shouldDisableEdits={props.isSharedCollection}
showInfo={showInfo}
handleCloseInfo={handleCloseInfo}
items={items}
photoSwipe={photoSwipe}
metadata={metadata}
exif={exif}
scheduleUpdate={scheduleUpdate}
/>
</>
);
}
export default PhotoSwipe;

View file

@ -0,0 +1,7 @@
import styled from 'styled-components';
export const Legend = styled.span`
font-size: 20px;
color: #ddd;
display: inline;
`;

View file

@ -0,0 +1,6 @@
import styled from 'styled-components';
export const LegendContainer = styled.div`
display: flex;
justify-content: space-between;
`;

View file

@ -0,0 +1,16 @@
import styled from 'styled-components';
export const LivePhotoBtn = styled.button`
position: absolute;
bottom: 6vh;
right: 6vh;
height: 40px;
width: 80px;
background: #d7d7d7;
outline: none;
border: none;
border-radius: 10%;
z-index: 10;
cursor: ${(props) => (props.disabled ? 'not-allowed' : 'pointer')};
}
`;

View file

@ -0,0 +1,6 @@
import styled from 'styled-components';
export const Pre = styled.pre`
color: #aaa;
padding: 7px 15px;
`;

View file

@ -0,0 +1,11 @@
import React from 'react';
import EnteSpinner from 'components/EnteSpinner';
export const SmallLoadingSpinner = () => (
<EnteSpinner
style={{
width: '20px',
height: '20px',
}}
/>
);

View file

@ -7,10 +7,13 @@ import CodeBlock from '../CodeBlock';
import { ButtonProps, Typography } from '@mui/material';
import * as bip39 from 'bip39';
import { DashedBorderWrapper } from './styledComponents';
import { DialogBoxAttributes } from 'types/dialogBox';
// mobile client library only supports english.
bip39.setDefaultWordlist('english');
const RECOVERY_KEY_FILE_NAME = 'ente-recovery-key.txt';
interface Props {
show: boolean;
onHide: () => void;
@ -36,17 +39,16 @@ function RecoveryKey({ somethingWentWrong, ...props }: Props) {
}, [props.show]);
function onSaveClick() {
downloadAsFile(constants.RECOVERY_KEY_FILENAME, recoveryKey);
downloadAsFile(RECOVERY_KEY_FILE_NAME, recoveryKey);
props.onHide();
}
const recoveryKeyDialogAttributes = {
const recoveryKeyDialogAttributes: DialogBoxAttributes = {
title: constants.RECOVERY_KEY,
close: {
text: constants.SAVE_LATER,
variant: 'secondary' as ButtonProps['color'],
},
staticBackdrop: true,
proceed: {
text: constants.SAVE,
action: onSaveClick,

View file

@ -1,9 +1,9 @@
import React from 'react';
import CollectionIcon from 'components/icons/CollectionIcon';
import DateIcon from 'components/icons/DateIcon';
import ImageIcon from 'components/icons/ImageIcon';
import LocationIcon from 'components/icons/LocationIcon';
import VideoIcon from 'components/icons/VideoIcon';
import FolderIcon from '@mui/icons-material/Folder';
import CalendarIcon from '@mui/icons-material/CalendarMonth';
import ImageIcon from '@mui/icons-material/Image';
import LocationIcon from '@mui/icons-material/LocationOn';
import VideoFileIcon from '@mui/icons-material/VideoFile';
import { components } from 'react-select';
import { SearchOption, SuggestionType } from 'types/search';
import SearchIcon from '@mui/icons-material/Search';
@ -14,15 +14,15 @@ const { Control } = components;
const getIconByType = (type: SuggestionType) => {
switch (type) {
case SuggestionType.DATE:
return <DateIcon />;
return <CalendarIcon />;
case SuggestionType.LOCATION:
return <LocationIcon />;
case SuggestionType.COLLECTION:
return <CollectionIcon />;
return <FolderIcon />;
case SuggestionType.IMAGE:
return <ImageIcon />;
case SuggestionType.VIDEO:
return <VideoIcon />;
return <VideoFileIcon />;
default:
return <SearchIcon />;
}

View file

@ -32,7 +32,7 @@ export const SelectStyles = {
cursor: 'pointer',
},
'& .main': {
backgroundColor: isFocused && '#343434',
backgroundColor: isFocused && '#202020',
},
'&:last-child .MuiDivider-root': {
display: 'none',

View file

@ -1,37 +1,35 @@
import React, { FC } from 'react';
import { Button, ButtonProps } from '@mui/material';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import { Button, ButtonProps, Theme, TypographyVariant } from '@mui/material';
import { FluidContainer } from 'components/Container';
interface IProps {
hideArrow?: boolean;
smallerArrow?: boolean;
}
const SidebarButton: FC<ButtonProps<'button', IProps>> = ({
import { SystemStyleObject } from '@mui/system';
type Iprops = ButtonProps<'button', { typographyVariant?: TypographyVariant }>;
const SidebarButton: FC<Iprops> = ({
children,
hideArrow,
smallerArrow,
sx,
typographyVariant = 'body1',
...props
}) => {
return (
<Button
variant="text"
fullWidth
sx={{ my: 0.5, px: 1, py: '10px', ...sx }}
css={`
font-size: 16px;
font-weight: 600;
line-height: 24px;
letter-spacing: 0em;
`}
{...props}>
<FluidContainer>{children}</FluidContainer>
{!hideArrow && (
<NavigateNextIcon
fontSize={smallerArrow ? 'small' : 'medium'}
/>
)}
</Button>
<>
<Button
variant="text"
fullWidth
sx={(theme) =>
({
...theme.typography[typographyVariant],
fontWeight: 'bold',
my: 0.5,
px: 1,
py: '10px',
...sx,
} as SystemStyleObject<Theme>)
}
{...props}>
<FluidContainer>{children}</FluidContainer>
</Button>
</>
);
};

View file

@ -1,8 +1,8 @@
import { Link } from '@mui/material';
import React from 'react';
import { downloadAsFile } from 'utils/file';
import constants from 'utils/strings/constants';
import { logUploadInfo, getUploadLogs } from 'utils/upload';
import SidebarButton from './Button';
export default function DebugLogs() {
const downloadUploadLogs = () => {
@ -13,18 +13,11 @@ export default function DebugLogs() {
};
return (
<Link
sx={{
width: '100%',
marginTop: '30px',
marginBottom: '10px',
fontSize: '14px',
textAlign: 'center',
color: 'grey.500',
}}
component="button"
onClick={downloadUploadLogs}>
<SidebarButton
onClick={downloadUploadLogs}
typographyVariant="caption"
sx={{ fontWeight: 'normal', color: 'text.secondary' }}>
{constants.DOWNLOAD_UPLOAD_LOGS}
</Link>
</SidebarButton>
);
}

View file

@ -12,7 +12,7 @@ export default function ExitSection() {
setDialogMessage({
title: `${constants.CONFIRM} ${constants.LOGOUT}`,
content: constants.LOGOUT_MESSAGE,
staticBackdrop: true,
proceed: {
text: constants.LOGOUT,
action: logoutUser,
@ -26,7 +26,7 @@ export default function ExitSection() {
setDialogMessage({
title: `${constants.DELETE_ACCOUNT}`,
content: constants.DELETE_ACCOUNT_MESSAGE(),
staticBackdrop: true,
proceed: {
text: constants.DELETE_ACCOUNT,
action: () => {
@ -40,13 +40,10 @@ export default function ExitSection() {
return (
<>
<SidebarButton onClick={confirmLogout} hideArrow color="danger">
<SidebarButton onClick={confirmLogout} color="danger">
{constants.LOGOUT}
</SidebarButton>
<SidebarButton
onClick={showDeleteAccountDirections}
hideArrow
color="danger">
<SidebarButton onClick={showDeleteAccountDirections} color="danger">
{constants.DELETE_ACCOUNT}
</SidebarButton>
</>

View file

@ -37,7 +37,7 @@ export default function HelpSection() {
setDialogMessage({
title: constants.DOWNLOAD_APP,
content: constants.DOWNLOAD_APP_MESSAGE(),
staticBackdrop: true,
proceed: {
text: constants.DOWNLOAD,
action: downloadApp,

View file

@ -17,7 +17,6 @@ const NavigationButton: FC<ButtonProps<'button', IProps>> = ({
}) => {
return (
<SidebarButton
smallerArrow
variant="contained"
color="secondary"
sx={{ px: '12px' }}

View file

@ -0,0 +1,10 @@
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import Box from '@mui/material/Box';
import React from 'react';
export function ClickIndicator() {
return (
<Box position={'absolute'} top={64} right={0}>
<ChevronRightIcon />
</Box>
);
}

View file

@ -0,0 +1,43 @@
import { Legend } from './legend';
import { FamilyUsageProgressBar } from './progressBar';
import { Box, Stack, Typography } from '@mui/material';
import { SpaceBetweenFlex } from 'components/Container';
import React, { useMemo } from 'react';
import { UserDetails } from 'types/user';
import constants from 'utils/strings/constants';
export function FamilyUsageSection({
userDetails,
}: {
userDetails: UserDetails;
}) {
const totalUsage = useMemo(
() =>
userDetails.familyData.members.reduce(
(sum, currentMember) => sum + currentMember.usage,
0
),
[userDetails]
);
return (
<Box height={64} padding={2}>
<FamilyUsageProgressBar
totalUsage={totalUsage}
userDetails={userDetails}
/>
<SpaceBetweenFlex
style={{
marginTop: '12px',
}}>
<Stack direction={'row'} spacing={'12px'}>
<Legend label={constants.YOU} color="text.primary" />
<Legend label={constants.FAMILY} color="text.secondary" />
</Stack>
<Typography variant="caption" fontWeight={'bold'}>
{constants.PHOTO_COUNT(userDetails.fileCount)}
</Typography>
</SpaceBetweenFlex>
</Box>
);
}

View file

@ -0,0 +1,19 @@
import { Typography } from '@mui/material';
import { FlexWrapper } from 'components/Container';
import React from 'react';
import { LegendIndicator } from '../styledComponents';
interface Iprops {
label: string;
color: string;
}
export function Legend({ label, color }: Iprops) {
return (
<FlexWrapper>
<LegendIndicator sx={{ color }} />
<Typography variant="caption" fontWeight={'bold'}>
{label}
</Typography>
</FlexWrapper>
);
}

View file

@ -0,0 +1,27 @@
import { Box } from '@mui/material';
import React from 'react';
import { Progressbar } from '../styledComponents';
export function FamilyUsageProgressBar({ totalUsage, userDetails }) {
return (
<Box position={'relative'}>
<Progressbar
sx={{ backgroundColor: 'transparent' }}
value={
(userDetails.usage * 100) / userDetails.familyData.storage
}
/>
<Progressbar
sx={{
position: 'absolute',
top: 0,
zIndex: 1,
'.MuiLinearProgress-bar ': {
backgroundColor: 'text.secondary',
},
width: '100%',
}}
value={(totalUsage * 100) / userDetails.familyData.storage}
/>
</Box>
);
}

View file

@ -0,0 +1,70 @@
import { ClickIndicator } from './clickIndicator';
import { IndividualUsageSection } from './individualUsageSection';
import React, { useContext, useMemo } from 'react';
import { Box, Skeleton } from '@mui/material';
import { UserDetails } from 'types/user';
import StorageSection from './storageSection';
import { GalleryContext } from 'pages/gallery';
import { FamilyUsageSection } from './familyUsageSection';
import { hasNonAdminFamilyMembers, isPartOfFamily } from 'utils/billing';
interface Iprops {
userDetails: UserDetails;
closeSidebar: () => void;
}
export default function SubscriptionCard({ userDetails }: Iprops) {
const { showPlanSelectorModal } = useContext(GalleryContext);
if (!userDetails) {
return (
<Skeleton
animation="wave"
variant="rectangular"
width={'100%'}
height={148}
sx={{ borderRadius: '8px' }}
/>
);
}
const totalUsage = useMemo(() => {
if (isPartOfFamily(userDetails.familyData)) {
return userDetails.familyData.members.reduce(
(sum, currentMember) => sum + currentMember.usage,
0
);
} else {
return userDetails.usage;
}
}, [userDetails]);
const totalStorage = useMemo(() => {
if (isPartOfFamily(userDetails.familyData)) {
return userDetails.familyData.storage;
} else {
return userDetails.subscription.storage;
}
}, [userDetails]);
return (
<Box onClick={showPlanSelectorModal} sx={{ cursor: 'pointer' }}>
<img
style={{ position: 'absolute' }}
src="/subscription-card-background.png"
/>
<Box zIndex={1} position={'relative'} height={148}>
<StorageSection
totalStorage={totalStorage}
totalUsage={totalUsage}
/>
{hasNonAdminFamilyMembers(userDetails.familyData) ? (
<FamilyUsageSection userDetails={userDetails} />
) : (
<IndividualUsageSection userDetails={userDetails} />
)}
<ClickIndicator />
</Box>
</Box>
);
}

View file

@ -0,0 +1,30 @@
import { Box, Typography } from '@mui/material';
import { SpaceBetweenFlex } from 'components/Container';
import React from 'react';
import { convertBytesToHumanReadable } from 'utils/billing';
import constants from 'utils/strings/constants';
import { Progressbar } from './styledComponents';
export function IndividualUsageSection({ userDetails }) {
return (
<Box height={64} padding={2}>
<Progressbar
value={
(userDetails.usage * 100) / userDetails.subscription.storage
}
/>
<SpaceBetweenFlex
style={{
marginTop: '12px',
}}>
<Typography variant="caption">{`${convertBytesToHumanReadable(
userDetails.usage,
1
)} ${constants.USED}`}</Typography>
<Typography variant="caption" fontWeight={'bold'}>
{constants.PHOTO_COUNT(userDetails.fileCount)}
</Typography>
</SpaceBetweenFlex>
</Box>
);
}

View file

@ -0,0 +1,32 @@
import { Box, Typography } from '@mui/material';
import React from 'react';
import { convertBytesToHumanReadable } from 'utils/billing';
import constants from 'utils/strings/constants';
interface Iprops {
totalUsage: number;
totalStorage: number;
}
export default function StorageSection({ totalUsage, totalStorage }: Iprops) {
return (
<Box padding={2}>
<Typography variant="body2" color={'text.secondary'}>
{constants.STORAGE}
</Typography>
<Typography
fontWeight={'bold'}
sx={{
fontSize: '24px',
}}>
{`${convertBytesToHumanReadable(
totalStorage - totalUsage,
1
)} ${constants.OF} ${convertBytesToHumanReadable(
totalStorage,
0
)} ${constants.FREE}`}
</Typography>
</Box>
);
}

View file

@ -0,0 +1,22 @@
import { LinearProgress, styled } from '@mui/material';
import { DotSeparator } from '../styledComponents';
export const Progressbar = styled(LinearProgress)(() => ({
'.MuiLinearProgress-bar': {
borderRadius: '2px',
},
borderRadius: '2px',
backgroundColor: 'rgba(255, 255, 255, 0.2)',
}));
Progressbar.defaultProps = {
variant: 'determinate',
};
export const LegendIndicator = styled(DotSeparator)`
width: 8.71px;
height: 8.71px;
margin: 0;
margin-right: 4px;
color: inherit;
`;

View file

@ -1,187 +0,0 @@
import React from 'react';
import {
Box,
CircularProgress,
LinearProgress,
Paper,
Typography,
} from '@mui/material';
import Container, { SpaceBetweenFlex } from 'components/Container';
import { UserDetails } from 'types/user';
import constants from 'utils/strings/constants';
import { formatDateShort } from 'utils/time';
import { convertBytesToHumanReadable } from 'utils/billing';
interface Iprops {
userDetails: UserDetails;
closeSidebar: () => void;
}
export default function SubscriptionDetails({ userDetails }: Iprops) {
// const { setDialogMessage } = useContext(AppContext);
// async function onLeaveFamilyClick() {
// try {
// await billingService.leaveFamily();
// closeSidebar();
// } catch (e) {
// setDialogMessage({
// title: constants.ERROR,
// staticBackdrop: true,
// close: { variant: 'danger' },
// content: constants.UNKNOWN_ERROR,
// });
// }
// }
// const { showPlanSelectorModal } = useContext(GalleryContext);
// function onManageClick() {
// closeSidebar();
// showPlanSelectorModal();
// }
return (
<Box
display="flex"
flexDirection={'column'}
height={160}
bgcolor="accent.main"
// position={'relative'}
// onClick={onManageClick}
>
{userDetails ? (
<>
<Box padding={2}>
<SpaceBetweenFlex>
<Typography variant="subtitle2">
Current Plan
</Typography>
<Typography
variant="subtitle2"
sx={{ color: 'text.secondary' }}>
{`${constants.ENDS} ${formatDateShort(
userDetails.subscription.expiryTime / 1000
)}`}
</Typography>
</SpaceBetweenFlex>
<Typography
sx={{ fontWeight: '700', fontSize: '24px' }}>
{convertBytesToHumanReadable(
userDetails.subscription.storage,
0
)}
</Typography>
</Box>
<Box
position={'absolute'}
right="17px"
top="10px"
component={'img'}
src="/images/locker.png"
/>
<Paper
component={Box}
position={'relative'}
zIndex="100"
height="64px"
bgcolor="accent.dark"
padding={2}>
<LinearProgress
sx={{ bgcolor: 'text.secondary' }}
variant="determinate"
value={
userDetails.usage /
userDetails.subscription.storage
}
/>
<SpaceBetweenFlex style={{ marginTop: '8px' }}>
<Typography variant="caption">
{`${convertBytesToHumanReadable(
userDetails.usage,
1
)} of ${convertBytesToHumanReadable(
userDetails.subscription.storage,
0
)}`}
</Typography>
<Typography variant="caption">
{`${userDetails.fileCount} Photos`}
</Typography>
</SpaceBetweenFlex>
</Paper>
</>
) : (
<Container>
<CircularProgress />
</Container>
)}
</Box>
);
{
/* {!hasNonAdminFamilyMembers(userDetails.familyData) ||
isFamilyAdmin(userDetails.familyData) ? (
<div style={{ color: '#959595' }}>
{isSubscriptionActive(userDetails.subscription) ? (
isOnFreePlan(userDetails.subscription) ? (
constants.FREE_SUBSCRIPTION_INFO(
userDetails.subscription?.expiryTime
)
) : isSubscriptionCancelled(
userDetails.subscription
) ? (
constants.RENEWAL_CANCELLED_SUBSCRIPTION_INFO(
userDetails.subscription?.expiryTime
)
) : (
constants.RENEWAL_ACTIVE_SUBSCRIPTION_INFO(
userDetails.subscription?.expiryTime
)
)
) : (
<p>{constants.SUBSCRIPTION_EXPIRED(onManageClick)}</p>
)}
<Button onClick={onManageClick}>
{isSubscribed(userDetails.subscription)
? constants.MANAGE
: constants.SUBSCRIBE}
</Button>
</div>
) : (
<div style={{ color: '#959595' }}>
{constants.FAMILY_PLAN_MANAGE_ADMIN_ONLY(
getFamilyPlanAdmin(userDetails.familyData)?.email
)}
<Button
onClick={() =>
setDialogMessage({
title: `${constants.LEAVE_FAMILY}`,
content: constants.LEAVE_FAMILY_CONFIRM,
staticBackdrop: true,
proceed: {
text: constants.LEAVE_FAMILY,
action: onLeaveFamilyClick,
variant: 'danger',
},
close: { text: constants.CANCEL },
})
}>
{constants.LEAVE_FAMILY}
</Button>
</div>
)}
{hasNonAdminFamilyMembers(userDetails.familyData)
? constants.FAMILY_USAGE_INFO(
userDetails.usage,
convertBytesToHumanReadable(
getStorage(userDetails.familyData)
)
)
: constants.USAGE_INFO(
userDetails.usage,
convertBytesToHumanReadable(
userDetails.subscription?.storage
)
)} */
}
}

View file

@ -0,0 +1,31 @@
import { Typography } from '@mui/material';
import React from 'react';
import {
isSubscriptionActive,
isOnFreePlan,
isSubscriptionCancelled,
} from 'utils/billing';
import constants from 'utils/strings/constants';
export function AdminSubscriptionStatus({
userDetails,
showPlanSelectorModal,
}) {
return (
<Typography
variant="body2"
color={'text.secondary'}
onClick={showPlanSelectorModal}>
{isSubscriptionActive(userDetails.subscription)
? isOnFreePlan(userDetails.subscription)
? constants.FREE_SUBSCRIPTION_INFO(
userDetails.subscription?.expiryTime
)
: isSubscriptionCancelled(userDetails.subscription) &&
constants.RENEWAL_CANCELLED_SUBSCRIPTION_INFO(
userDetails.subscription?.expiryTime
)
: constants.SUBSCRIPTION_EXPIRED_MESSAGE(showPlanSelectorModal)}
</Typography>
);
}

View file

@ -0,0 +1,49 @@
import { MemberSubscriptionStatus } from './member';
import { AdminSubscriptionStatus as AdminSubscriptionStatus } from './admin';
import { GalleryContext } from 'pages/gallery';
import React, { useContext, useMemo } from 'react';
import {
hasNonAdminFamilyMembers,
isFamilyAdmin,
isOnFreePlan,
isSubscriptionActive,
isSubscriptionCancelled,
} from 'utils/billing';
import Box from '@mui/material/Box';
import { UserDetails } from 'types/user';
export default function SubscriptionStatus({
userDetails,
}: {
userDetails: UserDetails;
}) {
const { showPlanSelectorModal } = useContext(GalleryContext);
if (!userDetails) {
return <></>;
}
const hasAMessage = useMemo(
() =>
!isSubscriptionActive(userDetails.subscription) ||
isOnFreePlan(userDetails.subscription) ||
isSubscriptionCancelled(userDetails.subscription),
[userDetails]
);
return hasAMessage ? (
<Box px={1}>
{!hasNonAdminFamilyMembers(userDetails.familyData) ||
isFamilyAdmin(userDetails.familyData) ? (
<AdminSubscriptionStatus
userDetails={userDetails}
showPlanSelectorModal={showPlanSelectorModal}
/>
) : (
<MemberSubscriptionStatus userDetails={userDetails} />
)}
</Box>
) : (
<></>
);
}

View file

@ -0,0 +1,46 @@
import { Button, Stack, Typography } from '@mui/material';
import { AppContext } from 'pages/_app';
import React, { useContext } from 'react';
import billingService from 'services/billingService';
import { getFamilyPlanAdmin } from 'utils/billing';
import constants from 'utils/strings/constants';
export function MemberSubscriptionStatus({ userDetails }) {
const { setDialogMessage } = useContext(AppContext);
async function onLeaveFamilyClick() {
try {
await billingService.leaveFamily();
} catch (e) {
setDialogMessage({
title: constants.ERROR,
close: { variant: 'danger' },
content: constants.UNKNOWN_ERROR,
});
}
}
const confirmLeaveFamily = () =>
setDialogMessage({
title: `${constants.LEAVE_FAMILY}`,
content: constants.LEAVE_FAMILY_CONFIRM,
proceed: {
text: constants.LEAVE_FAMILY,
action: onLeaveFamilyClick,
variant: 'danger',
},
close: {
text: constants.CANCEL,
},
});
return (
<Stack spacing={1}>
<Typography variant="body2" color="text.secondary ">
{constants.FAMILY_PLAN_MANAGE_ADMIN_ONLY(
getFamilyPlanAdmin(userDetails.familyData)?.email
)}
</Typography>
<Button onClick={confirmLeaveFamily}>
{constants.LEAVE_FAMILY}
</Button>
</Stack>
);
}

View file

@ -1,22 +1,25 @@
import React, { useContext } from 'react';
import React from 'react';
import NavigationSection from './NavigationSection';
import UtilitySection from './UtilitySection';
import HelpSection from './HelpSection';
import ExitSection from './ExitSection';
// import DebugLogs from './DebugLogs';
import DebugLogs from './DebugLogs';
import { DrawerSidebar, PaddedDivider } from './styledComponents';
import HeaderSection from './Header';
import { CollectionSummaries } from 'types/collection';
import UserDetailsSection from './userDetailsSection';
import { GalleryContext } from 'pages/gallery';
interface Iprops {
collectionSummaries: CollectionSummaries;
sidebarView: boolean;
closeSidebar: () => void;
}
export default function Sidebar({ collectionSummaries }: Iprops) {
const { sidebarView, closeSidebar } = useContext(GalleryContext);
export default function Sidebar({
collectionSummaries,
sidebarView,
closeSidebar,
}: Iprops) {
return (
<DrawerSidebar open={sidebarView} onClose={closeSidebar}>
<HeaderSection closeSidebar={closeSidebar} />
@ -35,8 +38,8 @@ export default function Sidebar({ collectionSummaries }: Iprops) {
<HelpSection />
<PaddedDivider />
<ExitSection />
{/* <PaddedDivider />
<DebugLogs /> */}
<PaddedDivider />
<DebugLogs />
</DrawerSidebar>
);
}

View file

@ -1,20 +1,18 @@
import React, { useEffect } from 'react';
import { SpaceBetweenFlex } from 'components/Container';
import { PaddedDivider } from './styledComponents';
import SubscriptionDetails from './SubscriptionDetails';
import SubscriptionCard from './SubscriptionCard';
import { getUserDetailsV2 } from 'services/userService';
import { UserDetails } from 'types/user';
import { LS_KEYS } from 'utils/storage/localStorage';
import { useLocalState } from 'hooks/useLocalState';
import { THEMES } from 'types/theme';
import ThemeSwitcher from './ThemeSwitcher';
import Typography from '@mui/material/Typography';
import SubscriptionStatus from './SubscriptionStatus';
import Stack from '@mui/material/Stack';
import { Skeleton } from '@mui/material';
export default function UserDetailsSection({ sidebarView, closeSidebar }) {
const [userDetails, setUserDetails] = useLocalState<UserDetails>(
LS_KEYS.USER_DETAILS
);
const [theme, setTheme] = useLocalState<THEMES>(LS_KEYS.THEME, THEMES.DARK);
useEffect(() => {
if (!sidebarView) {
@ -28,16 +26,20 @@ export default function UserDetailsSection({ sidebarView, closeSidebar }) {
}, [sidebarView]);
return (
<>
<SpaceBetweenFlex px={1}>
<Typography>{userDetails?.email}</Typography>
<ThemeSwitcher theme={theme} setTheme={setTheme} />
</SpaceBetweenFlex>
<PaddedDivider invisible />
<SubscriptionDetails
<Stack spacing={1}>
<Typography>
{userDetails ? (
userDetails.email
) : (
<Skeleton animation="wave" />
)}
</Typography>
<SubscriptionCard
userDetails={userDetails}
closeSidebar={closeSidebar}
/>
</>
<SubscriptionStatus userDetails={userDetails} />
</Stack>
);
}

View file

@ -5,6 +5,7 @@ import * as Yup from 'yup';
import SubmitButton from './SubmitButton';
import TextField from '@mui/material/TextField';
import ShowHidePassword from './Form/ShowHidePassword';
import { FlexWrapper } from './Container';
interface formValues {
inputValue: string;
@ -17,6 +18,7 @@ export interface SingleInputFormProps {
fieldType: 'text' | 'email' | 'password';
placeholder: string;
buttonText: string;
customSubmitButton?: any;
}
export default function SingleInputForm(props: SingleInputFormProps) {
@ -97,12 +99,19 @@ export default function SingleInputForm(props: SingleInputFormProps) {
),
}}
/>
<SubmitButton
sx={{ mt: 2 }}
buttonText={props.buttonText}
loading={loading}
/>
<FlexWrapper></FlexWrapper>
{props.customSubmitButton ? (
<props.customSubmitButton
buttonText={props.buttonText}
loading={loading}
/>
) : (
<SubmitButton
sx={{ mt: 2 }}
buttonText={props.buttonText}
loading={loading}
/>
)}
</form>
)}
</Formik>

View file

@ -1,13 +1,13 @@
import { Button, ButtonProps, CircularProgress } from '@mui/material';
import React, { FC } from 'react';
interface Props {
export interface SubmitButtonProps {
loading: boolean;
buttonText: string;
inline?: any;
disabled?: boolean;
}
const SubmitButton: FC<ButtonProps<'button', Props>> = ({
const SubmitButton: FC<ButtonProps<'button', SubmitButtonProps>> = ({
loading,
buttonText,
inline,

View file

@ -1,59 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Toast } from 'react-bootstrap';
import styled from 'styled-components';
import { NotificationAttributes } from 'types/gallery';
const Wrapper = styled.div`
position: absolute;
top: 60px;
right: 10px;
z-index: 1501;
min-height: 100px;
`;
const AUTO_HIDE_TIME_IN_MILLISECONDS = 3000;
interface Iprops {
attributes: NotificationAttributes;
clearAttributes: () => void;
}
export default function ToastNotification({
attributes,
clearAttributes,
}: Iprops) {
const [show, setShow] = useState(false);
const closeToast = () => {
setShow(false);
clearAttributes();
};
useEffect(() => {
if (!attributes) {
setShow(false);
} else {
setShow(true);
}
}, [attributes]);
return (
<Wrapper>
<Toast
onClose={closeToast}
show={show}
delay={AUTO_HIDE_TIME_IN_MILLISECONDS}
autohide>
{attributes?.title && (
<Toast.Header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<h6 style={{ marginBottom: 0 }}>{attributes.title} </h6>
</Toast.Header>
)}
{attributes?.message && (
<Toast.Body>{attributes.message}</Toast.Body>
)}
</Toast>
</Wrapper>
);
}

View file

@ -0,0 +1,17 @@
import Tooltip from '@mui/material/Tooltip';
import React from 'react';
import styled from 'styled-components';
export const EllipseText = styled.div`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
export default function TruncateText({ text }) {
return (
<Tooltip title={text}>
<EllipseText>{text}</EllipseText>
</Tooltip>
);
}

View file

@ -19,7 +19,7 @@ export default function TwoFactorModalManageSection(props: Iprops) {
const warnTwoFactorDisable = async () => {
setDialogMessage({
title: constants.DISABLE_TWO_FACTOR,
staticBackdrop: true,
content: constants.DISABLE_TWO_FACTOR_MESSAGE,
close: { text: constants.CANCEL },
proceed: {
@ -53,7 +53,7 @@ export default function TwoFactorModalManageSection(props: Iprops) {
const warnTwoFactorReconfigure = async () => {
setDialogMessage({
title: constants.UPDATE_TWO_FACTOR,
staticBackdrop: true,
content: constants.UPDATE_TWO_FACTOR_MESSAGE,
close: { text: constants.CANCEL },
proceed: {

View file

@ -48,7 +48,6 @@ function TwoFactorModal(props: Props) {
onClose={props.onHide}
attributes={{
title: constants.TWO_FACTOR_AUTHENTICATION,
staticBackdrop: true,
}}>
<>
{isTwoFactorEnabled ? (

View file

@ -82,7 +82,6 @@ export default function UploadProgress(props: Props) {
}
const handleClose = dialogCloseHandler({
staticBackdrop: true,
onClose: confirmCancelUpload,
});

View file

@ -4,7 +4,7 @@ import { UploadProgressHeader } from './header';
export function MinimizedUploadProgress(props) {
return (
<Snackbar
open={props.open}
open={!props.expanded}
anchorOrigin={{
horizontal: 'right',
vertical: 'bottom',

View file

@ -44,7 +44,6 @@ export default function DeduplicateOptions({
setDialogMessage({
title: constants.CONFIRM_DELETE,
content: constants.TRASH_MESSAGE,
staticBackdrop: true,
proceed: {
action: deleteFileHelper,
text: constants.MOVE_TO_TRASH,

View file

@ -1,7 +1,7 @@
import React, { useEffect, useMemo } from 'react';
import AddCollectionButton from './AddCollectionButton';
import { Collection, CollectionSummaries } from 'types/collection';
import { CollectionType } from 'constants/collection';
import { SPECIAL_COLLECTION_TYPES } from 'constants/collection';
import DialogBoxBase from 'components/DialogBox/base';
import DialogTitleWithCloseButton from 'components/DialogBox/titleWithCloseButton';
import { DialogContent } from '@mui/material';
@ -36,11 +36,9 @@ function CollectionSelector({
const personalCollectionsOtherThanFrom = [
...collectionSummaries.values(),
]?.filter(
({ type, id, isSharedAlbum }) =>
({ type, id }) =>
id !== attributes?.fromCollection &&
!isSharedAlbum &&
type !== CollectionType.favorites &&
type !== CollectionType.system
!SPECIAL_COLLECTION_TYPES.has(type)
);
return personalCollectionsOtherThanFrom;
}, [collectionSummaries, attributes]);

View file

@ -32,16 +32,16 @@ export function getVariantColor(variant: string) {
const LinkButton: FC<LinkProps<'button', { color?: ButtonProps['color'] }>> = ({
children,
sx,
color,
...props
}) => {
return (
<Link
component="button"
sx={{
color:
props.color && typeof props.color === 'object'
? `${props.color}.main`
: props.color,
'&:hover': {
color: `${color}.main`,
},
...sx,
}}
{...props}>

View file

@ -1,418 +0,0 @@
import React, { useContext, useEffect, useState } from 'react';
import { Form, Modal, Button } from 'react-bootstrap';
import constants from 'utils/strings/constants';
import styled, { css } from 'styled-components';
import { Plan, Subscription } from 'types/billing';
import {
convertBytesToGBs,
getUserSubscription,
isUserSubscribedPlan,
isSubscriptionCancelled,
updatePaymentMethod,
updateSubscription,
activateSubscription,
cancelSubscription,
hasStripeSubscription,
hasPaidSubscription,
isOnFreePlan,
planForSubscription,
hasMobileSubscription,
hasPaypalSubscription,
manageFamilyMethod,
} from 'utils/billing';
import { reverseString } from 'utils/common';
import ArrowEast from 'components/icons/ArrowEast';
import LinkButton from './LinkButton';
import { DeadCenter, GalleryContext } from 'pages/gallery';
import billingService from 'services/billingService';
import { SetLoading } from 'types/gallery';
import { logError } from 'utils/sentry';
import { AppContext } from 'pages/_app';
export const PlanIcon = styled.div<{ currentlySubscribed: boolean }>`
border-radius: 20px;
width: 220px;
border: 2px solid #333;
padding: 30px;
margin: 10px;
text-align: center;
font-size: 20px;
background-color: #ffffff00;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
cursor: ${(props) =>
props.currentlySubscribed ? 'not-allowed' : 'pointer'};
border-color: ${(props) => props.currentlySubscribed && '#56e066'};
transition: all 0.3s ease-out;
overflow: hidden;
position: relative;
& > div:first-child::before {
content: ' ';
height: 600px;
width: 50px;
background-color: #444;
left: 0;
top: -50%;
position: absolute;
transform: rotate(45deg) translateX(-200px);
transition: all 0.5s ease-out;
}
&:hover
${(props) =>
!props.currentlySubscribed &&
css`
{
transform: scale(1.1);
background-color: #ffffff11;
}
`}
&:hover
> div:first-child::before {
transform: rotate(45deg) translateX(300px);
}
`;
interface Props {
modalView: boolean;
closeModal: any;
setLoading: SetLoading;
}
enum PLAN_PERIOD {
MONTH = 'month',
YEAR = 'year',
}
function PlanSelector(props: Props) {
const subscription: Subscription = getUserSubscription();
const [plans, setPlans] = useState<Plan[]>(null);
const [planPeriod, setPlanPeriod] = useState<PLAN_PERIOD>(PLAN_PERIOD.YEAR);
const galleryContext = useContext(GalleryContext);
const appContext = useContext(AppContext);
const togglePeriod = () => {
setPlanPeriod((prevPeriod) =>
prevPeriod === PLAN_PERIOD.MONTH
? PLAN_PERIOD.YEAR
: PLAN_PERIOD.MONTH
);
};
function onReopenClick() {
appContext.closeMessageDialog();
galleryContext.showPlanSelectorModal();
}
useEffect(() => {
if (!props.modalView) {
return;
}
const main = async () => {
try {
props.setLoading(true);
let plans = await billingService.getPlans();
const planNotListed =
plans.filter((plan) =>
isUserSubscribedPlan(plan, subscription)
).length === 0;
if (
subscription &&
!isOnFreePlan(subscription) &&
planNotListed
) {
plans = [planForSubscription(subscription), ...plans];
}
setPlans(plans);
} catch (e) {
logError(e, 'plan selector modal open failed');
props.closeModal();
appContext.setDialogMessage({
title: constants.OPEN_PLAN_SELECTOR_MODAL_FAILED,
content: constants.UNKNOWN_ERROR,
close: { text: 'close', variant: 'danger' },
proceed: {
text: constants.REOPEN_PLAN_SELECTOR_MODAL,
variant: 'success',
action: onReopenClick,
},
});
} finally {
props.setLoading(false);
}
};
main();
}, [props.modalView]);
async function onPlanSelect(plan: Plan) {
if (
hasMobileSubscription(subscription) &&
!isSubscriptionCancelled(subscription)
) {
appContext.setDialogMessage({
title: constants.ERROR,
content: constants.CANCEL_SUBSCRIPTION_ON_MOBILE,
close: { variant: 'danger' },
});
} else if (
hasPaypalSubscription(subscription) &&
!isSubscriptionCancelled(subscription)
) {
appContext.setDialogMessage({
title: constants.MANAGE_PLAN,
content: constants.PAYPAL_MANAGE_NOT_SUPPORTED_MESSAGE(),
close: { variant: 'danger' },
});
} else if (hasStripeSubscription(subscription)) {
appContext.setDialogMessage({
title: `${constants.CONFIRM} ${reverseString(
constants.UPDATE_SUBSCRIPTION
)}`,
content: constants.UPDATE_SUBSCRIPTION_MESSAGE,
staticBackdrop: true,
proceed: {
text: constants.UPDATE_SUBSCRIPTION,
action: updateSubscription.bind(
null,
plan,
appContext.setDialogMessage,
props.setLoading,
props.closeModal
),
variant: 'success',
},
close: { text: constants.CANCEL },
});
} else {
try {
props.setLoading(true);
await billingService.buySubscription(plan.stripeID);
} catch (e) {
props.setLoading(false);
appContext.setDialogMessage({
title: constants.ERROR,
content: constants.SUBSCRIPTION_PURCHASE_FAILED,
close: { variant: 'danger' },
});
}
}
}
const PlanIcons: JSX.Element[] = plans
?.filter((plan) => plan.period === planPeriod)
?.map((plan) => (
<PlanIcon
key={plan.stripeID}
className="subscription-plan-selector"
currentlySubscribed={isUserSubscribedPlan(plan, subscription)}
onClick={
isUserSubscribedPlan(plan, subscription)
? () => {}
: async () => await onPlanSelect(plan)
}>
<div>
<span
style={{
color: '#ECECEC',
fontWeight: 900,
fontSize: '40px',
lineHeight: '40px',
}}>
{convertBytesToGBs(plan.storage, 0)}
</span>
<span
style={{
color: '#858585',
fontSize: '24px',
fontWeight: 900,
}}>
{' '}
GB
</span>
</div>
<div
className="bold-text"
style={{
color: '#aaa',
lineHeight: '36px',
fontSize: '20px',
}}>
{`${plan.price} / ${plan.period}`}
</div>
<Button
variant="outline-success"
block
style={{
marginTop: '20px',
fontSize: '14px',
display: 'flex',
justifyContent: 'center',
}}
disabled={isUserSubscribedPlan(plan, subscription)}>
{constants.CHOOSE_PLAN_BTN}
<ArrowEast style={{ marginLeft: '5px' }} />
</Button>
</PlanIcon>
));
return (
<Modal
show={props.modalView}
onHide={props.closeModal}
size="xl"
centered
backdrop={hasPaidSubscription(subscription) ? true : 'static'}
contentClassName="plan-selector-modal-content">
<Modal.Header closeButton>
<Modal.Title
style={{
marginLeft: '12px',
width: '100%',
textAlign: 'center',
}}>
<span>
{hasPaidSubscription(subscription)
? constants.MANAGE_PLAN
: constants.CHOOSE_PLAN}
</span>
</Modal.Title>
</Modal.Header>
<Modal.Body style={{ marginTop: '20px' }}>
<DeadCenter>
<div style={{ display: 'flex' }}>
<span
className="bold-text"
style={{ fontSize: '16px' }}>
{constants.MONTHLY}
</span>
<Form.Switch
checked={planPeriod === PLAN_PERIOD.YEAR}
id="plan-period-toggler"
style={{
margin: '-4px 0 20px 15px',
fontSize: '10px',
}}
className="custom-switch-md"
onChange={togglePeriod}
/>
<span
className="bold-text"
style={{ fontSize: '16px' }}>
{constants.YEARLY}
</span>
</div>
</DeadCenter>
<div
style={{
display: 'flex',
justifyContent: 'space-around',
flexWrap: 'wrap',
minHeight: '212px',
margin: '5px 0',
}}>
{plans && PlanIcons}
</div>
<DeadCenter style={{ marginBottom: '30px' }}>
{hasPaidSubscription(subscription) ? (
<>
{hasStripeSubscription(subscription) && (
<>
{isSubscriptionCancelled(subscription) ? (
<LinkButton
color="success"
onClick={() =>
appContext.setDialogMessage({
title: constants.CONFIRM_ACTIVATE_SUBSCRIPTION,
content:
constants.ACTIVATE_SUBSCRIPTION_MESSAGE(
subscription.expiryTime
),
staticBackdrop: true,
proceed: {
text: constants.ACTIVATE_SUBSCRIPTION,
action: activateSubscription.bind(
null,
appContext.setDialogMessage,
props.closeModal,
props.setLoading
),
variant: 'success',
},
close: {
text: constants.CANCEL,
},
})
}>
{constants.ACTIVATE_SUBSCRIPTION}
</LinkButton>
) : (
<LinkButton
color="danger"
onClick={() =>
appContext.setDialogMessage({
title: constants.CONFIRM_CANCEL_SUBSCRIPTION,
content:
constants.CANCEL_SUBSCRIPTION_MESSAGE(),
staticBackdrop: true,
proceed: {
text: constants.CANCEL_SUBSCRIPTION,
action: cancelSubscription.bind(
null,
appContext.setDialogMessage,
props.closeModal,
props.setLoading
),
variant: 'danger',
},
close: {
text: constants.CANCEL,
},
})
}>
{constants.CANCEL_SUBSCRIPTION}
</LinkButton>
)}
<LinkButton
color="primary"
onClick={updatePaymentMethod.bind(
null,
appContext.setDialogMessage,
props.setLoading
)}
style={{ marginTop: '20px' }}>
{constants.MANAGEMENT_PORTAL}
</LinkButton>
</>
)}
<LinkButton
color="primary"
onClick={manageFamilyMethod.bind(
null,
appContext.setDialogMessage,
props.setLoading
)}
style={{ marginTop: '20px' }}>
{constants.MANAGE_FAMILY_PORTAL}
</LinkButton>
</>
) : (
<LinkButton
color="primary"
onClick={props.closeModal}
style={{
color: 'rgb(121, 121, 121)',
marginTop: '20px',
}}>
{isOnFreePlan(subscription)
? constants.SKIP
: constants.CLOSE}
</LinkButton>
)}
</DeadCenter>
</Modal.Body>
</Modal>
);
}
export default PlanSelector;

View file

@ -0,0 +1,184 @@
import { PeriodToggler } from './periodToggler';
import { ManageSubscription } from './manageSubscription';
import React, { useContext, useEffect, useState } from 'react';
import constants from 'utils/strings/constants';
import { Plan, Subscription } from 'types/billing';
import {
getUserSubscription,
isUserSubscribedPlan,
isSubscriptionCancelled,
updateSubscription,
hasStripeSubscription,
hasPaidSubscription,
isOnFreePlan,
planForSubscription,
hasMobileSubscription,
hasPaypalSubscription,
} from 'utils/billing';
import { reverseString } from 'utils/common';
import { GalleryContext } from 'pages/gallery';
import billingService from 'services/billingService';
import { SetLoading } from 'types/gallery';
import { logError } from 'utils/sentry';
import { AppContext } from 'pages/_app';
import DialogBox from 'components/DialogBox';
import Plans from './plans';
import { DialogBoxAttributes } from 'types/dialogBox';
interface Props {
modalView: boolean;
closeModal: any;
setLoading: SetLoading;
}
export enum PLAN_PERIOD {
MONTH = 'month',
YEAR = 'year',
}
function PlanSelector(props: Props) {
const subscription: Subscription = getUserSubscription();
const [plans, setPlans] = useState<Plan[]>(null);
const [planPeriod, setPlanPeriod] = useState<PLAN_PERIOD>(PLAN_PERIOD.YEAR);
const galleryContext = useContext(GalleryContext);
const appContext = useContext(AppContext);
const togglePeriod = () => {
setPlanPeriod((prevPeriod) =>
prevPeriod === PLAN_PERIOD.MONTH
? PLAN_PERIOD.YEAR
: PLAN_PERIOD.MONTH
);
};
function onReopenClick() {
appContext.closeMessageDialog();
galleryContext.showPlanSelectorModal();
}
useEffect(() => {
if (!props.modalView) {
return;
}
const main = async () => {
try {
props.setLoading(true);
let plans = await billingService.getPlans();
const planNotListed =
plans.filter((plan) =>
isUserSubscribedPlan(plan, subscription)
).length === 0;
if (
subscription &&
!isOnFreePlan(subscription) &&
planNotListed
) {
plans = [planForSubscription(subscription), ...plans];
}
setPlans(plans);
} catch (e) {
logError(e, 'plan selector modal open failed');
props.closeModal();
appContext.setDialogMessage({
title: constants.OPEN_PLAN_SELECTOR_MODAL_FAILED,
content: constants.UNKNOWN_ERROR,
close: { text: 'close', variant: 'danger' },
proceed: {
text: constants.REOPEN_PLAN_SELECTOR_MODAL,
variant: 'success',
action: onReopenClick,
},
});
} finally {
props.setLoading(false);
}
};
main();
}, [props.modalView]);
async function onPlanSelect(plan: Plan) {
if (
hasMobileSubscription(subscription) &&
!isSubscriptionCancelled(subscription)
) {
appContext.setDialogMessage({
title: constants.ERROR,
content: constants.CANCEL_SUBSCRIPTION_ON_MOBILE,
close: { variant: 'danger' },
});
} else if (
hasPaypalSubscription(subscription) &&
!isSubscriptionCancelled(subscription)
) {
appContext.setDialogMessage({
title: constants.MANAGE_PLAN,
content: constants.PAYPAL_MANAGE_NOT_SUPPORTED_MESSAGE(),
close: { variant: 'danger' },
});
} else if (hasStripeSubscription(subscription)) {
appContext.setDialogMessage({
title: `${constants.CONFIRM} ${reverseString(
constants.UPDATE_SUBSCRIPTION
)}`,
content: constants.UPDATE_SUBSCRIPTION_MESSAGE,
proceed: {
text: constants.UPDATE_SUBSCRIPTION,
action: updateSubscription.bind(
null,
plan,
appContext.setDialogMessage,
props.setLoading,
props.closeModal
),
variant: 'success',
},
close: { text: constants.CANCEL },
});
} else {
try {
props.setLoading(true);
await billingService.buySubscription(plan.stripeID);
} catch (e) {
props.setLoading(false);
appContext.setDialogMessage({
title: constants.ERROR,
content: constants.SUBSCRIPTION_PURCHASE_FAILED,
close: { variant: 'danger' },
});
}
}
}
const planSelectorAttributes: DialogBoxAttributes = {
closeOnBackdropClick: hasPaidSubscription(subscription) ? true : false,
title: hasPaidSubscription(subscription)
? constants.MANAGE_PLAN
: constants.CHOOSE_PLAN,
};
return (
<DialogBox
open={props.modalView}
titleCloseButton
onClose={props.closeModal}
size={'xl'}
attributes={planSelectorAttributes}
fullWidth={false}>
<PeriodToggler
planPeriod={planPeriod}
togglePeriod={togglePeriod}
/>
<Plans
plans={plans}
planPeriod={planPeriod}
onPlanSelect={onPlanSelect}
subscription={subscription}
/>
<ManageSubscription
subscription={subscription}
closeModal={props.closeModal}
setLoading={props.setLoading}
/>
</DialogBox>
);
}
export default PlanSelector;

View file

@ -0,0 +1,116 @@
import { DeadCenter } from 'pages/gallery';
import { AppContext } from 'pages/_app';
import React, { useContext } from 'react';
import {
activateSubscription,
cancelSubscription,
updatePaymentMethod,
manageFamilyMethod,
hasPaidSubscription,
hasStripeSubscription,
isOnFreePlan,
isSubscriptionCancelled,
} from 'utils/billing';
import constants from 'utils/strings/constants';
import LinkButton from '../LinkButton';
export function ManageSubscription({ subscription, ...props }) {
const appContext = useContext(AppContext);
return (
<DeadCenter>
{hasPaidSubscription(subscription) ? (
<>
{hasStripeSubscription(subscription) && (
<>
{isSubscriptionCancelled(subscription) ? (
<LinkButton
color="accent"
onClick={() =>
appContext.setDialogMessage({
title: constants.CONFIRM_ACTIVATE_SUBSCRIPTION,
content:
constants.ACTIVATE_SUBSCRIPTION_MESSAGE(
subscription.expiryTime
),
proceed: {
text: constants.ACTIVATE_SUBSCRIPTION,
action: activateSubscription.bind(
null,
appContext.setDialogMessage,
props.closeModal,
props.setLoading
),
variant: 'success',
},
close: {
text: constants.CANCEL,
},
})
}>
{constants.ACTIVATE_SUBSCRIPTION}
</LinkButton>
) : (
<LinkButton
color="danger"
onClick={() =>
appContext.setDialogMessage({
title: constants.CONFIRM_CANCEL_SUBSCRIPTION,
content:
constants.CANCEL_SUBSCRIPTION_MESSAGE(),
proceed: {
text: constants.CANCEL_SUBSCRIPTION,
action: cancelSubscription.bind(
null,
appContext.setDialogMessage,
props.closeModal,
props.setLoading
),
variant: 'danger',
},
close: {
text: constants.CANCEL,
},
})
}>
{constants.CANCEL_SUBSCRIPTION}
</LinkButton>
)}
<LinkButton
onClick={updatePaymentMethod.bind(
null,
appContext.setDialogMessage,
props.setLoading
)}
sx={{
mt: 1,
}}>
{constants.MANAGEMENT_PORTAL}
</LinkButton>
</>
)}
<LinkButton
onClick={manageFamilyMethod.bind(
null,
appContext.setDialogMessage,
props.setLoading
)}
sx={{
mt: 1,
}}>
{constants.MANAGE_FAMILY_PORTAL}
</LinkButton>
</>
) : (
<LinkButton
onClick={props.closeModal}
style={{
color: 'rgb(121, 121, 121)',
marginTop: '20px',
}}>
{isOnFreePlan(subscription)
? constants.SKIP
: constants.CLOSE}
</LinkButton>
)}
</DeadCenter>
);
}

View file

@ -0,0 +1,41 @@
import { DeadCenter } from 'pages/gallery';
import React from 'react';
import { Form } from 'react-bootstrap';
import constants from 'utils/strings/constants';
import { PLAN_PERIOD } from '.';
export function PeriodToggler({ planPeriod, togglePeriod }) {
return (
<DeadCenter>
<div
style={{
display: 'flex',
}}>
<span
className="bold-text"
style={{
fontSize: '16px',
}}>
{constants.MONTHLY}
</span>
<Form.Switch
checked={planPeriod === PLAN_PERIOD.YEAR}
id="plan-period-toggler"
style={{
margin: '-4px 0 20px 15px',
fontSize: '10px',
}}
className="custom-switch-md"
onChange={togglePeriod}
/>
<span
className="bold-text"
style={{
fontSize: '16px',
}}>
{constants.YEARLY}
</span>
</div>
</DeadCenter>
);
}

View file

@ -0,0 +1,29 @@
import React from 'react';
import { isUserSubscribedPlan, convertBytesToGBs } from 'utils/billing';
import { PlanCard } from './planCard';
const Plans = ({ plans, planPeriod, subscription, onPlanSelect }) => (
<div
style={{
display: 'flex',
justifyContent: 'space-around',
flexWrap: 'wrap',
minHeight: '212px',
margin: '5px 0',
}}>
{plans
?.filter((plan) => plan.period === planPeriod)
?.map((plan) => (
<PlanCard
key={plan.stripeID}
isUserSubscribedPlan={isUserSubscribedPlan}
plan={plan}
subscription={subscription}
onPlanSelect={onPlanSelect}
convertBytesToGBs={convertBytesToGBs}
/>
))}
</div>
);
export default Plans;

View file

@ -0,0 +1,72 @@
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import React from 'react';
import { Button } from 'react-bootstrap';
import constants from 'utils/strings/constants';
import { PlanTile } from './planTile';
export function PlanCard({
isUserSubscribedPlan,
plan,
subscription,
onPlanSelect,
convertBytesToGBs,
}) {
return (
<PlanTile
key={plan.stripeID}
className="subscription-plan-selector"
currentlySubscribed={isUserSubscribedPlan(plan, subscription)}
onClick={
isUserSubscribedPlan(plan, subscription)
? () => {}
: async () => await onPlanSelect(plan)
}>
<div>
<span
style={{
color: '#ECECEC',
fontWeight: 900,
fontSize: '40px',
lineHeight: '40px',
}}>
{convertBytesToGBs(plan.storage, 0)}
</span>
<span
style={{
color: '#858585',
fontSize: '24px',
fontWeight: 900,
}}>
{' '}
GB
</span>
</div>
<div
className="bold-text"
style={{
color: '#aaa',
lineHeight: '36px',
fontSize: '20px',
}}>
{`${plan.price} / ${plan.period}`}
</div>
<Button
variant="outline-success"
block
style={{
marginTop: '20px',
fontSize: '14px',
display: 'flex',
justifyContent: 'center',
}}
disabled={isUserSubscribedPlan(plan, subscription)}>
{constants.CHOOSE_PLAN_BTN}
<ArrowForwardIcon
style={{
marginLeft: '5px',
}}
/>
</Button>
</PlanTile>
);
}

View file

@ -0,0 +1,48 @@
import styled, { css } from 'styled-components';
export const PlanTile = styled.div<{ currentlySubscribed: boolean }>`
border-radius: 20px;
width: 220px;
border: 2px solid #333;
padding: 30px;
margin: 10px;
text-align: center;
font-size: 20px;
background-color: #ffffff00;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
cursor: ${(props) =>
props.currentlySubscribed ? 'not-allowed' : 'pointer'};
border-color: ${(props) => props.currentlySubscribed && '#56e066'};
transition: all 0.3s ease-out;
overflow: hidden;
position: relative;
& > div:first-child::before {
content: ' ';
height: 600px;
width: 50px;
background-color: #444;
left: 0;
top: -50%;
position: absolute;
transform: rotate(45deg) translateX(-200px);
transition: all 0.5s ease-out;
}
&:hover
${(props) =>
!props.currentlySubscribed &&
css`
{
transform: scale(1.1);
background-color: #ffffff11;
}
`}
&:hover
> div:first-child::before {
transform: rotate(45deg) translateX(300px);
}
`;

View file

@ -1,7 +1,7 @@
import React, { useContext, useLayoutEffect, useRef, useState } from 'react';
import { EnteFile } from 'types/file';
import styled from 'styled-components';
import PlayCircleOutline from 'components/icons/PlayCircleOutline';
import PlayCircleOutlineOutlinedIcon from '@mui/icons-material/PlayCircleOutlineOutlined';
import DownloadManager from 'services/downloadManager';
import useLongPress from 'utils/common/useLongPress';
import { GalleryContext } from 'pages/gallery';
@ -305,7 +305,7 @@ export default function PreviewCard(props: IProps) {
/>
)}
{(file?.msrc || imgSrc) && <img src={file?.msrc || imgSrc} />}
{file?.metadata.fileType === 1 && <PlayCircleOutline />}
{file?.metadata.fileType === 1 && <PlayCircleOutlineOutlinedIcon />}
<SelectedOverlay selected={selected} />
<HoverOverlay checked={selected} />
<InSelectRangeOverLay

View file

@ -81,7 +81,6 @@ const SelectedFileOptions = ({
setDialogMessage({
title: constants.CONFIRM_DELETE,
content: constants.TRASH_MESSAGE,
staticBackdrop: true,
proceed: {
action: deleteFileHelper,
text: constants.MOVE_TO_TRASH,
@ -94,7 +93,6 @@ const SelectedFileOptions = ({
setDialogMessage({
title: constants.CONFIRM_DELETE,
content: constants.DELETE_MESSAGE,
staticBackdrop: true,
proceed: {
action: () => deleteFileHelper(true),
text: constants.DELETE,
@ -116,7 +114,7 @@ const SelectedFileOptions = ({
setDialogMessage({
title: constants.CONFIRM_REMOVE,
content: constants.CONFIRM_REMOVE_MESSAGE(),
staticBackdrop: true,
proceed: {
action: removeFromCollectionHelper,
text: constants.REMOVE,

View file

@ -16,7 +16,7 @@ import uploadManager from 'services/upload/uploadManager';
import ImportService from 'services/importService';
import isElectron from 'is-electron';
import { METADATA_FOLDER_NAME } from 'constants/export';
import { getUserFacingErrorMessage } from 'utils/error';
import { CustomError } from 'utils/error';
import { Collection } from 'types/collection';
import { SetLoading, SetFiles } from 'types/gallery';
import { FileUploadResults, UPLOAD_STAGES } from 'constants/upload';
@ -26,6 +26,8 @@ import Router from 'next/router';
import { isCanvasBlocked } from 'utils/upload/isCanvasBlocked';
import { downloadApp } from 'utils/common';
import watchService from 'services/watchService';
import DiscFullIcon from '@mui/icons-material/DiscFull';
import { NotificationAttributes } from 'types/Notification';
const FIRST_ALBUM_NAME = 'My First Album';
@ -48,6 +50,7 @@ interface Props {
setElectronFiles: (files: ElectronFile[]) => void;
uploadTypeSelectorView: boolean;
setUploadTypeSelectorView: (open: boolean) => void;
showSessionExpiredMessage: () => void;
}
enum UPLOAD_STRATEGY {
@ -77,7 +80,7 @@ export default function Upload(props: Props) {
UPLOAD_STAGES.START
);
const [filenames, setFilenames] = useState(new Map<number, string>());
const [fileCounter, setFileCounter] = useState({ success: 0, issues: 0 });
const [fileCounter, setFileCounter] = useState({ finished: 0, total: 0 });
const [fileProgress, setFileProgress] = useState(new Map<number, number>());
const [uploadResult, setUploadResult] = useState(
new Map<number, FileUploadResults>()
@ -148,7 +151,7 @@ export default function Upload(props: Props) {
} else if (isCanvasBlocked()) {
appContext.setDialogMessage({
title: constants.CANVAS_BLOCKED_TITLE,
staticBackdrop: true,
content: constants.CANVAS_BLOCKED_MESSAGE(),
close: { text: constants.CLOSE },
proceed: {
@ -185,7 +188,7 @@ export default function Upload(props: Props) {
const uploadInit = function () {
setUploadStage(UPLOAD_STAGES.START);
setFileCounter({ success: 0, issues: 0 });
setFileCounter({ finished: 0, total: 0 });
setFileProgress(new Map<number, number>());
setUploadResult(new Map<number, number>());
setPercentComplete(0);
@ -325,7 +328,7 @@ export default function Upload(props: Props) {
logError(e, 'Failed to create album');
appContext.setDialogMessage({
title: constants.ERROR,
staticBackdrop: true,
close: { variant: 'danger' },
content: constants.CREATE_ALBUM_FAILED,
});
@ -367,11 +370,7 @@ export default function Upload(props: Props) {
collections
);
} catch (err) {
const message = getUserFacingErrorMessage(
err.message,
galleryContext.showPlanSelectorModal
);
props.setBannerMessage(message);
showUserFacingError(err.message);
setProgressView(false);
throw err;
} finally {
@ -385,6 +384,7 @@ export default function Upload(props: Props) {
}
}
};
const retryFailed = async () => {
try {
props.setUploadInProgress(true);
@ -392,12 +392,8 @@ export default function Upload(props: Props) {
await props.syncWithRemote(true, true);
await uploadManager.retryFailedFiles();
} catch (err) {
const message = getUserFacingErrorMessage(
err.message,
galleryContext.showPlanSelectorModal
);
appContext.resetSharedFiles();
props.setBannerMessage(message);
showUserFacingError(err.message);
setProgressView(false);
} finally {
props.setUploadInProgress(false);
@ -405,6 +401,41 @@ export default function Upload(props: Props) {
}
};
function showUserFacingError(err: CustomError) {
let notification: NotificationAttributes;
switch (err) {
case CustomError.SESSION_EXPIRED:
return props.showSessionExpiredMessage();
case CustomError.SUBSCRIPTION_EXPIRED:
notification = {
variant: 'danger',
message: constants.SUBSCRIPTION_EXPIRED,
action: {
text: constants.UPGRADE_NOW,
callback: galleryContext.showPlanSelectorModal,
},
};
break;
case CustomError.STORAGE_QUOTA_EXCEEDED:
notification = {
variant: 'danger',
message: constants.STORAGE_QUOTA_EXCEEDED,
action: {
text: constants.RENEW_NOW,
callback: galleryContext.showPlanSelectorModal,
},
icon: <DiscFullIcon fontSize="large" />,
};
break;
default:
notification = {
variant: 'danger',
message: constants.UNKNOWN_ERROR,
};
}
galleryContext.setNotificationAttributes(notification);
}
const uploadToSingleNewCollection = (collectionName: string) => {
if (collectionName) {
uploadFilesToNewCollections(

View file

@ -18,7 +18,6 @@ function UploadStrategyChoiceModal({
...props
}: Props) {
const handleClose = dialogCloseHandler({
staticBackdrop: true,
onClose: props.onClose,
});

View file

@ -104,7 +104,6 @@ export function AbuseReportForm({ show, close, url }: Iprops) {
onClose={close}
attributes={{
title: constants.ABUSE_REPORT,
staticBackdrop: true,
}}>
<Wrapper>
<h6>{constants.ABUSE_REPORT_DESCRIPTION}</h6>

View file

@ -6,7 +6,10 @@ export enum CollectionType {
folder = 'folder',
favorites = 'favorites',
album = 'album',
system = 'system',
archive = 'archive',
trash = 'trash',
all = 'all',
shared = 'shared',
}
export enum COLLECTION_SORT_BY {
@ -19,3 +22,20 @@ export enum COLLECTION_SORT_BY {
export const COLLECTION_SHARE_DEFAULT_VALID_DURATION =
10 * 24 * 60 * 60 * 1000 * 1000;
export const COLLECTION_SHARE_DEFAULT_DEVICE_LIMIT = 4;
export const COLLECTION_SORT_ORDER = new Map([
[CollectionType.all, 0],
[CollectionType.favorites, 1],
[CollectionType.album, 2],
[CollectionType.folder, 2],
[CollectionType.shared, 2],
[CollectionType.archive, 3],
[CollectionType.trash, 4],
]);
export const SPECIAL_COLLECTION_TYPES = new Set([
CollectionType.all,
CollectionType.archive,
CollectionType.trash,
CollectionType.shared,
]);

Some files were not shown because too many files have changed in this diff Show more