Pārlūkot izejas kodu

Merge pull request #5 from ente-io/file-upload

File upload
Pushkar Anand 4 gadi atpakaļ
vecāks
revīzija
9f6388b8ef

+ 3 - 1
package.json

@@ -12,6 +12,7 @@
     "axios": "^0.20.0",
     "bootstrap": "^4.5.2",
     "comlink": "^4.3.0",
+    "exif-js": "^2.3.0",
     "formik": "^2.1.5",
     "http-proxy-middleware": "^1.0.5",
     "libsodium-wrappers": "^0.7.8",
@@ -21,6 +22,7 @@
     "react": "16.13.1",
     "react-bootstrap": "^1.3.0",
     "react-dom": "16.13.1",
+    "react-dropzone": "^11.2.4",
     "react-photoswipe": "^1.3.0",
     "react-virtualized-auto-sizer": "^1.0.2",
     "react-window": "^1.8.6",
@@ -42,7 +44,7 @@
     "@types/yup": "^0.29.7",
     "babel-plugin-styled-components": "^1.11.1",
     "next-on-netlify": "^2.4.0",
-    "typescript": "^4.0.2",
+    "typescript": "^4.1.3",
     "worker-plugin": "^5.0.0"
   },
   "standard": {

BIN
public/fav-button.png


BIN
public/plus-sign.png


+ 23 - 0
src/components/FavButton.tsx

@@ -0,0 +1,23 @@
+import React from "react";
+import styled from "styled-components";
+
+const HeartUI = styled.button<{
+    isClick: boolean,
+    size: number,
+}>`
+  width: ${props => props.size}px;
+  height: ${props => props.size}px;
+  float:right;
+  background: url("/fav-button.png") no-repeat;
+  cursor: pointer;
+  background-size: cover;
+  border: none;
+  ${({ isClick, size }) => isClick && `background-position: -${28 * size}px;transition: background 1s steps(28);`}
+`;
+
+
+export default function FavButton({ isClick, onClick, size }) {
+    return (
+        <HeartUI isClick={isClick} onClick={onClick} size={size}/>
+    );
+}

+ 41 - 0
src/components/FullScreenDropZone.tsx

@@ -0,0 +1,41 @@
+import React, { useRef } from 'react';
+import styled from 'styled-components';
+
+const DropDiv = styled.div`
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+`;
+
+type Props = React.PropsWithChildren<{
+    showModal: () => void;
+    closeModal: () => void;
+}>;
+
+export default function FullScreenDropZone({ children, showModal, closeModal }: Props) {
+    const closeTimer = useRef<number>();
+    
+    const clearTimer = () => {
+        if (closeTimer.current) {
+            clearTimeout(closeTimer.current);
+        }
+    }
+
+    const onDragOver = (e) => {
+        e.preventDefault();
+        clearTimer();
+        showModal();
+    }
+
+    const onDragLeave = (e) => {
+        e.preventDefault();
+        clearTimer();
+        closeTimer.current = setTimeout(closeModal, 1000);
+    }
+
+    return (
+        <DropDiv onDragOver={onDragOver} onDragLeave={onDragLeave}>
+            {children}
+        </DropDiv>
+    );
+};

+ 196 - 0
src/components/PhotoSwipe/PhotoSwipe.tsx

@@ -0,0 +1,196 @@
+import React, { useEffect, useState } from 'react';
+import Photoswipe from 'photoswipe';
+import PhotoswipeUIDefault from 'photoswipe/dist/photoswipe-ui-default';
+import classnames from 'classnames';
+import events from './events';
+import FavButton from 'components/FavButton';
+import { addToFavorites, removeFromFavorites } from 'services/collectionService';
+import { file } from 'services/fileService';
+
+interface Iprops {
+    isOpen: boolean
+    items: any[];
+    options?: Object;
+    onClose?: () => void;
+    gettingData?: (instance: any, index: number, item: file) => void;
+    id?: string;
+    className?: string;
+    favItemIds: Set<number>;
+    setFavItemIds: (favItemIds: Set<number>) => void;
+};
+
+function PhotoSwipe(props: Iprops) {
+
+    let pswpElement;
+    const [photoSwipe, setPhotoSwipe] = useState<Photoswipe<any>>();
+
+    const { isOpen } = props;
+    const [isFav, setIsFav] = useState(false)
+
+
+    useEffect(() => {
+        if (!pswpElement)
+            return;
+        if (isOpen)
+            openPhotoSwipe();
+
+    }, [pswpElement]);
+
+    useEffect(() => {
+        if (!pswpElement)
+            return;
+        if (isOpen) {
+            openPhotoSwipe();
+        }
+        if (!isOpen) {
+            closePhotoSwipe();
+        }
+        return () => {
+            closePhotoSwipe();
+        }
+    }, [isOpen]);
+
+    function updateFavButton() {
+        console.log(this.currItem.id, props.favItemIds)
+        setIsFav(isInFav(this?.currItem));
+    }
+
+
+    const openPhotoSwipe = () => {
+        const { items, options } = props;
+        let photoSwipe = new Photoswipe(pswpElement, 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();
+                    }
+                });
+            }
+        });
+        photoSwipe.listen('beforeChange', updateFavButton);
+        photoSwipe.init();
+        setPhotoSwipe(photoSwipe);
+
+    };
+
+    const updateItems = (items = []) => {
+        photoSwipe.items = [];
+        items.forEach((item) => {
+            photoSwipe.items.push(item);
+        });
+        photoSwipe.invalidateCurrItems();
+        photoSwipe.updateSize(true);
+    };
+
+    const closePhotoSwipe = () => {
+        if (photoSwipe)
+            photoSwipe.close();
+    };
+
+    const handleClose = () => {
+        const { onClose } = props;
+        if (onClose) {
+            onClose();
+        }
+    };
+    const isInFav = (file) => {
+        const { favItemIds } = props;
+        if (favItemIds && file) {
+            return favItemIds.has(file.id);
+        }
+        else
+            return false;
+    }
+
+    const onFavClick = async (file) => {
+        const { favItemIds, setFavItemIds } = props;
+        if (!isInFav(file)) {
+            favItemIds.add(file.id);
+            await addToFavorites(file);
+            console.log("added to Favorites");
+            setIsFav(true);
+            setFavItemIds(favItemIds);
+        }
+        else {
+            favItemIds.delete(file.id);
+            await removeFromFavorites(file)
+            console.log("removed from Favorites");
+            setIsFav(false);
+            setFavItemIds(favItemIds);
+
+        }
+    }
+    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={(node) => {
+                pswpElement = node;
+            }}
+        >
+            <div className="pswp__bg" />
+            <div className="pswp__scroll-wrap">
+                <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="Share"
+                        />
+                        <button
+                            className="pswp__button pswp__button--share"
+                            title="Share"
+                        />
+                        <button
+                            className="pswp__button pswp__button--fs"
+                            title="Toggle fullscreen"
+                        />
+                        <button className="pswp__button pswp__button--zoom" title="Zoom in/out" />
+                        <FavButton size={44} isClick={isFav} onClick={() => { onFavClick(photoSwipe?.currItem) }} />
+                        <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="Previous (arrow left)"
+                    />
+                    <button
+                        className="pswp__button pswp__button--arrow--right"
+                        title="Next (arrow right)"
+                    />
+                    <div className="pswp__caption">
+                        <div className="pswp__caption__center" />
+                    </div>
+                </div>
+            </div>
+        </div>
+    );
+}
+
+export default PhotoSwipe;

+ 19 - 0
src/components/PhotoSwipe/events.ts

@@ -0,0 +1,19 @@
+export default [
+    'beforeChange',
+    'afterChange',
+    'imageLoadComplete',
+    'resize',
+    'gettingData',
+    'mouseUsed',
+    'initialZoomIn',
+    'initialZoomInEnd',
+    'initialZoomOut',
+    'initialZoomOutEnd',
+    'parseVerticalMargin',
+    'close',
+    'unbindEvents',
+    'destroy',
+    'updateScrollOffset',
+    'preventDragEvent',
+    'shareLinkClick'
+  ];

+ 68 - 20
src/pages/_app.tsx

@@ -1,5 +1,5 @@
 import React, { useEffect, useState } from 'react';
-import styled, {createGlobalStyle } from 'styled-components';
+import styled, { createGlobalStyle } from 'styled-components';
 import Navbar from 'components/Navbar';
 import constants from 'utils/strings/constants';
 import Button from 'react-bootstrap/Button';
@@ -13,6 +13,8 @@ import Head from 'next/head';
 import 'bootstrap/dist/css/bootstrap.min.css';
 import 'react-photoswipe/lib/photoswipe.css';
 import localForage from 'localforage';
+import UploadButton from 'pages/gallery/components/UploadButton';
+import FullScreenDropZone from 'components/FullScreenDropZone';
 
 localForage.config({
     driver: localForage.INDEXEDDB,
@@ -78,26 +80,59 @@ const GlobalStyles = createGlobalStyle`
     .pswp__img {
         object-fit: contain;
     }
+    .modal-90w{
+        width:90vw;
+        max-width:880px!important;
+    }
+    .modal .modal-header, .modal  .modal-footer {
+        border-color: #444 !important;
+    }
+    .modal .modal-header .close {
+        color: #aaa;
+        text-shadow: none;
+    }
+    .modal .card {
+        background-color: #303030;
+        border: none;
+        color: #aaa;
+    }
+    .modal .card > div {
+        border-radius: 30px;
+        overflow: hidden;
+        margin: 0 0 5px 0;
+    }
+    .modal-content{
+        background-color:#303030 !important;
+        color:#aaa;
+    }
 `;
 
 const Image = styled.img`
-    max-height: 28px;
-    margin-right: 5px;
+  max-height: 28px;
+  margin-right: 5px;
 `;
 
 const FlexContainer = styled.div`
-    flex: 1;
+  flex: 1;
 `;
 
 export default function App({ Component, pageProps }) {
     const router = useRouter();
     const [user, setUser] = useState();
     const [loading, setLoading] = useState(false);
+    const [uploadButtonView, setUploadButtonView] = useState(false);
+    const [uploadModalView, setUploadModalView] = useState(false);
+
+    const closeUploadModal = () => setUploadModalView(false);
+    const showUploadModal = () => setUploadModalView(true);
 
     useEffect(() => {
         const user = getData(LS_KEYS.USER);
         setUser(user);
-        console.log(`%c${constants.CONSOLE_WARNING_STOP}`, 'color: red; font-size: 52px;');
+        console.log(
+            `%c${constants.CONSOLE_WARNING_STOP}`,
+            'color: red; font-size: 52px;'
+        );
         console.log(`%c${constants.CONSOLE_WARNING_DESC}`, 'font-size: 20px;');
 
         router.events.on('routeChangeStart', (url: string) => {
@@ -116,34 +151,47 @@ export default function App({ Component, pageProps }) {
     const logout = async () => {
         clearKeys();
         clearData();
+        setUploadButtonView(false);
         localForage.clear();
         const cache = await caches.delete('thumbs');
-        router.push("/");
-    }
+        router.push('/');
+    };
 
     return (
-        <>
+        <FullScreenDropZone
+            closeModal={closeUploadModal}
+            showModal={showUploadModal}
+        >
             <Head>
                 <title>ente.io | Privacy friendly alternative to Google Photos</title>
             </Head>
             <GlobalStyles />
             <Navbar>
                 <FlexContainer>
-                    <Image alt='logo' src="/icon.png" />
+                    <Image alt='logo' src='/icon.png' />
                     {constants.COMPANY_NAME}
                 </FlexContainer>
-                {user && <Button variant='link' onClick={logout}>
-                    <PowerSettings />
-                </Button>}
+                {uploadButtonView && <UploadButton showModal={showUploadModal} />}
+                {user &&
+                    <Button variant='link' onClick={logout}>
+                        <PowerSettings />
+                    </Button>
+                }
             </Navbar>
-            {loading
-                ? <Container>
-                    <Spinner animation="border" role="status" variant="primary">
-                        <span className="sr-only">Loading...</span>
+            {loading ? (
+                <Container>
+                    <Spinner animation='border' role='status' variant='primary'>
+                        <span className='sr-only'>Loading...</span>
                     </Spinner>
                 </Container>
-                : <Component />
-            }
-        </>
+            ) : (
+                    <Component
+                        uploadModalView={uploadModalView}
+                        showUploadModal={showUploadModal}
+                        closeUploadModal={closeUploadModal}
+                        setUploadButtonView={setUploadButtonView}
+                    />
+                )}
+        </FullScreenDropZone>
     );
-}
+}

+ 6 - 10
src/pages/credentials/index.tsx

@@ -52,18 +52,14 @@ export default function Credentials() {
         try {
             const cryptoWorker = await new CryptoWorker();
             const { passphrase } = values;
-            const kek = await cryptoWorker.deriveKey(await cryptoWorker.fromString(passphrase),
-                await cryptoWorker.fromB64(keyAttributes.kekSalt));
+            const kek: string = await cryptoWorker.deriveKey(passphrase, keyAttributes.kekSalt);
 
             if (await cryptoWorker.verifyHash(keyAttributes.kekHash, kek)) {
-                const key = await cryptoWorker.decrypt(
-                    await cryptoWorker.fromB64(keyAttributes.encryptedKey),
-                    await cryptoWorker.fromB64(keyAttributes.keyDecryptionNonce),
-                    kek);
-                const sessionKeyAttributes = await cryptoWorker.encrypt(key);
-                const sessionKey = await cryptoWorker.toB64(sessionKeyAttributes.key);
-                const sessionNonce = await cryptoWorker.toB64(sessionKeyAttributes.nonce);
-                const encryptionKey = await cryptoWorker.toB64(sessionKeyAttributes.encryptedData);
+                const key: string = await cryptoWorker.decryptB64(keyAttributes.encryptedKey, keyAttributes.keyDecryptionNonce, kek);
+                const sessionKeyAttributes = await cryptoWorker.encryptToB64(key);
+                const sessionKey = sessionKeyAttributes.key;
+                const sessionNonce = sessionKeyAttributes.nonce;
+                const encryptionKey = sessionKeyAttributes.encryptedData;
                 setKey(SESSION_KEYS.ENCRYPTION_KEY, { encryptionKey });
                 setData(LS_KEYS.SESSION, { sessionKey, sessionNonce });
                 router.push('/gallery');

+ 55 - 0
src/pages/gallery/components/AddCollection.tsx

@@ -0,0 +1,55 @@
+import React, { useState } from "react";
+import { Card } from "react-bootstrap";
+import styled from "styled-components";
+import CreateCollection from "./CreateCollection";
+import DropzoneWrapper from "./DropzoneWrapper";
+
+const ImageContainer = styled.div`
+    min-height: 192px;
+    max-width: 192px;
+    border: 1px solid #555;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 42px;
+`;
+
+const StyledCard = styled(Card)`
+    cursor: pointer;
+`;
+
+export default function AddCollection(props) {
+
+    const [acceptedFiles, setAcceptedFiles] = useState<File[]>();
+    const [createCollectionView, setCreateCollectionView] = useState(false);
+
+    const { closeUploadModal, showUploadModal, ...rest } = props;
+
+    const createCollection = (acceptedFiles) => {
+        setAcceptedFiles(acceptedFiles);
+        setCreateCollectionView(true);
+    };
+    const children = (
+        <StyledCard>
+            <ImageContainer>+</ImageContainer>
+            <Card.Text style={{ textAlign: "center" }}>Create New Album</Card.Text>
+        </StyledCard>
+    );
+    return (
+        <>
+            <DropzoneWrapper
+                onDropAccepted={createCollection}
+                onDropRejected={closeUploadModal}
+                onDragOver={showUploadModal}
+                children={children}
+            />
+            <CreateCollection
+                {...rest}
+                modalView={createCollectionView}
+                closeUploadModal={closeUploadModal}
+                closeModal={() => setCreateCollectionView(false)}
+                acceptedFiles={acceptedFiles}
+            />
+        </>
+    )
+}

+ 38 - 0
src/pages/gallery/components/CollectionDropZone.tsx

@@ -0,0 +1,38 @@
+import React from 'react';
+import UploadService from 'services/uploadService';
+import { getToken } from 'utils/common/key';
+import DropzoneWrapper from './DropzoneWrapper';
+
+
+function CollectionDropZone({
+    children,
+    closeModal,
+    showModal,
+    refetchData,
+    collectionLatestFile,
+    setProgressView,
+    progressBarProps
+
+}) {
+
+    const upload = async (acceptedFiles) => {
+        const token = getToken();
+        closeModal();
+        progressBarProps.setPercentComplete(0);
+        setProgressView(true);
+
+        await UploadService.uploadFiles(acceptedFiles, collectionLatestFile, token, progressBarProps);
+        refetchData();
+        setProgressView(false);
+    }
+    return (
+        <DropzoneWrapper
+            children={children}
+            onDropAccepted={upload}
+            onDragOver={showModal}
+            onDropRejected={closeModal}
+        />
+    );
+};
+
+export default CollectionDropZone;

+ 56 - 0
src/pages/gallery/components/CollectionSelector.tsx

@@ -0,0 +1,56 @@
+import React, { useEffect, useState } from 'react';
+import { Button, Card, Modal } from 'react-bootstrap';
+import CollectionDropZone from './CollectionDropZone';
+import AddCollection from './AddCollection';
+import PreviewCard from './PreviewCard';
+import constants from 'utils/strings/constants';
+
+function CollectionSelector(props) {
+    const {
+        uploadModalView,
+        closeUploadModal,
+        showUploadModal,
+        collectionLatestFile,
+        ...rest
+    } = props;
+
+
+    const CollectionIcons = collectionLatestFile?.map((item) => (
+        <CollectionDropZone key={item.collection.id}
+            {...rest}
+            closeModal={closeUploadModal}
+            showModal={showUploadModal}
+            collectionLatestFile={item}
+        >
+            <Card>
+                <PreviewCard data={item.file} updateUrl={() => { }} forcedEnable />
+                <Card.Text className="text-center">{item.collection.name}</Card.Text>
+            </Card>
+
+        </CollectionDropZone>
+    ));
+
+    return (
+        <Modal
+            show={uploadModalView}
+            onHide={closeUploadModal}
+            dialogClassName="modal-90w"
+        >
+            <Modal.Header closeButton>
+                <Modal.Title >
+                    {constants.SELECT_COLLECTION}
+                </Modal.Title>
+            </Modal.Header>
+            <Modal.Body style={{ display: "flex", justifyContent: "flex-start", flexWrap: "wrap" }}>
+                <AddCollection
+                    {...rest}
+                    showUploadModal={showUploadModal}
+                    closeUploadModal={closeUploadModal}
+                />
+                {CollectionIcons}
+            </Modal.Body>
+        </Modal>
+    );
+}
+
+export default CollectionSelector;

+ 2 - 1
src/pages/gallery/components/Collections.tsx

@@ -1,5 +1,5 @@
 import React from 'react';
-import { collection } from 'services/fileService';
+import { collection } from 'services/collectionService';
 import styled from 'styled-components';
 
 interface CollectionProps {
@@ -57,6 +57,7 @@ export default function Collections(props: CollectionProps) {
         <Wrapper>
             <Chip active={!selected} onClick={clickHandler()}>All</Chip>
             {collections?.map(item => <Chip
+                key={item.id}
                 active={selected === item.id.toString()}
                 onClick={clickHandler(item.id)}
             >{item.name}</Chip>)}

+ 72 - 0
src/pages/gallery/components/CreateCollection.tsx

@@ -0,0 +1,72 @@
+import React, { useEffect, useState } from 'react';
+import { Button, Form, Modal } from 'react-bootstrap';
+import { createAlbum } from 'services/collectionService';
+import UploadService from 'services/uploadService';
+import { collectionLatestFile } from 'services/collectionService'
+import { getToken } from 'utils/common/key';
+
+export default function CreateCollection(props) {
+
+    const { acceptedFiles, setProgressView, progressBarProps, refetchData, modalView, closeModal, closeUploadModal } = props;
+    const [albumName, setAlbumName] = useState("");
+
+    const handleChange = (event) => { setAlbumName(event.target.value); }
+
+    useEffect(() => {
+        if (acceptedFiles == null)
+            return;
+        let commonPathPrefix: string = (() => {
+            const paths: string[] = acceptedFiles.map(files => files.path);
+            paths.sort();
+            let firstPath = paths[0], lastPath = paths[paths.length - 1], L = firstPath.length, i = 0;
+            while (i < L && firstPath.charAt(i) === lastPath.charAt(i)) i++;
+            return firstPath.substring(0, i);
+        })();
+        if (commonPathPrefix)
+            commonPathPrefix = commonPathPrefix.substr(1, commonPathPrefix.lastIndexOf('/') - 1);
+        setAlbumName(commonPathPrefix);
+    }, [acceptedFiles]);
+    const handleSubmit = async (event) => {
+        const token = getToken();
+        event.preventDefault();
+
+        closeModal();
+        closeUploadModal();
+
+        const collection = await createAlbum(albumName);
+
+        const collectionLatestFile: collectionLatestFile = { collection, file: null }
+
+        progressBarProps.setPercentComplete(0);
+        setProgressView(true);
+
+        await UploadService.uploadFiles(acceptedFiles, collectionLatestFile, token, progressBarProps);
+        refetchData();
+        setProgressView(false);
+    }
+    return (
+        <Modal
+            show={modalView}
+            onHide={closeModal}
+            centered
+            backdrop="static"
+        >
+            <Modal.Header closeButton>
+                <Modal.Title>
+                    Create Collection
+        </Modal.Title>
+            </Modal.Header>
+            <Modal.Body>
+                <Form onSubmit={handleSubmit}>
+                    <Form.Group controlId="formBasicEmail">
+                        <Form.Label>Album Name:</Form.Label>
+                        <Form.Control type="text" placeholder="Enter Album Name" value={albumName} onChange={handleChange} />
+                    </Form.Group>
+                    <Button variant="primary" type="submit" style={{ width: "100%" }}>
+                        Submit
+                     </Button>
+                </Form>
+            </Modal.Body>
+        </Modal>
+    );
+}

+ 64 - 0
src/pages/gallery/components/DropzoneWrapper.tsx

@@ -0,0 +1,64 @@
+import React from 'react';
+import Dropzone from 'react-dropzone';
+import styled from 'styled-components';
+
+export const getColor = (props) => {
+    if (props.isDragAccept) {
+        return '#00e676';
+    }
+    if (props.isDragReject) {
+        return '#ff1744';
+    }
+    if (props.isDragActive) {
+        return '#2196f3';
+    }
+};
+
+export const enableBorder = (props) => (props.isDragActive ? 'dashed' : 'none');
+
+export const DropDiv = styled.div`
+  width:200px;
+  margin:5px;
+  height:230px;
+  color:black;
+  border-width: 2px;
+  border-radius: 2px;
+  border-color: ${(props) => getColor(props)};
+  border-style: ${(props) => enableBorder(props)};
+  outline: none;
+  transition: border 0.24s ease-in-out;
+`;
+
+export function DropzoneWrapper(props) {
+    const { children, ...callbackProps } = props
+    return (
+        <Dropzone
+            noDragEventsBubbling
+            accept="image/*, video/*, application/json, "
+            {...callbackProps}
+        >
+            {({
+                getRootProps,
+                getInputProps,
+                isDragActive,
+                isDragAccept,
+                isDragReject,
+            }) => {
+                return (
+                    <DropDiv
+                        {...getRootProps({
+                            isDragActive,
+                            isDragAccept,
+                            isDragReject,
+                        })}
+                    >
+                        <input {...getInputProps()} />
+                        {children}
+                    </DropDiv>
+                );
+            }}
+        </Dropzone>
+    );
+};
+
+export default DropzoneWrapper;

+ 6 - 5
src/pages/gallery/components/PreviewCard.tsx

@@ -7,7 +7,8 @@ import PlayCircleOutline from 'components/PlayCircleOutline';
 interface IProps {
     data: file,
     updateUrl: (url: string) => void,
-    onClick: () => void,
+    onClick?: () => void,
+    forcedEnable?: boolean,
 }
 
 const Cont = styled.div<{ disabled: boolean }>`
@@ -41,7 +42,7 @@ const Cont = styled.div<{ disabled: boolean }>`
 
 export default function PreviewCard(props: IProps) {
     const [imgSrc, setImgSrc] = useState<string>();
-    const { data, onClick, updateUrl } = props;
+    const { data, onClick, updateUrl, forcedEnable } = props;
 
     useEffect(() => {
         if (data && !data.msrc) {
@@ -57,12 +58,12 @@ export default function PreviewCard(props: IProps) {
     }, [data]);
 
     const handleClick = () => {
-        if (data.msrc || imgSrc) {
-            onClick();
+        if (data?.msrc || imgSrc) {
+            onClick?.();
         }
     }
 
-    return <Cont onClick={handleClick} disabled={!data?.msrc && !imgSrc}>
+    return <Cont onClick={handleClick} disabled={!forcedEnable && !data?.msrc && !imgSrc}>
         <img src={data?.msrc || imgSrc} />
         {data?.metadata.fileType === 1 && <PlayCircleOutline />}
     </Cont>;

+ 32 - 0
src/pages/gallery/components/Upload.tsx

@@ -0,0 +1,32 @@
+import React, { useState } from "react"
+import { UPLOAD_STAGES } from "services/uploadService";
+import CollectionSelector from "./CollectionSelector"
+import UploadProgress from "./UploadProgress"
+
+export default function Upload(props) {
+    const [progressView, setProgressView] = useState(false);
+    const [uploadStage, setUploadStage] = useState<UPLOAD_STAGES>(UPLOAD_STAGES.START);
+    const [fileCounter, setFileCounter] = useState({ current: 0, total: 0 });
+    const [percentComplete, setPercentComplete] = useState(0);
+    const init = () => {
+        setProgressView(false);
+        setUploadStage(UPLOAD_STAGES.START);
+        setFileCounter({ current: 0, total: 0 });
+        setPercentComplete(0);
+    }
+    return (<>
+        <CollectionSelector
+            {...props}
+            setProgressView={setProgressView}
+            progressBarProps={{ setPercentComplete, setFileCounter, setUploadStage }}
+        />
+        <UploadProgress
+            now={percentComplete}
+            fileCounter={fileCounter}
+            uploadStage={uploadStage}
+            show={progressView}
+            onHide={init}
+        />
+    </>
+    )
+}

+ 12 - 0
src/pages/gallery/components/UploadButton.tsx

@@ -0,0 +1,12 @@
+import React from 'react';
+import { Button } from 'react-bootstrap';
+
+function UploadButton({ showModal }) {
+    return (
+        <Button variant='primary' onClick={showModal}>
+            Upload New Photos
+        </Button>
+    );
+};
+
+export default UploadButton;

+ 31 - 0
src/pages/gallery/components/UploadProgress.tsx

@@ -0,0 +1,31 @@
+import React from 'react';
+import { Alert, Modal, ProgressBar } from 'react-bootstrap';
+import constants from 'utils/strings/constants'
+
+export default function UploadProgress({ fileCounter, uploadStage, now, ...props }) {
+    return (
+        <Modal
+            {...props}
+            size='lg'
+            aria-labelledby='contained-modal-title-vcenter'
+            centered
+            backdrop="static"
+        >
+            <Modal.Header>
+                <Modal.Title id='contained-modal-title-vcenter'>
+                    Uploading Files
+        </Modal.Title>
+            </Modal.Header>
+            <Modal.Body>
+                {now === 100 ? (
+                    <Alert variant='success'>{constants.UPLOAD[3]}</Alert>
+                ) : (
+                        <>
+                            <Alert variant='info'>{constants.UPLOAD[uploadStage]} {fileCounter?.total != 0 ? `${fileCounter?.current} ${constants.OF} ${fileCounter?.total}` : ''}</Alert>
+                            <ProgressBar animated now={now} />
+                        </>
+                    )}
+            </Modal.Body>
+        </Modal>
+    );
+}

+ 156 - 95
src/pages/gallery/index.tsx

@@ -2,28 +2,41 @@ import React, { useEffect, useState } from 'react';
 import { useRouter } from 'next/router';
 import Spinner from 'react-bootstrap/Spinner';
 import { getKey, SESSION_KEYS } from 'utils/storage/sessionStorage';
-import { collection, fetchCollections, file, getFile, getFiles, getPreview } from 'services/fileService';
+import {
+    file,
+    getFile,
+    getPreview,
+    fetchData,
+} from 'services/fileService';
 import { getData, LS_KEYS } from 'utils/storage/localStorage';
 import PreviewCard from './components/PreviewCard';
 import { getActualKey } from 'utils/common/key';
 import styled from 'styled-components';
-import { PhotoSwipe } from 'react-photoswipe';
+import PhotoSwipe from 'components/PhotoSwipe/PhotoSwipe';
 import { Options } from 'photoswipe';
 import AutoSizer from 'react-virtualized-auto-sizer';
 import { VariableSizeList as List } from 'react-window';
 import Collections from './components/Collections';
 import SadFace from 'components/SadFace';
+import Upload from './components/Upload';
+import { collection, fetchCollections, collectionLatestFile, getCollectionLatestFile, getFavItemIds } from 'services/collectionService';
+import constants from 'utils/strings/constants';
 
 enum ITEM_TYPE {
-    TIME='TIME',
-    TILE='TILE'
+    TIME = 'TIME',
+    TILE = 'TILE',
+}
+export enum FILE_TYPE {
+    IMAGE,
+    VIDEO,
+    OTHERS
 }
 
 interface TimeStampListItem {
-    itemType: ITEM_TYPE,
-    items?: file[],
-    itemStartIndex?: number,
-    date?: string,
+    itemType: ITEM_TYPE;
+    items?: file[];
+    itemStartIndex?: number;
+    date?: string;
 }
 
 const Container = styled.div`
@@ -76,47 +89,57 @@ const DateContainer = styled.div`
     padding: 0 4px;
 `;
 
-const PAGE_SIZE = 12;
-const COLUMNS = 3;
-
-export default function Gallery() {
+export default function Gallery(props) {
     const router = useRouter();
     const [loading, setLoading] = useState(false);
-    const [collections, setCollections] = useState<collection[]>([])
+    const [reload, setReload] = useState(0);
+    const [collections, setCollections] = useState<collection[]>([]);
+    const [collectionLatestFile, setCollectionLatestFile] = useState<
+        collectionLatestFile[]
+    >([]);
     const [data, setData] = useState<file[]>();
+    const [favItemIds, setFavItemIds] = useState<Set<number>>();
     const [open, setOpen] = useState(false);
     const [options, setOptions] = useState<Options>({
         history: false,
         maxSpreadZoom: 5,
     });
-    const fetching: { [k: number]: boolean }  = {};
+    const fetching: { [k: number]: boolean } = {};
+
+
 
     useEffect(() => {
         const key = getKey(SESSION_KEYS.ENCRYPTION_KEY);
-        const token = getData(LS_KEYS.USER).token;
         if (!key) {
-            router.push("/");
+            router.push('/');
         }
         const main = async () => {
             setLoading(true);
-            const encryptionKey = await getActualKey();
-            const collections = await fetchCollections(token, encryptionKey);
-            const resp = await getFiles("0", token, "100", encryptionKey, collections);
+            await syncWithRemote();
             setLoading(false);
-            setCollections(collections);
-            setData(resp.map(item => ({
-                ...item,
-                w: window.innerWidth,
-                h: window.innerHeight,
-            })));
         };
-        main(); 
+        main();
+        props.setUploadButtonView(true);
     }, []);
 
+    const syncWithRemote = async () => {
+        const token = getData(LS_KEYS.USER).token;
+        const encryptionKey = await getActualKey();
+        const collections = await fetchCollections(token, encryptionKey);
+        const data = await fetchData(token, collections);
+        const collectionLatestFile = await getCollectionLatestFile(collections, data);
+        const favItemIds = await getFavItemIds(data);
+        setCollections(collections);
+        setData(data);
+        setCollectionLatestFile(collectionLatestFile);
+        setFavItemIds(favItemIds);
+    }
     if (!data || loading) {
-        return <div className="text-center">
-            <Spinner animation="border" variant="primary" />
-        </div>
+        return (
+            <div className='text-center'>
+                <Spinner animation='border' variant='primary' />
+            </div>
+        );
     }
 
     const updateUrl = (index: number) => (url: string) => {
@@ -125,8 +148,8 @@ export default function Gallery() {
             msrc: url,
             w: window.innerWidth,
             h: window.innerHeight,
-        }
-        if (data[index].metadata.fileType === 1 && !data[index].html) {
+        };
+        if (data[index].metadata.fileType === FILE_TYPE.VIDEO && !data[index].html) {
             data[index].html = `
                 <div class="video-loading">
                     <img src="${url}" />
@@ -137,11 +160,11 @@ export default function Gallery() {
             `;
             delete data[index].src;
         }
-        if (data[index].metadata.fileType === 0 && !data[index].src) {
+        if (data[index].metadata.fileType === FILE_TYPE.IMAGE && !data[index].src) {
             data[index].src = url;
         }
         setData(data);
-    }
+    };
 
     const updateSrcUrl = (index: number, url: string) => {
         data[index] = {
@@ -149,8 +172,8 @@ export default function Gallery() {
             src: url,
             w: window.innerWidth,
             h: window.innerHeight,
-        }
-        if (data[index].metadata.fileType === 1) {
+        };
+        if (data[index].metadata.fileType === FILE_TYPE.VIDEO) {
             data[index].html = `
                 <video controls>
                     <source src="${url}" />
@@ -160,11 +183,12 @@ export default function Gallery() {
             delete data[index].src;
         }
         setData(data);
-    }
+    };
 
     const handleClose = () => {
         setOpen(false);
-    }
+        // syncWithRemote();
+    };
 
     const onThumbnailClick = (index: number) => () => {
         setOptions({
@@ -172,16 +196,18 @@ export default function Gallery() {
             index,
         });
         setOpen(true);
-    }
+    };
 
     const getThumbnail = (file: file[], index: number) => {
-        return (<PreviewCard
-            key={`tile-${file[index].id}`}
-            data={file[index]}
-            updateUrl={updateUrl(file[index].dataIndex)}
-            onClick={onThumbnailClick(index)}
-        />);
-    }
+        return (
+            <PreviewCard
+                key={`tile-${file[index].id}`}
+                data={file[index]}
+                updateUrl={updateUrl(file[index].dataIndex)}
+                onClick={onThumbnailClick(index)}
+            />
+        );
+    };
 
     const getSlideData = async (instance: any, index: number, item: file) => {
         const token = getData(LS_KEYS.USER).token;
@@ -205,7 +231,7 @@ export default function Gallery() {
             fetching[item.dataIndex] = true;
             const url = await getFile(token, item);
             updateSrcUrl(item.dataIndex, url);
-            if (item.metadata.fileType === 1) {
+            if (item.metadata.fileType === FILE_TYPE.VIDEO) {
                 item.html = `
                     <video width="320" height="240" controls>
                         <source src="${url}" />
@@ -225,43 +251,58 @@ export default function Gallery() {
                 // ignore
             }
         }
-    }
+    };
 
     const selectCollection = (id?: string) => {
         const href = `/gallery?collection=${id || ''}`;
         router.push(href, undefined, { shallow: true });
-    }
+    };
 
-    const idSet = new Set();
-    const filteredData = data.map((item, index) => ({
-        ...item,
-        dataIndex: index,
-    })).filter(item => {
-        if (!idSet.has(item.id)) {
-            if (!router.query.collection || router.query.collection === item.collectionID.toString()) {
-                idSet.add(item.id);
-                return true;
+    let idSet = new Set();
+    const filteredData = data
+        .map((item, index) => ({
+            ...item,
+            dataIndex: index,
+        }))
+        .filter((item) => {
+            if (!idSet.has(item.id)) {
+                if (
+                    !router.query.collection ||
+                    router.query.collection === item.collectionID.toString()
+                ) {
+                    idSet.add(item.id);
+                    return true;
+                }
+                return false;
             }
             return false;
-        }
-        return false;
-    });
+        });
 
     const isSameDay = (first, second) => {
-        return first.getFullYear() === second.getFullYear() &&
+        return (
+            first.getFullYear() === second.getFullYear() &&
             first.getMonth() === second.getMonth() &&
-            first.getDate() === second.getDate();
-    }
+            first.getDate() === second.getDate()
+        );
+    };
 
-    return (<>
-        <Collections
-            collections={collections}
-            selected={router.query.collection?.toString()}
-            selectCollection={selectCollection}
-        />
-        {
-            filteredData.length
-                ? <Container>
+    return (
+        <>
+            <Collections
+                collections={collections}
+                selected={router.query.collection?.toString()}
+                selectCollection={selectCollection}
+            />
+            <Upload
+                uploadModalView={props.uploadModalView}
+                closeUploadModal={props.closeUploadModal}
+                showUploadModal={props.showUploadModal}
+                collectionLatestFile={collectionLatestFile}
+                refetchData={syncWithRemote}
+
+            />
+            {filteredData.length ? (
+                <Container>
                     <AutoSizer>
                         {({ height, width }) => {
                             let columns;
@@ -279,13 +320,18 @@ export default function Gallery() {
                             let listItemIndex = 0;
                             let currentDate = -1;
                             filteredData.forEach((item, index) => {
-                                if (!isSameDay(new Date(item.metadata.creationTime/1000), new Date(currentDate))) {
-                                    currentDate = item.metadata.creationTime/1000;
+                                if (
+                                    !isSameDay(
+                                        new Date(item.metadata.creationTime / 1000),
+                                        new Date(currentDate)
+                                    )
+                                ) {
+                                    currentDate = item.metadata.creationTime / 1000;
                                     const dateTimeFormat = new Intl.DateTimeFormat('en-IN', {
                                         weekday: 'short',
                                         year: 'numeric',
                                         month: 'long',
-                                        day: 'numeric'
+                                        day: 'numeric',
                                     });
                                     timeStampList.push({
                                         itemType: ITEM_TYPE.TIME,
@@ -307,34 +353,46 @@ export default function Gallery() {
                                             itemType: ITEM_TYPE.TILE,
                                             items: [item],
                                             itemStartIndex: index,
-                                        })
+                                        });
                                     }
                                 }
                             });
 
                             return (
                                 <List
-                                    itemSize={(index) => timeStampList[index].itemType === ITEM_TYPE.TIME ? 30 : 200}
+                                    itemSize={(index) =>
+                                        timeStampList[index].itemType === ITEM_TYPE.TIME
+                                            ? 30
+                                            : 200
+                                    }
                                     height={height}
                                     width={width}
                                     itemCount={timeStampList.length}
                                     key={`${router.query.collection}-${columns}`}
                                 >
                                     {({ index, style }) => {
-                                        return (<ListItem style={style}>
-                                            <ListContainer>
-                                                {
-                                                    timeStampList[index].itemType === ITEM_TYPE.TIME
-                                                        ? <DateContainer>{timeStampList[index].date}</DateContainer>
-                                                        : timeStampList[index].items.map((item, idx) =>{
-                                                            return getThumbnail(filteredData, timeStampList[index].itemStartIndex + idx);
-                                                        })
-                                                }
-                                            </ListContainer>
-                                        </ListItem>);
+                                        return (
+                                            <ListItem style={style}>
+                                                <ListContainer>
+                                                    {timeStampList[index].itemType ===
+                                                        ITEM_TYPE.TIME ? (
+                                                            <DateContainer>
+                                                                {timeStampList[index].date}
+                                                            </DateContainer>
+                                                        ) : (
+                                                            timeStampList[index].items.map((item, idx) => {
+                                                                return getThumbnail(
+                                                                    filteredData,
+                                                                    timeStampList[index].itemStartIndex + idx
+                                                                );
+                                                            })
+                                                        )}
+                                                </ListContainer>
+                                            </ListItem>
+                                        );
                                     }}
                                 </List>
-                            )
+                            );
                         }}
                     </AutoSizer>
                     <PhotoSwipe
@@ -343,12 +401,15 @@ export default function Gallery() {
                         options={options}
                         onClose={handleClose}
                         gettingData={getSlideData}
+                        favItemIds={favItemIds}
+                        setFavItemIds={setFavItemIds}
                     />
                 </Container>
-                : <DeadCenter>
-                    <SadFace height={100} width={100} />
-                    <div>No content found!</div>
-                </DeadCenter>
-        }
-    </>);
+            ) : (
+                    <DeadCenter>
+                        <div>{constants.NOTHING_HERE}</div>
+                    </DeadCenter>
+                )}
+        </>
+    );
 }

+ 18 - 17
src/pages/generate/index.tsx

@@ -12,6 +12,7 @@ import { getData, LS_KEYS, setData } from 'utils/storage/localStorage';
 import { useRouter } from 'next/router';
 import { getKey, SESSION_KEYS, setKey } from 'utils/storage/sessionStorage';
 import * as Comlink from "comlink";
+import { keyEncryptionResult } from 'services/uploadService';
 
 const CryptoWorker: any = typeof window !== 'undefined'
     && Comlink.wrap(new Worker("worker/crypto.worker.js", { type: 'module' }));
@@ -51,30 +52,30 @@ export default function Generate() {
             const { passphrase, confirm } = values;
             if (passphrase === confirm) {
                 const cryptoWorker = await new CryptoWorker();
-                const key = await cryptoWorker.generateMasterKey();
-                const kekSalt = await cryptoWorker.generateSaltToDeriveKey();
-                const kek = await cryptoWorker.deriveKey(
-                    await cryptoWorker.fromString(passphrase), kekSalt);
-                const kekHash = await cryptoWorker.hash(kek);
-                const encryptedKeyAttributes = await cryptoWorker.encrypt(key, kek);
+                const key: string = await cryptoWorker.generateMasterKey();
+                const kekSalt: string = await cryptoWorker.generateSaltToDeriveKey();
+                const kek: string = await cryptoWorker.deriveKey(passphrase, kekSalt);
+                const kekHash: string = await cryptoWorker.hash(kek);
+                const encryptedKeyAttributes: keyEncryptionResult = await cryptoWorker.encryptToB64(key, kek);
                 const keyPair = await cryptoWorker.generateKeyPair();
-                const encryptedKeyPairAttributes = await cryptoWorker.encrypt(keyPair.privateKey, key);
+                const encryptedKeyPairAttributes: keyEncryptionResult = await cryptoWorker.encryptToB64(keyPair.privateKey, key);
+
                 const keyAttributes = {
-                    kekSalt: await cryptoWorker.toB64(kekSalt),
+                    kekSalt,
                     kekHash: kekHash,
-                    encryptedKey: await cryptoWorker.toB64(encryptedKeyAttributes.encryptedData),
-                    keyDecryptionNonce: await cryptoWorker.toB64(encryptedKeyAttributes.nonce),
-                    publicKey: await cryptoWorker.toB64(keyPair.publicKey),
-                    encryptedSecretKey: await cryptoWorker.toB64(encryptedKeyPairAttributes.encryptedData),
-                    secretKeyDecryptionNonce: await cryptoWorker.toB64(encryptedKeyPairAttributes.nonce)
+                    encryptedKey: encryptedKeyAttributes.encryptedData,
+                    keyDecryptionNonce: encryptedKeyAttributes.nonce,
+                    publicKey: keyPair.publicKey,
+                    encryptedSecretKey: encryptedKeyPairAttributes.encryptedData,
+                    secretKeyDecryptionNonce: encryptedKeyPairAttributes.nonce
                 };
                 await putAttributes(token, getData(LS_KEYS.USER).name, keyAttributes);
                 setData(LS_KEYS.KEY_ATTRIBUTES, keyAttributes);
 
-                const sessionKeyAttributes = await cryptoWorker.encrypt(key);
-                const sessionKey = await cryptoWorker.toB64(sessionKeyAttributes.key);
-                const sessionNonce = await cryptoWorker.toB64(sessionKeyAttributes.nonce);
-                const encryptionKey = await cryptoWorker.toB64(sessionKeyAttributes.encryptedData);
+                const sessionKeyAttributes = await cryptoWorker.encryptToB64(key);
+                const sessionKey = sessionKeyAttributes.key;
+                const sessionNonce = sessionKeyAttributes.nonce;
+                const encryptionKey = sessionKeyAttributes.encryptedData;
                 setKey(SESSION_KEYS.ENCRYPTION_KEY, { encryptionKey });
                 setData(LS_KEYS.SESSION, { sessionKey, sessionNonce });
                 router.push('/gallery');

+ 235 - 0
src/services/collectionService.ts

@@ -0,0 +1,235 @@
+import { getEndpoint } from "utils/common/apiUtil";
+import { getData, LS_KEYS } from "utils/storage/localStorage";
+import { file, user, getFiles } from "./fileService";
+import localForage from 'localforage';
+
+import HTTPService from "./HTTPService";
+import * as Comlink from 'comlink';
+import { keyEncryptionResult } from "./uploadService";
+import { getActualKey, getToken } from "utils/common/key";
+
+
+const CryptoWorker: any =
+    typeof window !== 'undefined' &&
+    Comlink.wrap(new Worker('worker/crypto.worker.js', { type: 'module' }));
+const ENDPOINT = getEndpoint();
+
+
+enum CollectionType {
+    folder = "folder",
+    favorites = "favorites",
+    album = "album",
+}
+
+export interface collection {
+    id: string;
+    owner: user;
+    key?: string;
+    name?: string;
+    encryptedName?: string;
+    nameDecryptionNonce?: string;
+    type: string;
+    attributes: collectionAttributes
+    sharees: user[];
+    updationTime: number;
+    encryptedKey: string;
+    keyDecryptionNonce: string;
+    isDeleted: boolean;
+}
+
+interface collectionAttributes {
+    encryptedPath?: string;
+    pathDecryptionNonce?: string
+};
+
+export interface collectionLatestFile {
+    collection: collection
+    file: file;
+}
+
+
+const getCollectionSecrets = async (collection: collection, masterKey: string) => {
+    const worker = await new CryptoWorker();
+    const userID = getData(LS_KEYS.USER).id;
+    let decryptedKey: string;
+    if (collection.owner.id == userID) {
+        decryptedKey = await worker.decryptB64(
+            collection.encryptedKey,
+            collection.keyDecryptionNonce,
+            masterKey
+        );
+
+    } else {
+        const keyAttributes = getData(LS_KEYS.KEY_ATTRIBUTES);
+        const secretKey = await worker.decryptB64(
+            keyAttributes.encryptedSecretKey,
+            keyAttributes.secretKeyDecryptionNonce,
+            masterKey
+        );
+        decryptedKey = await worker.boxSealOpen(
+            collection.encryptedKey,
+            keyAttributes.publicKey,
+            secretKey
+        );
+    }
+    collection.name = collection.name || await worker.decryptString(
+        collection.encryptedName,
+        collection.nameDecryptionNonce,
+        decryptedKey);
+    return {
+        ...collection,
+        key: decryptedKey,
+    };
+};
+
+const getCollections = async (
+    token: string,
+    sinceTime: string,
+    key: string
+): Promise<collection[]> => {
+    try {
+        const resp = await HTTPService.get(`${ENDPOINT}/collections`, {
+            sinceTime: sinceTime,
+        }, { 'X-Auth-Token': token, });
+        const promises: Promise<collection>[] = resp.data.collections.map(
+            (collection: collection) => getCollectionSecrets(collection, key)
+        );
+        return await Promise.all(promises);
+    }
+    catch (e) {
+        console.log("getCollections falied- " + e);
+    }
+};
+
+export const fetchCollections = async (token: string, key: string) => {
+    const collections = await getCollections(token, '0', key);
+    const favCollection = collections.filter(collection => collection.type === CollectionType.favorites);
+    await localForage.setItem('fav-collection', favCollection);
+    return collections;
+};
+
+export const getCollectionLatestFile = (
+    collections: collection[],
+    files: file[]
+): collectionLatestFile[] => {
+    const latestFile = new Map<number, file>();
+    const collectionMap = new Map<number, collection>();
+
+    collections.forEach(collection => collectionMap.set(Number(collection.id), collection));
+    files.forEach(file => {
+        if (!latestFile.has(file.collectionID)) {
+            latestFile.set(file.collectionID, file)
+        }
+    });
+    let allCollectionLatestFile: collectionLatestFile[] = [];
+    for (const [collectionID, file] of latestFile) {
+        allCollectionLatestFile.push({ collection: collectionMap.get(collectionID), file });
+    }
+    return allCollectionLatestFile;
+}
+
+export const getFavItemIds = async (files: file[]): Promise<Set<number>> => {
+
+    let favCollection: collection = (await localForage.getItem<collection>('fav-collection'))[0];
+    if (!favCollection)
+        return new Set();
+
+    return new Set(files.filter(file => file.collectionID === Number(favCollection.id)).map((file): number => file.id));
+}
+
+export const createAlbum = async (albumName: string) => {
+    return AddCollection(albumName, CollectionType.album);
+}
+
+
+export const AddCollection = async (collectionName: string, type: CollectionType) => {
+    const worker = await new CryptoWorker();
+    const encryptionKey = await getActualKey();
+    const token = getToken();
+    const collectionKey: string = await worker.generateMasterKey();
+    const { encryptedData: encryptedKey, nonce: keyDecryptionNonce }: keyEncryptionResult = await worker.encryptToB64(collectionKey, encryptionKey);
+    const { encryptedData: encryptedName, nonce: nameDecryptionNonce }: keyEncryptionResult = await worker.encryptToB64(collectionName, collectionKey);
+    const newCollection: collection = {
+        id: null,
+        owner: null,
+        encryptedKey,
+        keyDecryptionNonce,
+        encryptedName,
+        nameDecryptionNonce,
+        type,
+        attributes: {},
+        sharees: null,
+        updationTime: null,
+        isDeleted: false
+    };
+    let createdCollection: collection = await createCollection(newCollection, token);
+    createdCollection = await getCollectionSecrets(createdCollection, encryptionKey);
+    return createdCollection;
+}
+
+const createCollection = async (collectionData: collection, token: string): Promise<collection> => {
+    try {
+        const response = await HTTPService.post(`${ENDPOINT}/collections`, collectionData, null, { 'X-Auth-Token': token });
+        return response.data.collection;
+    } catch (e) {
+        console.log("create Collection failed " + e);
+    }
+}
+
+export const addToFavorites = async (file: file) => {
+    let favCollection: collection = (await localForage.getItem<collection>('fav-collection'))[0];
+    if (!favCollection) {
+        favCollection = await AddCollection("Favorites", CollectionType.favorites);
+        await localForage.setItem('fav-collection', favCollection);
+    }
+    await addtoCollection(favCollection, [file])
+}
+
+export const removeFromFavorites = async (file: file) => {
+    let favCollection: collection = (await localForage.getItem<collection>('fav-collection'))[0];
+    await removeFromCollection(favCollection, [file])
+}
+
+const addtoCollection = async (collection: collection, files: file[]) => {
+    try {
+        const params = new Object();
+        const worker = await new CryptoWorker();
+        const token = getToken();
+        params["collectionID"] = collection.id;
+        await Promise.all(files.map(async file => {
+            file.collectionID = Number(collection.id);
+            const newEncryptedKey: keyEncryptionResult = await worker.encryptToB64(file.key, collection.key);
+            file.encryptedKey = newEncryptedKey.encryptedData;
+            file.keyDecryptionNonce = newEncryptedKey.nonce;
+            if (params["files"] == undefined) {
+                params["files"] = [];
+            }
+            params["files"].push({
+                id: file.id,
+                encryptedKey: file.encryptedKey,
+                keyDecryptionNonce: file.keyDecryptionNonce
+            })
+            return file;
+        }));
+        await HTTPService.post(`${ENDPOINT}/collections/add-files`, params, null, { 'X-Auth-Token': token });
+    } catch (e) {
+        console.log("Add to collection Failed " + e);
+    }
+}
+const removeFromCollection = async (collection: collection, files: file[]) => {
+    try {
+        const params = new Object();
+        const token = getToken();
+        params["collectionID"] = collection.id;
+        await Promise.all(files.map(async file => {
+            if (params["fileIDs"] == undefined) {
+                params["fileIDs"] = [];
+            }
+            params["fileIDs"].push(file.id);
+        }));
+        await HTTPService.post(`${ENDPOINT}/collections/remove-files`, params, null, { 'X-Auth-Token': token });
+    } catch (e) {
+        console.log("remove from collection failed " + e);
+    }
+}
+

+ 141 - 121
src/services/fileService.ts

@@ -1,11 +1,12 @@
-import { getEndpoint } from "utils/common/apiUtil";
-import HTTPService from "./HTTPService";
-import * as Comlink from "comlink";
-import { getData, LS_KEYS } from "utils/storage/localStorage";
-import localForage from "localforage";
-
-const CryptoWorker: any = typeof window !== 'undefined'
-    && Comlink.wrap(new Worker("worker/crypto.worker.js", { type: 'module' }));
+import { getEndpoint } from 'utils/common/apiUtil';
+import HTTPService from './HTTPService';
+import * as Comlink from 'comlink';
+import localForage from 'localforage';
+import { collection } from './collectionService';
+
+const CryptoWorker: any =
+    typeof window !== 'undefined' &&
+    Comlink.wrap(new Worker('worker/crypto.worker.js', { type: 'module' }));
 const ENDPOINT = getEndpoint();
 
 localForage.config({
@@ -16,11 +17,11 @@ localForage.config({
 });
 
 export interface fileAttribute {
-    encryptedData: string;
+    encryptedData: Uint8Array | string;
     decryptionHeader: string;
     creationTime: number;
     fileType: number;
-};
+}
 
 export interface user {
     id: number;
@@ -28,17 +29,6 @@ export interface user {
     email: string;
 }
 
-export interface collection {
-    id: string;
-    owner: user;
-    key: string;
-    name: string;
-    type: string;
-    creationTime: number;
-    encryptedKey: string;
-    keyDecryptionNonce: string;
-    isDeleted: boolean;
-}
 
 export interface file {
     id: number;
@@ -48,7 +38,7 @@ export interface file {
     metadata: fileAttribute;
     encryptedKey: string;
     keyDecryptionNonce: string;
-    key: Uint8Array;
+    key: string;
     src: string;
     msrc: string;
     html: string;
@@ -56,119 +46,149 @@ export interface file {
     h: number;
     isDeleted: boolean;
     dataIndex: number;
-};
-
-const getCollectionKey = async (collection: collection, key: Uint8Array) => {
-    const worker = await new CryptoWorker();
-    const userID = getData(LS_KEYS.USER).id;
-    var decryptedKey;
-    if (collection.owner.id == userID) {
-        decryptedKey = await worker.decrypt(
-            await worker.fromB64(collection.encryptedKey),
-            await worker.fromB64(collection.keyDecryptionNonce),
-            key);
-    } else {
-        const keyAttributes = getData(LS_KEYS.KEY_ATTRIBUTES);
-        const secretKey = await worker.decrypt(
-            await worker.fromB64(keyAttributes.encryptedSecretKey),
-            await worker.fromB64(keyAttributes.secretKeyDecryptionNonce),
-            key);
-        decryptedKey = await worker.boxSealOpen(
-            await worker.fromB64(collection.encryptedKey),
-            await worker.fromB64(keyAttributes.publicKey),
-            secretKey);
-    }
-    return {
-        ...collection,
-        key: decryptedKey
-    };
+    updationTime: number;
 }
 
-const getCollections = async (token: string, sinceTime: string, key: Uint8Array): Promise<collection[]> => {
-    const resp = await HTTPService.get(`${ENDPOINT}/collections`, {
-        'token': token,
-        'sinceTime': sinceTime,
-    });
 
-    const promises: Promise<collection>[] = resp.data.collections.map(
-        (collection: collection) => getCollectionKey(collection, key));
-    return await Promise.all(promises);
-}
 
-export const fetchCollections = async (token: string, key: string) => {
-    const worker = await new CryptoWorker();
-    return getCollections(token, "0", await worker.fromB64(key));
+export const fetchData = async (token, collections) => {
+    const resp = await fetchFiles(
+        token,
+        collections
+    );
+
+    return (
+        resp.map((item) => ({
+            ...item,
+            w: window.innerWidth,
+            h: window.innerHeight,
+        }))
+    );
 }
 
-export const getFiles = async (sinceTime: string, token: string, limit: string, key: string, collections: collection[]) => {
-    const worker = await new CryptoWorker();
-    let files: Array<file> = await localForage.getItem<file[]>('files') || [];
-    for (const index in collections) {
-        const collection = collections[index];
-        if (collection.isDeleted) {
-            // TODO: Remove files in this collection from localForage and cache
-            continue;
+export const fetchFiles = async (
+    token: string,
+    collections: collection[]
+) => {
+    let files: Array<file> = (await localForage.getItem<file[]>('files')) || [];
+    const fetchedFiles = await getFiles(collections, null, "100", token);
+
+    files.push(...fetchedFiles);
+    var latestFiles = new Map<string, file>();
+    files.forEach((file) => {
+        let uid = `${file.collectionID}-${file.id}`;
+        if (!latestFiles.has(uid) || latestFiles.get(uid).updationTime < file.updationTime) {
+            latestFiles.set(uid, file);
         }
-        let time = await localForage.getItem<string>(`${collection.id}-time`) || sinceTime;
-        let resp;
-        do {
-            resp = await HTTPService.get(`${ENDPOINT}/collections/diff`, {
-                'collectionID': collection.id, sinceTime: time, token, limit,
-            });
-            const promises: Promise<file>[] = resp.data.diff.map(
-                async (file: file) => {
-                    file.key = await worker.decrypt(
-                        await worker.fromB64(file.encryptedKey),
-                        await worker.fromB64(file.keyDecryptionNonce),
-                        collection.key);
-                    file.metadata = await worker.decryptMetadata(file);
-                    return file;
-                });
-            files.push(...await Promise.all(promises));
-            files = files.sort((a, b) => b.metadata.creationTime - a.metadata.creationTime);
-            if (resp.data.diff.length) {
-                time = (resp.data.diff.slice(-1)[0].updationTime).toString();
-            }
-        } while (resp.data.diff.length);
-        await localForage.setItem(`${collection.id}-time`, time);
+    });
+    files = [];
+    for (const [_, file] of latestFiles.entries()) {
+        if (!file.isDeleted)
+            files.push(file);
     }
-    files = files.filter(item => !item.isDeleted)
+    files = files.sort(
+        (a, b) => b.metadata.creationTime - a.metadata.creationTime
+    );
     await localForage.setItem('files', files);
     return files;
-}
+};
 
-export const getPreview = async (token: string, file: file) => {
-    const cache = await caches.open('thumbs');
-    const cacheResp: Response = await cache.match(file.id.toString());
-    if (cacheResp) {
-        return URL.createObjectURL(await cacheResp.blob());
-    }
-    const resp = await HTTPService.get(
-        `${ENDPOINT}/files/preview/${file.id}`,
-        { token }, null, { responseType: 'arraybuffer' },
-    );
-    const worker = await new CryptoWorker();
-    const decrypted: any = await worker.decryptThumbnail(
-        new Uint8Array(resp.data),
-        await worker.fromB64(file.thumbnail.decryptionHeader),
-        file.key);
+export const getFiles = async (collections: collection[], sinceTime: string, limit: string, token: string): Promise<file[]> => {
     try {
-        await cache.put(file.id.toString(), new Response(new Blob([decrypted])));
+        const worker = await new CryptoWorker();
+        let promises: Promise<file>[] = [];
+        for (const index in collections) {
+            const collection = collections[index];
+            if (collection.isDeleted) {
+                // TODO: Remove files in this collection from localForage and cache
+                continue;
+            }
+            let time =
+                sinceTime || (await localForage.getItem<string>(`${collection.id}-time`)) || "0";
+            let resp;
+            do {
+                resp = await HTTPService.get(`${ENDPOINT}/collections/diff`, {
+                    collectionID: collection.id,
+                    sinceTime: time,
+                    limit,
+                },
+                    {
+                        'X-Auth-Token': token
+                    });
+                promises.push(...resp.data.diff.map(
+                    async (file: file) => {
+                        if (!file.isDeleted) {
+
+                            file.key = await worker.decryptB64(
+                                file.encryptedKey,
+                                file.keyDecryptionNonce,
+                                collection.key
+                            );
+                            file.metadata = await worker.decryptMetadata(file);
+                        }
+                        return file;
+                    }
+                ));
+
+                if (resp.data.diff.length) {
+                    time = resp.data.diff.slice(-1)[0].updationTime.toString();
+                }
+            } while (resp.data.diff.length);
+            await localForage.setItem(`${collection.id}-time`, time);
+        }
+        return Promise.all(promises);
     } catch (e) {
-        // TODO: handle storage full exception.
+        console.log("Get files failed" + e);
     }
-    return URL.createObjectURL(new Blob([decrypted]));
 }
+export const getPreview = async (token: string, file: file) => {
+    try {
+        const cache = await caches.open('thumbs');
+        const cacheResp: Response = await cache.match(file.id.toString());
+        if (cacheResp) {
+            return URL.createObjectURL(await cacheResp.blob());
+        }
+        const resp = await HTTPService.get(
+            `${ENDPOINT}/files/preview/${file.id}`,
+            null,
+            { 'X-Auth-Token': token },
+            { responseType: 'arraybuffer' }
+        );
+        const worker = await new CryptoWorker();
+        const decrypted: any = await worker.decryptThumbnail(
+            new Uint8Array(resp.data),
+            await worker.fromB64(file.thumbnail.decryptionHeader),
+            file.key
+        );
+        try {
+            await cache.put(file.id.toString(), new Response(new Blob([decrypted])));
+        } catch (e) {
+            // TODO: handle storage full exception.
+        }
+        return URL.createObjectURL(new Blob([decrypted]));
+    } catch (e) {
+        console.log("get preview Failed" + e);
+    }
+};
 
 export const getFile = async (token: string, file: file) => {
-    const resp = await HTTPService.get(
-        `${ENDPOINT}/files/download/${file.id}`,
-        { token }, null, { responseType: 'arraybuffer' },
-    );
-    const worker = await new CryptoWorker();
-    const decrypted: any = await worker.decryptFile(
-        new Uint8Array(resp.data),
-        await worker.fromB64(file.file.decryptionHeader),
-        file.key);
-    return URL.createObjectURL(new Blob([decrypted]));
-}
+    try {
+        const resp = await HTTPService.get(
+            `${ENDPOINT}/files/download/${file.id}`,
+            null,
+            { 'X-Auth-Token': token },
+            { responseType: 'arraybuffer' }
+        );
+        const worker = await new CryptoWorker();
+        const decrypted: any = await worker.decryptFile(
+            new Uint8Array(resp.data),
+            await worker.fromB64(file.file.decryptionHeader),
+            file.key
+        );
+        return URL.createObjectURL(new Blob([decrypted]));
+    }
+    catch (e) {
+        console.log("get file failed " + e);
+    }
+};
+

+ 495 - 0
src/services/uploadService.ts

@@ -0,0 +1,495 @@
+import { getEndpoint } from 'utils/common/apiUtil';
+import HTTPService from './HTTPService';
+import * as Comlink from 'comlink';
+import EXIF from "exif-js";
+import { fileAttribute } from './fileService';
+import { collection, collectionLatestFile } from "./collectionService"
+import { FILE_TYPE } from 'pages/gallery';
+const CryptoWorker: any =
+    typeof window !== 'undefined' &&
+    Comlink.wrap(new Worker('worker/crypto.worker.js', { type: 'module' }));
+const ENDPOINT = getEndpoint();
+
+
+interface encryptionResult {
+    file: fileAttribute,
+    key: string
+}
+export interface keyEncryptionResult {
+    encryptedData: string,
+    key: string,
+    nonce: string,
+}
+
+interface uploadURL {
+    url: string,
+    objectKey: string
+}
+
+interface FileinMemory {
+    filedata: Uint8Array,
+    thumbnail: Uint8Array,
+    filename: string
+}
+
+
+interface encryptedFile {
+    filedata: fileAttribute;
+    thumbnail: fileAttribute;
+    fileKey: keyEncryptionResult;
+}
+
+interface objectKey {
+    objectKey: string,
+    decryptionHeader: string
+}
+interface objectKeys {
+    file: objectKey
+    thumbnail: objectKey
+}
+
+interface uploadFile extends objectKeys {
+    collectionID: string,
+    encryptedKey: string;
+    keyDecryptionNonce: string;
+    metadata?: {
+        encryptedData: string | Uint8Array,
+        decryptionHeader: string
+    }
+}
+
+interface UploadFileWithoutMetaData {
+    tempUploadFile: uploadFile,
+    encryptedFileKey: keyEncryptionResult,
+    fileName: string
+}
+
+export enum UPLOAD_STAGES {
+    START,
+    ENCRYPTION,
+    UPLOAD,
+    FINISH
+}
+
+class UploadService {
+
+    private uploadURLs: uploadURL[];
+    private uploadURLFetchInProgress: Promise<any>;
+    private perStepProgress: number
+    private stepsCompleted: number
+    private totalFilesCount: number
+    private metadataMap: Map<string, Object>;
+
+    public async uploadFiles(recievedFiles: File[], collectionLatestFile: collectionLatestFile, token: string, progressBarProps) {
+        try {
+            const worker = await new CryptoWorker();
+            this.stepsCompleted = 0;
+            this.metadataMap = new Map<string, object>();
+            this.uploadURLs = [];
+            this.uploadURLFetchInProgress = null;
+
+            let metadataFiles: File[] = [];
+            let actualFiles: File[] = [];
+            recievedFiles.forEach(file => {
+                if (file.type.substr(0, 5) === "image" || file.type.substr(0, 5) === "video")
+                    actualFiles.push(file);
+                if (file.name.slice(-4) == "json")
+                    metadataFiles.push(file);
+            });
+            this.totalFilesCount = actualFiles.length;
+            this.perStepProgress = 100 / (3 * actualFiles.length);
+
+            progressBarProps.setUploadStage(UPLOAD_STAGES.START);
+            this.changeProgressBarProps(progressBarProps);
+
+            const uploadFilesWithoutMetaData: UploadFileWithoutMetaData[] = [];
+
+            while (actualFiles.length > 0) {
+                var promises = [];
+                for (var i = 0; i < 5 && actualFiles.length > 0; i++)
+                    promises.push(this.uploadHelper(progressBarProps, actualFiles.pop(), collectionLatestFile.collection, token));
+                uploadFilesWithoutMetaData.push(...await Promise.all(promises));
+            }
+
+            for await (const rawFile of metadataFiles) {
+                await this.updateMetadata(rawFile)
+            };
+
+            progressBarProps.setUploadStage(UPLOAD_STAGES.ENCRYPTION);
+            const completeUploadFiles: uploadFile[] = await Promise.all(uploadFilesWithoutMetaData.map(async (file: UploadFileWithoutMetaData) => {
+                const { file: encryptedMetaData } = await this.encryptMetadata(worker, file.fileName, file.encryptedFileKey);
+                const completeUploadFile = {
+                    ...file.tempUploadFile,
+                    metadata: {
+                        encryptedData: encryptedMetaData.encryptedData,
+                        decryptionHeader: encryptedMetaData.decryptionHeader
+                    }
+                }
+                this.changeProgressBarProps(progressBarProps);
+                return completeUploadFile;
+            }));
+
+            progressBarProps.setUploadStage(UPLOAD_STAGES.UPLOAD);
+            await Promise.all(completeUploadFiles.map(async (uploadFile: uploadFile) => {
+
+                await this.uploadFile(uploadFile, token);
+                this.changeProgressBarProps(progressBarProps);
+            }));
+
+            progressBarProps.setUploadStage(UPLOAD_STAGES.FINISH);
+            progressBarProps.setPercentComplete(100);
+
+        } catch (e) {
+            console.log(e);
+        }
+    }
+    private async uploadHelper(progressBarProps, rawFile, collection, token) {
+        try {
+            const worker = await new CryptoWorker();
+            let file: FileinMemory = await this.readFile(rawFile);
+            let encryptedFile: encryptedFile = await this.encryptFile(worker, file, collection.key);
+            let objectKeys = await this.uploadtoBucket(encryptedFile, token, 2 * this.totalFilesCount);
+            let uploadFileWithoutMetaData: uploadFile = this.getuploadFile(collection, encryptedFile.fileKey, objectKeys);
+            this.changeProgressBarProps(progressBarProps);
+
+            return {
+                tempUploadFile: uploadFileWithoutMetaData,
+                encryptedFileKey: encryptedFile.fileKey,
+                fileName: file.filename
+            };
+        }
+        catch (e) {
+            console.log(e);
+        }
+    }
+
+    private changeProgressBarProps({ setPercentComplete, setFileCounter }) {
+        this.stepsCompleted++;
+        const fileCompleted = this.stepsCompleted % this.totalFilesCount;
+        setFileCounter({ current: fileCompleted, total: this.totalFilesCount });
+        setPercentComplete(this.perStepProgress * this.stepsCompleted);
+    }
+
+    private async readFile(recievedFile: File) {
+        try {
+            const filedata: Uint8Array = await this.getUint8ArrayView(recievedFile);
+            let fileType;
+            switch (recievedFile.type.split('/')[0]) {
+                case "image":
+                    fileType = FILE_TYPE.IMAGE;
+                    break;
+                case "video":
+                    fileType = FILE_TYPE.VIDEO;
+                    break;
+                default:
+                    fileType = FILE_TYPE.OTHERS;
+            }
+
+            const { location, creationTime } = await this.getExifData(recievedFile);
+            this.metadataMap.set(recievedFile.name, {
+                title: recievedFile.name,
+                creationTime: creationTime || (recievedFile.lastModified) * 1000,
+                modificationTime: (recievedFile.lastModified) * 1000,
+                latitude: location?.latitude,
+                longitude: location?.latitude,
+                fileType,
+            });
+            return {
+                filedata,
+                filename: recievedFile.name,
+                thumbnail: await this.generateThumbnail(recievedFile)
+            }
+        } catch (e) {
+            console.log("error reading files " + e);
+        }
+    }
+    private async encryptFile(worker, file: FileinMemory, encryptionKey: string): Promise<encryptedFile> {
+        try {
+
+            const { key: fileKey, file: encryptedFiledata }: encryptionResult = await worker.encryptFile(file.filedata);
+
+            const { file: encryptedThumbnail }: encryptionResult = await worker.encryptThumbnail(file.thumbnail, fileKey);
+
+            const encryptedKey: keyEncryptionResult = await worker.encryptToB64(fileKey, encryptionKey);
+
+
+            const result: encryptedFile = {
+                filedata: encryptedFiledata,
+                thumbnail: encryptedThumbnail,
+                fileKey: encryptedKey
+            };
+            return result;
+        }
+        catch (e) {
+            console.log("Error encrypting files " + e);
+        }
+    }
+
+    private async encryptMetadata(worker: any, fileName: string, encryptedFileKey: keyEncryptionResult) {
+        const metaData = this.metadataMap.get(fileName);
+        const fileKey = await worker.decryptB64(encryptedFileKey.encryptedData, encryptedFileKey.nonce, encryptedFileKey.key);
+        const encryptedMetaData = await worker.encryptMetadata(metaData, fileKey);
+        return encryptedMetaData;
+    }
+
+    private async uploadtoBucket(file: encryptedFile, token, count: number): Promise<objectKeys> {
+        try {
+            const fileUploadURL = await this.getUploadURL(token, count);
+            const fileObjectKey = await this.putFile(fileUploadURL, file.filedata.encryptedData)
+
+            const thumbnailUploadURL = await this.getUploadURL(token, count);
+            const thumbnailObjectKey = await this.putFile(thumbnailUploadURL, file.thumbnail.encryptedData)
+
+            return {
+                file: { objectKey: fileObjectKey, decryptionHeader: file.filedata.decryptionHeader },
+                thumbnail: { objectKey: thumbnailObjectKey, decryptionHeader: file.thumbnail.decryptionHeader }
+            };
+        } catch (e) {
+            console.log("error uploading to bucket " + e);
+        }
+    }
+
+    private getuploadFile(collection: collection, encryptedKey: keyEncryptionResult, objectKeys: objectKeys): uploadFile {
+        const uploadFile: uploadFile = {
+            collectionID: collection.id,
+            encryptedKey: encryptedKey.encryptedData,
+            keyDecryptionNonce: encryptedKey.nonce,
+            ...objectKeys
+        }
+        return uploadFile;
+    }
+
+    private async uploadFile(uploadFile: uploadFile, token) {
+        try {
+            const response = await HTTPService.post(`${ENDPOINT}/files`, uploadFile, null, { 'X-Auth-Token': token });
+
+            return response.data;
+        } catch (e) {
+            console.log("upload Files Failed " + e);
+        }
+    }
+
+    private async updateMetadata(recievedFile: File) {
+        try {
+            const metadataJSON: object = await new Promise((resolve, reject) => {
+                const reader = new FileReader()
+                reader.onload = () => {
+                    var result = typeof reader.result !== "string" ? new TextDecoder().decode(reader.result) : reader.result
+                    resolve(JSON.parse(result));
+                }
+                reader.readAsText(recievedFile)
+            });
+            if (!this.metadataMap.has(metadataJSON['title']))
+                return;
+
+            const metaDataObject = this.metadataMap.get(metadataJSON['title']);
+            metaDataObject['creationTime'] = metadataJSON['photoTakenTime']['timestamp'] * 1000000;
+            metaDataObject['modificationTime'] = metadataJSON['modificationTime']['timestamp'] * 1000000;
+
+            if (metaDataObject['latitude'] == null || (metaDataObject['latitude'] == 0.0 && metaDataObject['longitude'] == 0.0)) {
+                var locationData = null;
+                if (metadataJSON['geoData']['latitude'] != 0.0 || metadataJSON['geoData']['longitude'] != 0.0) {
+                    locationData = metadataJSON['geoData'];
+                }
+                else if (metadataJSON['geoDataExif']['latitude'] != 0.0 || metadataJSON['geoDataExif']['longitude'] != 0.0) {
+                    locationData = metadataJSON['geoDataExif'];
+                }
+                if (locationData != null) {
+                    metaDataObject['latitude'] = locationData['latitide'];
+                    metaDataObject['longitude'] = locationData['longitude'];
+                }
+            }
+        } catch (e) {
+            console.log("error reading metaData Files " + e);
+
+        }
+    }
+    private async generateThumbnail(file: File): Promise<Uint8Array> {
+        try {
+            let canvas = document.createElement("canvas");
+            let canvas_CTX = canvas.getContext("2d");
+            let imageURL = null;
+            if (file.type.match("image")) {
+                let image = new Image();
+                imageURL = URL.createObjectURL(file);
+                image.setAttribute("src", imageURL);
+                await new Promise((resolve) => {
+                    image.onload = () => {
+                        canvas.width = image.width;
+                        canvas.height = image.height;
+                        canvas_CTX.drawImage(image, 0, 0, image.width, image.height);
+                        image = undefined;
+                        resolve(null);
+                    }
+                });
+
+            }
+            else {
+                await new Promise(async (resolve) => {
+                    let video = document.createElement('video');
+                    imageURL = URL.createObjectURL(file);
+                    var timeupdate = function () {
+                        if (snapImage()) {
+                            video.removeEventListener('timeupdate', timeupdate);
+                            video.pause();
+                            resolve(null);
+                        }
+                    };
+                    video.addEventListener('loadeddata', function () {
+                        if (snapImage()) {
+                            video.removeEventListener('timeupdate', timeupdate);
+                            resolve(null);
+                        }
+                    });
+                    var snapImage = function () {
+                        canvas.width = video.videoWidth;
+                        canvas.height = video.videoHeight;
+                        canvas_CTX.drawImage(video, 0, 0, canvas.width, canvas.height);
+                        var image = canvas.toDataURL();
+                        var success = image.length > 100000;
+                        return success;
+                    };
+                    video.addEventListener('timeupdate', timeupdate);
+                    video.preload = 'metadata';
+                    video.src = imageURL;
+                    // Load video in Safari / IE11
+                    video.muted = true;
+                    video.playsInline = true;
+                    video.play();
+                });
+            }
+            URL.revokeObjectURL(imageURL);
+            var thumbnailBlob = await new Promise(resolve => {
+                canvas.toBlob(function (blob) {
+                    resolve(blob);
+                }), 'image/jpeg', 0.4
+            });
+            console.log(URL.createObjectURL(thumbnailBlob));
+            const thumbnail = this.getUint8ArrayView(thumbnailBlob);
+            return thumbnail;
+        } catch (e) {
+            console.log("Error generatin thumbnail " + e);
+        }
+    }
+
+    private async getUint8ArrayView(file): Promise<Uint8Array> {
+        try {
+            return await new Promise((resolve, reject) => {
+                const reader = new FileReader()
+
+                reader.onabort = () => reject('file reading was aborted')
+                reader.onerror = () => reject('file reading has failed')
+                reader.onload = () => {
+                    // Do whatever you want with the file contents
+                    const result = typeof reader.result === "string" ? new TextEncoder().encode(reader.result) : new Uint8Array(reader.result);
+                    resolve(result);
+                }
+                reader.readAsArrayBuffer(file)
+            });
+        } catch (e) {
+            console.log("error readinf file to bytearray " + e);
+            throw e;
+        }
+    }
+
+    private async getUploadURL(token: string, count: number) {
+        if (this.uploadURLs.length == 0) {
+            await this.fetchUploadURLs(token, count);
+        }
+        return this.uploadURLs.pop();
+    }
+
+    private async fetchUploadURLs(token: string, count: number): Promise<void> {
+        try {
+            if (!this.uploadURLFetchInProgress) {
+                this.uploadURLFetchInProgress = HTTPService.get(`${ENDPOINT}/files/upload-urls`,
+                    {
+                        count: Math.min(50, count).toString()  //m4gic number
+                    }, { 'X-Auth-Token': token })
+                const response = await this.uploadURLFetchInProgress;
+
+                this.uploadURLFetchInProgress = null;
+                this.uploadURLs.push(...response.data["urls"]);
+            }
+            return this.uploadURLFetchInProgress;
+        } catch (e) {
+            console.log("fetch upload-url failed " + e);
+            throw e;
+        }
+    }
+
+    private async putFile(fileUploadURL: uploadURL, file: Uint8Array | string): Promise<string> {
+        try {
+            const fileSize = file.length.toString();
+            await HTTPService.put(fileUploadURL.url, file, null, { contentLengthHeader: fileSize })
+            return fileUploadURL.objectKey;
+        } catch (e) {
+            console.log('putFile to dataStore failed ' + e);
+            throw e;
+        }
+    }
+
+    private async getExifData(recievedFile) {
+        try {
+            const exifData: any = await new Promise((resolve, reject) => {
+                const reader = new FileReader()
+                reader.onload = () => {
+                    resolve(EXIF.readFromBinaryFile(reader.result));
+                }
+                reader.readAsArrayBuffer(recievedFile)
+            });
+            if (!exifData)
+                return { location: null, creationTime: null };
+            return {
+                location: this.getLocation(exifData),
+                creationTime: this.getUNIXTime(exifData)
+            };
+        } catch (e) {
+            console.log("error reading exif data");
+        }
+    }
+    private getUNIXTime(exifData: any) {
+        if (!exifData.DateTimeOriginal)
+            return null;
+        let dateString: string = exifData.DateTimeOriginal;
+        var parts = dateString.split(' ')[0].split(":");
+        var date = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
+        return date.getTime() * 1000;
+    }
+
+    private getLocation(exifData) {
+
+        if (!exifData.GPSLatitude)
+            return null;
+        var latDegree = exifData.GPSLatitude[0].numerator;
+        var latMinute = exifData.GPSLatitude[1].numerator;
+        var latSecond = exifData.GPSLatitude[2].numerator;
+        var latDirection = exifData.GPSLatitudeRef;
+
+        var latFinal = this.convertDMSToDD(latDegree, latMinute, latSecond, latDirection);
+
+        // Calculate longitude decimal
+        var lonDegree = exifData.GPSLongitude[0].numerator;
+        var lonMinute = exifData.GPSLongitude[1].numerator;
+        var lonSecond = exifData.GPSLongitude[2].numerator;
+        var lonDirection = exifData.GPSLongitudeRef;
+
+        var lonFinal = this.convertDMSToDD(lonDegree, lonMinute, lonSecond, lonDirection);
+
+        return { latitude: latFinal * 1.0, longitude: lonFinal * 1.0 };
+    }
+
+    private convertDMSToDD(degrees, minutes, seconds, direction) {
+
+        var dd = degrees + (minutes / 60) + (seconds / 3600);
+
+        if (direction == "S" || direction == "W") {
+            dd = dd * -1;
+        }
+
+        return dd;
+    }
+}
+
+export default new UploadService();
+

+ 3 - 1
src/utils/common/apiUtil.ts

@@ -1,3 +1,5 @@
 export const getEndpoint = () => {
-    return process.env.NEXT_PUBLIC_ENTE_ENDPOINT || "https://api.ente.io";
+    const endPoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT ?? "https://api.ente.io";
+    console.log(endPoint);
+    return endPoint;
 }

+ 9 - 3
src/utils/common/key.ts

@@ -6,9 +6,15 @@ const CryptoWorker: any = typeof window !== 'undefined'
     && Comlink.wrap(new Worker("worker/crypto.worker.js", { type: 'module' }));
 
 export const getActualKey = async () => {
-    const cryptoWorker = await new CryptoWorker();
-    const encryptedKey = getKey(SESSION_KEYS.ENCRYPTION_KEY).encryptionKey;
     const session = getData(LS_KEYS.SESSION);
-    const key = await cryptoWorker.decryptToB64(encryptedKey, session.sessionNonce, session.sessionKey);
+    if (session == null)
+        return;
+    const cryptoWorker = await new CryptoWorker();
+    const encryptedKey = getKey(SESSION_KEYS.ENCRYPTION_KEY)?.encryptionKey;
+    const key: string = await cryptoWorker.decryptB64(encryptedKey, session.sessionNonce, session.sessionKey);
     return key;
 }
+
+export const getToken = () => {
+    return getData(LS_KEYS.USER)?.token;
+}

+ 103 - 37
src/utils/crypto/libsodium.ts

@@ -2,16 +2,16 @@ import sodium from 'libsodium-wrappers';
 
 const encryptionChunkSize = 4 * 1024 * 1024;
 
-export async function decryptChaChaOneShot(data: Uint8Array, header: Uint8Array, key: Uint8Array) {
+export async function decryptChaChaOneShot(data: Uint8Array, header: Uint8Array, key: string) {
     await sodium.ready;
-    const pullState = sodium.crypto_secretstream_xchacha20poly1305_init_pull(header, key);
+    const pullState = sodium.crypto_secretstream_xchacha20poly1305_init_pull(header, await fromB64(key));
     const pullResult = sodium.crypto_secretstream_xchacha20poly1305_pull(pullState, data, null);
     return pullResult.message;
 }
 
-export async function decryptChaCha(data: Uint8Array, header: Uint8Array, key: Uint8Array) {
+export async function decryptChaCha(data: Uint8Array, header: Uint8Array, key: string) {
     await sodium.ready;
-    const pullState = sodium.crypto_secretstream_xchacha20poly1305_init_pull(header, key);
+    const pullState = sodium.crypto_secretstream_xchacha20poly1305_init_pull(header, await fromB64(key));
     const decryptionChunkSize =
         encryptionChunkSize + sodium.crypto_secretstream_xchacha20poly1305_ABYTES;
     var bytesRead = 0;
@@ -33,41 +33,97 @@ export async function decryptChaCha(data: Uint8Array, header: Uint8Array, key: U
     return Uint8Array.from(decryptedData);
 }
 
-export async function encryptToB64(data: string, key: string) {
+export async function encryptChaChaOneShot(data: Uint8Array, key?: string) {
     await sodium.ready;
-    var bKey: Uint8Array;
-    if (key == null) {
-        bKey = sodium.crypto_secretbox_keygen();
-    } else {
-        bKey = await fromB64(key)
+
+    const uintkey: Uint8Array = key ? await fromB64(key) : sodium.crypto_secretstream_xchacha20poly1305_keygen();
+    let initPushResult = sodium.crypto_secretstream_xchacha20poly1305_init_push(uintkey);
+    let [pushState, header] = [initPushResult.state, initPushResult.header];
+
+    const pushResult = sodium.crypto_secretstream_xchacha20poly1305_push(pushState, data, null, sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL);
+    return {
+        key: await toB64(uintkey), file: {
+            encryptedData: pushResult,
+            decryptionHeader: await toB64(header),
+            creationTime: Date.now(),
+            fileType: 0
+        }
+    }
+}
+
+export async function encryptChaCha(data: Uint8Array, key?: string) {
+    await sodium.ready;
+
+    const uintkey: Uint8Array = key ? await fromB64(key) : sodium.crypto_secretstream_xchacha20poly1305_keygen();
+
+    let initPushResult = sodium.crypto_secretstream_xchacha20poly1305_init_push(uintkey);
+    let [pushState, header] = [initPushResult.state, initPushResult.header];
+    let bytesRead = 0;
+    let tag = sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
+
+    let encryptedData = [];
+
+    while (tag !== sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL) {
+        let chunkSize = encryptionChunkSize;
+        if (bytesRead + chunkSize >= data.length) {
+            chunkSize = data.length - bytesRead;
+            tag = sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL;
+        }
+
+        const buffer = data.slice(bytesRead, bytesRead + chunkSize);
+        bytesRead += chunkSize;
+        const pushResult = sodium.crypto_secretstream_xchacha20poly1305_push(pushState, buffer, null, tag);
+        for (var index = 0; index < pushResult.length; index++) {
+            encryptedData.push(pushResult[index]);
+        }
     }
-    const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
-    const encryptedData = sodium.crypto_secretbox_easy(data, nonce, bKey);
     return {
-        encryptedData: await toB64(encryptedData),
-        key: await toB64(bKey),
-        nonce: await toB64(nonce),
+        key: await toB64(uintkey), file: {
+            encryptedData: new Uint8Array(encryptedData),
+            decryptionHeader: await toB64(header),
+            creationTime: Date.now(),
+            fileType: 0
+        }
+    }
+}
+
+export async function encryptToB64(data: string, key?: string) {
+    await sodium.ready;
+    const encrypted = await encrypt(await fromB64(data), (key ? await fromB64(key) : null));
+
+    return {
+        encryptedData: await toB64(encrypted.encryptedData),
+        key: await toB64(encrypted.key),
+        nonce: await toB64(encrypted.nonce),
     }
 }
 
-export async function decryptToB64(data: string, nonce: string, key: string) {
+export async function decryptB64(data: string, nonce: string, key: string) {
     await sodium.ready;
     const decrypted = await decrypt(await fromB64(data),
         await fromB64(nonce),
-        await fromB64(key))
+        await fromB64(key));
+
     return await toB64(decrypted);
 }
 
+export async function decryptString(data: string, nonce: string, key: string) {
+    await sodium.ready;
+    const decrypted = await decrypt(await fromB64(data),
+        await fromB64(nonce),
+        await fromB64(key));
+
+    return sodium.to_string(decrypted);
+}
+
 export async function encrypt(data: Uint8Array, key?: Uint8Array) {
     await sodium.ready;
-    if (key == null) {
-        key = sodium.crypto_secretbox_keygen();
-    }
+    const uintkey: Uint8Array = key ? key : sodium.crypto_secretbox_keygen();
     const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
-    const encryptedData = sodium.crypto_secretbox_easy(data, nonce, key);
+    const encryptedData = sodium.crypto_secretbox_easy(data, nonce, uintkey);
     return {
         encryptedData: encryptedData,
-        key: key,
+        key: uintkey,
         nonce: nonce,
     }
 }
@@ -77,55 +133,65 @@ export async function decrypt(data: Uint8Array, nonce: Uint8Array, key: Uint8Arr
     return sodium.crypto_secretbox_open_easy(data, nonce, key);
 }
 
-export async function verifyHash(hash: string, input: Uint8Array) {
+export async function verifyHash(hash: string, input: string) {
     await sodium.ready;
-    return sodium.crypto_pwhash_str_verify(hash, input);
+    return sodium.crypto_pwhash_str_verify(hash, await fromB64(input));
 }
 
-export async function hash(input: string | Uint8Array) {
+export async function hash(input: string) {
     await sodium.ready;
     return sodium.crypto_pwhash_str(
-        input,
+        await fromB64(input),
         sodium.crypto_pwhash_OPSLIMIT_SENSITIVE,
         sodium.crypto_pwhash_MEMLIMIT_MODERATE,
     );
 }
 
-export async function deriveKey(passphrase: Uint8Array, salt: Uint8Array) {
+export async function deriveKey(passphrase: string, salt: string) {
     await sodium.ready;
-    return sodium.crypto_pwhash(
+    return await toB64(sodium.crypto_pwhash(
         sodium.crypto_secretbox_KEYBYTES,
-        passphrase,
-        salt,
+        await fromString(passphrase),
+        await fromB64(salt),
         sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
         sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
         sodium.crypto_pwhash_ALG_DEFAULT,
-    );
+    ));
 }
 
 export async function generateMasterKey() {
     await sodium.ready;
-    return sodium.crypto_kdf_keygen();
+    return await toB64(sodium.crypto_kdf_keygen());
 }
 
 export async function generateSaltToDeriveKey() {
     await sodium.ready;
-    return sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
+    return await toB64(sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES));
 }
 
 export async function generateKeyPair() {
     await sodium.ready;
-    return sodium.crypto_box_keypair();
+    const keyPair: sodium.KeyPair = sodium.crypto_box_keypair();
+    return { privateKey: await toB64(keyPair.privateKey), publicKey: await toB64(keyPair.publicKey) }
 }
 
-export async function boxSealOpen(input: Uint8Array, publicKey: Uint8Array, secretKey: Uint8Array) {
+export async function boxSealOpen(input: string, publicKey: string, secretKey: string) {
     await sodium.ready;
-    return sodium.crypto_box_seal_open(input, publicKey, secretKey);
+    return await toB64(sodium.crypto_box_seal_open(await fromB64(input), await fromB64(publicKey), await fromB64(secretKey)));
 }
 
 export async function fromB64(input: string) {
     await sodium.ready;
-    return sodium.from_base64(input, sodium.base64_variants.ORIGINAL);
+    let result;
+    try {
+        result = sodium.from_base64(input, sodium.base64_variants.ORIGINAL);
+    } catch (e) {
+        result = await fromB64(await toB64(await fromString(input)));
+
+    }
+    finally {
+        return result;
+    }
 }
 
 export async function toB64(input: Uint8Array) {

+ 11 - 1
src/utils/strings/englishConstants.tsx

@@ -41,7 +41,17 @@ const englishConstants = {
     PASSPHRASE_CONFIRM: 'Please repeat it once more',
     PASSPHRASE_MATCH_ERROR: `Passphrase didn't match`,
     CONSOLE_WARNING_STOP: 'STOP!',
-    CONSOLE_WARNING_DESC: `This is a browser feature intended for developers. If someone told you to copy-paste something here to enable a feature or "hack" someone's account, it is a scam and will give them access to your account.`
+    CONSOLE_WARNING_DESC: `This is a browser feature intended for developers. If someone told you to copy-paste something here to enable a feature or "hack" someone's account, it is a scam and will give them access to your account.`,
+    SELECT_COLLECTION: `Select/Click on Collection to upload`,
+    CLOSE: 'Close',
+    NOTHING_HERE: `nothing to see here! 👀`,
+    UPLOAD: {
+        0: "Preparing to upload",
+        1: "Encryting your files",
+        2: "Uploading your Files",
+        3: "Files Uploaded Successfully !!!"
+    },
+    OF: 'of'
 };
 
 export default englishConstants;

+ 36 - 2
src/worker/crypto.worker.js

@@ -24,6 +24,32 @@ export class Crypto {
             key);
     }
 
+    async encryptMetadata(metadata, key) {
+        const encodedMetadata = new TextEncoder().encode(JSON.stringify(metadata));
+
+        const { file: encryptedMetadata } = await libsodium.encryptChaChaOneShot(
+            encodedMetadata,
+            key);
+        const { encryptedData, ...other } = encryptedMetadata
+        return {
+            file: {
+                encryptedData: await libsodium.toB64(encryptedData),
+                ...other
+            },
+            key
+        };
+    }
+
+    async encryptThumbnail(fileData, key) {
+        return libsodium.encryptChaChaOneShot(
+            fileData,
+            key);
+    }
+
+    async encryptFile(fileData, key) {
+        return libsodium.encryptChaCha(fileData, key);
+    }
+
     async encrypt(data, key) {
         return libsodium.encrypt(data, key);
     }
@@ -44,8 +70,16 @@ export class Crypto {
         return libsodium.deriveKey(passphrase, salt);
     }
 
-    async decryptToB64(encryptedKey, sessionNonce, sessionKey) {
-        return libsodium.decryptToB64(encryptedKey, sessionNonce, sessionKey)
+    async decryptB64(data, nonce, key) {
+        return libsodium.decryptB64(data, nonce, key)
+    }
+
+    async decryptString(data, nonce, key) {
+        return libsodium.decryptString(data, nonce, key)
+    }
+
+    async encryptToB64(data, key) {
+        return libsodium.encryptToB64(data, key);
     }
 
     async generateMasterKey() {

+ 31 - 30
tsconfig.json

@@ -1,32 +1,33 @@
 {
-  "compilerOptions": {
-    "target": "es5",
-    "lib": [
-      "dom",
-      "dom.iterable",
-      "esnext",
-      "webworker"
+    "compilerOptions": {
+        "target": "es5",
+        "lib": [
+            "dom",
+            "dom.iterable",
+            "esnext",
+            "webworker"
+        ],
+        "allowJs": true,
+        "skipLibCheck": true,
+        "strict": false,
+        "forceConsistentCasingInFileNames": true,
+        "noEmit": true,
+        "esModuleInterop": true,
+        "module": "esnext",
+        "moduleResolution": "node",
+        "resolveJsonModule": true,
+        "isolatedModules": true,
+        "jsx": "preserve",
+        "baseUrl": "./src",
+        "downlevelIteration": true
+    },
+    "include": [
+        "next-env.d.ts",
+        "**/*.ts",
+        "**/*.tsx",
+        "src/pages/index.tsx"
     ],
-    "allowJs": true,
-    "skipLibCheck": true,
-    "strict": false,
-    "forceConsistentCasingInFileNames": true,
-    "noEmit": true,
-    "esModuleInterop": true,
-    "module": "esnext",
-    "moduleResolution": "node",
-    "resolveJsonModule": true,
-    "isolatedModules": true,
-    "jsx": "preserve",
-    "baseUrl": "./src",
-    "downlevelIteration": true
-  },
-  "include": [
-    "next-env.d.ts",
-    "**/*.ts",
-    "**/*.tsx", "src/pages/index.tsx"
-  ],
-  "exclude": [
-    "node_modules"
-  ]
-}
+    "exclude": [
+        "node_modules"
+    ]
+}

+ 35 - 4
yarn.lock

@@ -1673,6 +1673,11 @@ atob@^2.1.2:
   resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
   integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
 
+attr-accept@^2.2.1:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.2.tgz#646613809660110749e92f2c10833b70968d929b"
+  integrity sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==
+
 axios@^0.20.0:
   version "0.20.0"
   resolved "https://registry.yarnpkg.com/axios/-/axios-0.20.0.tgz#057ba30f04884694993a8cd07fa394cff11c50bd"
@@ -2848,6 +2853,11 @@ execa@^4.0.2:
     signal-exit "^3.0.2"
     strip-final-newline "^2.0.0"
 
+exif-js@^2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/exif-js/-/exif-js-2.3.0.tgz#9d10819bf571f873813e7640241255ab9ce1a814"
+  integrity sha1-nRCBm/Vx+HOBPnZAJBJVq5zhqBQ=
+
 expand-brackets@^2.1.4:
   version "2.1.4"
   resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622"
@@ -2948,6 +2958,13 @@ figgy-pudding@^3.5.1:
   resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e"
   integrity sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==
 
+file-selector@^0.2.2:
+  version "0.2.4"
+  resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.2.4.tgz#7b98286f9dbb9925f420130ea5ed0a69238d4d80"
+  integrity sha512-ZDsQNbrv6qRi1YTDOEWzf5J2KjZ9KMI1Q2SGeTkCJmNNW25Jg4TW4UMcmoqcg4WrAyKRcpBXdbWRxkfrOzVRbA==
+  dependencies:
+    tslib "^2.0.3"
+
 file-uri-to-path@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
@@ -4923,6 +4940,15 @@ react-dom@16.13.1:
     prop-types "^15.6.2"
     scheduler "^0.19.1"
 
+react-dropzone@^11.2.4:
+  version "11.3.0"
+  resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-11.3.0.tgz#516561c5003e0c0f7d63bd5621f410b1b3496ab3"
+  integrity sha512-5ffIOi5Uf1X52m4fN8QdcRuAX88nQPfmx6HTTIfF9I3W9Ss1SvRDl/ruZmFf53K7+g3TSaIgVw6a9EK7XoDwHw==
+  dependencies:
+    attr-accept "^2.2.1"
+    file-selector "^0.2.2"
+    prop-types "^15.7.2"
+
 react-fast-compare@^2.0.1:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9"
@@ -5851,6 +5877,11 @@ tslib@^1.10.0, tslib@^1.9.0:
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
   integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
 
+tslib@^2.0.3:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a"
+  integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==
+
 tty-browserify@0.0.0:
   version "0.0.0"
   resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
@@ -5884,10 +5915,10 @@ typedarray@^0.0.6:
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
   integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
 
-typescript@^4.0.2:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.2.tgz#7ea7c88777c723c681e33bf7988be5d008d05ac2"
-  integrity sha512-e4ERvRV2wb+rRZ/IQeb3jm2VxBsirQLpQhdxplZ2MEzGvDkkMmPglecnNDfSUBivMjP93vRbngYYDQqQ/78bcQ==
+typescript@^4.1.3:
+  version "4.1.3"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.3.tgz#519d582bd94cba0cf8934c7d8e8467e473f53bb7"
+  integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==
 
 uncontrollable@^7.0.0:
   version "7.1.1"