Merge branch 'main' into cast
This commit is contained in:
commit
36cad03c71
62 changed files with 2075 additions and 854 deletions
|
@ -82,7 +82,9 @@ An important part of our journey is to build better software by consistently lis
|
|||
|
||||
<br/>
|
||||
|
||||
---
|
||||
## 🙇 Attributions
|
||||
|
||||
Cross-browser testing provided by
|
||||
- Cross-browser testing provided by
|
||||
[<img src="https://d98b8t1nnulk5.cloudfront.net/production/images/layout/logo-header.png?1469004780" width="115" height="25">](https://www.browserstack.com/open-source)
|
||||
|
||||
- Location search powered by [Simple Maps](https://simplemaps.com/data/world-cities)
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 422 B After Width: | Height: | Size: 1.1 KiB |
|
@ -206,6 +206,7 @@
|
|||
"SEARCH_TYPE": {
|
||||
"COLLECTION": "Album",
|
||||
"LOCATION": "Location",
|
||||
"CITY": "Location",
|
||||
"DATE": "Date",
|
||||
"FILE_NAME": "File name",
|
||||
"THING": "Content",
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 422 B After Width: | Height: | Size: 1.1 KiB |
|
@ -210,6 +210,7 @@
|
|||
"SEARCH_TYPE": {
|
||||
"COLLECTION": "Album",
|
||||
"LOCATION": "Standort",
|
||||
"CITY": "",
|
||||
"DATE": "Datum",
|
||||
"FILE_NAME": "Dateiname",
|
||||
"THING": "Inhalt",
|
||||
|
@ -622,7 +623,10 @@
|
|||
"PHOTO_EDITOR": "",
|
||||
"FASTER_UPLOAD": "",
|
||||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"STATUS": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CACHE_DIRECTORY": ""
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
|
||||
}
|
||||
|
|
|
@ -210,6 +210,7 @@
|
|||
"SEARCH_TYPE": {
|
||||
"COLLECTION": "Album",
|
||||
"LOCATION": "Location",
|
||||
"CITY": "Location",
|
||||
"DATE": "Date",
|
||||
"FILE_NAME": "File name",
|
||||
"THING": "Content",
|
||||
|
@ -495,16 +496,16 @@
|
|||
"ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED": "Cannot create albums from file/folder mix",
|
||||
"ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED_MESSAGE": "<p>You have dragged and dropped a mixture of files and folders.</p><p>Please provide either only files, or only folders when selecting option to create separate albums</p>",
|
||||
"CHOSE_THEME": "Choose theme",
|
||||
"ML_SEARCH": "ML search (beta)",
|
||||
"ML_SEARCH": "Face recognition",
|
||||
"ENABLE_ML_SEARCH_DESCRIPTION": "<p>This will enable on-device machine learning and face search which will start analyzing your uploaded photos locally.</p><p>For the first run after login or enabling this feature, it will download all images on local device to analyze them. So please only enable this if you are ok with bandwidth and local processing of all images in your photo library.</p><p>If this is the first time you're enabling this, we'll also ask your permission to process face data.</p>",
|
||||
"ML_MORE_DETAILS": "More details",
|
||||
"ENABLE_FACE_SEARCH": "Enable face search",
|
||||
"ENABLE_FACE_SEARCH_TITLE": "Enable face search?",
|
||||
"ENABLE_FACE_SEARCH_DESCRIPTION": "<p>If you enable face search, ente will extract face geometry from your photos. This will happen on your device, and any generated biometric data will be end-to-encrypted.<p/><p><a>Please click here for more details about this feature in our privacy policy</a></p>",
|
||||
"DISABLE_BETA": "Disable beta",
|
||||
"DISABLE_FACE_SEARCH": "Disable face search",
|
||||
"DISABLE_FACE_SEARCH_TITLE": "Disable face search?",
|
||||
"DISABLE_FACE_SEARCH_DESCRIPTION": "<p>ente will stop processing face geometry, and will also disable ML search (beta)</p><p>You can reenable face search again if you wish, so this operation is safe.</p>",
|
||||
"ENABLE_FACE_SEARCH": "Enable face recognition",
|
||||
"ENABLE_FACE_SEARCH_TITLE": "Enable face recognition?",
|
||||
"ENABLE_FACE_SEARCH_DESCRIPTION": "<p>If you enable face recognition, ente will extract face geometry from your photos. This will happen on your device, and any generated biometric data will be end-to-encrypted.<p/><p><a>Please click here for more details about this feature in our privacy policy</a></p>",
|
||||
"DISABLE_BETA": "Pause recognition",
|
||||
"DISABLE_FACE_SEARCH": "Disable face recognition",
|
||||
"DISABLE_FACE_SEARCH_TITLE": "Disable face recognition?",
|
||||
"DISABLE_FACE_SEARCH_DESCRIPTION": "<p>Ente will stop processing face geometry.</p><p>You can reenable face recognition again if you wish, so this operation is safe.</p>",
|
||||
"ADVANCED": "Advanced",
|
||||
"FACE_SEARCH_CONFIRMATION": "I understand, and wish to allow ente to process face geometry",
|
||||
"LABS": "Labs",
|
||||
|
@ -622,8 +623,9 @@
|
|||
"PHOTO_EDITOR": "Photo Editor",
|
||||
"FASTER_UPLOAD": "Faster uploads",
|
||||
"FASTER_UPLOAD_DESCRIPTION": "Route uploads through nearby servers",
|
||||
"STATUS": "Status",
|
||||
"MAGIC_SEARCH_STATUS": "Magic Search Status",
|
||||
"INDEXED_ITEMS": "Indexed items",
|
||||
<<<<<<< HEAD
|
||||
"CAST_ALBUM_TO_TV": "Play album on TV",
|
||||
"ENTER_CAST_PIN_CODE": "Enter the code you see on the TV below to pair this device.",
|
||||
"PAIR_DEVICE_TO_TV": "Pair devices",
|
||||
|
@ -635,5 +637,8 @@
|
|||
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "Pair with PIN works for any large screen device you want to play your album on.",
|
||||
"VISIT_CAST_ENTE_IO": "Visit cast.ente.io on the device you want to pair.",
|
||||
"CAST_AUTO_PAIR_FAILED": "Chromecast Auto Pair failed. Please try again.",
|
||||
"CACHE_DIRECTORY": "Cache folder"
|
||||
"CACHE_DIRECTORY": "Cache folder",
|
||||
"FREEHAND": "Freehand",
|
||||
"APPLY_CROP": "Apply Crop",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": "At least one transformation or color adjustment must be performed before saving."
|
||||
}
|
||||
|
|
|
@ -210,6 +210,7 @@
|
|||
"SEARCH_TYPE": {
|
||||
"COLLECTION": "Álbum",
|
||||
"LOCATION": "Localización",
|
||||
"CITY": "",
|
||||
"DATE": "Fecha",
|
||||
"FILE_NAME": "Nombre del archivo",
|
||||
"THING": "Contenido",
|
||||
|
@ -622,7 +623,10 @@
|
|||
"PHOTO_EDITOR": "",
|
||||
"FASTER_UPLOAD": "",
|
||||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"STATUS": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CACHE_DIRECTORY": ""
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
|
||||
}
|
||||
|
|
|
@ -210,6 +210,7 @@
|
|||
"SEARCH_TYPE": {
|
||||
"COLLECTION": "",
|
||||
"LOCATION": "",
|
||||
"CITY": "",
|
||||
"DATE": "",
|
||||
"FILE_NAME": "",
|
||||
"THING": "",
|
||||
|
@ -622,7 +623,10 @@
|
|||
"PHOTO_EDITOR": "",
|
||||
"FASTER_UPLOAD": "",
|
||||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"STATUS": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CACHE_DIRECTORY": ""
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
|
||||
}
|
||||
|
|
|
@ -210,6 +210,7 @@
|
|||
"SEARCH_TYPE": {
|
||||
"COLLECTION": "",
|
||||
"LOCATION": "",
|
||||
"CITY": "",
|
||||
"DATE": "",
|
||||
"FILE_NAME": "",
|
||||
"THING": "",
|
||||
|
@ -622,7 +623,10 @@
|
|||
"PHOTO_EDITOR": "",
|
||||
"FASTER_UPLOAD": "",
|
||||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"STATUS": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CACHE_DIRECTORY": ""
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
|
||||
}
|
||||
|
|
|
@ -210,6 +210,7 @@
|
|||
"SEARCH_TYPE": {
|
||||
"COLLECTION": "l'album",
|
||||
"LOCATION": "Emplacement",
|
||||
"CITY": "",
|
||||
"DATE": "Date",
|
||||
"FILE_NAME": "Nom de fichier",
|
||||
"THING": "Chose",
|
||||
|
@ -622,7 +623,10 @@
|
|||
"PHOTO_EDITOR": "Éditeur de photos",
|
||||
"FASTER_UPLOAD": "Chargements plus rapides",
|
||||
"FASTER_UPLOAD_DESCRIPTION": "Router les chargements vers les serveurs à proximité",
|
||||
"STATUS": "État",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "Éléments indexés",
|
||||
"CACHE_DIRECTORY": ""
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
|
||||
}
|
||||
|
|
|
@ -210,6 +210,7 @@
|
|||
"SEARCH_TYPE": {
|
||||
"COLLECTION": "Album",
|
||||
"LOCATION": "Posizione",
|
||||
"CITY": "",
|
||||
"DATE": "Data",
|
||||
"FILE_NAME": "Nome file",
|
||||
"THING": "",
|
||||
|
@ -622,7 +623,10 @@
|
|||
"PHOTO_EDITOR": "",
|
||||
"FASTER_UPLOAD": "",
|
||||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"STATUS": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CACHE_DIRECTORY": ""
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
|
||||
}
|
||||
|
|
|
@ -85,9 +85,9 @@
|
|||
"ZOOM_IN_OUT": "In/uitzoomen",
|
||||
"PREVIOUS": "Vorige (←)",
|
||||
"NEXT": "Volgende (→)",
|
||||
"TITLE_PHOTOS": "",
|
||||
"TITLE_ALBUMS": "",
|
||||
"TITLE_AUTH": "",
|
||||
"TITLE_PHOTOS": "Ente Foto's",
|
||||
"TITLE_ALBUMS": "Ente Foto's",
|
||||
"TITLE_AUTH": "Ente Auth",
|
||||
"UPLOAD_FIRST_PHOTO": "Je eerste foto uploaden",
|
||||
"IMPORT_YOUR_FOLDERS": "Importeer uw mappen",
|
||||
"UPLOAD_DROPZONE_MESSAGE": "Sleep om een back-up van je bestanden te maken",
|
||||
|
@ -210,6 +210,7 @@
|
|||
"SEARCH_TYPE": {
|
||||
"COLLECTION": "Album",
|
||||
"LOCATION": "Locatie",
|
||||
"CITY": "",
|
||||
"DATE": "Datum",
|
||||
"FILE_NAME": "Bestandsnaam",
|
||||
"THING": "Inhoud",
|
||||
|
@ -622,7 +623,10 @@
|
|||
"PHOTO_EDITOR": "Fotobewerker",
|
||||
"FASTER_UPLOAD": "Snellere uploads",
|
||||
"FASTER_UPLOAD_DESCRIPTION": "Uploaden door nabije servers",
|
||||
"STATUS": "Status",
|
||||
"MAGIC_SEARCH_STATUS": "Magische Zoekfunctie Status",
|
||||
"INDEXED_ITEMS": "Geïndexeerde bestanden",
|
||||
"CACHE_DIRECTORY": "Cache map"
|
||||
"CACHE_DIRECTORY": "Cache map",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
|
||||
}
|
||||
|
|
|
@ -210,6 +210,7 @@
|
|||
"SEARCH_TYPE": {
|
||||
"COLLECTION": "",
|
||||
"LOCATION": "",
|
||||
"CITY": "",
|
||||
"DATE": "",
|
||||
"FILE_NAME": "",
|
||||
"THING": "",
|
||||
|
@ -622,7 +623,10 @@
|
|||
"PHOTO_EDITOR": "",
|
||||
"FASTER_UPLOAD": "",
|
||||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"STATUS": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CACHE_DIRECTORY": ""
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
|
||||
}
|
||||
|
|
|
@ -210,6 +210,7 @@
|
|||
"SEARCH_TYPE": {
|
||||
"COLLECTION": "",
|
||||
"LOCATION": "",
|
||||
"CITY": "",
|
||||
"DATE": "",
|
||||
"FILE_NAME": "",
|
||||
"THING": "",
|
||||
|
@ -622,7 +623,10 @@
|
|||
"PHOTO_EDITOR": "",
|
||||
"FASTER_UPLOAD": "",
|
||||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"STATUS": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CACHE_DIRECTORY": ""
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
|
||||
}
|
||||
|
|
|
@ -210,6 +210,7 @@
|
|||
"SEARCH_TYPE": {
|
||||
"COLLECTION": "",
|
||||
"LOCATION": "",
|
||||
"CITY": "",
|
||||
"DATE": "",
|
||||
"FILE_NAME": "",
|
||||
"THING": "",
|
||||
|
@ -622,7 +623,10 @@
|
|||
"PHOTO_EDITOR": "",
|
||||
"FASTER_UPLOAD": "",
|
||||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"STATUS": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CACHE_DIRECTORY": ""
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
|
||||
}
|
||||
|
|
|
@ -210,6 +210,7 @@
|
|||
"SEARCH_TYPE": {
|
||||
"COLLECTION": "相册",
|
||||
"LOCATION": "地理位置",
|
||||
"CITY": "位置",
|
||||
"DATE": "日期",
|
||||
"FILE_NAME": "文件名",
|
||||
"THING": "内容",
|
||||
|
@ -622,7 +623,10 @@
|
|||
"PHOTO_EDITOR": "照片编辑器",
|
||||
"FASTER_UPLOAD": "更快上传",
|
||||
"FASTER_UPLOAD_DESCRIPTION": "通过附近的服务器路由上传",
|
||||
"STATUS": "状态",
|
||||
"MAGIC_SEARCH_STATUS": "魔法搜索状态",
|
||||
"INDEXED_ITEMS": "索引项目",
|
||||
"CACHE_DIRECTORY": "缓存文件夹"
|
||||
"CACHE_DIRECTORY": "缓存文件夹",
|
||||
"FREEHAND": "手画",
|
||||
"APPLY_CROP": "应用裁剪",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": "保存之前必须至少执行一项转换或颜色调整。"
|
||||
}
|
||||
|
|
|
@ -1,160 +0,0 @@
|
|||
import Notification from 'components/Notification';
|
||||
import { t } from 'i18next';
|
||||
import isElectron from 'is-electron';
|
||||
import { AppContext } from 'pages/_app';
|
||||
import { GalleryContext } from 'pages/gallery';
|
||||
import { useContext } from 'react';
|
||||
import ElectronAPIs from '@ente/shared/electron';
|
||||
|
||||
export interface CollectionDownloadProgressAttributes {
|
||||
success: number;
|
||||
failed: number;
|
||||
total: number;
|
||||
collectionName: string;
|
||||
collectionID: number;
|
||||
isHidden: boolean;
|
||||
downloadDirPath: string;
|
||||
canceller: AbortController;
|
||||
}
|
||||
|
||||
interface CollectionDownloadProgressProps {
|
||||
attributesList: CollectionDownloadProgressAttributes[];
|
||||
setAttributesList: (value: CollectionDownloadProgressAttributes[]) => void;
|
||||
}
|
||||
|
||||
export const isCollectionDownloadCompleted = (
|
||||
attributes: CollectionDownloadProgressAttributes
|
||||
) => {
|
||||
return (
|
||||
attributes &&
|
||||
attributes.success + attributes.failed === attributes.total
|
||||
);
|
||||
};
|
||||
|
||||
export const isCollectionDownloadCompletedWithErrors = (
|
||||
attributes: CollectionDownloadProgressAttributes
|
||||
) => {
|
||||
return (
|
||||
attributes &&
|
||||
attributes.failed > 0 &&
|
||||
isCollectionDownloadCompleted(attributes)
|
||||
);
|
||||
};
|
||||
|
||||
export const isCollectionDownloadCancelled = (
|
||||
attributes: CollectionDownloadProgressAttributes
|
||||
) => {
|
||||
return attributes && attributes.canceller?.signal?.aborted;
|
||||
};
|
||||
|
||||
export const CollectionDownloadProgress: React.FC<CollectionDownloadProgressProps> =
|
||||
({ attributesList, setAttributesList }) => {
|
||||
const appContext = useContext(AppContext);
|
||||
const galleryContext = useContext(GalleryContext);
|
||||
|
||||
if (!attributesList) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const onClose = (collectionID: number) => {
|
||||
setAttributesList(
|
||||
attributesList.filter(
|
||||
(attr) => attr.collectionID !== collectionID
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const confirmCancelUpload = (
|
||||
attributes: CollectionDownloadProgressAttributes
|
||||
) => {
|
||||
appContext.setDialogMessage({
|
||||
title: t('STOP_DOWNLOADS_HEADER'),
|
||||
content: t('STOP_ALL_DOWNLOADS_MESSAGE'),
|
||||
proceed: {
|
||||
text: t('YES_STOP_DOWNLOADS'),
|
||||
variant: 'critical',
|
||||
action: () => {
|
||||
attributes?.canceller.abort();
|
||||
onClose(attributes.collectionID);
|
||||
},
|
||||
},
|
||||
close: {
|
||||
text: t('NO'),
|
||||
variant: 'secondary',
|
||||
action: () => {},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose =
|
||||
(attributes: CollectionDownloadProgressAttributes) => () => {
|
||||
if (isCollectionDownloadCompleted(attributes)) {
|
||||
onClose(attributes.collectionID);
|
||||
} else {
|
||||
confirmCancelUpload(attributes);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnClick = (collectionID: number) => () => {
|
||||
const attributes = attributesList.find(
|
||||
(attr) => attr.collectionID === collectionID
|
||||
);
|
||||
if (isElectron()) {
|
||||
ElectronAPIs.openDirectory(attributes.downloadDirPath);
|
||||
} else {
|
||||
if (attributes.isHidden) {
|
||||
galleryContext.openHiddenSection(() => {
|
||||
galleryContext.setActiveCollectionID(
|
||||
attributes.collectionID
|
||||
);
|
||||
});
|
||||
} else {
|
||||
galleryContext.setActiveCollectionID(
|
||||
attributes.collectionID
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{attributesList.map((attributes, index) => (
|
||||
<Notification
|
||||
key={attributes.collectionID}
|
||||
horizontal="left"
|
||||
sx={{ '&&': { bottom: `${index * 80 + 20}px` } }}
|
||||
open
|
||||
onClose={handleClose(attributes)}
|
||||
keepOpenOnClick
|
||||
attributes={{
|
||||
variant: isCollectionDownloadCompletedWithErrors(
|
||||
attributes
|
||||
)
|
||||
? 'critical'
|
||||
: 'secondary',
|
||||
title: isCollectionDownloadCompletedWithErrors(
|
||||
attributes
|
||||
)
|
||||
? t('DOWNLOAD_FAILED')
|
||||
: isCollectionDownloadCompleted(attributes)
|
||||
? t(`DOWNLOAD_COMPLETE`)
|
||||
: t('DOWNLOADING_COLLECTION', {
|
||||
name: attributes.collectionName,
|
||||
}),
|
||||
caption: isCollectionDownloadCompleted(attributes)
|
||||
? attributes.collectionName
|
||||
: t('DOWNLOAD_PROGRESS', {
|
||||
progress: {
|
||||
current:
|
||||
attributes.success +
|
||||
attributes.failed,
|
||||
total: attributes.total,
|
||||
},
|
||||
}),
|
||||
onClick: handleOnClick(attributes.collectionID),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -11,16 +11,14 @@ import Favorite from '@mui/icons-material/FavoriteRounded';
|
|||
import ArchiveOutlined from '@mui/icons-material/ArchiveOutlined';
|
||||
import PeopleIcon from '@mui/icons-material/People';
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import { SetCollectionDownloadProgressAttributes } from 'types/gallery';
|
||||
import { SetFilesDownloadProgressAttributesCreator } from 'types/gallery';
|
||||
|
||||
interface Iprops {
|
||||
activeCollection: Collection;
|
||||
collectionSummary: CollectionSummary;
|
||||
setCollectionNamerAttributes: SetCollectionNamerAttributes;
|
||||
showCollectionShareModal: () => void;
|
||||
setCollectionDownloadProgressAttributesCreator: (
|
||||
collectionID: number
|
||||
) => SetCollectionDownloadProgressAttributes;
|
||||
setFilesDownloadProgressAttributesCreator: SetFilesDownloadProgressAttributesCreator;
|
||||
isActiveCollectionDownloadInProgress: () => boolean;
|
||||
setActiveCollectionID: (collectionID: number) => void;
|
||||
setShowAlbumCastDialog: Dispatch<SetStateAction<boolean>>;
|
||||
|
|
|
@ -39,13 +39,11 @@ import { Trans } from 'react-i18next';
|
|||
import { t } from 'i18next';
|
||||
import { Box } from '@mui/material';
|
||||
import CollectionSortOrderMenu from './CollectionSortOrderMenu';
|
||||
import { SetCollectionDownloadProgressAttributes } from 'types/gallery';
|
||||
import { SetFilesDownloadProgressAttributesCreator } from 'types/gallery';
|
||||
|
||||
interface CollectionOptionsProps {
|
||||
setCollectionNamerAttributes: SetCollectionNamerAttributes;
|
||||
setCollectionDownloadProgressAttributesCreator: (
|
||||
collectionID: number
|
||||
) => SetCollectionDownloadProgressAttributes;
|
||||
setFilesDownloadProgressAttributesCreator: SetFilesDownloadProgressAttributesCreator;
|
||||
isActiveCollectionDownloadInProgress: () => boolean;
|
||||
activeCollection: Collection;
|
||||
collectionSummaryType: CollectionSummaryType;
|
||||
|
@ -84,7 +82,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
|
|||
setActiveCollectionID,
|
||||
setCollectionNamerAttributes,
|
||||
showCollectionShareModal,
|
||||
setCollectionDownloadProgressAttributesCreator,
|
||||
setFilesDownloadProgressAttributesCreator,
|
||||
isActiveCollectionDownloadInProgress,
|
||||
setShowAlbumCastDialog,
|
||||
} = props;
|
||||
|
@ -235,21 +233,25 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
|
|||
return;
|
||||
}
|
||||
if (collectionSummaryType === CollectionSummaryType.hiddenItems) {
|
||||
const setCollectionDownloadProgressAttributes =
|
||||
setCollectionDownloadProgressAttributesCreator(
|
||||
HIDDEN_ITEMS_SECTION
|
||||
const setFilesDownloadProgressAttributes =
|
||||
setFilesDownloadProgressAttributesCreator(
|
||||
activeCollection.name,
|
||||
HIDDEN_ITEMS_SECTION,
|
||||
true
|
||||
);
|
||||
downloadDefaultHiddenCollectionHelper(
|
||||
setCollectionDownloadProgressAttributes
|
||||
setFilesDownloadProgressAttributes
|
||||
);
|
||||
} else {
|
||||
const setCollectionDownloadProgressAttributes =
|
||||
setCollectionDownloadProgressAttributesCreator(
|
||||
activeCollection.id
|
||||
const setFilesDownloadProgressAttributes =
|
||||
setFilesDownloadProgressAttributesCreator(
|
||||
activeCollection.name,
|
||||
activeCollection.id,
|
||||
isHiddenCollection(activeCollection)
|
||||
);
|
||||
downloadCollectionHelper(
|
||||
activeCollection.id,
|
||||
setCollectionDownloadProgressAttributes
|
||||
setFilesDownloadProgressAttributes
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -140,8 +140,8 @@ function CollectionSelector({
|
|||
? t('UNHIDE_TO_COLLECTION')
|
||||
: t('SELECT_COLLECTION')}
|
||||
</DialogTitleWithCloseButton>
|
||||
<DialogContent>
|
||||
<FlexWrapper flexWrap="wrap" gap={0.5}>
|
||||
<DialogContent sx={{ '&&&': { padding: 0 } }}>
|
||||
<FlexWrapper flexWrap="wrap" gap={'4px'} padding={'16px'}>
|
||||
<AddCollectionButton
|
||||
showNextModal={attributes.showNextModal}
|
||||
/>
|
||||
|
|
|
@ -16,12 +16,11 @@ import { useLocalState } from '@ente/shared/hooks/useLocalState';
|
|||
import { sortCollectionSummaries } from 'services/collectionService';
|
||||
import { LS_KEYS } from '@ente/shared/storage/localStorage';
|
||||
import {
|
||||
CollectionDownloadProgress,
|
||||
CollectionDownloadProgressAttributes,
|
||||
isCollectionDownloadCancelled,
|
||||
isCollectionDownloadCompleted,
|
||||
} from './CollectionDownloadProgress';
|
||||
import { SetCollectionDownloadProgressAttributes } from 'types/gallery';
|
||||
FilesDownloadProgressAttributes,
|
||||
isFilesDownloadCancelled,
|
||||
isFilesDownloadCompleted,
|
||||
} from '../FilesDownloadProgress';
|
||||
import { SetFilesDownloadProgressAttributesCreator } from 'types/gallery';
|
||||
import AlbumCastDialog from './CollectionOptions/AlbumCastDialog';
|
||||
|
||||
interface Iprops {
|
||||
|
@ -34,6 +33,8 @@ interface Iprops {
|
|||
hiddenCollectionSummaries: CollectionSummaries;
|
||||
setCollectionNamerAttributes: SetCollectionNamerAttributes;
|
||||
setPhotoListHeader: (value: TimeStampListItem) => void;
|
||||
filesDownloadProgressAttributesList: FilesDownloadProgressAttributes[];
|
||||
setFilesDownloadProgressAttributesCreator: SetFilesDownloadProgressAttributesCreator;
|
||||
}
|
||||
|
||||
export default function Collections(props: Iprops) {
|
||||
|
@ -47,17 +48,14 @@ export default function Collections(props: Iprops) {
|
|||
hiddenCollectionSummaries,
|
||||
setCollectionNamerAttributes,
|
||||
setPhotoListHeader,
|
||||
filesDownloadProgressAttributesList,
|
||||
setFilesDownloadProgressAttributesCreator,
|
||||
} = props;
|
||||
|
||||
const [allCollectionView, setAllCollectionView] = useState(false);
|
||||
const [collectionShareModalView, setCollectionShareModalView] =
|
||||
useState(false);
|
||||
|
||||
const [
|
||||
collectionDownloadProgressAttributesList,
|
||||
setCollectionDownloadProgressAttributesList,
|
||||
] = useState<CollectionDownloadProgressAttributes[]>([]);
|
||||
|
||||
const [showAlbumCastDialog, setShowAlbumCastDialog] = useState(false);
|
||||
|
||||
const [collectionListSortBy, setCollectionListSortBy] =
|
||||
|
@ -89,38 +87,16 @@ export default function Collections(props: Iprops) {
|
|||
[collectionListSortBy, toShowCollectionSummaries]
|
||||
);
|
||||
|
||||
const setCollectionDownloadProgressAttributesCreator =
|
||||
(collectionID: number): SetCollectionDownloadProgressAttributes =>
|
||||
(value) => {
|
||||
setCollectionDownloadProgressAttributesList((prev) => {
|
||||
const attributes = prev?.find(
|
||||
(attr) => attr.collectionID === collectionID
|
||||
);
|
||||
const updatedAttributes =
|
||||
typeof value === 'function' ? value(attributes) : value;
|
||||
|
||||
const updatedAttributesList = attributes
|
||||
? prev.map((attr) =>
|
||||
attr.collectionID === collectionID
|
||||
? updatedAttributes
|
||||
: attr
|
||||
)
|
||||
: [...prev, updatedAttributes];
|
||||
|
||||
return updatedAttributesList;
|
||||
});
|
||||
};
|
||||
|
||||
const isActiveCollectionDownloadInProgress = useCallback(() => {
|
||||
const attributes = collectionDownloadProgressAttributesList.find(
|
||||
const attributes = filesDownloadProgressAttributesList.find(
|
||||
(attr) => attr.collectionID === activeCollectionID
|
||||
);
|
||||
return (
|
||||
attributes &&
|
||||
!isCollectionDownloadCancelled(attributes) &&
|
||||
!isCollectionDownloadCompleted(attributes)
|
||||
!isFilesDownloadCancelled(attributes) &&
|
||||
!isFilesDownloadCompleted(attributes)
|
||||
);
|
||||
}, [activeCollectionID, collectionDownloadProgressAttributesList]);
|
||||
}, [activeCollectionID, filesDownloadProgressAttributesList]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInSearchMode) {
|
||||
|
@ -137,8 +113,8 @@ export default function Collections(props: Iprops) {
|
|||
showCollectionShareModal={() =>
|
||||
setCollectionShareModalView(true)
|
||||
}
|
||||
setCollectionDownloadProgressAttributesCreator={
|
||||
setCollectionDownloadProgressAttributesCreator
|
||||
setFilesDownloadProgressAttributesCreator={
|
||||
setFilesDownloadProgressAttributesCreator
|
||||
}
|
||||
isActiveCollectionDownloadInProgress={
|
||||
isActiveCollectionDownloadInProgress
|
||||
|
@ -199,10 +175,6 @@ export default function Collections(props: Iprops) {
|
|||
onClose={closeCollectionShare}
|
||||
collection={activeCollection}
|
||||
/>
|
||||
<CollectionDownloadProgress
|
||||
attributesList={collectionDownloadProgressAttributesList}
|
||||
setAttributesList={setCollectionDownloadProgressAttributesList}
|
||||
/>
|
||||
<AlbumCastDialog
|
||||
currentCollection={props.activeCollection}
|
||||
show={showAlbumCastDialog}
|
||||
|
|
159
apps/photos/src/components/FilesDownloadProgress.tsx
Normal file
159
apps/photos/src/components/FilesDownloadProgress.tsx
Normal file
|
@ -0,0 +1,159 @@
|
|||
import Notification from 'components/Notification';
|
||||
import { t } from 'i18next';
|
||||
import isElectron from 'is-electron';
|
||||
import { AppContext } from 'pages/_app';
|
||||
import { GalleryContext } from 'pages/gallery';
|
||||
import { useContext } from 'react';
|
||||
import ElectronAPIs from '@ente/shared/electron';
|
||||
|
||||
export interface FilesDownloadProgressAttributes {
|
||||
id: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
total: number;
|
||||
folderName: string;
|
||||
collectionID: number;
|
||||
isHidden: boolean;
|
||||
downloadDirPath: string;
|
||||
canceller: AbortController;
|
||||
}
|
||||
|
||||
interface FilesDownloadProgressProps {
|
||||
attributesList: FilesDownloadProgressAttributes[];
|
||||
setAttributesList: (value: FilesDownloadProgressAttributes[]) => void;
|
||||
}
|
||||
|
||||
export const isFilesDownloadStarted = (
|
||||
attributes: FilesDownloadProgressAttributes
|
||||
) => {
|
||||
return attributes && attributes.total > 0;
|
||||
};
|
||||
|
||||
export const isFilesDownloadCompleted = (
|
||||
attributes: FilesDownloadProgressAttributes
|
||||
) => {
|
||||
return (
|
||||
attributes &&
|
||||
attributes.success + attributes.failed === attributes.total
|
||||
);
|
||||
};
|
||||
|
||||
export const isFilesDownloadCompletedWithErrors = (
|
||||
attributes: FilesDownloadProgressAttributes
|
||||
) => {
|
||||
return (
|
||||
attributes &&
|
||||
attributes.failed > 0 &&
|
||||
isFilesDownloadCompleted(attributes)
|
||||
);
|
||||
};
|
||||
|
||||
export const isFilesDownloadCancelled = (
|
||||
attributes: FilesDownloadProgressAttributes
|
||||
) => {
|
||||
return attributes && attributes.canceller?.signal?.aborted;
|
||||
};
|
||||
|
||||
export const FilesDownloadProgress: React.FC<FilesDownloadProgressProps> = ({
|
||||
attributesList,
|
||||
setAttributesList,
|
||||
}) => {
|
||||
const appContext = useContext(AppContext);
|
||||
const galleryContext = useContext(GalleryContext);
|
||||
|
||||
if (!attributesList) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const onClose = (id: number) => {
|
||||
setAttributesList(attributesList.filter((attr) => attr.id !== id));
|
||||
};
|
||||
|
||||
const confirmCancelUpload = (
|
||||
attributes: FilesDownloadProgressAttributes
|
||||
) => {
|
||||
appContext.setDialogMessage({
|
||||
title: t('STOP_DOWNLOADS_HEADER'),
|
||||
content: t('STOP_ALL_DOWNLOADS_MESSAGE'),
|
||||
proceed: {
|
||||
text: t('YES_STOP_DOWNLOADS'),
|
||||
variant: 'critical',
|
||||
action: () => {
|
||||
attributes?.canceller.abort();
|
||||
onClose(attributes.id);
|
||||
},
|
||||
},
|
||||
close: {
|
||||
text: t('NO'),
|
||||
variant: 'secondary',
|
||||
action: () => {},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = (attributes: FilesDownloadProgressAttributes) => () => {
|
||||
if (isFilesDownloadCompleted(attributes)) {
|
||||
onClose(attributes.id);
|
||||
} else {
|
||||
confirmCancelUpload(attributes);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnClick = (id: number) => () => {
|
||||
const attributes = attributesList.find((attr) => attr.id === id);
|
||||
if (isElectron()) {
|
||||
ElectronAPIs.openDirectory(attributes.downloadDirPath);
|
||||
} else {
|
||||
if (attributes.isHidden) {
|
||||
galleryContext.openHiddenSection(() => {
|
||||
galleryContext.setActiveCollectionID(
|
||||
attributes.collectionID
|
||||
);
|
||||
});
|
||||
} else {
|
||||
galleryContext.setActiveCollectionID(attributes.collectionID);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{attributesList.map((attributes, index) => (
|
||||
<Notification
|
||||
key={attributes.id}
|
||||
horizontal="left"
|
||||
sx={{
|
||||
'&&': { bottom: `${index * 80 + 20}px` },
|
||||
zIndex: 1600,
|
||||
}}
|
||||
open={isFilesDownloadStarted(attributes)}
|
||||
onClose={handleClose(attributes)}
|
||||
keepOpenOnClick
|
||||
attributes={{
|
||||
variant: isFilesDownloadCompletedWithErrors(attributes)
|
||||
? 'critical'
|
||||
: 'secondary',
|
||||
title: isFilesDownloadCompletedWithErrors(attributes)
|
||||
? t('DOWNLOAD_FAILED')
|
||||
: isFilesDownloadCompleted(attributes)
|
||||
? t(`DOWNLOAD_COMPLETE`)
|
||||
: t('DOWNLOADING_COLLECTION', {
|
||||
name: attributes.folderName,
|
||||
}),
|
||||
caption: isFilesDownloadCompleted(attributes)
|
||||
? attributes.folderName
|
||||
: t('DOWNLOAD_PROGRESS', {
|
||||
progress: {
|
||||
current:
|
||||
attributes.success +
|
||||
attributes.failed,
|
||||
total: attributes.total,
|
||||
},
|
||||
}),
|
||||
onClick: handleOnClick(attributes.id),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -11,8 +11,10 @@ import AutoSizer from 'react-virtualized-auto-sizer';
|
|||
import PhotoViewer from 'components/PhotoViewer';
|
||||
import { TRASH_SECTION } from 'constants/collection';
|
||||
import { updateFileMsrcProps, updateFileSrcProps } from 'utils/photoFrame';
|
||||
import { SelectedState } from 'types/gallery';
|
||||
import { PublicCollectionGalleryContext } from 'utils/publicCollectionGallery';
|
||||
import {
|
||||
SelectedState,
|
||||
SetFilesDownloadProgressAttributesCreator,
|
||||
} from 'types/gallery';
|
||||
import { useRouter } from 'next/router';
|
||||
import { logError } from '@ente/shared/sentry';
|
||||
import { addLogLine } from '@ente/shared/logging';
|
||||
|
@ -53,8 +55,8 @@ interface Props {
|
|||
selected: SelectedState | ((selected: SelectedState) => SelectedState)
|
||||
) => void;
|
||||
selected: SelectedState;
|
||||
deletedFileIds?: Set<number>;
|
||||
setDeletedFileIds?: (value: Set<number>) => void;
|
||||
tempDeletedFileIds?: Set<number>;
|
||||
setTempDeletedFileIds?: (value: Set<number>) => void;
|
||||
activeCollectionID: number;
|
||||
enableDownload?: boolean;
|
||||
fileToCollectionsMap: Map<number, number[]>;
|
||||
|
@ -62,6 +64,7 @@ interface Props {
|
|||
showAppDownloadBanner?: boolean;
|
||||
setIsPhotoSwipeOpen?: (value: boolean) => void;
|
||||
isInHiddenSection?: boolean;
|
||||
setFilesDownloadProgressAttributesCreator?: SetFilesDownloadProgressAttributesCreator;
|
||||
}
|
||||
|
||||
const PhotoFrame = ({
|
||||
|
@ -72,8 +75,8 @@ const PhotoFrame = ({
|
|||
favItemIds,
|
||||
setSelected,
|
||||
selected,
|
||||
deletedFileIds,
|
||||
setDeletedFileIds,
|
||||
tempDeletedFileIds,
|
||||
setTempDeletedFileIds,
|
||||
activeCollectionID,
|
||||
enableDownload,
|
||||
fileToCollectionsMap,
|
||||
|
@ -81,6 +84,7 @@ const PhotoFrame = ({
|
|||
showAppDownloadBanner,
|
||||
setIsPhotoSwipeOpen,
|
||||
isInHiddenSection,
|
||||
setFilesDownloadProgressAttributesCreator,
|
||||
}: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [currentIndex, setCurrentIndex] = useState<number>(0);
|
||||
|
@ -89,9 +93,6 @@ const PhotoFrame = ({
|
|||
[k: number]: boolean;
|
||||
}>({});
|
||||
const galleryContext = useContext(GalleryContext);
|
||||
const publicCollectionGalleryContext = useContext(
|
||||
PublicCollectionGalleryContext
|
||||
);
|
||||
const [rangeStart, setRangeStart] = useState(null);
|
||||
const [currentHover, setCurrentHover] = useState(null);
|
||||
const [isShiftKeyPressed, setIsShiftKeyPressed] = useState(false);
|
||||
|
@ -315,9 +316,7 @@ const PhotoFrame = ({
|
|||
file={item}
|
||||
updateURL={updateURL(index)}
|
||||
onClick={onThumbnailClick(index)}
|
||||
selectable={
|
||||
!publicCollectionGalleryContext?.accessedThroughSharedURL
|
||||
}
|
||||
selectable={enableDownload}
|
||||
onSelect={handleSelect(
|
||||
item.id,
|
||||
item.ownerID === galleryContext.user?.id,
|
||||
|
@ -600,13 +599,16 @@ const PhotoFrame = ({
|
|||
gettingData={getSlideData}
|
||||
getConvertedItem={getConvertedItem}
|
||||
favItemIds={favItemIds}
|
||||
deletedFileIds={deletedFileIds}
|
||||
setDeletedFileIds={setDeletedFileIds}
|
||||
tempDeletedFileIds={tempDeletedFileIds}
|
||||
setTempDeletedFileIds={setTempDeletedFileIds}
|
||||
isTrashCollection={activeCollectionID === TRASH_SECTION}
|
||||
isInHiddenSection={isInHiddenSection}
|
||||
enableDownload={enableDownload}
|
||||
fileToCollectionsMap={fileToCollectionsMap}
|
||||
collectionNameMap={collectionNameMap}
|
||||
setFilesDownloadProgressAttributesCreator={
|
||||
setFilesDownloadProgressAttributesCreator
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,133 @@
|
|||
import { EnteMenuItem } from 'components/Menu/EnteMenuItem';
|
||||
import { MenuItemGroup } from 'components/Menu/MenuItemGroup';
|
||||
import MenuSectionTitle from 'components/Menu/MenuSectionTitle';
|
||||
import { useContext } from 'react';
|
||||
import { ImageEditorOverlayContext } from './';
|
||||
import { CropBoxProps } from './';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { t } from 'i18next';
|
||||
import CropIcon from '@mui/icons-material/Crop';
|
||||
|
||||
interface IProps {
|
||||
previewScale: number;
|
||||
cropBoxProps: CropBoxProps;
|
||||
cropBoxRef: MutableRefObject<HTMLDivElement>;
|
||||
resetCropBox: () => void;
|
||||
}
|
||||
|
||||
export const cropRegionOfCanvas = (
|
||||
canvas: HTMLCanvasElement,
|
||||
topLeftX: number,
|
||||
topLeftY: number,
|
||||
bottomRightX: number,
|
||||
bottomRightY: number,
|
||||
scale: number = 1
|
||||
) => {
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context || !canvas) return;
|
||||
context.imageSmoothingEnabled = false;
|
||||
|
||||
const width = (bottomRightX - topLeftX) * scale;
|
||||
const height = (bottomRightY - topLeftY) * scale;
|
||||
|
||||
const img = new Image();
|
||||
img.src = canvas.toDataURL();
|
||||
img.onload = () => {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
context.drawImage(
|
||||
img,
|
||||
topLeftX,
|
||||
topLeftY,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export const getCropRegionArgs = (
|
||||
cropBoxEle: HTMLDivElement,
|
||||
canvasEle: HTMLCanvasElement
|
||||
) => {
|
||||
// get the bounding rectangle of the crop box
|
||||
const cropBoxRect = cropBoxEle.getBoundingClientRect();
|
||||
// Get the bounding rectangle of the canvas
|
||||
const canvasRect = canvasEle.getBoundingClientRect();
|
||||
|
||||
// calculate the scale of the canvas display relative to its actual dimensions
|
||||
const displayScale = canvasEle.width / canvasRect.width;
|
||||
|
||||
// calculate the coordinates of the crop box relative to the canvas and adjust for any scrolling by adding scroll offsets
|
||||
const x1 =
|
||||
(cropBoxRect.left - canvasRect.left + window.scrollX) * displayScale;
|
||||
const y1 =
|
||||
(cropBoxRect.top - canvasRect.top + window.scrollY) * displayScale;
|
||||
const x2 = x1 + cropBoxRect.width * displayScale;
|
||||
const y2 = y1 + cropBoxRect.height * displayScale;
|
||||
|
||||
return {
|
||||
x1,
|
||||
x2,
|
||||
y1,
|
||||
y2,
|
||||
};
|
||||
};
|
||||
|
||||
const CropMenu = (props: IProps) => {
|
||||
const {
|
||||
canvasRef,
|
||||
originalSizeCanvasRef,
|
||||
canvasLoading,
|
||||
setCanvasLoading,
|
||||
setTransformationPerformed,
|
||||
setCurrentTab,
|
||||
} = useContext(ImageEditorOverlayContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuSectionTitle title={t('FREEHAND')} />
|
||||
<MenuItemGroup
|
||||
style={{
|
||||
marginBottom: '0.5rem',
|
||||
}}>
|
||||
<EnteMenuItem
|
||||
disabled={canvasLoading}
|
||||
startIcon={<CropIcon />}
|
||||
onClick={() => {
|
||||
if (!props.cropBoxRef.current || !canvasRef.current)
|
||||
return;
|
||||
|
||||
const { x1, x2, y1, y2 } = getCropRegionArgs(
|
||||
props.cropBoxRef.current,
|
||||
canvasRef.current
|
||||
);
|
||||
setCanvasLoading(true);
|
||||
setTransformationPerformed(true);
|
||||
cropRegionOfCanvas(canvasRef.current, x1, y1, x2, y2);
|
||||
cropRegionOfCanvas(
|
||||
originalSizeCanvasRef.current,
|
||||
x1 / props.previewScale,
|
||||
y1 / props.previewScale,
|
||||
x2 / props.previewScale,
|
||||
y2 / props.previewScale
|
||||
);
|
||||
props.resetCropBox();
|
||||
setCanvasLoading(false);
|
||||
|
||||
setCurrentTab('transform');
|
||||
}}
|
||||
label={t('APPLY_CROP')}
|
||||
/>
|
||||
</MenuItemGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CropMenu;
|
|
@ -0,0 +1,118 @@
|
|||
import { CropBoxProps } from './';
|
||||
import type { Ref, Dispatch, SetStateAction, CSSProperties } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
const handleStyle: CSSProperties = {
|
||||
position: 'absolute',
|
||||
height: '10px',
|
||||
width: '10px',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid black',
|
||||
};
|
||||
|
||||
const seHandleStyle: CSSProperties = {
|
||||
...handleStyle,
|
||||
right: '-5px',
|
||||
bottom: '-5px',
|
||||
cursor: 'se-resize',
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
cropBox: CropBoxProps;
|
||||
setIsDragging: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const FreehandCropRegion = forwardRef(
|
||||
(props: IProps, ref: Ref<HTMLDivElement>) => {
|
||||
return (
|
||||
<>
|
||||
{/* Top overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: props.cropBox.y + 'px', // height up to the top of the crop box
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
pointerEvents: 'none',
|
||||
}}></div>
|
||||
|
||||
{/* Bottom overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: `calc(100% - ${
|
||||
props.cropBox.y + props.cropBox.height
|
||||
}px)`, // height from the bottom of the crop box to the bottom of the canvas
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
pointerEvents: 'none',
|
||||
}}></div>
|
||||
|
||||
{/* Left overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: props.cropBox.y + 'px',
|
||||
left: 0,
|
||||
width: props.cropBox.x + 'px', // width up to the left side of the crop box
|
||||
height: props.cropBox.height + 'px', // same height as the crop box
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
pointerEvents: 'none',
|
||||
}}></div>
|
||||
|
||||
{/* Right overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: props.cropBox.y + 'px',
|
||||
right: 0,
|
||||
width: `calc(100% - ${
|
||||
props.cropBox.x + props.cropBox.width
|
||||
}px)`, // width from the right side of the crop box to the right side of the canvas
|
||||
height: props.cropBox.height + 'px', // same height as the crop box
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
pointerEvents: 'none',
|
||||
}}></div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
position: 'absolute',
|
||||
left: props.cropBox.x + 'px',
|
||||
top: props.cropBox.y + 'px',
|
||||
width: props.cropBox.width + 'px',
|
||||
height: props.cropBox.height + 'px',
|
||||
border: '1px solid white',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gridTemplateRows: '1fr 1fr 1fr',
|
||||
gap: '0px',
|
||||
zIndex: 30, // make sure the crop box is above the overlays
|
||||
}}
|
||||
ref={ref}>
|
||||
{Array.from({ length: 9 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
border: '1px solid white',
|
||||
boxSizing: 'border-box',
|
||||
pointerEvents: 'none',
|
||||
}}></div>
|
||||
))}
|
||||
|
||||
<div
|
||||
style={seHandleStyle}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
props.setIsDragging(true);
|
||||
}}></div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default FreehandCropRegion;
|
|
@ -89,6 +89,7 @@ const TransformMenu = () => {
|
|||
);
|
||||
};
|
||||
};
|
||||
|
||||
const flipCanvas = (
|
||||
canvas: HTMLCanvasElement,
|
||||
direction: 'vertical' | 'horizontal'
|
||||
|
|
|
@ -29,6 +29,7 @@ import mime from 'mime-types';
|
|||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { HorizontalFlex } from '@ente/shared/components/Container';
|
||||
import TransformMenu from './TransformMenu';
|
||||
import CropMenu from './CropMenu';
|
||||
import ColoursMenu from './ColoursMenu';
|
||||
import { FileWithCollection } from 'types/upload';
|
||||
import uploadManager from 'services/upload/uploadManager';
|
||||
|
@ -44,6 +45,12 @@ import { getEditorCloseConfirmationMessage } from 'utils/ui';
|
|||
import { logError } from '@ente/shared/sentry';
|
||||
import { getFileType } from 'services/typeDetectionService';
|
||||
import { downloadUsingAnchor } from '@ente/shared/utils';
|
||||
import { CORNER_THRESHOLD, FILTER_DEFAULT_VALUES } from 'constants/photoEditor';
|
||||
import FreehandCropRegion from './FreehandCropRegion';
|
||||
import EnteButton from '@ente/shared/components/EnteButton';
|
||||
import { CenteredFlex } from '@ente/shared/components/Container';
|
||||
import CropIcon from '@mui/icons-material/Crop';
|
||||
import { cropRegionOfCanvas, getCropRegionArgs } from './CropMenu';
|
||||
|
||||
interface IProps {
|
||||
file: EnteFile;
|
||||
|
@ -59,16 +66,11 @@ export const ImageEditorOverlayContext = createContext(
|
|||
setTransformationPerformed: Dispatch<SetStateAction<boolean>>;
|
||||
setCanvasLoading: Dispatch<SetStateAction<boolean>>;
|
||||
canvasLoading: boolean;
|
||||
setCurrentTab: Dispatch<SetStateAction<OperationTab>>;
|
||||
}
|
||||
);
|
||||
|
||||
const filterDefaultValues = {
|
||||
brightness: 100,
|
||||
contrast: 100,
|
||||
blur: 0,
|
||||
saturation: 100,
|
||||
invert: false,
|
||||
};
|
||||
type OperationTab = 'crop' | 'transform' | 'colours';
|
||||
|
||||
const getEditedFileName = (fileName: string) => {
|
||||
const fileNameParts = fileName.split('.');
|
||||
|
@ -77,6 +79,13 @@ const getEditedFileName = (fileName: string) => {
|
|||
return editedFileName;
|
||||
};
|
||||
|
||||
export interface CropBoxProps {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const ImageEditorOverlay = (props: IProps) => {
|
||||
const appContext = useContext(AppContext);
|
||||
|
||||
|
@ -88,19 +97,17 @@ const ImageEditorOverlay = (props: IProps) => {
|
|||
|
||||
const [currentRotationAngle, setCurrentRotationAngle] = useState(0);
|
||||
|
||||
const [currentTab, setCurrentTab] = useState<'transform' | 'colours'>(
|
||||
'transform'
|
||||
);
|
||||
const [currentTab, setCurrentTab] = useState<OperationTab>('transform');
|
||||
|
||||
const [brightness, setBrightness] = useState(
|
||||
filterDefaultValues.brightness
|
||||
FILTER_DEFAULT_VALUES.brightness
|
||||
);
|
||||
const [contrast, setContrast] = useState(filterDefaultValues.contrast);
|
||||
const [blur, setBlur] = useState(filterDefaultValues.blur);
|
||||
const [contrast, setContrast] = useState(FILTER_DEFAULT_VALUES.contrast);
|
||||
const [blur, setBlur] = useState(FILTER_DEFAULT_VALUES.blur);
|
||||
const [saturation, setSaturation] = useState(
|
||||
filterDefaultValues.saturation
|
||||
FILTER_DEFAULT_VALUES.saturation
|
||||
);
|
||||
const [invert, setInvert] = useState(filterDefaultValues.invert);
|
||||
const [invert, setInvert] = useState(FILTER_DEFAULT_VALUES.invert);
|
||||
|
||||
const [transformationPerformed, setTransformationPerformed] =
|
||||
useState(false);
|
||||
|
@ -110,6 +117,149 @@ const ImageEditorOverlay = (props: IProps) => {
|
|||
|
||||
const [showControlsDrawer, setShowControlsDrawer] = useState(true);
|
||||
|
||||
const [previewCanvasScale, setPreviewCanvasScale] = useState(0);
|
||||
|
||||
const [cropBox, setCropBox] = useState<CropBoxProps>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
const [startX, setStartX] = useState(0);
|
||||
const [startY, setStartY] = useState(0);
|
||||
|
||||
const [beforeGrowthHeight, setBeforeGrowthHeight] = useState(0);
|
||||
const [beforeGrowthWidth, setBeforeGrowthWidth] = useState(0);
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isGrowing, setIsGrowing] = useState(false);
|
||||
|
||||
const cropBoxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getCanvasBoundsOffsets = () => {
|
||||
const canvasBounds = {
|
||||
height: canvasRef.current.height,
|
||||
width: canvasRef.current.width,
|
||||
};
|
||||
const parentBounds = parentRef.current.getBoundingClientRect();
|
||||
|
||||
// calculate the offset created by centering the canvas in its parent
|
||||
const offsetX = (parentBounds.width - canvasBounds.width) / 2;
|
||||
const offsetY = (parentBounds.height - canvasBounds.height) / 2;
|
||||
|
||||
return {
|
||||
offsetY,
|
||||
offsetX,
|
||||
canvasBounds,
|
||||
parentBounds,
|
||||
};
|
||||
};
|
||||
|
||||
const handleDragStart = (e) => {
|
||||
if (currentTab !== 'crop') return;
|
||||
|
||||
const rect = cropBoxRef.current.getBoundingClientRect();
|
||||
const offsetX = e.pageX - rect.left - rect.width / 2;
|
||||
const offsetY = e.pageY - rect.top - rect.height / 2;
|
||||
|
||||
// check if the cursor is near the corners of the box
|
||||
const isNearLeftOrRightEdge =
|
||||
e.pageX < rect.left + CORNER_THRESHOLD ||
|
||||
e.pageX > rect.right - CORNER_THRESHOLD;
|
||||
const isNearTopOrBottomEdge =
|
||||
e.pageY < rect.top + CORNER_THRESHOLD ||
|
||||
e.pageY > rect.bottom - CORNER_THRESHOLD;
|
||||
|
||||
if (isNearLeftOrRightEdge && isNearTopOrBottomEdge) {
|
||||
// cursor is near a corner, do not initiate dragging
|
||||
setIsGrowing(true);
|
||||
setStartX(e.pageX);
|
||||
setStartY(e.pageY);
|
||||
setBeforeGrowthWidth(cropBox.width);
|
||||
setBeforeGrowthHeight(cropBox.height);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDragging(true);
|
||||
setStartX(e.pageX - offsetX);
|
||||
setStartY(e.pageY - offsetY);
|
||||
};
|
||||
|
||||
const handleDrag = (e) => {
|
||||
if (!isDragging && !isGrowing) return;
|
||||
|
||||
// d- variables are the delta change between start and now
|
||||
const dx = e.pageX - startX;
|
||||
const dy = e.pageY - startY;
|
||||
|
||||
const { offsetX, offsetY, canvasBounds } = getCanvasBoundsOffsets();
|
||||
|
||||
if (isGrowing) {
|
||||
setCropBox((prev) => {
|
||||
const newWidth = Math.min(
|
||||
beforeGrowthWidth + dx,
|
||||
canvasBounds.width - prev.x + offsetX
|
||||
);
|
||||
const newHeight = Math.min(
|
||||
beforeGrowthHeight + dy,
|
||||
canvasBounds.height - prev.y + offsetY
|
||||
);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
setCropBox((prev) => {
|
||||
let newX = prev.x + dx;
|
||||
let newY = prev.y + dy;
|
||||
|
||||
// constrain the new position to the canvas boundaries, accounting for the offset
|
||||
newX = Math.max(
|
||||
offsetX,
|
||||
Math.min(newX, offsetX + canvasBounds.width - prev.width)
|
||||
);
|
||||
newY = Math.max(
|
||||
offsetY,
|
||||
Math.min(newY, offsetY + canvasBounds.height - prev.height)
|
||||
);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
x: newX,
|
||||
y: newY,
|
||||
};
|
||||
});
|
||||
setStartX(e.pageX);
|
||||
setStartY(e.pageY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setStartX(0);
|
||||
setStartY(0);
|
||||
|
||||
setIsGrowing(false);
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const resetCropBox = () => {
|
||||
setCropBox((prev) => {
|
||||
const { offsetX, offsetY, canvasBounds } = getCanvasBoundsOffsets();
|
||||
|
||||
return {
|
||||
...prev,
|
||||
x: offsetX,
|
||||
y: offsetY,
|
||||
height: canvasBounds.height,
|
||||
width: canvasBounds.width,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) {
|
||||
return;
|
||||
|
@ -117,17 +267,23 @@ const ImageEditorOverlay = (props: IProps) => {
|
|||
try {
|
||||
applyFilters([canvasRef.current, originalSizeCanvasRef.current]);
|
||||
setColoursAdjusted(
|
||||
brightness !== filterDefaultValues.brightness ||
|
||||
contrast !== filterDefaultValues.contrast ||
|
||||
blur !== filterDefaultValues.blur ||
|
||||
saturation !== filterDefaultValues.saturation ||
|
||||
invert !== filterDefaultValues.invert
|
||||
brightness !== FILTER_DEFAULT_VALUES.brightness ||
|
||||
contrast !== FILTER_DEFAULT_VALUES.contrast ||
|
||||
blur !== FILTER_DEFAULT_VALUES.blur ||
|
||||
saturation !== FILTER_DEFAULT_VALUES.saturation ||
|
||||
invert !== FILTER_DEFAULT_VALUES.invert
|
||||
);
|
||||
} catch (e) {
|
||||
logError(e, 'Error applying filters');
|
||||
}
|
||||
}, [brightness, contrast, blur, saturation, invert, canvasRef, fileURL]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentTab !== 'crop') return;
|
||||
resetCropBox();
|
||||
setShowControlsDrawer(false);
|
||||
}, [currentTab]);
|
||||
|
||||
const applyFilters = async (canvases: HTMLCanvasElement[]) => {
|
||||
try {
|
||||
for (const canvas of canvases) {
|
||||
|
@ -203,6 +359,7 @@ const ImageEditorOverlay = (props: IProps) => {
|
|||
}
|
||||
|
||||
setCanvasLoading(true);
|
||||
|
||||
resetFilters();
|
||||
setCurrentRotationAngle(0);
|
||||
|
||||
|
@ -226,6 +383,7 @@ const ImageEditorOverlay = (props: IProps) => {
|
|||
parentRef.current.clientWidth / img.width,
|
||||
parentRef.current.clientHeight / img.height
|
||||
);
|
||||
setPreviewCanvasScale(scale);
|
||||
|
||||
const width = img.width * scale;
|
||||
const height = img.height * scale;
|
||||
|
@ -246,6 +404,13 @@ const ImageEditorOverlay = (props: IProps) => {
|
|||
setColoursAdjusted(false);
|
||||
|
||||
setCanvasLoading(false);
|
||||
|
||||
resetCropBox();
|
||||
setStartX(0);
|
||||
setStartY(0);
|
||||
setIsDragging(false);
|
||||
setIsGrowing(false);
|
||||
|
||||
resolve(true);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
|
@ -387,35 +552,97 @@ const ImageEditorOverlay = (props: IProps) => {
|
|||
boxSizing={'border-box'}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center">
|
||||
justifyContent="center"
|
||||
position="relative"
|
||||
onMouseUp={handleDragEnd}
|
||||
onMouseMove={isDragging ? handleDrag : null}
|
||||
onMouseDown={handleDragStart}>
|
||||
<Box
|
||||
height="90%"
|
||||
width="100%"
|
||||
ref={parentRef}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center">
|
||||
{(fileURL === null || canvasLoading) && (
|
||||
<CircularProgress />
|
||||
)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}>
|
||||
<Box
|
||||
height="90%"
|
||||
width="100%"
|
||||
ref={parentRef}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
position="relative">
|
||||
{(fileURL === null || canvasLoading) && (
|
||||
<CircularProgress />
|
||||
)}
|
||||
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
display:
|
||||
fileURL === null || canvasLoading
|
||||
? 'none'
|
||||
: 'block',
|
||||
position: 'absolute',
|
||||
}}
|
||||
/>
|
||||
<canvas
|
||||
ref={originalSizeCanvasRef}
|
||||
style={{
|
||||
display: 'none',
|
||||
}}
|
||||
/>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
display:
|
||||
fileURL === null || canvasLoading
|
||||
? 'none'
|
||||
: 'block',
|
||||
position: 'absolute',
|
||||
}}
|
||||
/>
|
||||
<canvas
|
||||
ref={originalSizeCanvasRef}
|
||||
style={{
|
||||
display: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{currentTab === 'crop' && (
|
||||
<FreehandCropRegion
|
||||
cropBox={cropBox}
|
||||
ref={cropBoxRef}
|
||||
setIsDragging={setIsDragging}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{currentTab === 'crop' && (
|
||||
<CenteredFlex marginTop="1rem">
|
||||
<EnteButton
|
||||
color="accent"
|
||||
startIcon={<CropIcon />}
|
||||
onClick={() => {
|
||||
if (
|
||||
!cropBoxRef.current ||
|
||||
!canvasRef.current
|
||||
)
|
||||
return;
|
||||
|
||||
const { x1, x2, y1, y2 } =
|
||||
getCropRegionArgs(
|
||||
cropBoxRef.current,
|
||||
canvasRef.current
|
||||
);
|
||||
setCanvasLoading(true);
|
||||
setTransformationPerformed(true);
|
||||
cropRegionOfCanvas(
|
||||
canvasRef.current,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2
|
||||
);
|
||||
cropRegionOfCanvas(
|
||||
originalSizeCanvasRef.current,
|
||||
x1 / previewCanvasScale,
|
||||
y1 / previewCanvasScale,
|
||||
x2 / previewCanvasScale,
|
||||
y2 / previewCanvasScale
|
||||
);
|
||||
resetCropBox();
|
||||
setCanvasLoading(false);
|
||||
|
||||
setCurrentTab('transform');
|
||||
}}>
|
||||
{t('APPLY_CROP')}
|
||||
</EnteButton>
|
||||
</CenteredFlex>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
@ -441,6 +668,7 @@ const ImageEditorOverlay = (props: IProps) => {
|
|||
onChange={(_, value) => {
|
||||
setCurrentTab(value);
|
||||
}}>
|
||||
<Tab label={t('CROP')} value="crop" />
|
||||
<Tab label={t('TRANSFORM')} value="transform" />
|
||||
<Tab
|
||||
label={t('COLORS')}
|
||||
|
@ -463,18 +691,25 @@ const ImageEditorOverlay = (props: IProps) => {
|
|||
label={t('RESTORE_ORIGINAL')}
|
||||
/>
|
||||
</MenuItemGroup>
|
||||
{currentTab === 'transform' && (
|
||||
<ImageEditorOverlayContext.Provider
|
||||
value={{
|
||||
originalSizeCanvasRef,
|
||||
canvasRef,
|
||||
setCanvasLoading,
|
||||
canvasLoading,
|
||||
setTransformationPerformed,
|
||||
}}>
|
||||
<TransformMenu />
|
||||
</ImageEditorOverlayContext.Provider>
|
||||
)}
|
||||
<ImageEditorOverlayContext.Provider
|
||||
value={{
|
||||
originalSizeCanvasRef,
|
||||
canvasRef,
|
||||
setCanvasLoading,
|
||||
canvasLoading,
|
||||
setTransformationPerformed,
|
||||
setCurrentTab,
|
||||
}}>
|
||||
{currentTab === 'crop' && (
|
||||
<CropMenu
|
||||
previewScale={previewCanvasScale}
|
||||
cropBoxProps={cropBox}
|
||||
cropBoxRef={cropBoxRef}
|
||||
resetCropBox={resetCropBox}
|
||||
/>
|
||||
)}
|
||||
{currentTab === 'transform' && <TransformMenu />}
|
||||
</ImageEditorOverlayContext.Provider>
|
||||
{currentTab === 'colours' && (
|
||||
<ColoursMenu
|
||||
brightness={brightness}
|
||||
|
@ -495,14 +730,25 @@ const ImageEditorOverlay = (props: IProps) => {
|
|||
startIcon={<DownloadIcon />}
|
||||
onClick={downloadEditedPhoto}
|
||||
label={t('DOWNLOAD_EDITED')}
|
||||
disabled={
|
||||
!transformationPerformed && !coloursAdjusted
|
||||
}
|
||||
/>
|
||||
<MenuItemDivider />
|
||||
<EnteMenuItem
|
||||
startIcon={<CloudUploadIcon />}
|
||||
onClick={saveCopyToEnte}
|
||||
label={t('SAVE_A_COPY_TO_ENTE')}
|
||||
disabled={
|
||||
!transformationPerformed && !coloursAdjusted
|
||||
}
|
||||
/>
|
||||
</MenuItemGroup>
|
||||
{!transformationPerformed && !coloursAdjusted && (
|
||||
<MenuSectionTitle
|
||||
title={t('PHOTO_EDIT_REQUIRED_TO_SAVE')}
|
||||
/>
|
||||
)}
|
||||
</EnteDrawer>
|
||||
</Backdrop>
|
||||
</>
|
||||
|
|
|
@ -8,12 +8,12 @@ import {
|
|||
} from 'services/collectionService';
|
||||
import { EnteFile } from 'types/file';
|
||||
import {
|
||||
downloadFile,
|
||||
copyFileToClipboard,
|
||||
getFileExtension,
|
||||
getFileFromURL,
|
||||
isSupportedRawFormat,
|
||||
isRawFile,
|
||||
downloadSingleFile,
|
||||
} from 'utils/file';
|
||||
import { logError } from '@ente/shared/sentry';
|
||||
|
||||
|
@ -58,6 +58,7 @@ import isElectron from 'is-electron';
|
|||
import ReplayIcon from '@mui/icons-material/Replay';
|
||||
import ImageEditorOverlay from './ImageEditorOverlay';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import { SetFilesDownloadProgressAttributesCreator } from 'types/gallery';
|
||||
|
||||
interface PhotoswipeFullscreenAPI {
|
||||
enter: () => void;
|
||||
|
@ -85,13 +86,14 @@ interface Iprops {
|
|||
id?: string;
|
||||
className?: string;
|
||||
favItemIds: Set<number>;
|
||||
deletedFileIds: Set<number>;
|
||||
setDeletedFileIds?: (value: Set<number>) => void;
|
||||
tempDeletedFileIds: Set<number>;
|
||||
setTempDeletedFileIds?: (value: Set<number>) => void;
|
||||
isTrashCollection: boolean;
|
||||
isInHiddenSection: boolean;
|
||||
enableDownload: boolean;
|
||||
fileToCollectionsMap: Map<number, number[]>;
|
||||
collectionNameMap: Map<number, string>;
|
||||
setFilesDownloadProgressAttributesCreator: SetFilesDownloadProgressAttributesCreator;
|
||||
}
|
||||
|
||||
function PhotoViewer(props: Iprops) {
|
||||
|
@ -192,6 +194,12 @@ function PhotoViewer(props: Iprops) {
|
|||
case 'L':
|
||||
onFavClick(photoSwipe?.currItem as EnteFile);
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
handleArrowClick(event, 'left');
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
handleArrowClick(event, 'right');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
@ -259,7 +267,7 @@ function PhotoViewer(props: Iprops) {
|
|||
`download-btn-${item.id}`
|
||||
) as HTMLButtonElement;
|
||||
const downloadFile = () => {
|
||||
downloadFileHelper(photoSwipe.currItem);
|
||||
downloadFileHelper(photoSwipe.currItem as unknown as EnteFile);
|
||||
};
|
||||
|
||||
if (downloadLivePhotoBtn) {
|
||||
|
@ -352,6 +360,7 @@ function PhotoViewer(props: Iprops) {
|
|||
maxSpreadZoom: 5,
|
||||
index: currentIndex,
|
||||
showHideOpacity: true,
|
||||
arrowKeys: false,
|
||||
getDoubleTapZoom(isMouseClick, item) {
|
||||
if (isMouseClick) {
|
||||
return 2.5;
|
||||
|
@ -484,13 +493,13 @@ function PhotoViewer(props: Iprops) {
|
|||
};
|
||||
|
||||
const trashFile = async (file: EnteFile) => {
|
||||
const { deletedFileIds, setDeletedFileIds } = props;
|
||||
const { tempDeletedFileIds, setTempDeletedFileIds } = props;
|
||||
try {
|
||||
appContext.startLoading();
|
||||
await trashFiles([file]);
|
||||
appContext.finishLoading();
|
||||
deletedFileIds.add(file.id);
|
||||
setDeletedFileIds(new Set(deletedFileIds));
|
||||
tempDeletedFileIds.add(file.id);
|
||||
setTempDeletedFileIds(new Set(tempDeletedFileIds));
|
||||
updateItems(props.items.filter((item) => item.id !== file.id));
|
||||
needUpdate.current = true;
|
||||
} catch (e) {
|
||||
|
@ -505,6 +514,24 @@ function PhotoViewer(props: Iprops) {
|
|||
appContext.setDialogMessage(getTrashFileMessage(() => trashFile(file)));
|
||||
};
|
||||
|
||||
const handleArrowClick = (
|
||||
e: KeyboardEvent,
|
||||
direction: 'left' | 'right'
|
||||
) => {
|
||||
// ignore arrow clicks if the user is typing in a text field
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (direction === 'left') {
|
||||
photoSwipe.prev();
|
||||
} else {
|
||||
photoSwipe.next();
|
||||
}
|
||||
};
|
||||
|
||||
const updateItems = (items: EnteFile[]) => {
|
||||
try {
|
||||
if (photoSwipe) {
|
||||
|
@ -599,15 +626,21 @@ function PhotoViewer(props: Iprops) {
|
|||
setShowImageEditorOverlay(false);
|
||||
};
|
||||
|
||||
const downloadFileHelper = async (file) => {
|
||||
if (file && props.enableDownload) {
|
||||
appContext.startLoading();
|
||||
const downloadFileHelper = async (file: EnteFile) => {
|
||||
if (
|
||||
file &&
|
||||
props.enableDownload &&
|
||||
props.setFilesDownloadProgressAttributesCreator
|
||||
) {
|
||||
try {
|
||||
await downloadFile(file);
|
||||
const setSingleFileDownloadProgress =
|
||||
props.setFilesDownloadProgressAttributesCreator(
|
||||
file.metadata.title
|
||||
);
|
||||
await downloadSingleFile(file, setSingleFileDownloadProgress);
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
appContext.finishLoading();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -702,7 +735,9 @@ function PhotoViewer(props: Iprops) {
|
|||
onClose={() =>
|
||||
setConversionFailedNotificationOpen(false)
|
||||
}
|
||||
onClick={() => downloadFileHelper(photoSwipe.currItem)}
|
||||
onClick={() =>
|
||||
downloadFileHelper(photoSwipe.currItem as EnteFile)
|
||||
}
|
||||
/>
|
||||
|
||||
<Box
|
||||
|
@ -746,7 +781,9 @@ function PhotoViewer(props: Iprops) {
|
|||
className="pswp__button pswp__button--custom"
|
||||
title={t('DOWNLOAD_OPTION')}
|
||||
onClick={() =>
|
||||
downloadFileHelper(photoSwipe.currItem)
|
||||
downloadFileHelper(
|
||||
photoSwipe.currItem as EnteFile
|
||||
)
|
||||
}>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
|
|
|
@ -1,13 +1,7 @@
|
|||
import { IconButton } from '@mui/material';
|
||||
import pDebounce from 'p-debounce';
|
||||
import { AppContext } from 'pages/_app';
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
getAutoCompleteSuggestions,
|
||||
getDefaultOptions,
|
||||
|
@ -34,6 +28,9 @@ import { t } from 'i18next';
|
|||
import memoize from 'memoize-one';
|
||||
import { LocationTagData } from 'types/entity';
|
||||
import { FILE_TYPE } from 'constants/file';
|
||||
import { InputActionMeta } from 'react-select/src/types';
|
||||
import { components } from 'react-select';
|
||||
import { City } from 'services/locationSearchService';
|
||||
|
||||
interface Iprops {
|
||||
isOpen: boolean;
|
||||
|
@ -43,20 +40,33 @@ interface Iprops {
|
|||
collections: Collection[];
|
||||
}
|
||||
|
||||
const createComponents = memoize((Option, ValueContainer, Menu) => ({
|
||||
const createComponents = memoize((Option, ValueContainer, Menu, Input) => ({
|
||||
Option,
|
||||
ValueContainer,
|
||||
Menu,
|
||||
Input,
|
||||
}));
|
||||
|
||||
const VisibleInput = (props) => (
|
||||
<components.Input {...props} isHidden={false} />
|
||||
);
|
||||
|
||||
export default function SearchInput(props: Iprops) {
|
||||
const selectRef = useRef(null);
|
||||
const [value, setValue] = useState<SearchOption>(null);
|
||||
const appContext = useContext(AppContext);
|
||||
const handleChange = (value: SearchOption) => {
|
||||
setValue(value);
|
||||
setQuery(value.label);
|
||||
blur();
|
||||
};
|
||||
const handleInputChange = (value: string, actionMeta: InputActionMeta) => {
|
||||
if (actionMeta.action === 'input-change') {
|
||||
setQuery(value);
|
||||
}
|
||||
};
|
||||
const [defaultOptions, setDefaultOptions] = useState([]);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
search(value);
|
||||
|
@ -64,10 +74,12 @@ export default function SearchInput(props: Iprops) {
|
|||
|
||||
useEffect(() => {
|
||||
refreshDefaultOptions();
|
||||
const t = setInterval(() => refreshDefaultOptions(), 2000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
async function refreshDefaultOptions() {
|
||||
const defaultOptions = await getDefaultOptions(props.files);
|
||||
const defaultOptions = await getDefaultOptions();
|
||||
setDefaultOptions(defaultOptions);
|
||||
}
|
||||
|
||||
|
@ -80,14 +92,22 @@ export default function SearchInput(props: Iprops) {
|
|||
}, 10);
|
||||
props.setIsOpen(false);
|
||||
setValue(null);
|
||||
setQuery('');
|
||||
}
|
||||
};
|
||||
|
||||
const getOptions = pDebounce(
|
||||
getAutoCompleteSuggestions(props.files, props.collections),
|
||||
250
|
||||
const getOptions = useCallback(
|
||||
pDebounce(
|
||||
getAutoCompleteSuggestions(props.files, props.collections),
|
||||
250
|
||||
),
|
||||
[props.files, props.collections]
|
||||
);
|
||||
|
||||
const blur = () => {
|
||||
selectRef.current?.blur();
|
||||
};
|
||||
|
||||
const search = (selectedOption: SearchOption) => {
|
||||
if (!selectedOption) {
|
||||
return;
|
||||
|
@ -106,9 +126,16 @@ export default function SearchInput(props: Iprops) {
|
|||
};
|
||||
props.setIsOpen(true);
|
||||
break;
|
||||
case SuggestionType.CITY:
|
||||
search = {
|
||||
city: selectedOption.value as City,
|
||||
};
|
||||
props.setIsOpen(true);
|
||||
break;
|
||||
case SuggestionType.COLLECTION:
|
||||
search = { collection: selectedOption.value as number };
|
||||
setValue(null);
|
||||
setQuery('');
|
||||
break;
|
||||
case SuggestionType.FILE_NAME:
|
||||
search = { files: selectedOption.value as number[] };
|
||||
|
@ -159,7 +186,8 @@ export default function SearchInput(props: Iprops) {
|
|||
const components = createComponents(
|
||||
OptionWithInfo,
|
||||
ValueContainerWithIcon,
|
||||
MemoizedMenuWithPeople
|
||||
MemoizedMenuWithPeople,
|
||||
VisibleInput
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -173,6 +201,8 @@ export default function SearchInput(props: Iprops) {
|
|||
onChange={handleChange}
|
||||
onFocus={handleOnFocus}
|
||||
isClearable
|
||||
inputValue={query}
|
||||
onInputChange={handleInputChange}
|
||||
escapeClearsValue
|
||||
styles={SelectStyles}
|
||||
defaultOptions={
|
||||
|
|
|
@ -17,6 +17,7 @@ const getIconByType = (type: SuggestionType) => {
|
|||
case SuggestionType.DATE:
|
||||
return <CalendarIcon />;
|
||||
case SuggestionType.LOCATION:
|
||||
case SuggestionType.CITY:
|
||||
return <LocationIcon />;
|
||||
case SuggestionType.COLLECTION:
|
||||
return <FolderIcon />;
|
||||
|
|
|
@ -52,15 +52,13 @@ export default function AdvancedSettings({ open, onClose, onRootClose }) {
|
|||
});
|
||||
|
||||
useEffect(() => {
|
||||
ClipService.setOnUpdateHandler(setIndexingStatus);
|
||||
const main = async () => {
|
||||
setIndexingStatus(await ClipService.getIndexingStatus());
|
||||
ClipService.setOnUpdateHandler(setIndexingStatus);
|
||||
};
|
||||
main();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
ClipService.updateIndexStatus();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<EnteDrawer
|
||||
transitionDuration={0}
|
||||
|
@ -112,7 +110,9 @@ export default function AdvancedSettings({ open, onClose, onRootClose }) {
|
|||
|
||||
{isElectron() && (
|
||||
<Box>
|
||||
<MenuSectionTitle title={t('STATUS')} />
|
||||
<MenuSectionTitle
|
||||
title={t('MAGIC_SEARCH_STATUS')}
|
||||
/>
|
||||
<Stack py={'12px'} px={'12px'} spacing={'24px'}>
|
||||
<VerticallyCenteredFlex
|
||||
justifyContent="space-between"
|
||||
|
|
|
@ -12,6 +12,7 @@ import UploadProgressContext from 'contexts/uploadProgress';
|
|||
import { t } from 'i18next';
|
||||
|
||||
import { UPLOAD_STAGES } from 'constants/upload';
|
||||
import { CaptionedText } from 'components/CaptionedText';
|
||||
|
||||
export const InProgressSection = () => {
|
||||
const { inProgressUploads, hasLivePhotos, uploadFileNames, uploadStage } =
|
||||
|
@ -44,9 +45,14 @@ export const InProgressSection = () => {
|
|||
return (
|
||||
<UploadProgressSection>
|
||||
<UploadProgressSectionTitle expandIcon={<ExpandMoreIcon />}>
|
||||
{uploadStage === UPLOAD_STAGES.EXTRACTING_METADATA
|
||||
? t('INPROGRESS_METADATA_EXTRACTION')
|
||||
: t('INPROGRESS_UPLOADS')}
|
||||
<CaptionedText
|
||||
mainText={
|
||||
uploadStage === UPLOAD_STAGES.EXTRACTING_METADATA
|
||||
? t('INPROGRESS_METADATA_EXTRACTION')
|
||||
: t('INPROGRESS_UPLOADS')
|
||||
}
|
||||
subText={String(inProgressUploads?.length ?? 0)}
|
||||
/>
|
||||
</UploadProgressSectionTitle>
|
||||
<UploadProgressSectionContent>
|
||||
{hasLivePhotos && (
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import React, { useContext } from 'react';
|
||||
import { useContext } from 'react';
|
||||
import ItemList from 'components/ItemList';
|
||||
import { Typography } from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import { ResultItemContainer } from './styledComponents';
|
||||
import { UPLOAD_RESULT } from 'constants/upload';
|
||||
|
@ -11,6 +10,7 @@ import {
|
|||
UploadProgressSectionTitle,
|
||||
} from './section';
|
||||
import UploadProgressContext from 'contexts/uploadProgress';
|
||||
import { CaptionedText } from 'components/CaptionedText';
|
||||
|
||||
export interface ResultSectionProps {
|
||||
uploadResult: UPLOAD_RESULT;
|
||||
|
@ -46,7 +46,10 @@ export const ResultSection = (props: ResultSectionProps) => {
|
|||
return (
|
||||
<UploadProgressSection>
|
||||
<UploadProgressSectionTitle expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography> {props.sectionTitle}</Typography>
|
||||
<CaptionedText
|
||||
mainText={props.sectionTitle}
|
||||
subText={String(fileList?.length ?? 0)}
|
||||
/>
|
||||
</UploadProgressSectionTitle>
|
||||
<UploadProgressSectionContent>
|
||||
{props.sectionInfo && (
|
||||
|
|
|
@ -391,7 +391,7 @@ export default function Uploader(props: Props) {
|
|||
) => {
|
||||
try {
|
||||
addLogLine(
|
||||
`upload file to an existing collection - "${collection.name}"`
|
||||
`upload file to an existing collection name:${collection.name}, collectionID:${collection.id}`
|
||||
);
|
||||
await preCollectionCreationAction();
|
||||
const filesWithCollectionToUpload: FileWithCollection[] =
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { EnteFile } from 'types/file';
|
||||
import { styled } from '@mui/material';
|
||||
import { Tooltip, styled } from '@mui/material';
|
||||
import PlayCircleOutlineOutlinedIcon from '@mui/icons-material/PlayCircleOutlineOutlined';
|
||||
import DownloadManager from 'services/download';
|
||||
import useLongPress from '@ente/shared/hooks/useLongPress';
|
||||
|
@ -218,6 +218,12 @@ export default function PreviewCard(props: IProps) {
|
|||
const galleryContext = useContext(GalleryContext);
|
||||
const deduplicateContext = useContext(DeduplicateContext);
|
||||
|
||||
const longPressCallback = () => {
|
||||
onSelect(!selected);
|
||||
};
|
||||
|
||||
const longPress = useLongPress(longPressCallback, 500);
|
||||
|
||||
const {
|
||||
file,
|
||||
onClick,
|
||||
|
@ -289,22 +295,19 @@ export default function PreviewCard(props: IProps) {
|
|||
}
|
||||
};
|
||||
|
||||
const longPressCallback = () => {
|
||||
onSelect(!selected);
|
||||
};
|
||||
const handleHover = () => {
|
||||
if (isRangeSelectActive) {
|
||||
onHover();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
const renderFn = () => (
|
||||
<Cont
|
||||
key={`thumb-${file.id}}`}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleHover}
|
||||
disabled={!file?.msrc && !imgSrc}
|
||||
{...(selectable ? useLongPress(longPressCallback, 500) : {})}>
|
||||
{...(selectable ? longPress : {})}>
|
||||
{selectable && (
|
||||
<Check
|
||||
type="checkbox"
|
||||
|
@ -360,4 +363,22 @@ export default function PreviewCard(props: IProps) {
|
|||
)}
|
||||
</Cont>
|
||||
);
|
||||
|
||||
if (deduplicateContext.isOnDeduplicatePage) {
|
||||
return (
|
||||
<Tooltip
|
||||
placement="bottom-start"
|
||||
enterDelay={300}
|
||||
enterNextDelay={100}
|
||||
title={`${
|
||||
file.metadata.title
|
||||
} - ${deduplicateContext.collectionNameMap.get(
|
||||
file.collectionID
|
||||
)}`}>
|
||||
{renderFn()}
|
||||
</Tooltip>
|
||||
);
|
||||
} else {
|
||||
return renderFn();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
import { useContext } from 'react';
|
||||
import { FluidContainer } from '@ente/shared/components/Container';
|
||||
import { SelectionBar } from '@ente/shared/components/Navbar/SelectionBar';
|
||||
import { AppContext } from 'pages/_app';
|
||||
import { Box, IconButton, Stack, Tooltip } from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import { t } from 'i18next';
|
||||
import { formatNumber } from 'utils/number/format';
|
||||
|
||||
interface Props {
|
||||
count: number;
|
||||
clearSelection: () => void;
|
||||
downloadFilesHelper: () => void;
|
||||
}
|
||||
|
||||
const SelectedFileOptions = ({
|
||||
downloadFilesHelper,
|
||||
count,
|
||||
clearSelection,
|
||||
}: Props) => {
|
||||
const { isMobile } = useContext(AppContext);
|
||||
|
||||
return (
|
||||
<SelectionBar isMobile={isMobile}>
|
||||
<FluidContainer>
|
||||
<IconButton onClick={clearSelection}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Box ml={1.5}>
|
||||
{formatNumber(count)} {t('SELECTED')}{' '}
|
||||
</Box>
|
||||
</FluidContainer>
|
||||
<Stack spacing={2} direction="row" mr={2}>
|
||||
<Tooltip title={t('DOWNLOAD')}>
|
||||
<IconButton onClick={downloadFilesHelper}>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</SelectionBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectedFileOptions;
|
10
apps/photos/src/constants/photoEditor.ts
Normal file
10
apps/photos/src/constants/photoEditor.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
export const FILTER_DEFAULT_VALUES = {
|
||||
brightness: 100,
|
||||
contrast: 100,
|
||||
blur: 0,
|
||||
saturation: 100,
|
||||
invert: false,
|
||||
};
|
||||
|
||||
// CORNER_THRESHOLD defines the threshold near the corners of the crop box in which dragging is assumed as not the intention
|
||||
export const CORNER_THRESHOLD = 20;
|
|
@ -62,6 +62,7 @@ import exportService from 'services/export';
|
|||
import { REDIRECTS } from 'constants/redirects';
|
||||
import {
|
||||
getLocalMapEnabled,
|
||||
getToken,
|
||||
setLocalMapEnabled,
|
||||
} from '@ente/shared/storage/localStorage/helpers';
|
||||
import { isExportInProgress } from 'utils/export';
|
||||
|
@ -76,6 +77,7 @@ import { useLocalState } from '@ente/shared/hooks/useLocalState';
|
|||
import { PHOTOS_PAGES as PAGES } from '@ente/shared/constants/pages';
|
||||
import { getTheme } from '@ente/shared/themes';
|
||||
import { AppUpdateInfo } from '@ente/shared/electron/types';
|
||||
import DownloadManager from 'services/download';
|
||||
|
||||
const redirectMap = new Map([
|
||||
[REDIRECTS.ROADMAP, getRoadmapRedirectURL],
|
||||
|
@ -232,6 +234,14 @@ export default function App(props: EnteAppProps) {
|
|||
const initExport = async () => {
|
||||
try {
|
||||
addLogLine('init export');
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
addLogLine(
|
||||
'User not logged in, not starting export continuous sync job'
|
||||
);
|
||||
return;
|
||||
}
|
||||
await DownloadManager.init(APPS.PHOTOS, { token });
|
||||
const exportSettings = exportService.getExportSettings();
|
||||
if (!exportService.exportFolderExists(exportSettings?.folder)) {
|
||||
return;
|
||||
|
|
|
@ -97,6 +97,8 @@ import { EnteFile } from 'types/file';
|
|||
import {
|
||||
GalleryContextType,
|
||||
SelectedState,
|
||||
SetFilesDownloadProgressAttributes,
|
||||
SetFilesDownloadProgressAttributesCreator,
|
||||
UploadTypeSelectorIntent,
|
||||
} from 'types/gallery';
|
||||
import Collections from 'components/Collections';
|
||||
|
@ -120,7 +122,6 @@ import GalleryEmptyState from 'components/GalleryEmptyState';
|
|||
import AuthenticateUserModal from 'components/AuthenticateUserModal';
|
||||
import useMemoSingleThreaded from '@ente/shared/hooks/useMemoSingleThreaded';
|
||||
import { isArchivedFile } from 'utils/magicMetadata';
|
||||
import { isSameDayAnyYear, isInsideLocationTag } from 'utils/search';
|
||||
import { getSessionExpiredMessage } from 'utils/ui';
|
||||
import { syncEntities } from 'services/entityService';
|
||||
import { constructUserIDToEmailMap } from 'services/collectionService';
|
||||
|
@ -131,6 +132,13 @@ import { ClipService } from 'services/clipService';
|
|||
import isElectron from 'is-electron';
|
||||
import downloadManager from 'services/download';
|
||||
import { APPS } from '@ente/shared/apps/constants';
|
||||
import {
|
||||
FilesDownloadProgress,
|
||||
FilesDownloadProgressAttributes,
|
||||
} from 'components/FilesDownloadProgress';
|
||||
import locationSearchService from 'services/locationSearchService';
|
||||
import ComlinkSearchWorker from 'utils/comlink/ComlinkSearchWorker';
|
||||
import useEffectSingleThreaded from '@ente/shared/hooks/useEffectSingleThreaded';
|
||||
|
||||
export const DeadCenter = styled('div')`
|
||||
flex: 1;
|
||||
|
@ -225,10 +233,11 @@ export default function Gallery() {
|
|||
const syncInProgress = useRef(true);
|
||||
const syncInterval = useRef<NodeJS.Timeout>();
|
||||
const resync = useRef<{ force: boolean; silent: boolean }>();
|
||||
const [deletedFileIds, setDeletedFileIds] = useState<Set<number>>(
|
||||
// tempDeletedFileIds and tempHiddenFileIds are used to keep track of files that are deleted/hidden in the current session but not yet synced with the server.
|
||||
const [tempDeletedFileIds, setTempDeletedFileIds] = useState<Set<number>>(
|
||||
new Set<number>()
|
||||
);
|
||||
const [hiddenFileIds, setHiddenFileIds] = useState<Set<number>>(
|
||||
const [tempHiddenFileIds, setTempHiddenFileIds] = useState<Set<number>>(
|
||||
new Set<number>()
|
||||
);
|
||||
const { startLoading, finishLoading, setDialogMessage, ...appContext } =
|
||||
|
@ -242,6 +251,9 @@ export default function Gallery() {
|
|||
const [emailList, setEmailList] = useState<string[]>(null);
|
||||
const [activeCollectionID, setActiveCollectionID] =
|
||||
useState<number>(undefined);
|
||||
const [hiddenFileIds, setHiddenFileIds] = useState<Set<number>>(
|
||||
new Set<number>()
|
||||
);
|
||||
const [fixCreationTimeView, setFixCreationTimeView] = useState(false);
|
||||
const [fixCreationTimeAttributes, setFixCreationTimeAttributes] =
|
||||
useState<FixCreationTimeAttributes>(null);
|
||||
|
@ -280,6 +292,11 @@ export default function Gallery() {
|
|||
|
||||
const [isInHiddenSection, setIsInHiddenSection] = useState(false);
|
||||
|
||||
const [
|
||||
filesDownloadProgressAttributesList,
|
||||
setFilesDownloadProgressAttributesList,
|
||||
] = useState<FilesDownloadProgressAttributes[]>([]);
|
||||
|
||||
const openHiddenSection: GalleryContextType['openHiddenSection'] = (
|
||||
callback
|
||||
) => {
|
||||
|
@ -341,6 +358,7 @@ export default function Gallery() {
|
|||
setIsFirstLoad(false);
|
||||
setJustSignedUp(false);
|
||||
setIsFirstFetch(false);
|
||||
locationSearchService.loadCities();
|
||||
syncInterval.current = setInterval(() => {
|
||||
syncWithRemote(false, true);
|
||||
}, SYNC_INTERVAL_IN_MICROSECONDS);
|
||||
|
@ -361,6 +379,14 @@ export default function Gallery() {
|
|||
};
|
||||
}, []);
|
||||
|
||||
useEffectSingleThreaded(
|
||||
async ([files]: [files: EnteFile[]]) => {
|
||||
const searchWorker = await ComlinkSearchWorker.getInstance();
|
||||
await searchWorker.setFiles(files);
|
||||
},
|
||||
[files]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !files || !collections || !hiddenFiles || !trashedFiles) {
|
||||
return;
|
||||
|
@ -466,7 +492,9 @@ export default function Gallery() {
|
|||
);
|
||||
}, [collections, activeCollectionID]);
|
||||
|
||||
const filteredData = useMemoSingleThreaded((): EnteFile[] => {
|
||||
const filteredData = useMemoSingleThreaded(async (): Promise<
|
||||
EnteFile[]
|
||||
> => {
|
||||
if (
|
||||
!files ||
|
||||
!user ||
|
||||
|
@ -480,117 +508,74 @@ export default function Gallery() {
|
|||
if (activeCollectionID === TRASH_SECTION && !isInSearchMode) {
|
||||
return getUniqueFiles([
|
||||
...trashedFiles,
|
||||
...files.filter((file) => deletedFileIds?.has(file.id)),
|
||||
...files.filter((file) => tempDeletedFileIds?.has(file.id)),
|
||||
]);
|
||||
}
|
||||
|
||||
const filteredFiles = getUniqueFiles(
|
||||
(isInHiddenSection ? hiddenFiles : files).filter((item) => {
|
||||
if (deletedFileIds?.has(item.id)) {
|
||||
return false;
|
||||
}
|
||||
const searchWorker = await ComlinkSearchWorker.getInstance();
|
||||
|
||||
if (!isInHiddenSection && hiddenFileIds?.has(item.id)) {
|
||||
return false;
|
||||
}
|
||||
let filteredFiles: EnteFile[] = [];
|
||||
if (isInSearchMode) {
|
||||
filteredFiles = getUniqueFiles(await searchWorker.search(search));
|
||||
} else {
|
||||
filteredFiles = getUniqueFiles(
|
||||
(isInHiddenSection ? hiddenFiles : files).filter((item) => {
|
||||
if (tempDeletedFileIds?.has(item.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// SEARCH MODE
|
||||
if (isInSearchMode) {
|
||||
if (
|
||||
search?.date &&
|
||||
!isSameDayAnyYear(search.date)(
|
||||
new Date(item.metadata.creationTime / 1000)
|
||||
)
|
||||
) {
|
||||
if (!isInHiddenSection && tempHiddenFileIds?.has(item.id)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
search?.location &&
|
||||
!isInsideLocationTag(
|
||||
{
|
||||
latitude: item.metadata.latitude,
|
||||
longitude: item.metadata.longitude,
|
||||
},
|
||||
search.location
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
search?.person &&
|
||||
search.person.files.indexOf(item.id) === -1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
search?.thing &&
|
||||
search.thing.files.indexOf(item.id) === -1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
search?.text &&
|
||||
search.text.files.indexOf(item.id) === -1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (search?.files && search.files.indexOf(item.id) === -1) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof search?.fileType !== 'undefined' &&
|
||||
search.fileType !== item.metadata.fileType
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (search?.clip && search.clip.has(item.id) === false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// archived collections files can only be seen in their respective collection
|
||||
if (archivedCollections.has(item.collectionID)) {
|
||||
// archived collections files can only be seen in their respective collection
|
||||
if (archivedCollections.has(item.collectionID)) {
|
||||
if (activeCollectionID === item.collectionID) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// HIDDEN ITEMS SECTION - show all individual hidden files
|
||||
if (
|
||||
activeCollectionID === HIDDEN_ITEMS_SECTION &&
|
||||
defaultHiddenCollectionIDs.has(item.collectionID)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Archived files can only be seen in archive section or their respective collection
|
||||
if (isArchivedFile(item)) {
|
||||
if (
|
||||
activeCollectionID === ARCHIVE_SECTION ||
|
||||
activeCollectionID === item.collectionID
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ALL SECTION - show all files
|
||||
if (activeCollectionID === ALL_SECTION) {
|
||||
// show all files except the ones in hidden collections
|
||||
if (hiddenFileIds.has(item.id)) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// COLLECTION SECTION - show files in the active collection
|
||||
if (activeCollectionID === item.collectionID) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// HIDDEN ITEMS SECTION - show all individual hidden files
|
||||
if (
|
||||
activeCollectionID === HIDDEN_ITEMS_SECTION &&
|
||||
defaultHiddenCollectionIDs.has(item.collectionID)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Archived files can only be seen in archive section or their respective collection
|
||||
if (isArchivedFile(item)) {
|
||||
if (
|
||||
activeCollectionID === ARCHIVE_SECTION ||
|
||||
activeCollectionID === item.collectionID
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ALL SECTION - show all files
|
||||
if (activeCollectionID === ALL_SECTION) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// COLLECTION SECTION - show files in the active collection
|
||||
if (activeCollectionID === item.collectionID) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
if (search?.clip) {
|
||||
return filteredFiles.sort((a, b) => {
|
||||
return search.clip.get(b.id) - search.clip.get(a.id);
|
||||
|
@ -606,7 +591,8 @@ export default function Gallery() {
|
|||
files,
|
||||
trashedFiles,
|
||||
hiddenFiles,
|
||||
deletedFileIds,
|
||||
tempDeletedFileIds,
|
||||
tempHiddenFileIds,
|
||||
hiddenFileIds,
|
||||
search,
|
||||
activeCollectionID,
|
||||
|
@ -737,8 +723,8 @@ export default function Gallery() {
|
|||
logError(e, 'syncWithRemote failed');
|
||||
}
|
||||
} finally {
|
||||
setDeletedFileIds(new Set());
|
||||
setHiddenFileIds(new Set());
|
||||
setTempDeletedFileIds(new Set());
|
||||
setTempHiddenFileIds(new Set());
|
||||
!silent && finishLoading();
|
||||
}
|
||||
syncInProgress.current = false;
|
||||
|
@ -783,6 +769,8 @@ export default function Gallery() {
|
|||
const defaultHiddenCollectionIDs =
|
||||
getDefaultHiddenCollectionIDs(hiddenCollections);
|
||||
setDefaultHiddenCollectionIDs(defaultHiddenCollectionIDs);
|
||||
const hiddenFileIds = new Set<number>(hiddenFiles.map((f) => f.id));
|
||||
setHiddenFileIds(hiddenFileIds);
|
||||
const collectionSummaries = getCollectionSummaries(
|
||||
user,
|
||||
collections,
|
||||
|
@ -816,6 +804,39 @@ export default function Gallery() {
|
|||
return <div />;
|
||||
}
|
||||
|
||||
const setFilesDownloadProgressAttributesCreator: SetFilesDownloadProgressAttributesCreator =
|
||||
(folderName, collectionID, isHidden) => {
|
||||
const id = filesDownloadProgressAttributesList?.length ?? 0;
|
||||
const updater: SetFilesDownloadProgressAttributes = (value) => {
|
||||
setFilesDownloadProgressAttributesList((prev) => {
|
||||
const attributes = prev?.find((attr) => attr.id === id);
|
||||
const updatedAttributes =
|
||||
typeof value === 'function'
|
||||
? value(attributes)
|
||||
: { ...attributes, ...value };
|
||||
const updatedAttributesList = attributes
|
||||
? prev.map((attr) =>
|
||||
attr.id === id ? updatedAttributes : attr
|
||||
)
|
||||
: [...prev, updatedAttributes];
|
||||
|
||||
return updatedAttributesList;
|
||||
});
|
||||
};
|
||||
updater({
|
||||
id,
|
||||
folderName,
|
||||
collectionID,
|
||||
isHidden,
|
||||
canceller: null,
|
||||
total: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
downloadDirPath: null,
|
||||
});
|
||||
return updater;
|
||||
};
|
||||
|
||||
const collectionOpsHelper =
|
||||
(ops: COLLECTION_OPS_TYPE) => async (collection: Collection) => {
|
||||
startLoading();
|
||||
|
@ -836,13 +857,20 @@ export default function Gallery() {
|
|||
selected.collectionID
|
||||
);
|
||||
}
|
||||
|
||||
if (selected?.ownCount === filteredData?.length) {
|
||||
if (
|
||||
ops === COLLECTION_OPS_TYPE.REMOVE ||
|
||||
ops === COLLECTION_OPS_TYPE.RESTORE ||
|
||||
ops === COLLECTION_OPS_TYPE.MOVE
|
||||
) {
|
||||
// redirect to all section when no items are left in the current collection.
|
||||
setActiveCollectionID(ALL_SECTION);
|
||||
} else if (ops === COLLECTION_OPS_TYPE.UNHIDE) {
|
||||
exitHiddenSection();
|
||||
}
|
||||
}
|
||||
clearSelection();
|
||||
await syncWithRemote(false, true);
|
||||
if (isInHiddenSection && ops === COLLECTION_OPS_TYPE.UNHIDE) {
|
||||
exitHiddenSection();
|
||||
}
|
||||
setActiveCollectionID(collection.id);
|
||||
} catch (e) {
|
||||
logError(e, 'collection ops failed', { ops });
|
||||
setDialogMessage({
|
||||
|
@ -872,11 +900,20 @@ export default function Gallery() {
|
|||
await handleFileOps(
|
||||
ops,
|
||||
toProcessFiles,
|
||||
setDeletedFileIds,
|
||||
setHiddenFileIds,
|
||||
setFixCreationTimeAttributes
|
||||
setTempDeletedFileIds,
|
||||
setTempHiddenFileIds,
|
||||
setFixCreationTimeAttributes,
|
||||
setFilesDownloadProgressAttributesCreator
|
||||
);
|
||||
}
|
||||
if (
|
||||
selected?.ownCount === filteredData?.length &&
|
||||
ops !== FILE_OPS_TYPE.ARCHIVE &&
|
||||
ops !== FILE_OPS_TYPE.DOWNLOAD &&
|
||||
ops !== FILE_OPS_TYPE.FIX_TIME
|
||||
) {
|
||||
setActiveCollectionID(ALL_SECTION);
|
||||
}
|
||||
clearSelection();
|
||||
await syncWithRemote(false, true);
|
||||
} catch (e) {
|
||||
|
@ -1013,6 +1050,10 @@ export default function Gallery() {
|
|||
attributes={collectionSelectorAttributes}
|
||||
collections={collections}
|
||||
/>
|
||||
<FilesDownloadProgress
|
||||
attributesList={filesDownloadProgressAttributesList}
|
||||
setAttributesList={setFilesDownloadProgressAttributesList}
|
||||
/>
|
||||
<FixCreationTime
|
||||
isOpen={fixCreationTimeView}
|
||||
hide={() => setFixCreationTimeView(false)}
|
||||
|
@ -1042,6 +1083,12 @@ export default function Gallery() {
|
|||
hiddenCollectionSummaries={hiddenCollectionSummaries}
|
||||
setCollectionNamerAttributes={setCollectionNamerAttributes}
|
||||
setPhotoListHeader={setPhotoListHeader}
|
||||
setFilesDownloadProgressAttributesCreator={
|
||||
setFilesDownloadProgressAttributesCreator
|
||||
}
|
||||
filesDownloadProgressAttributesList={
|
||||
filesDownloadProgressAttributesList
|
||||
}
|
||||
/>
|
||||
|
||||
<Uploader
|
||||
|
@ -1098,8 +1145,8 @@ export default function Gallery() {
|
|||
favItemIds={favItemIds}
|
||||
setSelected={setSelected}
|
||||
selected={selected}
|
||||
deletedFileIds={deletedFileIds}
|
||||
setDeletedFileIds={setDeletedFileIds}
|
||||
tempDeletedFileIds={tempDeletedFileIds}
|
||||
setTempDeletedFileIds={setTempDeletedFileIds}
|
||||
setIsPhotoSwipeOpen={setIsPhotoSwipeOpen}
|
||||
activeCollectionID={activeCollectionID}
|
||||
enableDownload={true}
|
||||
|
@ -1109,6 +1156,9 @@ export default function Gallery() {
|
|||
files.length < 30 && !isInSearchMode
|
||||
}
|
||||
isInHiddenSection={isInHiddenSection}
|
||||
setFilesDownloadProgressAttributesCreator={
|
||||
setFilesDownloadProgressAttributesCreator
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{selected.count > 0 &&
|
||||
|
|
|
@ -16,7 +16,12 @@ import {
|
|||
} from 'services/publicCollectionService';
|
||||
import { Collection } from 'types/collection';
|
||||
import { EnteFile } from 'types/file';
|
||||
import { downloadFile, mergeMetadata, sortFiles } from 'utils/file';
|
||||
import {
|
||||
downloadSelectedFiles,
|
||||
getSelectedFiles,
|
||||
mergeMetadata,
|
||||
sortFiles,
|
||||
} from 'utils/file';
|
||||
import { AppContext } from 'pages/_app';
|
||||
import { PublicCollectionGalleryContext } from 'utils/publicCollectionGallery';
|
||||
import { CustomError, parseSharingErrorCodes } from '@ente/shared/error';
|
||||
|
@ -52,7 +57,12 @@ import UploadButton from 'components/Upload/UploadButton';
|
|||
import bs58 from 'bs58';
|
||||
import AddPhotoAlternateOutlined from '@mui/icons-material/AddPhotoAlternateOutlined';
|
||||
import ComlinkCryptoWorker from '@ente/shared/crypto';
|
||||
import { UploadTypeSelectorIntent } from 'types/gallery';
|
||||
import {
|
||||
SelectedState,
|
||||
SetFilesDownloadProgressAttributes,
|
||||
SetFilesDownloadProgressAttributesCreator,
|
||||
UploadTypeSelectorIntent,
|
||||
} from 'types/gallery';
|
||||
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
|
||||
import MoreHoriz from '@mui/icons-material/MoreHoriz';
|
||||
import OverflowMenu from '@ente/shared/components/OverflowMenu/menu';
|
||||
|
@ -60,6 +70,12 @@ import { OverflowMenuOption } from '@ente/shared/components/OverflowMenu/option'
|
|||
import { ENTE_WEBSITE_LINK } from '@ente/shared/constants/urls';
|
||||
import { APPS } from '@ente/shared/apps/constants';
|
||||
import downloadManager from 'services/download';
|
||||
import {
|
||||
FilesDownloadProgress,
|
||||
FilesDownloadProgressAttributes,
|
||||
} from 'components/FilesDownloadProgress';
|
||||
import { downloadCollectionFiles, isHiddenCollection } from 'utils/collection';
|
||||
import SelectedFileOptions from 'components/pages/sharedAlbum/SelectedFileOptions';
|
||||
|
||||
export default function PublicCollectionGallery() {
|
||||
const token = useRef<string>(null);
|
||||
|
@ -86,6 +102,11 @@ export default function PublicCollectionGallery() {
|
|||
const [uploadTypeSelectorView, setUploadTypeSelectorView] = useState(false);
|
||||
const [blockingLoad, setBlockingLoad] = useState(false);
|
||||
const [shouldDisableDropzone, setShouldDisableDropzone] = useState(false);
|
||||
const [selected, setSelected] = useState<SelectedState>({
|
||||
ownCount: 0,
|
||||
count: 0,
|
||||
collectionID: 0,
|
||||
});
|
||||
|
||||
const {
|
||||
getRootProps: getDragAndDropRootProps,
|
||||
|
@ -111,6 +132,44 @@ export default function PublicCollectionGallery() {
|
|||
directory: true,
|
||||
});
|
||||
|
||||
const [
|
||||
filesDownloadProgressAttributesList,
|
||||
setFilesDownloadProgressAttributesList,
|
||||
] = useState<FilesDownloadProgressAttributes[]>([]);
|
||||
|
||||
const setFilesDownloadProgressAttributesCreator: SetFilesDownloadProgressAttributesCreator =
|
||||
(folderName, collectionID, isHidden) => {
|
||||
const id = filesDownloadProgressAttributesList?.length ?? 0;
|
||||
const updater: SetFilesDownloadProgressAttributes = (value) => {
|
||||
setFilesDownloadProgressAttributesList((prev) => {
|
||||
const attributes = prev?.find((attr) => attr.id === id);
|
||||
const updatedAttributes =
|
||||
typeof value === 'function'
|
||||
? value(attributes)
|
||||
: { ...attributes, ...value };
|
||||
const updatedAttributesList = attributes
|
||||
? prev.map((attr) =>
|
||||
attr.id === id ? updatedAttributes : attr
|
||||
)
|
||||
: [...prev, updatedAttributes];
|
||||
|
||||
return updatedAttributesList;
|
||||
});
|
||||
};
|
||||
updater({
|
||||
id,
|
||||
folderName,
|
||||
collectionID,
|
||||
isHidden,
|
||||
canceller: null,
|
||||
total: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
downloadDirPath: null,
|
||||
});
|
||||
return updater;
|
||||
};
|
||||
|
||||
const openUploader = () => {
|
||||
setUploadTypeSelectorView(true);
|
||||
};
|
||||
|
@ -217,18 +276,24 @@ export default function PublicCollectionGallery() {
|
|||
);
|
||||
|
||||
const downloadAllFiles = async () => {
|
||||
if (!downloadEnabled) {
|
||||
return;
|
||||
}
|
||||
appContext.startLoading();
|
||||
for (const file of publicFiles) {
|
||||
try {
|
||||
await downloadFile(file);
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
try {
|
||||
if (!downloadEnabled) {
|
||||
return;
|
||||
}
|
||||
const setFilesDownloadProgressAttributes =
|
||||
setFilesDownloadProgressAttributesCreator(
|
||||
publicCollection.name,
|
||||
publicCollection.id,
|
||||
isHiddenCollection(publicCollection)
|
||||
);
|
||||
await downloadCollectionFiles(
|
||||
publicCollection.name,
|
||||
publicFiles,
|
||||
setFilesDownloadProgressAttributes
|
||||
);
|
||||
} catch (e) {
|
||||
logError(e, 'failed to downloads shared album all files');
|
||||
}
|
||||
appContext.finishLoading();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -441,6 +506,30 @@ export default function PublicCollectionGallery() {
|
|||
}
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
if (!selected?.count) {
|
||||
return;
|
||||
}
|
||||
setSelected({ ownCount: 0, count: 0, collectionID: 0 });
|
||||
};
|
||||
|
||||
const downloadFilesHelper = async () => {
|
||||
try {
|
||||
const selectedFiles = getSelectedFiles(selected, publicFiles);
|
||||
const setFilesDownloadProgressAttributes =
|
||||
setFilesDownloadProgressAttributesCreator(
|
||||
`${selectedFiles.length} ${t('FILES')}`
|
||||
);
|
||||
await downloadSelectedFiles(
|
||||
selectedFiles,
|
||||
setFilesDownloadProgressAttributes
|
||||
);
|
||||
clearSelection();
|
||||
} catch (e) {
|
||||
logError(e, 'failed to download selected files');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PublicCollectionGalleryContext.Provider
|
||||
value={{
|
||||
|
@ -468,12 +557,15 @@ export default function PublicCollectionGallery() {
|
|||
page={PAGES.SHARED_ALBUMS}
|
||||
files={publicFiles}
|
||||
syncWithRemote={syncWithRemote}
|
||||
setSelected={() => null}
|
||||
selected={{ count: 0, collectionID: null, ownCount: 0 }}
|
||||
setSelected={setSelected}
|
||||
selected={selected}
|
||||
activeCollectionID={ALL_SECTION}
|
||||
enableDownload={downloadEnabled}
|
||||
fileToCollectionsMap={null}
|
||||
collectionNameMap={null}
|
||||
setFilesDownloadProgressAttributesCreator={
|
||||
setFilesDownloadProgressAttributesCreator
|
||||
}
|
||||
/>
|
||||
{blockingLoad && (
|
||||
<LoadingOverlay>
|
||||
|
@ -498,6 +590,17 @@ export default function PublicCollectionGallery() {
|
|||
UploadTypeSelectorIntent.collectPhotos
|
||||
}
|
||||
/>
|
||||
<FilesDownloadProgress
|
||||
attributesList={filesDownloadProgressAttributesList}
|
||||
setAttributesList={setFilesDownloadProgressAttributesList}
|
||||
/>
|
||||
{selected.count > 0 && (
|
||||
<SelectedFileOptions
|
||||
downloadFilesHelper={downloadFilesHelper}
|
||||
clearSelection={clearSelection}
|
||||
count={selected.count}
|
||||
/>
|
||||
)}
|
||||
</FullScreenDropZone>
|
||||
</PublicCollectionGalleryContext.Provider>
|
||||
);
|
||||
|
|
|
@ -1,8 +1,4 @@
|
|||
import {
|
||||
putEmbedding,
|
||||
getLatestEmbeddings,
|
||||
getLocalEmbeddings,
|
||||
} from './embeddingService';
|
||||
import { putEmbedding, getLocalEmbeddings } from './embeddingService';
|
||||
import { getAllLocalFiles, getLocalFiles } from './fileService';
|
||||
import downloadManager from './download';
|
||||
import { logError } from '@ente/shared/sentry';
|
||||
|
@ -45,12 +41,22 @@ class ClipServiceImpl {
|
|||
this.liveEmbeddingExtractionQueue = new PQueue({
|
||||
concurrency: 1,
|
||||
});
|
||||
eventBus.on(Events.LOGOUT, this.logoutHandler, this);
|
||||
}
|
||||
|
||||
isPlatformSupported = () => {
|
||||
return isElectron() && !this.unsupportedPlatform;
|
||||
};
|
||||
|
||||
private logoutHandler = async () => {
|
||||
if (this.embeddingExtractionInProgress) {
|
||||
this.embeddingExtractionInProgress.abort();
|
||||
}
|
||||
if (this.onFileUploadedHandler) {
|
||||
await this.removeOnFileUploadListener();
|
||||
}
|
||||
};
|
||||
|
||||
setupOnFileUploadListener = async () => {
|
||||
try {
|
||||
if (this.unsupportedPlatform) {
|
||||
|
@ -90,14 +96,18 @@ class ClipServiceImpl {
|
|||
}
|
||||
};
|
||||
|
||||
updateIndexStatus = async () => {
|
||||
getIndexingStatus = async () => {
|
||||
try {
|
||||
addLogLine('loading local clip index status');
|
||||
this.clipExtractionStatus = await getClipExtractionStatus();
|
||||
this.onUpdateHandler(this.clipExtractionStatus);
|
||||
addLogLine('loaded local clip index status');
|
||||
if (
|
||||
!this.clipExtractionStatus ||
|
||||
(this.clipExtractionStatus.pending === 0 &&
|
||||
this.clipExtractionStatus.indexed === 0)
|
||||
) {
|
||||
this.clipExtractionStatus = await getClipExtractionStatus();
|
||||
}
|
||||
return this.clipExtractionStatus;
|
||||
} catch (e) {
|
||||
logError(e, 'failed to load local clip index status');
|
||||
logError(e, 'failed to get clip indexing status');
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -106,7 +116,9 @@ class ClipServiceImpl {
|
|||
handler(this.clipExtractionStatus);
|
||||
};
|
||||
|
||||
scheduleImageEmbeddingExtraction = async () => {
|
||||
scheduleImageEmbeddingExtraction = async (
|
||||
model: Model = Model.ONNX_CLIP
|
||||
) => {
|
||||
try {
|
||||
if (this.embeddingExtractionInProgress) {
|
||||
addLogLine(
|
||||
|
@ -122,7 +134,7 @@ class ClipServiceImpl {
|
|||
const canceller = new AbortController();
|
||||
this.embeddingExtractionInProgress = canceller;
|
||||
try {
|
||||
await this.runClipEmbeddingExtraction(canceller);
|
||||
await this.runClipEmbeddingExtraction(canceller, model);
|
||||
} finally {
|
||||
this.embeddingExtractionInProgress = null;
|
||||
if (!canceller.signal.aborted && this.reRunNeeded) {
|
||||
|
@ -141,9 +153,12 @@ class ClipServiceImpl {
|
|||
}
|
||||
};
|
||||
|
||||
getTextEmbedding = async (text: string): Promise<Float32Array> => {
|
||||
getTextEmbedding = async (
|
||||
text: string,
|
||||
model: Model = Model.ONNX_CLIP
|
||||
): Promise<Float32Array> => {
|
||||
try {
|
||||
return ElectronAPIs.computeTextEmbedding(text);
|
||||
return ElectronAPIs.computeTextEmbedding(model, text);
|
||||
} catch (e) {
|
||||
if (e?.message?.includes(CustomError.UNSUPPORTED_PLATFORM)) {
|
||||
this.unsupportedPlatform = true;
|
||||
|
@ -153,7 +168,10 @@ class ClipServiceImpl {
|
|||
}
|
||||
};
|
||||
|
||||
private runClipEmbeddingExtraction = async (canceller: AbortController) => {
|
||||
private runClipEmbeddingExtraction = async (
|
||||
canceller: AbortController,
|
||||
model: Model
|
||||
) => {
|
||||
try {
|
||||
if (this.unsupportedPlatform) {
|
||||
addLogLine(
|
||||
|
@ -166,7 +184,7 @@ class ClipServiceImpl {
|
|||
return;
|
||||
}
|
||||
const localFiles = getPersonalFiles(await getAllLocalFiles(), user);
|
||||
const existingEmbeddings = await getLatestClipImageEmbeddings();
|
||||
const existingEmbeddings = await getLocalEmbeddings(model);
|
||||
const pendingFiles = await getNonClipEmbeddingExtractedFiles(
|
||||
localFiles,
|
||||
existingEmbeddings
|
||||
|
@ -191,11 +209,15 @@ class ClipServiceImpl {
|
|||
throw Error(CustomError.REQUEST_CANCELLED);
|
||||
}
|
||||
const embeddingData =
|
||||
await this.extractFileClipImageEmbedding(file);
|
||||
await this.extractFileClipImageEmbedding(model, file);
|
||||
addLogLine(
|
||||
`successfully extracted clip embedding for file: ${file.metadata.title} fileID: ${file.id} embedding length: ${embeddingData?.length}`
|
||||
);
|
||||
await this.encryptAndUploadEmbedding(file, embeddingData);
|
||||
await this.encryptAndUploadEmbedding(
|
||||
model,
|
||||
file,
|
||||
embeddingData
|
||||
);
|
||||
this.onSuccessStatusUpdater();
|
||||
addLogLine(
|
||||
`successfully put clip embedding to server for file: ${file.metadata.title} fileID: ${file.id}`
|
||||
|
@ -228,10 +250,13 @@ class ClipServiceImpl {
|
|||
}
|
||||
};
|
||||
|
||||
private async runLocalFileClipExtraction(arg: {
|
||||
enteFile: EnteFile;
|
||||
localFile: globalThis.File;
|
||||
}) {
|
||||
private async runLocalFileClipExtraction(
|
||||
arg: {
|
||||
enteFile: EnteFile;
|
||||
localFile: globalThis.File;
|
||||
},
|
||||
model: Model = Model.ONNX_CLIP
|
||||
) {
|
||||
const { enteFile, localFile } = arg;
|
||||
addLogLine(
|
||||
`clip embedding extraction onFileUploadedHandler file: ${enteFile.metadata.title} fileID: ${enteFile.id}`,
|
||||
|
@ -243,12 +268,27 @@ class ClipServiceImpl {
|
|||
);
|
||||
return;
|
||||
}
|
||||
const extension = enteFile.metadata.title.split('.').pop();
|
||||
if (!extension || !['jpg', 'jpeg'].includes(extension)) {
|
||||
addLogLine(
|
||||
`skipping non jpg file for clip embedding extraction file: ${enteFile.metadata.title} fileID: ${enteFile.id}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
addLogLine(
|
||||
`queuing up for local clip embedding extraction for file: ${enteFile.metadata.title} fileID: ${enteFile.id}`
|
||||
);
|
||||
try {
|
||||
await this.liveEmbeddingExtractionQueue.add(async () => {
|
||||
const embedding = await this.extractLocalFileClipImageEmbedding(
|
||||
model,
|
||||
localFile
|
||||
);
|
||||
await this.encryptAndUploadEmbedding(enteFile, embedding);
|
||||
await this.encryptAndUploadEmbedding(
|
||||
model,
|
||||
enteFile,
|
||||
embedding
|
||||
);
|
||||
});
|
||||
addLogLine(
|
||||
`successfully extracted clip embedding for file: ${enteFile.metadata.title} fileID: ${enteFile.id}`
|
||||
|
@ -258,15 +298,19 @@ class ClipServiceImpl {
|
|||
}
|
||||
}
|
||||
|
||||
private extractLocalFileClipImageEmbedding = async (localFile: File) => {
|
||||
private extractLocalFileClipImageEmbedding = async (
|
||||
model: Model,
|
||||
localFile: File
|
||||
) => {
|
||||
const file = await localFile
|
||||
.arrayBuffer()
|
||||
.then((buffer) => new Uint8Array(buffer));
|
||||
const embedding = await ElectronAPIs.computeImageEmbedding(file);
|
||||
const embedding = await ElectronAPIs.computeImageEmbedding(model, file);
|
||||
return embedding;
|
||||
};
|
||||
|
||||
private encryptAndUploadEmbedding = async (
|
||||
model: Model,
|
||||
file: EnteFile,
|
||||
embeddingData: Float32Array
|
||||
) => {
|
||||
|
@ -285,7 +329,7 @@ class ClipServiceImpl {
|
|||
fileID: file.id,
|
||||
encryptedEmbedding: encryptedEmbeddingData.encryptedData,
|
||||
decryptionHeader: encryptedEmbeddingData.decryptionHeader,
|
||||
model: Model.GGML_CLIP,
|
||||
model,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -296,9 +340,15 @@ class ClipServiceImpl {
|
|||
}
|
||||
};
|
||||
|
||||
private extractFileClipImageEmbedding = async (file: EnteFile) => {
|
||||
private extractFileClipImageEmbedding = async (
|
||||
model: Model,
|
||||
file: EnteFile
|
||||
) => {
|
||||
const thumb = await downloadManager.getThumbnail(file);
|
||||
const embedding = await ElectronAPIs.computeImageEmbedding(thumb);
|
||||
const embedding = await ElectronAPIs.computeImageEmbedding(
|
||||
model,
|
||||
thumb
|
||||
);
|
||||
return embedding;
|
||||
};
|
||||
|
||||
|
@ -333,13 +383,6 @@ const getNonClipEmbeddingExtractedFiles = async (
|
|||
});
|
||||
};
|
||||
|
||||
export const getLocalClipImageEmbeddings = async () => {
|
||||
const allEmbeddings = await getLocalEmbeddings();
|
||||
return allEmbeddings.filter(
|
||||
(embedding) => embedding.model === Model.GGML_CLIP
|
||||
);
|
||||
};
|
||||
|
||||
export const computeClipMatchScore = async (
|
||||
imageEmbedding: Float32Array,
|
||||
textEmbedding: Float32Array
|
||||
|
@ -367,19 +410,14 @@ export const computeClipMatchScore = async (
|
|||
return score;
|
||||
};
|
||||
|
||||
const getLatestClipImageEmbeddings = async () => {
|
||||
const allEmbeddings = await getLatestEmbeddings();
|
||||
return allEmbeddings.filter(
|
||||
(embedding) => embedding.model === Model.GGML_CLIP
|
||||
);
|
||||
};
|
||||
|
||||
const getClipExtractionStatus = async (): Promise<ClipExtractionStatus> => {
|
||||
const getClipExtractionStatus = async (
|
||||
model: Model = Model.ONNX_CLIP
|
||||
): Promise<ClipExtractionStatus> => {
|
||||
const user = getData(LS_KEYS.USER);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
const allEmbeddings = await getLocalClipImageEmbeddings();
|
||||
const allEmbeddings = await getLocalEmbeddings(model);
|
||||
const localFiles = getPersonalFiles(await getLocalFiles(), user);
|
||||
const pendingFiles = await getNonClipEmbeddingExtractedFiles(
|
||||
localFiles,
|
||||
|
|
|
@ -26,7 +26,11 @@ export async function getDuplicates(
|
|||
collectionNameMap: Map<number, string>
|
||||
) {
|
||||
try {
|
||||
const dupes = await fetchDuplicateFileIDs();
|
||||
const ascDupes = await fetchDuplicateFileIDs();
|
||||
|
||||
const descSortedDupes = ascDupes.sort((firstDupe, secondDupe) => {
|
||||
return secondDupe.size - firstDupe.size;
|
||||
});
|
||||
|
||||
const fileMap = new Map<number, EnteFile>();
|
||||
for (const file of files) {
|
||||
|
@ -35,7 +39,7 @@ export async function getDuplicates(
|
|||
|
||||
let result: Duplicate[] = [];
|
||||
|
||||
for (const dupe of dupes) {
|
||||
for (const dupe of descSortedDupes) {
|
||||
let duplicateFiles: EnteFile[] = [];
|
||||
for (const fileID of dupe.fileIDs) {
|
||||
if (fileMap.has(fileID)) {
|
||||
|
|
|
@ -19,6 +19,7 @@ import { PhotosDownloadClient } from './clients/photos';
|
|||
import { PublicAlbumsDownloadClient } from './clients/publicAlbums';
|
||||
import isElectron from 'is-electron';
|
||||
import { isInternalUser } from 'utils/user';
|
||||
import { Events, eventBus } from '@ente/shared/events';
|
||||
|
||||
export type LivePhotoSourceURL = {
|
||||
image: () => Promise<string>;
|
||||
|
@ -89,12 +90,30 @@ class DownloadManagerImpl {
|
|||
this.diskFileCache = isElectron() && (await openDiskFileCache());
|
||||
this.cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||
this.ready = true;
|
||||
eventBus.on(Events.LOGOUT, this.logoutHandler.bind(this), this);
|
||||
} catch (e) {
|
||||
logError(e, 'DownloadManager init failed');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private async logoutHandler() {
|
||||
try {
|
||||
addLogLine('downloadManger logoutHandler started');
|
||||
this.ready = false;
|
||||
this.cryptoWorker = null;
|
||||
this.downloadClient = null;
|
||||
this.fileObjectURLPromises.clear();
|
||||
this.fileConversionPromises.clear();
|
||||
this.thumbnailObjectURLPromises.clear();
|
||||
this.fileDownloadProgress.clear();
|
||||
this.progressUpdater = () => {};
|
||||
addLogLine('downloadManager logoutHandler completed');
|
||||
} catch (e) {
|
||||
logError(e, 'downloadManager logoutHandler failed');
|
||||
}
|
||||
}
|
||||
|
||||
updateToken(token: string, passwordToken?: string) {
|
||||
this.downloadClient.updateTokens(token, passwordToken);
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import {
|
|||
Embedding,
|
||||
EncryptedEmbedding,
|
||||
GetEmbeddingDiffResponse,
|
||||
Model,
|
||||
PutEmbeddingRequest,
|
||||
} from 'types/embedding';
|
||||
import ComlinkCryptoWorker from '@ente/shared/crypto';
|
||||
|
@ -16,105 +17,135 @@ import { getLatestVersionEmbeddings } from 'utils/embedding';
|
|||
import { getLocalTrashedFiles } from './trashService';
|
||||
import { getLocalCollections } from './collectionService';
|
||||
import { CustomError } from '@ente/shared/error';
|
||||
import { EnteFile } from 'types/file';
|
||||
|
||||
const ENDPOINT = getEndpoint();
|
||||
|
||||
const DIFF_LIMIT = 500;
|
||||
|
||||
const EMBEDDINGS_TABLE = 'embeddings';
|
||||
const EMBEDDINGS_TABLE_V1 = 'embeddings';
|
||||
const EMBEDDINGS_TABLE = 'embeddings_v2';
|
||||
const EMBEDDING_SYNC_TIME_TABLE = 'embedding_sync_time';
|
||||
|
||||
export const getLocalEmbeddings = async () => {
|
||||
const embeddings: Array<Embedding> =
|
||||
(await localForage.getItem<Embedding[]>(EMBEDDINGS_TABLE)) || [];
|
||||
export const getAllLocalEmbeddings = async () => {
|
||||
const embeddings: Array<Embedding> = await localForage.getItem<Embedding[]>(
|
||||
EMBEDDINGS_TABLE
|
||||
);
|
||||
if (!embeddings) {
|
||||
await localForage.removeItem(EMBEDDINGS_TABLE_V1);
|
||||
await localForage.removeItem(EMBEDDING_SYNC_TIME_TABLE);
|
||||
await localForage.setItem(EMBEDDINGS_TABLE, []);
|
||||
return [];
|
||||
}
|
||||
return embeddings;
|
||||
};
|
||||
|
||||
const getEmbeddingSyncTime = async () => {
|
||||
return (await localForage.getItem<number>(EMBEDDING_SYNC_TIME_TABLE)) ?? 0;
|
||||
export const getLocalEmbeddings = async (model: Model) => {
|
||||
const embeddings = await getAllLocalEmbeddings();
|
||||
return embeddings.filter((embedding) => embedding.model === model);
|
||||
};
|
||||
|
||||
export const getLatestEmbeddings = async () => {
|
||||
await syncEmbeddings();
|
||||
const embeddings = await getLocalEmbeddings();
|
||||
return embeddings;
|
||||
const getModelEmbeddingSyncTime = async (model: Model) => {
|
||||
return (
|
||||
(await localForage.getItem<number>(
|
||||
`${model}-${EMBEDDING_SYNC_TIME_TABLE}`
|
||||
)) ?? 0
|
||||
);
|
||||
};
|
||||
|
||||
export const syncEmbeddings = async () => {
|
||||
const setModelEmbeddingSyncTime = async (model: Model, time: number) => {
|
||||
await localForage.setItem(`${model}-${EMBEDDING_SYNC_TIME_TABLE}`, time);
|
||||
};
|
||||
|
||||
export const syncEmbeddings = async (models: Model[] = [Model.ONNX_CLIP]) => {
|
||||
try {
|
||||
let embeddings = await getLocalEmbeddings();
|
||||
let allEmbeddings = await getAllLocalEmbeddings();
|
||||
const localFiles = await getAllLocalFiles();
|
||||
const hiddenAlbums = await getLocalCollections('hidden');
|
||||
const localTrashFiles = await getLocalTrashedFiles();
|
||||
const fileIdToKeyMap = new Map<number, string>();
|
||||
[...localFiles, ...localTrashFiles].forEach((file) => {
|
||||
const allLocalFiles = [...localFiles, ...localTrashFiles];
|
||||
allLocalFiles.forEach((file) => {
|
||||
fileIdToKeyMap.set(file.id, file.key);
|
||||
});
|
||||
addLogLine(`Syncing embeddings localCount: ${embeddings.length}`);
|
||||
let sinceTime = await getEmbeddingSyncTime();
|
||||
addLogLine(`Syncing embeddings sinceTime: ${sinceTime}`);
|
||||
let response: GetEmbeddingDiffResponse;
|
||||
do {
|
||||
response = await getEmbeddingsDiff(sinceTime);
|
||||
if (!response.diff?.length) {
|
||||
return;
|
||||
}
|
||||
const newEmbeddings = await Promise.all(
|
||||
response.diff.map(async (embedding) => {
|
||||
try {
|
||||
const {
|
||||
encryptedEmbedding,
|
||||
decryptionHeader,
|
||||
...rest
|
||||
} = embedding;
|
||||
const worker = await ComlinkCryptoWorker.getInstance();
|
||||
const fileKey = fileIdToKeyMap.get(embedding.fileID);
|
||||
if (!fileKey) {
|
||||
throw Error(CustomError.FILE_NOT_FOUND);
|
||||
}
|
||||
const decryptedData = await worker.decryptEmbedding(
|
||||
encryptedEmbedding,
|
||||
decryptionHeader,
|
||||
fileIdToKeyMap.get(embedding.fileID)
|
||||
);
|
||||
|
||||
return {
|
||||
...rest,
|
||||
embedding: decryptedData,
|
||||
} as Embedding;
|
||||
} catch (e) {
|
||||
let info: Record<string, unknown>;
|
||||
if (e.message === CustomError.FILE_NOT_FOUND) {
|
||||
const hasHiddenAlbums = hiddenAlbums?.length > 0;
|
||||
info = {
|
||||
hasHiddenAlbums,
|
||||
};
|
||||
}
|
||||
logError(e, 'decryptEmbedding failed for file', info);
|
||||
}
|
||||
})
|
||||
);
|
||||
embeddings = getLatestVersionEmbeddings([
|
||||
...embeddings,
|
||||
...newEmbeddings,
|
||||
]);
|
||||
if (response.diff.length) {
|
||||
sinceTime = response.diff.slice(-1)[0].updatedAt;
|
||||
}
|
||||
await localForage.setItem(EMBEDDINGS_TABLE, embeddings);
|
||||
await localForage.setItem(EMBEDDING_SYNC_TIME_TABLE, sinceTime);
|
||||
await cleanupDeletedEmbeddings(allLocalFiles, allEmbeddings);
|
||||
addLogLine(`Syncing embeddings localCount: ${allEmbeddings.length}`);
|
||||
for (const model of models) {
|
||||
let modelLastSinceTime = await getModelEmbeddingSyncTime(model);
|
||||
addLogLine(
|
||||
`Syncing embeddings syncedEmbeddingsCount: ${newEmbeddings.length}`
|
||||
`Syncing ${model} model's embeddings sinceTime: ${modelLastSinceTime}`
|
||||
);
|
||||
} while (response.diff.length === DIFF_LIMIT);
|
||||
void cleanupDeletedEmbeddings();
|
||||
let response: GetEmbeddingDiffResponse;
|
||||
do {
|
||||
response = await getEmbeddingsDiff(modelLastSinceTime, model);
|
||||
if (!response.diff?.length) {
|
||||
return;
|
||||
}
|
||||
const newEmbeddings = await Promise.all(
|
||||
response.diff.map(async (embedding) => {
|
||||
try {
|
||||
const {
|
||||
encryptedEmbedding,
|
||||
decryptionHeader,
|
||||
...rest
|
||||
} = embedding;
|
||||
const worker =
|
||||
await ComlinkCryptoWorker.getInstance();
|
||||
const fileKey = fileIdToKeyMap.get(
|
||||
embedding.fileID
|
||||
);
|
||||
if (!fileKey) {
|
||||
throw Error(CustomError.FILE_NOT_FOUND);
|
||||
}
|
||||
const decryptedData = await worker.decryptEmbedding(
|
||||
encryptedEmbedding,
|
||||
decryptionHeader,
|
||||
fileIdToKeyMap.get(embedding.fileID)
|
||||
);
|
||||
|
||||
return {
|
||||
...rest,
|
||||
embedding: decryptedData,
|
||||
} as Embedding;
|
||||
} catch (e) {
|
||||
let info: Record<string, unknown>;
|
||||
if (e.message === CustomError.FILE_NOT_FOUND) {
|
||||
const hasHiddenAlbums =
|
||||
hiddenAlbums?.length > 0;
|
||||
info = {
|
||||
hasHiddenAlbums,
|
||||
};
|
||||
}
|
||||
logError(
|
||||
e,
|
||||
'decryptEmbedding failed for file',
|
||||
info
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
allEmbeddings = getLatestVersionEmbeddings([
|
||||
...allEmbeddings,
|
||||
...newEmbeddings,
|
||||
]);
|
||||
if (response.diff.length) {
|
||||
modelLastSinceTime = response.diff.slice(-1)[0].updatedAt;
|
||||
}
|
||||
await localForage.setItem(EMBEDDINGS_TABLE, allEmbeddings);
|
||||
await setModelEmbeddingSyncTime(model, modelLastSinceTime);
|
||||
addLogLine(
|
||||
`Syncing embeddings syncedEmbeddingsCount: ${allEmbeddings.length}`
|
||||
);
|
||||
} while (response.diff.length === DIFF_LIMIT);
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e, 'Sync embeddings failed');
|
||||
}
|
||||
};
|
||||
|
||||
export const getEmbeddingsDiff = async (
|
||||
sinceTime: number
|
||||
sinceTime: number,
|
||||
model: Model
|
||||
): Promise<GetEmbeddingDiffResponse> => {
|
||||
try {
|
||||
const token = getToken();
|
||||
|
@ -126,6 +157,7 @@ export const getEmbeddingsDiff = async (
|
|||
{
|
||||
sinceTime,
|
||||
limit: DIFF_LIMIT,
|
||||
model,
|
||||
},
|
||||
{
|
||||
'X-Auth-Token': token,
|
||||
|
@ -161,21 +193,21 @@ export const putEmbedding = async (
|
|||
}
|
||||
};
|
||||
|
||||
export const cleanupDeletedEmbeddings = async () => {
|
||||
const files = await getAllLocalFiles();
|
||||
const trashedFiles = await getLocalTrashedFiles();
|
||||
export const cleanupDeletedEmbeddings = async (
|
||||
allLocalFiles: EnteFile[],
|
||||
allLocalEmbeddings: Embedding[]
|
||||
) => {
|
||||
const activeFileIds = new Set<number>();
|
||||
[...files, ...trashedFiles].forEach((file) => {
|
||||
allLocalFiles.forEach((file) => {
|
||||
activeFileIds.add(file.id);
|
||||
});
|
||||
const embeddings = await getLocalEmbeddings();
|
||||
|
||||
const remainingEmbeddings = embeddings.filter((embedding) =>
|
||||
const remainingEmbeddings = allLocalEmbeddings.filter((embedding) =>
|
||||
activeFileIds.has(embedding.fileID)
|
||||
);
|
||||
if (embeddings.length !== remainingEmbeddings.length) {
|
||||
if (allLocalEmbeddings.length !== remainingEmbeddings.length) {
|
||||
addLogLine(
|
||||
`cleanupDeletedEmbeddings embeddingsCount: ${embeddings.length} remainingEmbeddingsCount: ${remainingEmbeddings.length}`
|
||||
`cleanupDeletedEmbeddings embeddingsCount: ${allLocalEmbeddings.length} remainingEmbeddingsCount: ${remainingEmbeddings.length}`
|
||||
);
|
||||
await localForage.setItem(EMBEDDINGS_TABLE, remainingEmbeddings);
|
||||
}
|
||||
|
|
97
apps/photos/src/services/locationSearchService.ts
Normal file
97
apps/photos/src/services/locationSearchService.ts
Normal file
|
@ -0,0 +1,97 @@
|
|||
import { CITIES_URL } from '@ente/shared/constants/urls';
|
||||
import { logError } from '@ente/shared/sentry';
|
||||
import { LocationTagData } from 'types/entity';
|
||||
import { Location } from 'types/upload';
|
||||
|
||||
export interface City {
|
||||
city: string;
|
||||
country: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CITY_RADIUS = 10;
|
||||
const KMS_PER_DEGREE = 111.16;
|
||||
|
||||
class LocationSearchService {
|
||||
private cities: Array<City> = [];
|
||||
private citiesPromise: Promise<void>;
|
||||
|
||||
async loadCities() {
|
||||
try {
|
||||
if (this.citiesPromise) {
|
||||
return;
|
||||
}
|
||||
this.citiesPromise = fetch(CITIES_URL).then((response) => {
|
||||
return response.json().then((data) => {
|
||||
this.cities = data['data'];
|
||||
});
|
||||
});
|
||||
await this.citiesPromise;
|
||||
} catch (e) {
|
||||
logError(e, 'LocationSearchService loadCities failed');
|
||||
this.citiesPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async searchCities(searchTerm: string) {
|
||||
try {
|
||||
if (!this.citiesPromise) {
|
||||
this.loadCities();
|
||||
}
|
||||
await this.citiesPromise;
|
||||
return this.cities.filter((city) => {
|
||||
return city.city
|
||||
.toLowerCase()
|
||||
.startsWith(searchTerm.toLowerCase());
|
||||
});
|
||||
} catch (e) {
|
||||
logError(e, 'LocationSearchService searchCities failed');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new LocationSearchService();
|
||||
|
||||
export function isInsideLocationTag(
|
||||
location: Location,
|
||||
locationTag: LocationTagData
|
||||
) {
|
||||
return isLocationCloseToPoint(
|
||||
location,
|
||||
locationTag.centerPoint,
|
||||
locationTag.radius
|
||||
);
|
||||
}
|
||||
|
||||
export function isInsideCity(location: Location, city: City) {
|
||||
return isLocationCloseToPoint(
|
||||
{ latitude: city.lat, longitude: city.lng },
|
||||
location,
|
||||
DEFAULT_CITY_RADIUS
|
||||
);
|
||||
}
|
||||
|
||||
function isLocationCloseToPoint(
|
||||
centerPoint: Location,
|
||||
location: Location,
|
||||
radius: number
|
||||
) {
|
||||
const a = (radius * _scaleFactor(centerPoint.latitude)) / KMS_PER_DEGREE;
|
||||
const b = radius / KMS_PER_DEGREE;
|
||||
const x = centerPoint.latitude - location.latitude;
|
||||
const y = centerPoint.longitude - location.longitude;
|
||||
if ((x * x) / (a * a) + (y * y) / (b * b) <= 1) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
///The area bounded by the location tag becomes more elliptical with increase
|
||||
///in the magnitude of the latitude on the caritesian plane. When latitude is
|
||||
///0 degrees, the ellipse is a circle with a = b = r. When latitude incrases,
|
||||
///the major axis (a) has to be scaled by the secant of the latitude.
|
||||
function _scaleFactor(lat: number) {
|
||||
return 1 / Math.cos(lat * (Math.PI / 180));
|
||||
}
|
|
@ -16,33 +16,29 @@ import {
|
|||
ClipSearchScores,
|
||||
} from 'types/search';
|
||||
import ObjectService from './machineLearning/objectService';
|
||||
import {
|
||||
getFormattedDate,
|
||||
isInsideLocationTag,
|
||||
isSameDayAnyYear,
|
||||
} from 'utils/search';
|
||||
import { getFormattedDate } from 'utils/search';
|
||||
import { Person, Thing } from 'types/machineLearning';
|
||||
import { getUniqueFiles } from 'utils/file';
|
||||
import { getLatestEntities } from './entityService';
|
||||
import { LocationTag, LocationTagData, EntityType } from 'types/entity';
|
||||
import { addLogLine } from '@ente/shared/logging';
|
||||
import { FILE_TYPE } from 'constants/file';
|
||||
import {
|
||||
ClipService,
|
||||
computeClipMatchScore,
|
||||
getLocalClipImageEmbeddings,
|
||||
} from './clipService';
|
||||
import { ClipService, computeClipMatchScore } from './clipService';
|
||||
import { CustomError } from '@ente/shared/error';
|
||||
import { Model } from 'types/embedding';
|
||||
import { getLocalEmbeddings } from './embeddingService';
|
||||
import locationSearchService, { City } from './locationSearchService';
|
||||
import ComlinkSearchWorker from 'utils/comlink/ComlinkSearchWorker';
|
||||
|
||||
const DIGITS = new Set(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']);
|
||||
|
||||
const CLIP_SCORE_THRESHOLD = 0.23;
|
||||
|
||||
export const getDefaultOptions = async (files: EnteFile[]) => {
|
||||
export const getDefaultOptions = async () => {
|
||||
return [
|
||||
await getIndexStatusSuggestion(),
|
||||
...convertSuggestionsToOptions(await getAllPeopleSuggestion(), files),
|
||||
];
|
||||
...(await convertSuggestionsToOptions(await getAllPeopleSuggestion())),
|
||||
].filter((t) => !!t);
|
||||
};
|
||||
|
||||
export const getAutoCompleteSuggestions =
|
||||
|
@ -62,47 +58,42 @@ export const getAutoCompleteSuggestions =
|
|||
...getCollectionSuggestion(searchPhrase, collections),
|
||||
getFileNameSuggestion(searchPhrase, files),
|
||||
getFileCaptionSuggestion(searchPhrase, files),
|
||||
...(await getLocationTagSuggestions(searchPhrase)),
|
||||
...(await getLocationSuggestions(searchPhrase)),
|
||||
...(await getThingSuggestion(searchPhrase)),
|
||||
].filter((suggestion) => !!suggestion);
|
||||
|
||||
return convertSuggestionsToOptions(suggestions, files);
|
||||
return convertSuggestionsToOptions(suggestions);
|
||||
} catch (e) {
|
||||
logError(e, 'getAutoCompleteSuggestions failed');
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
function convertSuggestionsToOptions(
|
||||
suggestions: Suggestion[],
|
||||
files: EnteFile[]
|
||||
) {
|
||||
const previewImageAppendedOptions: SearchOption[] = suggestions
|
||||
.map((suggestion) => ({
|
||||
suggestion,
|
||||
searchQuery: convertSuggestionToSearchQuery(suggestion),
|
||||
}))
|
||||
.map(({ suggestion, searchQuery }) => {
|
||||
const resultFiles = getUniqueFiles(
|
||||
files.filter((file) => isSearchedFile(file, searchQuery))
|
||||
);
|
||||
|
||||
if (searchQuery?.clip) {
|
||||
resultFiles.sort((a, b) => {
|
||||
const aScore = searchQuery.clip.get(a.id);
|
||||
const bScore = searchQuery.clip.get(b.id);
|
||||
return bScore - aScore;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
async function convertSuggestionsToOptions(
|
||||
suggestions: Suggestion[]
|
||||
): Promise<SearchOption[]> {
|
||||
const searchWorker = await ComlinkSearchWorker.getInstance();
|
||||
const previewImageAppendedOptions: SearchOption[] = [];
|
||||
for (const suggestion of suggestions) {
|
||||
const searchQuery = convertSuggestionToSearchQuery(suggestion);
|
||||
const resultFiles = getUniqueFiles(
|
||||
await searchWorker.search(searchQuery)
|
||||
);
|
||||
if (searchQuery?.clip) {
|
||||
resultFiles.sort((a, b) => {
|
||||
const aScore = searchQuery.clip.get(a.id);
|
||||
const bScore = searchQuery.clip.get(b.id);
|
||||
return bScore - aScore;
|
||||
});
|
||||
}
|
||||
if (resultFiles.length) {
|
||||
previewImageAppendedOptions.push({
|
||||
...suggestion,
|
||||
fileCount: resultFiles.length,
|
||||
previewFiles: resultFiles.slice(0, 3),
|
||||
};
|
||||
})
|
||||
.filter((option) => option.fileCount);
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
return previewImageAppendedOptions;
|
||||
}
|
||||
function getFileTypeSuggestion(searchPhrase: string): Suggestion[] {
|
||||
|
@ -190,28 +181,32 @@ export async function getAllPeopleSuggestion(): Promise<Array<Suggestion>> {
|
|||
}
|
||||
|
||||
export async function getIndexStatusSuggestion(): Promise<Suggestion> {
|
||||
const config = await getMLSyncConfig();
|
||||
const indexStatus = await mlIDbStorage.getIndexStatus(config.mlVersion);
|
||||
try {
|
||||
const config = await getMLSyncConfig();
|
||||
const indexStatus = await mlIDbStorage.getIndexStatus(config.mlVersion);
|
||||
|
||||
let label;
|
||||
if (!indexStatus.localFilesSynced) {
|
||||
label = t('INDEXING_SCHEDULED');
|
||||
} else if (indexStatus.outOfSyncFilesExists) {
|
||||
label = t('ANALYZING_PHOTOS', {
|
||||
indexStatus,
|
||||
});
|
||||
} else if (!indexStatus.peopleIndexSynced) {
|
||||
label = t('INDEXING_PEOPLE', { indexStatus });
|
||||
} else {
|
||||
label = t('INDEXING_DONE', { indexStatus });
|
||||
let label;
|
||||
if (!indexStatus.localFilesSynced) {
|
||||
label = t('INDEXING_SCHEDULED');
|
||||
} else if (indexStatus.outOfSyncFilesExists) {
|
||||
label = t('ANALYZING_PHOTOS', {
|
||||
indexStatus,
|
||||
});
|
||||
} else if (!indexStatus.peopleIndexSynced) {
|
||||
label = t('INDEXING_PEOPLE', { indexStatus });
|
||||
} else {
|
||||
label = t('INDEXING_DONE', { indexStatus });
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
type: SuggestionType.INDEX_STATUS,
|
||||
value: indexStatus,
|
||||
hide: true,
|
||||
};
|
||||
} catch (e) {
|
||||
logError(e, 'getIndexStatusSuggestion failed');
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
type: SuggestionType.INDEX_STATUS,
|
||||
value: indexStatus,
|
||||
hide: true,
|
||||
};
|
||||
}
|
||||
|
||||
function getDateSuggestion(searchPhrase: string): Suggestion[] {
|
||||
|
@ -264,10 +259,9 @@ function getFileCaptionSuggestion(
|
|||
};
|
||||
}
|
||||
|
||||
async function getLocationTagSuggestions(searchPhrase: string) {
|
||||
const searchResults = await searchLocationTag(searchPhrase);
|
||||
|
||||
return searchResults.map(
|
||||
async function getLocationSuggestions(searchPhrase: string) {
|
||||
const locationTagResults = await searchLocationTag(searchPhrase);
|
||||
const locationTagSuggestions = locationTagResults.map(
|
||||
(locationTag) =>
|
||||
({
|
||||
type: SuggestionType.LOCATION,
|
||||
|
@ -275,6 +269,28 @@ async function getLocationTagSuggestions(searchPhrase: string) {
|
|||
label: locationTag.data.name,
|
||||
} as Suggestion)
|
||||
);
|
||||
const locationTagNames = new Set(
|
||||
locationTagSuggestions.map((result) => result.label)
|
||||
);
|
||||
|
||||
const citySearchResults = await locationSearchService.searchCities(
|
||||
searchPhrase
|
||||
);
|
||||
|
||||
const nonConflictingCityResult = citySearchResults.filter(
|
||||
(city) => !locationTagNames.has(city.city)
|
||||
);
|
||||
|
||||
const citySearchSuggestions = nonConflictingCityResult.map(
|
||||
(city) =>
|
||||
({
|
||||
type: SuggestionType.CITY,
|
||||
value: city,
|
||||
label: city.city,
|
||||
} as Suggestion)
|
||||
);
|
||||
|
||||
return [...locationTagSuggestions, ...citySearchSuggestions];
|
||||
}
|
||||
|
||||
async function getThingSuggestion(searchPhrase: string): Promise<Suggestion[]> {
|
||||
|
@ -383,7 +399,7 @@ async function searchThing(searchPhrase: string) {
|
|||
}
|
||||
|
||||
async function searchClip(searchPhrase: string): Promise<ClipSearchScores> {
|
||||
const imageEmbeddings = await getLocalClipImageEmbeddings();
|
||||
const imageEmbeddings = await getLocalEmbeddings(Model.ONNX_CLIP);
|
||||
const textEmbedding = await ClipService.getTextEmbedding(searchPhrase);
|
||||
const clipSearchResult = new Map<number, number>(
|
||||
(
|
||||
|
@ -404,48 +420,6 @@ async function searchClip(searchPhrase: string): Promise<ClipSearchScores> {
|
|||
return clipSearchResult;
|
||||
}
|
||||
|
||||
function isSearchedFile(file: EnteFile, search: Search) {
|
||||
if (search?.collection) {
|
||||
return search.collection === file.collectionID;
|
||||
}
|
||||
|
||||
if (search?.date) {
|
||||
return isSameDayAnyYear(search.date)(
|
||||
new Date(file.metadata.creationTime / 1000)
|
||||
);
|
||||
}
|
||||
if (search?.location) {
|
||||
return isInsideLocationTag(
|
||||
{
|
||||
latitude: file.metadata.latitude,
|
||||
longitude: file.metadata.longitude,
|
||||
},
|
||||
search.location
|
||||
);
|
||||
}
|
||||
if (search?.files) {
|
||||
return search.files.indexOf(file.id) !== -1;
|
||||
}
|
||||
if (search?.person) {
|
||||
return search.person.files.indexOf(file.id) !== -1;
|
||||
}
|
||||
|
||||
if (search?.thing) {
|
||||
return search.thing.files.indexOf(file.id) !== -1;
|
||||
}
|
||||
|
||||
if (search?.text) {
|
||||
return search.text.files.indexOf(file.id) !== -1;
|
||||
}
|
||||
if (typeof search?.fileType !== 'undefined') {
|
||||
return search.fileType === file.metadata.fileType;
|
||||
}
|
||||
if (typeof search?.clip !== 'undefined') {
|
||||
return search.clip.has(file.id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function convertSuggestionToSearchQuery(option: Suggestion): Search {
|
||||
switch (option.type) {
|
||||
case SuggestionType.DATE:
|
||||
|
@ -458,6 +432,9 @@ function convertSuggestionToSearchQuery(option: Suggestion): Search {
|
|||
location: option.value as LocationTagData,
|
||||
};
|
||||
|
||||
case SuggestionType.CITY:
|
||||
return { city: option.value as City };
|
||||
|
||||
case SuggestionType.COLLECTION:
|
||||
return { collection: option.value as number };
|
||||
|
||||
|
|
|
@ -361,7 +361,9 @@ class UploadManager {
|
|||
try {
|
||||
eventBus.emit(Events.FILE_UPLOADED, {
|
||||
enteFile: decryptedFile,
|
||||
localFile: fileWithCollection.file,
|
||||
localFile:
|
||||
fileWithCollection.file ??
|
||||
fileWithCollection.livePhotoAssets.image,
|
||||
});
|
||||
} catch (e) {
|
||||
logError(e, 'Error in fileUploaded handlers');
|
||||
|
|
|
@ -49,11 +49,10 @@ export const SelectStyles = {
|
|||
...style,
|
||||
display: 'none',
|
||||
}),
|
||||
singleValue: (style, state) => ({
|
||||
singleValue: (style) => ({
|
||||
...style,
|
||||
backgroundColor: 'transparent',
|
||||
color: '#d1d1d1',
|
||||
display: state.selectProps.menuIsOpen ? 'none' : 'block',
|
||||
marginLeft: '36px',
|
||||
}),
|
||||
placeholder: (style) => ({
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
export enum Model {
|
||||
GGML_CLIP = 'ggml-clip',
|
||||
ONNX_CLIP = 'onnx-clip',
|
||||
}
|
||||
|
||||
export interface EncryptedEmbedding {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { CollectionDownloadProgressAttributes } from 'components/Collections/CollectionDownloadProgress';
|
||||
import { FilesDownloadProgressAttributes } from 'components/FilesDownloadProgress';
|
||||
import { CollectionSelectorAttributes } from 'components/Collections/CollectionSelector';
|
||||
import { TimeStampListItem } from 'components/PhotoList';
|
||||
import { Collection } from 'types/collection';
|
||||
|
@ -17,9 +17,19 @@ export type SetLoading = React.Dispatch<React.SetStateAction<boolean>>;
|
|||
export type SetCollectionSelectorAttributes = React.Dispatch<
|
||||
React.SetStateAction<CollectionSelectorAttributes>
|
||||
>;
|
||||
export type SetCollectionDownloadProgressAttributes = React.Dispatch<
|
||||
React.SetStateAction<CollectionDownloadProgressAttributes>
|
||||
>;
|
||||
export type SetFilesDownloadProgressAttributes = (
|
||||
value:
|
||||
| Partial<FilesDownloadProgressAttributes>
|
||||
| ((
|
||||
prev: FilesDownloadProgressAttributes
|
||||
) => FilesDownloadProgressAttributes)
|
||||
) => void;
|
||||
|
||||
export type SetFilesDownloadProgressAttributesCreator = (
|
||||
folderName: string,
|
||||
collectionID?: number,
|
||||
isHidden?: boolean
|
||||
) => SetFilesDownloadProgressAttributes;
|
||||
|
||||
export type MergedSourceURL = {
|
||||
original: string;
|
||||
|
|
|
@ -3,6 +3,7 @@ import { IndexStatus } from 'types/machineLearning/ui';
|
|||
import { EnteFile } from 'types/file';
|
||||
import { LocationTagData } from 'types/entity';
|
||||
import { FILE_TYPE } from 'constants/file';
|
||||
import { City } from 'services/locationSearchService';
|
||||
|
||||
export enum SuggestionType {
|
||||
DATE = 'DATE',
|
||||
|
@ -16,6 +17,7 @@ export enum SuggestionType {
|
|||
FILE_CAPTION = 'FILE_CAPTION',
|
||||
FILE_TYPE = 'FILE_TYPE',
|
||||
CLIP = 'CLIP',
|
||||
CITY = 'CITY',
|
||||
}
|
||||
|
||||
export interface DateValue {
|
||||
|
@ -35,6 +37,7 @@ export interface Suggestion {
|
|||
| Thing
|
||||
| WordGroup
|
||||
| LocationTagData
|
||||
| City
|
||||
| FILE_TYPE
|
||||
| ClipSearchScores;
|
||||
hide?: boolean;
|
||||
|
@ -43,6 +46,7 @@ export interface Suggestion {
|
|||
export type Search = {
|
||||
date?: DateValue;
|
||||
location?: LocationTagData;
|
||||
city?: City;
|
||||
collection?: number;
|
||||
files?: number[];
|
||||
person?: Person;
|
||||
|
|
|
@ -12,7 +12,7 @@ import {
|
|||
updatePublicCollectionMagicMetadata,
|
||||
updateSharedCollectionMagicMetadata,
|
||||
} from 'services/collectionService';
|
||||
import { downloadFiles, downloadFilesDesktop } from 'utils/file';
|
||||
import { downloadFilesWithProgress } from 'utils/file';
|
||||
import { getAllLocalFiles, getLocalFiles } from 'services/fileService';
|
||||
import { EnteFile } from 'types/file';
|
||||
import { CustomError } from '@ente/shared/error';
|
||||
|
@ -34,7 +34,6 @@ import {
|
|||
SYSTEM_COLLECTION_TYPES,
|
||||
MOVE_TO_NOT_ALLOWED_COLLECTION,
|
||||
ADD_TO_NOT_ALLOWED_COLLECTION,
|
||||
HIDDEN_ITEMS_SECTION,
|
||||
DEFAULT_HIDDEN_COLLECTION_USER_FACING_NAME,
|
||||
} from 'constants/collection';
|
||||
import { getUnixTimeInMicroSecondsWithDelta } from '@ente/shared/time';
|
||||
|
@ -44,14 +43,14 @@ import { getAlbumsURL } from '@ente/shared/network/api';
|
|||
import bs58 from 'bs58';
|
||||
import { t } from 'i18next';
|
||||
import isElectron from 'is-electron';
|
||||
import { SetCollectionDownloadProgressAttributes } from 'types/gallery';
|
||||
import { SetFilesDownloadProgressAttributes } from 'types/gallery';
|
||||
import ElectronAPIs from '@ente/shared/electron';
|
||||
import {
|
||||
getCollectionExportPath,
|
||||
getUniqueCollectionExportName,
|
||||
} from 'utils/export';
|
||||
import exportService from 'services/export';
|
||||
import { CollectionDownloadProgressAttributes } from 'components/Collections/CollectionDownloadProgress';
|
||||
import { addLogLine } from '@ente/shared/logging';
|
||||
|
||||
export enum COLLECTION_OPS_TYPE {
|
||||
ADD,
|
||||
|
@ -100,7 +99,7 @@ export function getSelectedCollection(
|
|||
|
||||
export async function downloadCollectionHelper(
|
||||
collectionID: number,
|
||||
setCollectionDownloadProgressAttributes: SetCollectionDownloadProgressAttributes
|
||||
setFilesDownloadProgressAttributes: SetFilesDownloadProgressAttributes
|
||||
) {
|
||||
try {
|
||||
const allFiles = await getAllLocalFiles();
|
||||
|
@ -116,10 +115,8 @@ export async function downloadCollectionHelper(
|
|||
}
|
||||
await downloadCollectionFiles(
|
||||
collection.name,
|
||||
collection.id,
|
||||
isHiddenCollection(collection),
|
||||
collectionFiles,
|
||||
setCollectionDownloadProgressAttributes
|
||||
setFilesDownloadProgressAttributes
|
||||
);
|
||||
} catch (e) {
|
||||
logError(e, 'download collection failed ');
|
||||
|
@ -127,7 +124,7 @@ export async function downloadCollectionHelper(
|
|||
}
|
||||
|
||||
export async function downloadDefaultHiddenCollectionHelper(
|
||||
setCollectionDownloadProgressAttributes: SetCollectionDownloadProgressAttributes
|
||||
setFilesDownloadProgressAttributes: SetFilesDownloadProgressAttributes
|
||||
) {
|
||||
try {
|
||||
const hiddenCollections = await getLocalCollections('hidden');
|
||||
|
@ -139,78 +136,38 @@ export async function downloadDefaultHiddenCollectionHelper(
|
|||
);
|
||||
await downloadCollectionFiles(
|
||||
DEFAULT_HIDDEN_COLLECTION_USER_FACING_NAME,
|
||||
HIDDEN_ITEMS_SECTION,
|
||||
true,
|
||||
defaultHiddenCollectionFiles,
|
||||
setCollectionDownloadProgressAttributes
|
||||
setFilesDownloadProgressAttributes
|
||||
);
|
||||
} catch (e) {
|
||||
logError(e, 'download hidden files failed ');
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadCollectionFiles(
|
||||
export async function downloadCollectionFiles(
|
||||
collectionName: string,
|
||||
collectionID: number,
|
||||
isHidden: boolean,
|
||||
collectionFiles: EnteFile[],
|
||||
setCollectionDownloadProgressAttributes: SetCollectionDownloadProgressAttributes
|
||||
setFilesDownloadProgressAttributes: SetFilesDownloadProgressAttributes
|
||||
) {
|
||||
if (!collectionFiles.length) {
|
||||
return;
|
||||
}
|
||||
const canceller = new AbortController();
|
||||
const increaseSuccess = () => {
|
||||
if (canceller.signal.aborted) return;
|
||||
setCollectionDownloadProgressAttributes((prev) => ({
|
||||
...prev,
|
||||
success: prev.success + 1,
|
||||
}));
|
||||
};
|
||||
const increaseFailed = () => {
|
||||
if (canceller.signal.aborted) return;
|
||||
setCollectionDownloadProgressAttributes((prev) => ({
|
||||
...prev,
|
||||
failed: prev.failed + 1,
|
||||
}));
|
||||
};
|
||||
const isCancelled = () => canceller.signal.aborted;
|
||||
const initialProgressAttributes: CollectionDownloadProgressAttributes = {
|
||||
collectionName,
|
||||
collectionID,
|
||||
isHidden,
|
||||
canceller,
|
||||
total: collectionFiles.length,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
downloadDirPath: null,
|
||||
};
|
||||
let downloadDirPath: string;
|
||||
if (isElectron()) {
|
||||
const selectedDir = await ElectronAPIs.selectDirectory();
|
||||
if (!selectedDir) {
|
||||
return;
|
||||
}
|
||||
const downloadDirPath = await createCollectionDownloadFolder(
|
||||
downloadDirPath = await createCollectionDownloadFolder(
|
||||
selectedDir,
|
||||
collectionName
|
||||
);
|
||||
setCollectionDownloadProgressAttributes({
|
||||
...initialProgressAttributes,
|
||||
downloadDirPath,
|
||||
});
|
||||
await downloadFilesDesktop(
|
||||
collectionFiles,
|
||||
{ increaseSuccess, increaseFailed, isCancelled },
|
||||
downloadDirPath
|
||||
);
|
||||
} else {
|
||||
setCollectionDownloadProgressAttributes(initialProgressAttributes);
|
||||
await downloadFiles(collectionFiles, {
|
||||
increaseSuccess,
|
||||
increaseFailed,
|
||||
isCancelled,
|
||||
});
|
||||
}
|
||||
await downloadFilesWithProgress(
|
||||
collectionFiles,
|
||||
downloadDirPath,
|
||||
setFilesDownloadProgressAttributes
|
||||
);
|
||||
}
|
||||
|
||||
async function createCollectionDownloadFolder(
|
||||
|
@ -521,7 +478,8 @@ export function isValidReplacementAlbum(
|
|||
return (
|
||||
collection.name === wantedCollectionName &&
|
||||
(collection.type === CollectionType.album ||
|
||||
collection.type === CollectionType.folder) &&
|
||||
collection.type === CollectionType.folder ||
|
||||
collection.type === CollectionType.uncategorized) &&
|
||||
!isHiddenCollection(collection) &&
|
||||
!isQuickLinkCollection(collection) &&
|
||||
!isIncomingShare(collection, user)
|
||||
|
@ -610,8 +568,13 @@ export const getOrCreateAlbum = async (
|
|||
}
|
||||
for (const collection of existingCollections) {
|
||||
if (isValidReplacementAlbum(collection, user, albumName)) {
|
||||
addLogLine(
|
||||
`Found existing album ${albumName} with id ${collection.id}`
|
||||
);
|
||||
return collection;
|
||||
}
|
||||
}
|
||||
return createAlbum(albumName);
|
||||
const album = await createAlbum(albumName);
|
||||
addLogLine(`Created new album ${albumName} with id ${album.id}`);
|
||||
return album;
|
||||
};
|
||||
|
|
30
apps/photos/src/utils/comlink/ComlinkSearchWorker.ts
Normal file
30
apps/photos/src/utils/comlink/ComlinkSearchWorker.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { Remote } from 'comlink';
|
||||
import { runningInBrowser } from 'utils/common';
|
||||
import { ComlinkWorker } from '@ente/shared/worker/comlinkWorker';
|
||||
import { DedicatedSearchWorker } from 'worker/search.worker';
|
||||
|
||||
class ComlinkSearchWorker {
|
||||
private comlinkWorkerInstance: Remote<DedicatedSearchWorker>;
|
||||
|
||||
async getInstance() {
|
||||
if (!this.comlinkWorkerInstance) {
|
||||
this.comlinkWorkerInstance = await getDedicatedSearchWorker()
|
||||
.remote;
|
||||
}
|
||||
return this.comlinkWorkerInstance;
|
||||
}
|
||||
}
|
||||
|
||||
export const getDedicatedSearchWorker = () => {
|
||||
if (runningInBrowser()) {
|
||||
const cryptoComlinkWorker = new ComlinkWorker<
|
||||
typeof DedicatedSearchWorker
|
||||
>(
|
||||
'ente-search-worker',
|
||||
new Worker(new URL('worker/search.worker.ts', import.meta.url))
|
||||
);
|
||||
return cryptoComlinkWorker;
|
||||
}
|
||||
};
|
||||
|
||||
export default new ComlinkSearchWorker();
|
|
@ -1,4 +1,8 @@
|
|||
import { SelectedState } from 'types/gallery';
|
||||
import {
|
||||
SelectedState,
|
||||
SetFilesDownloadProgressAttributes,
|
||||
SetFilesDownloadProgressAttributesCreator,
|
||||
} from 'types/gallery';
|
||||
import {
|
||||
EnteFile,
|
||||
EncryptedEnteFile,
|
||||
|
@ -52,6 +56,7 @@ import { getFileExportPath, getUniqueFileExportName } from 'utils/export';
|
|||
import imageProcessor from 'services/imageProcessor';
|
||||
import ElectronAPIs from '@ente/shared/electron';
|
||||
import { downloadUsingAnchor } from '@ente/shared/utils';
|
||||
import { t } from 'i18next';
|
||||
|
||||
const WAIT_TIME_IMAGE_CONVERSION = 30 * 1000;
|
||||
|
||||
|
@ -625,9 +630,96 @@ export function getUniqueFiles(files: EnteFile[]) {
|
|||
return uniqueFiles;
|
||||
}
|
||||
|
||||
export async function downloadFilesWithProgress(
|
||||
files: EnteFile[],
|
||||
downloadDirPath: string,
|
||||
setFilesDownloadProgressAttributes: SetFilesDownloadProgressAttributes
|
||||
) {
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
const canceller = new AbortController();
|
||||
const increaseSuccess = () => {
|
||||
if (canceller.signal.aborted) return;
|
||||
setFilesDownloadProgressAttributes((prev) => ({
|
||||
...prev,
|
||||
success: prev.success + 1,
|
||||
}));
|
||||
};
|
||||
const increaseFailed = () => {
|
||||
if (canceller.signal.aborted) return;
|
||||
setFilesDownloadProgressAttributes((prev) => ({
|
||||
...prev,
|
||||
failed: prev.failed + 1,
|
||||
}));
|
||||
};
|
||||
const isCancelled = () => canceller.signal.aborted;
|
||||
|
||||
setFilesDownloadProgressAttributes({
|
||||
downloadDirPath,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
total: files.length,
|
||||
canceller,
|
||||
});
|
||||
|
||||
if (isElectron()) {
|
||||
await downloadFilesDesktop(
|
||||
files,
|
||||
{ increaseSuccess, increaseFailed, isCancelled },
|
||||
downloadDirPath
|
||||
);
|
||||
} else {
|
||||
await downloadFiles(files, {
|
||||
increaseSuccess,
|
||||
increaseFailed,
|
||||
isCancelled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadSelectedFiles(
|
||||
files: EnteFile[],
|
||||
setFilesDownloadProgressAttributes: SetFilesDownloadProgressAttributes
|
||||
) {
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
let downloadDirPath: string;
|
||||
if (isElectron()) {
|
||||
downloadDirPath = await ElectronAPIs.selectDirectory();
|
||||
if (!downloadDirPath) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await downloadFilesWithProgress(
|
||||
files,
|
||||
downloadDirPath,
|
||||
setFilesDownloadProgressAttributes
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadSingleFile(
|
||||
file: EnteFile,
|
||||
setFilesDownloadProgressAttributes: SetFilesDownloadProgressAttributes
|
||||
) {
|
||||
let downloadDirPath: string;
|
||||
if (isElectron()) {
|
||||
downloadDirPath = await ElectronAPIs.selectDirectory();
|
||||
if (!downloadDirPath) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await downloadFilesWithProgress(
|
||||
[file],
|
||||
downloadDirPath,
|
||||
setFilesDownloadProgressAttributes
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadFiles(
|
||||
files: EnteFile[],
|
||||
progressBarUpdater?: {
|
||||
progressBarUpdater: {
|
||||
increaseSuccess: () => void;
|
||||
increaseFailed: () => void;
|
||||
isCancelled: () => boolean;
|
||||
|
@ -857,11 +949,11 @@ export const shouldShowAvatar = (file: EnteFile, user: User) => {
|
|||
export const handleFileOps = async (
|
||||
ops: FILE_OPS_TYPE,
|
||||
files: EnteFile[],
|
||||
setDeletedFileIds: (
|
||||
deletedFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
|
||||
setTempDeletedFileIds: (
|
||||
tempDeletedFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
|
||||
) => void,
|
||||
setHiddenFileIds: (
|
||||
hiddenFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
|
||||
setTempHiddenFileIds: (
|
||||
tempHiddenFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
|
||||
) => void,
|
||||
setFixCreationTimeAttributes: (
|
||||
fixCreationTimeAttributes:
|
||||
|
@ -869,21 +961,30 @@ export const handleFileOps = async (
|
|||
files: EnteFile[];
|
||||
}
|
||||
| ((prev: { files: EnteFile[] }) => { files: EnteFile[] })
|
||||
) => void
|
||||
) => void,
|
||||
setFilesDownloadProgressAttributesCreator: SetFilesDownloadProgressAttributesCreator
|
||||
) => {
|
||||
switch (ops) {
|
||||
case FILE_OPS_TYPE.TRASH:
|
||||
await deleteFileHelper(files, false, setDeletedFileIds);
|
||||
await deleteFileHelper(files, false, setTempDeletedFileIds);
|
||||
break;
|
||||
case FILE_OPS_TYPE.DELETE_PERMANENTLY:
|
||||
await deleteFileHelper(files, true, setDeletedFileIds);
|
||||
await deleteFileHelper(files, true, setTempDeletedFileIds);
|
||||
break;
|
||||
case FILE_OPS_TYPE.HIDE:
|
||||
await hideFilesHelper(files, setHiddenFileIds);
|
||||
await hideFilesHelper(files, setTempHiddenFileIds);
|
||||
break;
|
||||
case FILE_OPS_TYPE.DOWNLOAD:
|
||||
await downloadFiles(files);
|
||||
case FILE_OPS_TYPE.DOWNLOAD: {
|
||||
const setSelectedFileDownloadProgressAttributes =
|
||||
setFilesDownloadProgressAttributesCreator(
|
||||
`${files.length} ${t('FILES')}`
|
||||
);
|
||||
await downloadSelectedFiles(
|
||||
files,
|
||||
setSelectedFileDownloadProgressAttributes
|
||||
);
|
||||
break;
|
||||
}
|
||||
case FILE_OPS_TYPE.FIX_TIME:
|
||||
fixTimeHelper(files, setFixCreationTimeAttributes);
|
||||
break;
|
||||
|
@ -899,12 +1000,12 @@ export const handleFileOps = async (
|
|||
const deleteFileHelper = async (
|
||||
selectedFiles: EnteFile[],
|
||||
permanent: boolean,
|
||||
setDeletedFileIds: (
|
||||
deletedFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
|
||||
setTempDeletedFileIds: (
|
||||
tempDeletedFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
|
||||
) => void
|
||||
) => {
|
||||
try {
|
||||
setDeletedFileIds((deletedFileIds) => {
|
||||
setTempDeletedFileIds((deletedFileIds) => {
|
||||
selectedFiles.forEach((file) => deletedFileIds.add(file.id));
|
||||
return new Set(deletedFileIds);
|
||||
});
|
||||
|
@ -914,25 +1015,25 @@ const deleteFileHelper = async (
|
|||
await trashFiles(selectedFiles);
|
||||
}
|
||||
} catch (e) {
|
||||
setDeletedFileIds(new Set());
|
||||
setTempDeletedFileIds(new Set());
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const hideFilesHelper = async (
|
||||
selectedFiles: EnteFile[],
|
||||
setHiddenFileIds: (
|
||||
hiddenFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
|
||||
setTempHiddenFileIds: (
|
||||
tempHiddenFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
|
||||
) => void
|
||||
) => {
|
||||
try {
|
||||
setHiddenFileIds((hiddenFileIds) => {
|
||||
setTempHiddenFileIds((hiddenFileIds) => {
|
||||
selectedFiles.forEach((file) => hiddenFileIds.add(file.id));
|
||||
return new Set(hiddenFileIds);
|
||||
});
|
||||
await moveToHiddenCollection(selectedFiles);
|
||||
} catch (e) {
|
||||
setHiddenFileIds(new Set());
|
||||
setTempHiddenFileIds(new Set());
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
import { LocationTagData } from 'types/entity';
|
||||
import { DateValue } from 'types/search';
|
||||
import { Location } from 'types/upload';
|
||||
|
||||
export const isSameDayAnyYear =
|
||||
(baseDate: DateValue) => (compareDate: Date) => {
|
||||
|
@ -28,18 +26,3 @@ export function getFormattedDate(date: DateValue) {
|
|||
new Date(date.year ?? 1, date.month ?? 1, date.date ?? 1)
|
||||
);
|
||||
}
|
||||
|
||||
export function isInsideLocationTag(
|
||||
location: Location,
|
||||
locationTag: LocationTagData
|
||||
) {
|
||||
const { centerPoint, aSquare, bSquare } = locationTag;
|
||||
const { latitude, longitude } = location;
|
||||
const x = Math.abs(centerPoint.latitude - latitude);
|
||||
const y = Math.abs(centerPoint.longitude - longitude);
|
||||
if ((x * x) / aSquare + (y * y) / bSquare <= 1) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
75
apps/photos/src/worker/search.worker.ts
Normal file
75
apps/photos/src/worker/search.worker.ts
Normal file
|
@ -0,0 +1,75 @@
|
|||
import * as Comlink from 'comlink';
|
||||
import {
|
||||
isInsideLocationTag,
|
||||
isInsideCity,
|
||||
} from 'services/locationSearchService';
|
||||
import { EnteFile } from 'types/file';
|
||||
import { isSameDayAnyYear } from 'utils/search';
|
||||
import { Search } from 'types/search';
|
||||
|
||||
export class DedicatedSearchWorker {
|
||||
private files: EnteFile[] = [];
|
||||
|
||||
setFiles(files: EnteFile[]) {
|
||||
this.files = files;
|
||||
}
|
||||
|
||||
search(search: Search) {
|
||||
return this.files.filter((file) => {
|
||||
return isSearchedFile(file, search);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Comlink.expose(DedicatedSearchWorker, self);
|
||||
|
||||
function isSearchedFile(file: EnteFile, search: Search) {
|
||||
if (search?.collection) {
|
||||
return search.collection === file.collectionID;
|
||||
}
|
||||
|
||||
if (search?.date) {
|
||||
return isSameDayAnyYear(search.date)(
|
||||
new Date(file.metadata.creationTime / 1000)
|
||||
);
|
||||
}
|
||||
if (search?.location) {
|
||||
return isInsideLocationTag(
|
||||
{
|
||||
latitude: file.metadata.latitude,
|
||||
longitude: file.metadata.longitude,
|
||||
},
|
||||
search.location
|
||||
);
|
||||
}
|
||||
if (search?.city) {
|
||||
return isInsideCity(
|
||||
{
|
||||
latitude: file.metadata.latitude,
|
||||
longitude: file.metadata.longitude,
|
||||
},
|
||||
search.city
|
||||
);
|
||||
}
|
||||
if (search?.files) {
|
||||
return search.files.indexOf(file.id) !== -1;
|
||||
}
|
||||
if (search?.person) {
|
||||
return search.person.files.indexOf(file.id) !== -1;
|
||||
}
|
||||
|
||||
if (search?.thing) {
|
||||
return search.thing.files.indexOf(file.id) !== -1;
|
||||
}
|
||||
|
||||
if (search?.text) {
|
||||
return search.text.files.indexOf(file.id) !== -1;
|
||||
}
|
||||
if (typeof search?.fileType !== 'undefined') {
|
||||
return search.fileType === file.metadata.fileType;
|
||||
}
|
||||
if (typeof search?.clip !== 'undefined') {
|
||||
return search.clip.has(file.id);
|
||||
}
|
||||
return false;
|
||||
}
|
|
@ -17,3 +17,5 @@ export const WEB_ROADMAP_URL = 'https://github.com/ente-io/photos-web/issues';
|
|||
|
||||
export const DESKTOP_ROADMAP_URL =
|
||||
'https://github.com/ente-io/photos-desktop/issues';
|
||||
|
||||
export const CITIES_URL = 'https://static.ente.io/world_cities.json';
|
||||
|
|
|
@ -7,6 +7,11 @@ export interface AppUpdateInfo {
|
|||
version: string;
|
||||
}
|
||||
|
||||
export enum Model {
|
||||
GGML_CLIP = 'ggml-clip',
|
||||
ONNX_CLIP = 'onnx-clip',
|
||||
}
|
||||
|
||||
export interface ElectronAPIsType {
|
||||
exists: (path: string) => boolean;
|
||||
checkExistsAndCreateDir: (dirPath: string) => Promise<void>;
|
||||
|
@ -97,8 +102,11 @@ export interface ElectronAPIsType {
|
|||
deleteFile: (path: string) => void;
|
||||
rename: (oldPath: string, newPath: string) => Promise<void>;
|
||||
updateOptOutOfCrashReports: (optOut: boolean) => Promise<void>;
|
||||
computeImageEmbedding: (imageData: Uint8Array) => Promise<Float32Array>;
|
||||
computeTextEmbedding: (text: string) => Promise<Float32Array>;
|
||||
computeImageEmbedding: (
|
||||
model: Model,
|
||||
imageData: Uint8Array
|
||||
) => Promise<Float32Array>;
|
||||
computeTextEmbedding: (model: Model, text: string) => Promise<Float32Array>;
|
||||
getPlatform: () => Promise<'mac' | 'windows' | 'linux'>;
|
||||
setCustomCacheDirectory: (directory: string) => Promise<void>;
|
||||
getCacheDirectory: () => Promise<string>;
|
||||
|
|
33
packages/shared/hooks/useEffectSingleThreaded.tsx
Normal file
33
packages/shared/hooks/useEffectSingleThreaded.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { isPromise } from '../utils';
|
||||
|
||||
// useEffectSingleThreaded is a useEffect that will only run one at a time, and will
|
||||
// caches the latest deps of requests that come in while it is running, and will
|
||||
// run that after the current run is complete.
|
||||
export default function useEffectSingleThreaded(
|
||||
fn: (deps) => void | Promise<void>,
|
||||
deps: any[]
|
||||
): void {
|
||||
const updateInProgress = useRef(false);
|
||||
const nextRequestDepsRef = useRef<any[]>(null);
|
||||
useEffect(() => {
|
||||
const main = async (deps) => {
|
||||
if (updateInProgress.current) {
|
||||
nextRequestDepsRef.current = deps;
|
||||
return;
|
||||
}
|
||||
updateInProgress.current = true;
|
||||
const result = fn(deps);
|
||||
if (isPromise(result)) {
|
||||
await result;
|
||||
}
|
||||
updateInProgress.current = false;
|
||||
if (nextRequestDepsRef.current) {
|
||||
const deps = nextRequestDepsRef.current;
|
||||
nextRequestDepsRef.current = null;
|
||||
setTimeout(() => main(deps), 0);
|
||||
}
|
||||
};
|
||||
main(deps);
|
||||
}, deps);
|
||||
}
|
|
@ -9,7 +9,7 @@ export function useLocalState<T>(
|
|||
|
||||
useEffect(() => {
|
||||
const { value } = getData(key) ?? {};
|
||||
if (value) {
|
||||
if (typeof value !== 'undefined') {
|
||||
setValue(value);
|
||||
}
|
||||
}, []);
|
||||
|
|
|
@ -22,3 +22,7 @@ export function downloadUsingAnchor(link: string, name: string) {
|
|||
URL.revokeObjectURL(link);
|
||||
a.remove();
|
||||
}
|
||||
|
||||
export function isPromise<T>(obj: T | Promise<T>): obj is Promise<T> {
|
||||
return obj && typeof (obj as any).then === 'function';
|
||||
}
|
||||
|
|
|
@ -2275,9 +2275,9 @@ fn-name@~3.0.0:
|
|||
integrity sha512-eNMNr5exLoavuAMhIUVsOKF79SWd/zG104ef6sxBTSw+cZc6BXdQXDvYcGvp0VbxVVSp1XDUNoz7mg1xMtSznA==
|
||||
|
||||
follow-redirects@^1.15.0:
|
||||
version "1.15.2"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
|
||||
integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==
|
||||
version "1.15.4"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf"
|
||||
integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==
|
||||
|
||||
for-each@^0.3.3:
|
||||
version "0.3.3"
|
||||
|
|
Loading…
Reference in a new issue