diff --git a/src/components/PhotoFrame.tsx b/src/components/PhotoFrame.tsx
index 39fd968af..6a12beaf3 100644
--- a/src/components/PhotoFrame.tsx
+++ b/src/components/PhotoFrame.tsx
@@ -62,10 +62,10 @@ interface Props {
) => void;
selected: SelectedState;
isFirstLoad;
- openFileUploader;
+ openFileUploader?;
isInSearchMode: boolean;
- search: Search;
- setSearchStats: setSearchStats;
+ search?: Search;
+ setSearchStats?: setSearchStats;
deleted?: number[];
activeCollection: number;
isSharedCollection: boolean;
@@ -147,7 +147,7 @@ const PhotoFrame = ({
timeTaken: (Date.now() - startTime) / 1000,
});
}
- if (search.fileIndex || search.fileIndex === 0) {
+ if (search?.fileIndex || search?.fileIndex === 0) {
const filteredDataIdx = filteredData.findIndex(
(data) => data.dataIndex === search.fileIndex
);
@@ -186,7 +186,7 @@ const PhotoFrame = ({
return false;
}
if (
- search.date &&
+ search?.date &&
!isSameDayAnyYear(search.date)(
new Date(item.metadata.creationTime / 1000)
)
@@ -194,7 +194,7 @@ const PhotoFrame = ({
return false;
}
if (
- search.location &&
+ search?.location &&
!isInsideBox(item.metadata, search.location)
) {
return false;
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index b34d1f2c3..ea8d5b013 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -285,6 +285,13 @@ export default function Sidebar(props: Props) {
}}>
{constants.UPDATE_EMAIL}
+ {
+ router.push(PAGES.DEDUPLICATE);
+ }}>
+ {constants.DEDUPLICATE_FILES}
+
<>
+
+
+ );
+}
+
+LeftArrow.defaultProps = {
+ height: 32,
+ width: 32,
+ viewBox: '0 0 16 16',
+};
diff --git a/src/components/pages/deduplicate/SelectedDuplicatesOptions.tsx b/src/components/pages/deduplicate/SelectedDuplicatesOptions.tsx
new file mode 100644
index 000000000..956d0d088
--- /dev/null
+++ b/src/components/pages/deduplicate/SelectedDuplicatesOptions.tsx
@@ -0,0 +1,105 @@
+import { IconButton } from 'components/Container';
+import CloseIcon from 'components/icons/CloseIcon';
+import DeleteIcon from 'components/icons/DeleteIcon';
+import { SetDialogMessage } from 'components/MessageDialog';
+import Navbar from 'components/Navbar';
+import React from 'react';
+import { OverlayTrigger } from 'react-bootstrap';
+import styled from 'styled-components';
+import constants from 'utils/strings/constants';
+
+interface Props {
+ setDialogMessage: SetDialogMessage;
+ deleteFileHelper: () => void;
+ count: number;
+ clearSelection: () => void;
+ clubByTime: boolean;
+ setClubByTime: (clubByTime: boolean) => void;
+}
+
+const SelectionBar = styled(Navbar)`
+ position: fixed;
+ top: 0;
+ color: #fff;
+ z-index: 1001;
+ width: 100%;
+ padding: 0 16px;
+`;
+
+const SelectionContainer = styled.div`
+ flex: 1;
+ align-items: center;
+ display: flex;
+`;
+
+interface IconWithMessageProps {
+ children?: any;
+ message: string;
+}
+export const IconWithMessage = (props: IconWithMessageProps) => (
+ {props.message}
}>
+ {props.children}
+
+);
+
+const SelectedFileOptions = ({
+ setDialogMessage,
+ deleteFileHelper,
+ count,
+ clearSelection,
+ clubByTime,
+ setClubByTime,
+}: Props) => {
+ const trashHandler = () =>
+ setDialogMessage({
+ title: constants.CONFIRM_DELETE,
+ content: constants.TRASH_MESSAGE,
+ staticBackdrop: true,
+ proceed: {
+ action: deleteFileHelper,
+ text: constants.MOVE_TO_TRASH,
+ variant: 'danger',
+ },
+ close: { text: constants.CANCEL },
+ });
+
+ return (
+
+
+
+
+
+
+ {count} {constants.SELECTED}
+
+
+ <>
+ {
+ setClubByTime(!clubByTime);
+ }}>
+
+ {constants.CLUB_BY_CAPTURE_TIME}
+
+
+
+
+
+
+ >
+
+ );
+};
+
+export default SelectedFileOptions;
diff --git a/src/constants/pages/index.ts b/src/constants/pages/index.ts
index b79051e1d..083ed0ac3 100644
--- a/src/constants/pages/index.ts
+++ b/src/constants/pages/index.ts
@@ -13,6 +13,7 @@ export enum PAGES {
VERIFY = '/verify',
ROOT = '/',
SHARED_ALBUMS = '/shared-albums',
+ DEDUPLICATE = '/deduplicate',
}
export const getAlbumSiteHost = () =>
process.env.NODE_ENV === 'production'
diff --git a/src/pages/deduplicate/index.tsx b/src/pages/deduplicate/index.tsx
new file mode 100644
index 000000000..e4d1ae9f0
--- /dev/null
+++ b/src/pages/deduplicate/index.tsx
@@ -0,0 +1,297 @@
+import EnteSpinner from 'components/EnteSpinner';
+import LeftArrow from 'components/icons/LeftArrow';
+import { LoadingOverlay } from 'components/LoadingOverlay';
+import MessageDialog, { MessageAttributes } from 'components/MessageDialog';
+import SelectedDuplicatesOptions from 'components/pages/deduplicate/SelectedDuplicatesOptions';
+import AlertBanner from 'components/pages/gallery/AlertBanner';
+import PhotoFrame from 'components/PhotoFrame';
+import ToastNotification from 'components/ToastNotification';
+import { ALL_SECTION } from 'constants/collection';
+import { PAGES } from 'constants/pages';
+import { useRouter } from 'next/router';
+import { defaultGalleryContext, GalleryContext } from 'pages/gallery';
+import { AppContext } from 'pages/_app';
+import React, { useContext, useEffect, useRef, useState } from 'react';
+import LoadingBar from 'react-top-loading-bar';
+import { syncCollections } from 'services/collectionService';
+import deduplicationService from 'services/deduplicationService';
+import { getLocalFiles, syncFiles, trashFiles } from 'services/fileService';
+import { getLocalTrash, getTrashedFiles } from 'services/trashService';
+import { isTokenValid, logoutUser } from 'services/userService';
+import { EnteFile } from 'types/file';
+import { NotificationAttributes, SelectedState } from 'types/gallery';
+import { checkConnectivity } from 'utils/common';
+import { CustomError, ServerErrorCodes } from 'utils/error';
+import { getSelectedFiles, mergeMetadata, sortFiles } from 'utils/file';
+import { isFirstLogin, setIsFirstLogin, setJustSignedUp } from 'utils/storage';
+import { clearKeys, getKey, SESSION_KEYS } from 'utils/storage/sessionStorage';
+import constants from 'utils/strings/constants';
+
+export default function Deduplicate() {
+ const router = useRouter();
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const [localFiles, setLocalFiles] = useState(null);
+ const [duplicateFiles, setDuplicateFiles] = useState([]);
+ const [bannerMessage, setBannerMessage] = useState(
+ null
+ );
+ const [isFirstLoad, setIsFirstLoad] = useState(false);
+ const [selected, setSelected] = useState({
+ count: 0,
+ collectionID: 0,
+ });
+ const [dialogMessage, setDialogMessage] = useState();
+ const [messageDialogView, setMessageDialogView] = useState(false);
+ const [blockingLoad, setBlockingLoad] = useState(false);
+ const loadingBar = useRef(null);
+ const isLoadingBarRunning = useRef(false);
+ const syncInProgress = useRef(true);
+ const resync = useRef(false);
+ const appContext = useContext(AppContext);
+ const [clubByTime, setClubByTime] = useState(false);
+
+ const [notificationAttributes, setNotificationAttributes] =
+ useState(null);
+
+ const closeMessageDialog = () => setMessageDialogView(false);
+
+ const clearNotificationAttributes = () => setNotificationAttributes(null);
+
+ useEffect(() => {
+ const key = getKey(SESSION_KEYS.ENCRYPTION_KEY);
+ if (!key) {
+ appContext.setRedirectURL(router.asPath);
+ router.push(PAGES.ROOT);
+ return;
+ }
+ const main = async () => {
+ setIsFirstLoad(isFirstLogin());
+ setIsFirstLogin(false);
+ const localFiles = mergeMetadata(await getLocalFiles());
+ const trash = await getLocalTrash();
+ const trashedFile = getTrashedFiles(trash);
+ setLocalFiles(sortFiles([...localFiles, ...trashedFile]));
+
+ await syncWithRemote(true);
+ setIsFirstLoad(false);
+ setJustSignedUp(false);
+ };
+ main();
+ appContext.showNavBar(true);
+ }, []);
+
+ useEffect(() => setMessageDialogView(true), [dialogMessage]);
+
+ const syncWithRemote = async (force = false, silent = false) => {
+ if (syncInProgress.current && !force) {
+ resync.current = true;
+ return;
+ }
+ syncInProgress.current = true;
+ try {
+ checkConnectivity();
+ if (!(await isTokenValid())) {
+ throw new Error(ServerErrorCodes.SESSION_EXPIRED);
+ }
+ !silent && startLoading();
+
+ const collections = await syncCollections();
+ await syncFiles(collections, setLocalFiles);
+
+ let duplicates = await deduplicationService.getDuplicateFiles();
+ if (clubByTime) {
+ duplicates = await deduplicationService.clubDuplicatesByTime(
+ duplicates
+ );
+ }
+
+ let allDuplicateFiles: EnteFile[] = [];
+ let toSelectFileIDs: number[] = [];
+ let count = 0;
+
+ for (const dupe of duplicates) {
+ allDuplicateFiles = allDuplicateFiles.concat(dupe.files);
+ // select all except first file
+ toSelectFileIDs = toSelectFileIDs.concat(
+ dupe.files.slice(1).map((f) => f.id)
+ );
+ count += dupe.files.length - 1;
+ }
+ setDuplicateFiles(allDuplicateFiles);
+
+ const selectedFiles = {
+ count: count,
+ collectionID: 0,
+ };
+
+ for (const fileID of toSelectFileIDs) {
+ selectedFiles[fileID] = true;
+ }
+ setSelected(selectedFiles);
+ } catch (e) {
+ switch (e.message) {
+ case ServerErrorCodes.SESSION_EXPIRED:
+ setBannerMessage(constants.SESSION_EXPIRED_MESSAGE);
+ setDialogMessage({
+ title: constants.SESSION_EXPIRED,
+ content: constants.SESSION_EXPIRED_MESSAGE,
+ staticBackdrop: true,
+ nonClosable: true,
+ proceed: {
+ text: constants.LOGIN,
+ action: logoutUser,
+ variant: 'success',
+ },
+ });
+ break;
+ case CustomError.KEY_MISSING:
+ clearKeys();
+ router.push(PAGES.CREDENTIALS);
+ break;
+ }
+ } finally {
+ !silent && finishLoading();
+ }
+ syncInProgress.current = false;
+ if (resync.current) {
+ resync.current = false;
+ syncWithRemote();
+ }
+ };
+
+ useEffect(() => {
+ startLoading();
+ const sync = async () => {
+ await syncWithRemote();
+ };
+ sync();
+ finishLoading();
+ }, [clubByTime]);
+
+ const clearSelection = function () {
+ setSelected({ count: 0, collectionID: 0 });
+ };
+
+ const startLoading = () => {
+ !isLoadingBarRunning.current && loadingBar.current?.continuousStart();
+ isLoadingBarRunning.current = true;
+ };
+ const finishLoading = () => {
+ isLoadingBarRunning.current && loadingBar.current?.complete();
+ isLoadingBarRunning.current = false;
+ };
+
+ if (!duplicateFiles) {
+ return ;
+ }
+
+ const deleteFileHelper = async () => {
+ startLoading();
+ try {
+ const selectedFiles = getSelectedFiles(selected, duplicateFiles);
+ await trashFiles(selectedFiles);
+ clearSelection();
+ } catch (e) {
+ switch (e.status?.toString()) {
+ case ServerErrorCodes.FORBIDDEN:
+ setDialogMessage({
+ title: constants.ERROR,
+ staticBackdrop: true,
+ close: { variant: 'danger' },
+ content: constants.NOT_FILE_OWNER,
+ });
+ }
+ setDialogMessage({
+ title: constants.ERROR,
+ staticBackdrop: true,
+ close: { variant: 'danger' },
+ content: constants.UNKNOWN_ERROR,
+ });
+ } finally {
+ await syncWithRemote(false, true);
+ finishLoading();
+ }
+ };
+
+ return (
+
+ {blockingLoad && (
+
+
+
+ )}
+
+
+
+
+
+ {duplicateFiles.length > 0 ? (
+
+ ) : (
+
+ {constants.NO_DUPLICATES_FOUND}
+
+ )}
+
+ {selected.count > 0 ? (
+
+ ) : (
+ {
+ router.push(PAGES.GALLERY);
+ }}>
+
+
+ )}
+
+ );
+}
diff --git a/src/pages/gallery/index.tsx b/src/pages/gallery/index.tsx
index ec9b413c9..ae3ee9e7a 100644
--- a/src/pages/gallery/index.tsx
+++ b/src/pages/gallery/index.tsx
@@ -121,7 +121,7 @@ const AlertContainer = styled.div`
text-align: center;
`;
-const defaultGalleryContext: GalleryContextType = {
+export const defaultGalleryContext: GalleryContextType = {
thumbs: new Map(),
files: new Map(),
showPlanSelectorModal: () => null,
diff --git a/src/utils/strings/englishConstants.tsx b/src/utils/strings/englishConstants.tsx
index 7cbd7e346..1f3cb0d3d 100644
--- a/src/utils/strings/englishConstants.tsx
+++ b/src/utils/strings/englishConstants.tsx
@@ -684,10 +684,14 @@ const englishConstants = {
LIVE_PHOTO: 'this is a live photo',
LIVE: 'LIVE',
DISABLE_PASSWORD: 'disable password lock',
- DISABLE_PASSWORD_MESSAGE: 'are you sure that you want to disable the password lock?',
+ DISABLE_PASSWORD_MESSAGE:
+ 'are you sure that you want to disable the password lock?',
PASSWORD_LOCK: 'password lock',
LOCK: 'lock',
DOWNLOAD_UPLOAD_LOGS: 'debug logs',
+ DEDUPLICATE_FILES: 'deduplicate files',
+ NO_DUPLICATES_FOUND: "you've no duplicate files that can be cleared",
+ CLUB_BY_CAPTURE_TIME: 'club by capture time',
};
export default englishConstants;