Merge branch 'ui-redesign' into watch
This commit is contained in:
commit
02ff9aeaa7
BIN
public/subscription-card-background.png
Normal file
BIN
public/subscription-card-background.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 38 KiB |
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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;
|
22
src/components/Collections/CollectionShare/container.tsx
Normal file
22
src/components/Collections/CollectionShare/container.tsx
Normal 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,
|
||||
};
|
46
src/components/Collections/CollectionShare/emailShare.tsx
Normal file
46
src/components/Collections/CollectionShare/emailShare.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
43
src/components/Collections/CollectionShare/index.tsx
Normal file
43
src/components/Collections/CollectionShare/index.tsx
Normal 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;
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 />
|
||||
</>
|
||||
);
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
48
src/components/Collections/CollectionShare/sharees/index.tsx
Normal file
48
src/components/Collections/CollectionShare/sharees/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
22
src/components/Collections/CollectionShare/sharees/row.tsx
Normal file
22
src/components/Collections/CollectionShare/sharees/row.tsx
Normal 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;
|
|
@ -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)};
|
||||
`
|
||||
);
|
20
src/components/Collections/CollectionShare/styles.tsx
Normal file
20
src/components/Collections/CollectionShare/styles.tsx
Normal 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',
|
||||
}),
|
||||
};
|
10
src/components/Collections/CollectionShare/submitButton.tsx
Normal file
10
src/components/Collections/CollectionShare/submitButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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%
|
||||
);
|
||||
`;
|
||||
|
|
|
@ -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)`
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -141,7 +141,6 @@ export default function FixLargeThumbnails(props: Props) {
|
|||
onClose={props.hide}
|
||||
attributes={{
|
||||
title: constants.COMPRESS_THUMBNAILS,
|
||||
staticBackdrop: true,
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
|
|
|
@ -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;
|
||||
|
|
93
src/components/Notification.tsx
Normal file
93
src/components/Notification.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
|
|
63
src/components/PhotoSwipe/InfoDialog/ExifData.tsx
Normal file
63
src/components/PhotoSwipe/InfoDialog/ExifData.tsx
Normal 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()}
|
||||
</>
|
||||
);
|
||||
}
|
99
src/components/PhotoSwipe/InfoDialog/FileNameEditForm.tsx
Normal file
99
src/components/PhotoSwipe/InfoDialog/FileNameEditForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
121
src/components/PhotoSwipe/InfoDialog/RenderCreationTime.tsx
Normal file
121
src/components/PhotoSwipe/InfoDialog/RenderCreationTime.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
103
src/components/PhotoSwipe/InfoDialog/RenderFileName.tsx
Normal file
103
src/components/PhotoSwipe/InfoDialog/RenderFileName.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
9
src/components/PhotoSwipe/InfoDialog/RenderInfoItem.tsx
Normal file
9
src/components/PhotoSwipe/InfoDialog/RenderInfoItem.tsx
Normal 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>
|
||||
);
|
82
src/components/PhotoSwipe/InfoDialog/index.tsx
Normal file
82
src/components/PhotoSwipe/InfoDialog/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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;
|
433
src/components/PhotoSwipe/index.tsx
Normal file
433
src/components/PhotoSwipe/index.tsx
Normal 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;
|
7
src/components/PhotoSwipe/styledComponents/Legend.tsx
Normal file
7
src/components/PhotoSwipe/styledComponents/Legend.tsx
Normal file
|
@ -0,0 +1,7 @@
|
|||
import styled from 'styled-components';
|
||||
|
||||
export const Legend = styled.span`
|
||||
font-size: 20px;
|
||||
color: #ddd;
|
||||
display: inline;
|
||||
`;
|
|
@ -0,0 +1,6 @@
|
|||
import styled from 'styled-components';
|
||||
|
||||
export const LegendContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
16
src/components/PhotoSwipe/styledComponents/LivePhotoBtn.tsx
Normal file
16
src/components/PhotoSwipe/styledComponents/LivePhotoBtn.tsx
Normal 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')};
|
||||
}
|
||||
`;
|
6
src/components/PhotoSwipe/styledComponents/Pre.tsx
Normal file
6
src/components/PhotoSwipe/styledComponents/Pre.tsx
Normal file
|
@ -0,0 +1,6 @@
|
|||
import styled from 'styled-components';
|
||||
|
||||
export const Pre = styled.pre`
|
||||
color: #aaa;
|
||||
padding: 7px 15px;
|
||||
`;
|
|
@ -0,0 +1,11 @@
|
|||
import React from 'react';
|
||||
import EnteSpinner from 'components/EnteSpinner';
|
||||
|
||||
export const SmallLoadingSpinner = () => (
|
||||
<EnteSpinner
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
}}
|
||||
/>
|
||||
);
|
|
@ -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,
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
@ -32,7 +32,7 @@ export const SelectStyles = {
|
|||
cursor: 'pointer',
|
||||
},
|
||||
'& .main': {
|
||||
backgroundColor: isFocused && '#343434',
|
||||
backgroundColor: isFocused && '#202020',
|
||||
},
|
||||
'&:last-child .MuiDivider-root': {
|
||||
display: 'none',
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -17,7 +17,6 @@ const NavigationButton: FC<ButtonProps<'button', IProps>> = ({
|
|||
}) => {
|
||||
return (
|
||||
<SidebarButton
|
||||
smallerArrow
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
sx={{ px: '12px' }}
|
||||
|
|
10
src/components/Sidebar/SubscriptionCard/clickIndicator.tsx
Normal file
10
src/components/Sidebar/SubscriptionCard/clickIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
70
src/components/Sidebar/SubscriptionCard/index.tsx
Normal file
70
src/components/Sidebar/SubscriptionCard/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
32
src/components/Sidebar/SubscriptionCard/storageSection.tsx
Normal file
32
src/components/Sidebar/SubscriptionCard/storageSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
22
src/components/Sidebar/SubscriptionCard/styledComponents.tsx
Normal file
22
src/components/Sidebar/SubscriptionCard/styledComponents.tsx
Normal 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;
|
||||
`;
|
|
@ -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
|
||||
)
|
||||
)} */
|
||||
}
|
||||
}
|
31
src/components/Sidebar/SubscriptionStatus/admin.tsx
Normal file
31
src/components/Sidebar/SubscriptionStatus/admin.tsx
Normal 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>
|
||||
);
|
||||
}
|
49
src/components/Sidebar/SubscriptionStatus/index.tsx
Normal file
49
src/components/Sidebar/SubscriptionStatus/index.tsx
Normal 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>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
}
|
46
src/components/Sidebar/SubscriptionStatus/member.tsx
Normal file
46
src/components/Sidebar/SubscriptionStatus/member.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
17
src/components/TruncateText.tsx
Normal file
17
src/components/TruncateText.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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: {
|
||||
|
|
|
@ -48,7 +48,6 @@ function TwoFactorModal(props: Props) {
|
|||
onClose={props.onHide}
|
||||
attributes={{
|
||||
title: constants.TWO_FACTOR_AUTHENTICATION,
|
||||
staticBackdrop: true,
|
||||
}}>
|
||||
<>
|
||||
{isTwoFactorEnabled ? (
|
||||
|
|
|
@ -82,7 +82,6 @@ export default function UploadProgress(props: Props) {
|
|||
}
|
||||
|
||||
const handleClose = dialogCloseHandler({
|
||||
staticBackdrop: true,
|
||||
onClose: confirmCancelUpload,
|
||||
});
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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;
|
184
src/components/pages/gallery/PlanSelector/index.tsx
Normal file
184
src/components/pages/gallery/PlanSelector/index.tsx
Normal 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;
|
116
src/components/pages/gallery/PlanSelector/manageSubscription.tsx
Normal file
116
src/components/pages/gallery/PlanSelector/manageSubscription.tsx
Normal 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>
|
||||
);
|
||||
}
|
41
src/components/pages/gallery/PlanSelector/periodToggler.tsx
Normal file
41
src/components/pages/gallery/PlanSelector/periodToggler.tsx
Normal 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>
|
||||
);
|
||||
}
|
29
src/components/pages/gallery/PlanSelector/plans/index.tsx
Normal file
29
src/components/pages/gallery/PlanSelector/plans/index.tsx
Normal 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;
|
72
src/components/pages/gallery/PlanSelector/plans/planCard.tsx
Normal file
72
src/components/pages/gallery/PlanSelector/plans/planCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
48
src/components/pages/gallery/PlanSelector/plans/planTile.tsx
Normal file
48
src/components/pages/gallery/PlanSelector/plans/planTile.tsx
Normal 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);
|
||||
}
|
||||
`;
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -18,7 +18,6 @@ function UploadStrategyChoiceModal({
|
|||
...props
|
||||
}: Props) {
|
||||
const handleClose = dialogCloseHandler({
|
||||
staticBackdrop: true,
|
||||
onClose: props.onClose,
|
||||
});
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue