Merge pull request #199 from ente-io/master

Diwali Release
This commit is contained in:
Vishnu Mohandas 2021-11-04 20:12:32 +05:30 committed by GitHub
commit 9344acb1d2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 3407 additions and 1889 deletions

View file

@ -44,6 +44,7 @@
"react-bootstrap": "^1.3.0", "react-bootstrap": "^1.3.0",
"react-burger-menu": "^3.0.4", "react-burger-menu": "^3.0.4",
"react-collapse": "^5.1.0", "react-collapse": "^5.1.0",
"react-datepicker": "^4.3.0",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-dropzone": "^11.2.4", "react-dropzone": "^11.2.4",
"react-otp-input": "^2.3.1", "react-otp-input": "^2.3.1",
@ -70,6 +71,7 @@
"@types/photoswipe": "^4.1.1", "@types/photoswipe": "^4.1.1",
"@types/react": "^16.9.49", "@types/react": "^16.9.49",
"@types/react-collapse": "^5.0.1", "@types/react-collapse": "^5.0.1",
"@types/react-datepicker": "^4.1.7",
"@types/react-select": "^4.0.15", "@types/react-select": "^4.0.15",
"@types/react-window": "^1.8.2", "@types/react-window": "^1.8.2",
"@types/react-window-infinite-loader": "^1.0.3", "@types/react-window-infinite-loader": "^1.0.3",

View file

@ -22,8 +22,6 @@ export const IconButton = styled.button`
background: none; background: none;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
width: 40px;
height: 40px;
padding: 5px; padding: 5px;
color: inherit; color: inherit;
margin: 0 10px; margin: 0 10px;

View file

@ -1,13 +1,15 @@
import React from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import constants from 'utils/strings/constants';
import { IconWithMessage } from './pages/gallery/SelectedFileOptions';
const Wrapper = styled.button` const Wrapper = styled.button`
border: none; border: none;
background-color: #ff6666; background-color: #ff6666;
position: fixed; position: fixed;
z-index: 1; z-index: 1;
bottom: 20px; bottom: 30px;
right: 20px; right: 30px;
width: 60px; width: 60px;
height: 60px; height: 60px;
border-radius: 50%; border-radius: 50%;
@ -15,6 +17,7 @@ const Wrapper = styled.button`
`; `;
export default function DeleteBtn(props) { export default function DeleteBtn(props) {
return ( return (
<IconWithMessage message={constants.EMPTY_TRASH}>
<Wrapper onClick={props.onClick}> <Wrapper onClick={props.onClick}>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -25,6 +28,7 @@ export default function DeleteBtn(props) {
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" /> <path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg> </svg>
</Wrapper> </Wrapper>
</IconWithMessage>
); );
} }

View file

@ -0,0 +1,228 @@
import constants from 'utils/strings/constants';
import MessageDialog from './MessageDialog';
import React, { useEffect, useState } from 'react';
import { ProgressBar, Button } from 'react-bootstrap';
import { ComfySpan } from './ExportInProgress';
import {
getLargeThumbnailFiles,
replaceThumbnail,
} from 'services/migrateThumbnailService';
import { getData, LS_KEYS, setData } from 'utils/storage/localStorage';
import { logError } from 'utils/sentry';
export type SetProgressTracker = React.Dispatch<
React.SetStateAction<{
current: number;
total: number;
}>
>;
interface Props {
isOpen: boolean;
show: () => void;
hide: () => void;
}
export enum FIX_STATE {
NOT_STARTED,
FIX_LATER,
NOOP,
RUNNING,
COMPLETED,
COMPLETED_WITH_ERRORS,
}
function Message(props: { fixState: FIX_STATE }) {
let message = null;
switch (props.fixState) {
case FIX_STATE.NOT_STARTED:
case FIX_STATE.FIX_LATER:
message = constants.REPLACE_THUMBNAIL_NOT_STARTED();
break;
case FIX_STATE.COMPLETED:
message = constants.REPLACE_THUMBNAIL_COMPLETED();
break;
case FIX_STATE.NOOP:
message = constants.REPLACE_THUMBNAIL_NOOP();
break;
case FIX_STATE.COMPLETED_WITH_ERRORS:
message = constants.REPLACE_THUMBNAIL_COMPLETED_WITH_ERROR();
break;
}
return message ? (
<div style={{ marginBottom: '30px' }}>{message}</div>
) : (
<></>
);
}
export default function FixLargeThumbnails(props: Props) {
const [fixState, setFixState] = useState(FIX_STATE.NOT_STARTED);
const [progressTracker, setProgressTracker] = useState({
current: 0,
total: 0,
});
const [largeThumbnailFiles, setLargeThumbnailFiles] = useState<number[]>(
[]
);
const init = (): FIX_STATE => {
let fixState = getData(LS_KEYS.THUMBNAIL_FIX_STATE)?.state;
if (!fixState || fixState === FIX_STATE.RUNNING) {
fixState = FIX_STATE.NOT_STARTED;
updateFixState(fixState);
}
if (fixState === FIX_STATE.COMPLETED) {
fixState = FIX_STATE.NOOP;
updateFixState(fixState);
}
setFixState(fixState);
return fixState;
};
const fetchLargeThumbnail = async () => {
const largeThumbnailFiles = (await getLargeThumbnailFiles()) ?? [];
setLargeThumbnailFiles(largeThumbnailFiles);
return largeThumbnailFiles;
};
const main = async () => {
const largeThumbnailFiles = await fetchLargeThumbnail();
if (
fixState === FIX_STATE.NOT_STARTED &&
largeThumbnailFiles.length > 0
) {
props.show();
}
if (
(fixState === FIX_STATE.COMPLETED || fixState === FIX_STATE.NOOP) &&
largeThumbnailFiles.length > 0
) {
updateFixState(FIX_STATE.NOT_STARTED);
logError(Error(), 'large thumbnail files left after migration');
}
if (largeThumbnailFiles.length === 0 && fixState !== FIX_STATE.NOOP) {
updateFixState(FIX_STATE.NOOP);
}
};
useEffect(() => {
if (props.isOpen && fixState !== FIX_STATE.RUNNING) {
main();
}
}, [props.isOpen]);
useEffect(() => {
const fixState = init();
if (fixState === FIX_STATE.NOT_STARTED) {
main();
}
}, []);
const startFix = async (newlyFetchedLargeThumbnailFiles?: number[]) => {
updateFixState(FIX_STATE.RUNNING);
const completedWithError = await replaceThumbnail(
setProgressTracker,
new Set(
newlyFetchedLargeThumbnailFiles ?? largeThumbnailFiles ?? []
)
);
if (typeof completedWithError !== 'undefined') {
updateFixState(
completedWithError
? FIX_STATE.COMPLETED_WITH_ERRORS
: FIX_STATE.COMPLETED
);
}
await fetchLargeThumbnail();
};
const updateFixState = (fixState: FIX_STATE) => {
setFixState(fixState);
setData(LS_KEYS.THUMBNAIL_FIX_STATE, { state: fixState });
};
return (
<MessageDialog
show={props.isOpen}
onHide={props.hide}
attributes={{
title: constants.FIX_LARGE_THUMBNAILS,
staticBackdrop: true,
}}>
<div
style={{
marginBottom: '20px',
padding: '0 5%',
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
}}>
<Message fixState={fixState} />
{fixState === FIX_STATE.RUNNING && (
<>
<div style={{ marginBottom: '10px' }}>
<ComfySpan>
{' '}
{progressTracker.current} /{' '}
{progressTracker.total}{' '}
</ComfySpan>{' '}
<span style={{ marginLeft: '10px' }}>
{' '}
{constants.THUMBNAIL_REPLACED}
</span>
</div>
<div
style={{
width: '100%',
marginTop: '10px',
marginBottom: '20px',
}}>
<ProgressBar
now={Math.round(
(progressTracker.current * 100) /
progressTracker.total
)}
animated={true}
variant="upload-progress-bar"
/>
</div>
</>
)}
<div
style={{
width: '100%',
display: 'flex',
justifyContent: 'space-around',
}}>
{fixState === FIX_STATE.NOT_STARTED ? (
<Button
block
variant={'outline-secondary'}
onClick={() => {
updateFixState(FIX_STATE.FIX_LATER);
props.hide();
}}>
{constants.FIX_LATER}
</Button>
) : (
<Button
block
variant={'outline-secondary'}
onClick={props.hide}>
{constants.CLOSE}
</Button>
)}
{(fixState === FIX_STATE.NOT_STARTED ||
fixState === FIX_STATE.FIX_LATER ||
fixState === FIX_STATE.COMPLETED_WITH_ERRORS) && (
<>
<div style={{ width: '30px' }} />
<Button
block
variant={'outline-success'}
onClick={() => startFix()}>
{constants.FIX}
</Button>
</>
)}
</div>
</div>
</MessageDialog>
);
}

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import constants from 'utils/strings/constants'; import constants from 'utils/strings/constants';
import CrossIcon from './icons/CrossIcon'; import CloseIcon from './icons/CloseIcon';
const CloseButtonWrapper = styled.div` const CloseButtonWrapper = styled.div`
position: absolute; position: absolute;
@ -62,7 +62,7 @@ export default function FullScreenDropZone(props: Props) {
{isDragActive && ( {isDragActive && (
<Overlay onDrop={onDragLeave} onDragLeave={onDragLeave}> <Overlay onDrop={onDragLeave} onDragLeave={onDragLeave}>
<CloseButtonWrapper onClick={onDragLeave}> <CloseButtonWrapper onClick={onDragLeave}>
<CrossIcon /> <CloseIcon />
</CloseButtonWrapper> </CloseButtonWrapper>
{constants.UPLOAD_DROPZONE_MESSAGE} {constants.UPLOAD_DROPZONE_MESSAGE}
</Overlay> </Overlay>

View file

@ -1,5 +1,4 @@
import { import {
DeadCenter,
GalleryContext, GalleryContext,
Search, Search,
SelectedState, SelectedState,
@ -14,40 +13,18 @@ import styled from 'styled-components';
import DownloadManager from 'services/downloadManager'; import DownloadManager from 'services/downloadManager';
import constants from 'utils/strings/constants'; import constants from 'utils/strings/constants';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
import { VariableSizeList as List } from 'react-window';
import PhotoSwipe from 'components/PhotoSwipe/PhotoSwipe'; import PhotoSwipe from 'components/PhotoSwipe/PhotoSwipe';
import { isInsideBox, isSameDay as isSameDayAnyYear } from 'utils/search'; import { isInsideBox, isSameDay as isSameDayAnyYear } from 'utils/search';
import { SetDialogMessage } from './MessageDialog'; import { SetDialogMessage } from './MessageDialog';
import { fileIsArchived, formatDateRelative } from 'utils/file';
import { import {
GAP_BTW_TILES, ALL_SECTION,
DATE_CONTAINER_HEIGHT, ARCHIVE_SECTION,
IMAGE_CONTAINER_MAX_HEIGHT, TRASH_SECTION,
IMAGE_CONTAINER_MAX_WIDTH, } from './pages/gallery/Collections';
MIN_COLUMNS,
SPACE_BTW_DATES,
} from 'types';
import { fileIsArchived } from 'utils/file';
import { ALL_SECTION, ARCHIVE_SECTION } from './pages/gallery/Collections';
import { isSharedFile } from 'utils/file'; import { isSharedFile } from 'utils/file';
import { isPlaybackPossible } from 'utils/photoFrame'; import { isPlaybackPossible } from 'utils/photoFrame';
import { PhotoList } from './PhotoList';
const NO_OF_PAGES = 2;
const A_DAY = 24 * 60 * 60 * 1000;
interface TimeStampListItem {
itemType: ITEM_TYPE;
items?: File[];
itemStartIndex?: number;
date?: string;
dates?: {
date: string;
span: number;
}[];
groups?: number[];
banner?: any;
id?: string;
height?: number;
}
const Container = styled.div` const Container = styled.div`
display: block; display: block;
@ -62,58 +39,6 @@ const Container = styled.div`
} }
`; `;
const ListItem = styled.div`
display: flex;
justify-content: center;
`;
const getTemplateColumns = (columns: number, groups?: number[]): string => {
if (groups) {
const sum = groups.reduce((acc, item) => acc + item, 0);
if (sum < columns) {
groups[groups.length - 1] += columns - sum;
}
return groups
.map((x) => `repeat(${x}, 1fr)`)
.join(` ${SPACE_BTW_DATES}px `);
} else {
return `repeat(${columns}, 1fr)`;
}
};
const ListContainer = styled.div<{ columns: number; groups?: number[] }>`
display: grid;
grid-template-columns: ${({ columns, groups }) =>
getTemplateColumns(columns, groups)};
grid-column-gap: ${GAP_BTW_TILES}px;
padding: 0 24px;
width: 100%;
color: #fff;
@media (max-width: ${IMAGE_CONTAINER_MAX_WIDTH * 4}px) {
padding: 0 4px;
}
`;
const DateContainer = styled.div<{ span: number }>`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
grid-column: span ${(props) => props.span};
display: flex;
align-items: center;
height: ${DATE_CONTAINER_HEIGHT}px;
`;
const BannerContainer = styled.div<{ span: number }>`
color: #979797;
text-align: center;
grid-column: span ${(props) => props.span};
display: flex;
justify-content: center;
align-items: flex-end;
`;
const EmptyScreen = styled.div` const EmptyScreen = styled.div`
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -127,12 +52,6 @@ const EmptyScreen = styled.div`
} }
`; `;
enum ITEM_TYPE {
TIME = 'TIME',
TILE = 'TILE',
BANNER = 'BANNER',
}
interface Props { interface Props {
files: File[]; files: File[];
setFiles: SetFiles; setFiles: SetFiles;
@ -176,7 +95,29 @@ const PhotoFrame = ({
const [fetching, setFetching] = useState<{ [k: number]: boolean }>({}); const [fetching, setFetching] = useState<{ [k: number]: boolean }>({});
const startTime = Date.now(); const startTime = Date.now();
const galleryContext = useContext(GalleryContext); const galleryContext = useContext(GalleryContext);
const listRef = useRef(null); const [rangeStart, setRangeStart] = useState(null);
const [currentHover, setCurrentHover] = useState(null);
const [isShiftKeyPressed, setIsShiftKeyPressed] = useState(false);
const filteredDataRef = useRef([]);
const filteredData = filteredDataRef?.current ?? [];
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
setIsShiftKeyPressed(true);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
setIsShiftKeyPressed(false);
}
};
document.addEventListener('keydown', handleKeyDown, false);
document.addEventListener('keyup', handleKeyUp, false);
return () => {
document.addEventListener('keydown', handleKeyDown, false);
document.addEventListener('keyup', handleKeyUp, false);
};
}, []);
useEffect(() => { useEffect(() => {
if (isInSearchMode) { if (isInSearchMode) {
@ -195,10 +136,80 @@ const PhotoFrame = ({
} }
}, [search]); }, [search]);
useEffect(() => { const resetFetching = () => {
listRef.current?.resetAfterIndex(0);
setFetching({}); setFetching({});
}, [files, search, deleted]); };
useEffect(() => {
if (selected.count === 0) {
setRangeStart(null);
}
}, [selected]);
useEffect(() => {
const idSet = new Set();
filteredDataRef.current = files
.map((item, index) => ({
...item,
dataIndex: index,
...(item.deleteBy && {
title: constants.AUTOMATIC_BIN_DELETE_MESSAGE(
formatDateRelative(item.deleteBy / 1000)
),
}),
}))
.filter((item) => {
if (deleted.includes(item.id)) {
return false;
}
if (
search.date &&
!isSameDayAnyYear(search.date)(
new Date(item.metadata.creationTime / 1000)
)
) {
return false;
}
if (
search.location &&
!isInsideBox(item.metadata, search.location)
) {
return false;
}
if (activeCollection === ALL_SECTION && fileIsArchived(item)) {
return false;
}
if (
activeCollection === ARCHIVE_SECTION &&
!fileIsArchived(item)
) {
return false;
}
if (isSharedFile(item) && !isSharedCollection) {
return false;
}
if (activeCollection === TRASH_SECTION && !item.isTrashed) {
return false;
}
if (activeCollection !== TRASH_SECTION && item.isTrashed) {
return false;
}
if (!idSet.has(item.id)) {
if (
activeCollection === ALL_SECTION ||
activeCollection === ARCHIVE_SECTION ||
activeCollection === TRASH_SECTION ||
activeCollection === item.collectionID
) {
idSet.add(item.id);
return true;
}
return false;
}
return false;
});
}, [files, deleted, search, activeCollection]);
const updateUrl = (index: number) => (url: string) => { const updateUrl = (index: number) => (url: string) => {
files[index] = { files[index] = {
@ -272,10 +283,14 @@ const PhotoFrame = ({
setOpen(true); setOpen(true);
}; };
const handleSelect = (id: number) => (checked: boolean) => { const handleSelect = (id: number, index?: number) => (checked: boolean) => {
if (selected.collectionID !== activeCollection) { if (selected.collectionID !== activeCollection) {
setSelected({ count: 0, collectionID: 0 }); setSelected({ count: 0, collectionID: 0 });
} }
if (checked) {
setRangeStart(index);
}
setSelected((selected) => ({ setSelected((selected) => ({
...selected, ...selected,
[id]: checked, [id]: checked,
@ -283,19 +298,50 @@ const PhotoFrame = ({
collectionID: activeCollection, collectionID: activeCollection,
})); }));
}; };
const onHoverOver = (index: number) => () => {
setCurrentHover(index);
};
const handleRangeSelect = (index: number) => () => {
if (rangeStart !== index) {
let leftEnd = -1;
let rightEnd = -1;
if (index < rangeStart) {
leftEnd = index + 1;
rightEnd = rangeStart - 1;
} else {
leftEnd = rangeStart + 1;
rightEnd = index - 1;
}
for (let i = leftEnd; i <= rightEnd; i++) {
handleSelect(filteredData[i].id)(true);
}
}
};
const getThumbnail = (file: File[], index: number) => ( const getThumbnail = (file: File[], index: number) => (
<PreviewCard <PreviewCard
key={`tile-${file[index].id}`} key={`tile-${file[index].id}-selected-${
selected[file[index].id] ?? false
}`}
file={file[index]} file={file[index]}
updateUrl={updateUrl(file[index].dataIndex)} updateUrl={updateUrl(file[index].dataIndex)}
onClick={onThumbnailClick(index)} onClick={onThumbnailClick(index)}
selectable={!isSharedCollection} selectable={!isSharedCollection}
onSelect={handleSelect(file[index].id)} onSelect={handleSelect(file[index].id, index)}
selected={ selected={
selected.collectionID === activeCollection && selected.collectionID === activeCollection &&
selected[file[index].id] selected[file[index].id]
} }
selectOnClick={selected.count > 0} selectOnClick={selected.count > 0}
onHover={onHoverOver(index)}
onRangeSelect={handleRangeSelect(index)}
isRangeSelectActive={
isShiftKeyPressed && (rangeStart || rangeStart === 0)
}
isInsSelectRange={
(index >= rangeStart && index <= currentHover) ||
(index >= currentHover && index <= rangeStart)
}
/> />
); );
@ -355,137 +401,6 @@ const PhotoFrame = ({
} }
}; };
const idSet = new Set();
const filteredData = files
.map((item, index) => ({
...item,
dataIndex: index,
}))
.filter((item) => {
if (deleted.includes(item.id)) {
return false;
}
if (
search.date &&
!isSameDayAnyYear(search.date)(
new Date(item.metadata.creationTime / 1000)
)
) {
return false;
}
if (
search.location &&
!isInsideBox(item.metadata, search.location)
) {
return false;
}
if (activeCollection === ALL_SECTION && fileIsArchived(item)) {
return false;
}
if (activeCollection === ARCHIVE_SECTION && !fileIsArchived(item)) {
return false;
}
if (isSharedFile(item) && !isSharedCollection) {
return false;
}
if (!idSet.has(item.id)) {
if (
activeCollection === ALL_SECTION ||
activeCollection === ARCHIVE_SECTION ||
activeCollection === item.collectionID
) {
idSet.add(item.id);
return true;
}
return false;
}
return false;
});
const isSameDay = (first, second) =>
first.getFullYear() === second.getFullYear() &&
first.getMonth() === second.getMonth() &&
first.getDate() === second.getDate();
/**
* Checks and merge multiple dates into a single row.
*
* @param items
* @param columns
* @returns
*/
const mergeTimeStampList = (
items: TimeStampListItem[],
columns: number
): TimeStampListItem[] => {
const newList: TimeStampListItem[] = [];
let index = 0;
let newIndex = 0;
while (index < items.length) {
const currItem = items[index];
// If the current item is of type time, then it is not part of an ongoing date.
// So, there is a possibility of merge.
if (currItem.itemType === ITEM_TYPE.TIME) {
// If new list pointer is not at the end of list then
// we can add more items to the same list.
if (newList[newIndex]) {
// Check if items can be added to same list
if (
newList[newIndex + 1].items.length +
items[index + 1].items.length <=
columns
) {
newList[newIndex].dates.push({
date: currItem.date,
span: items[index + 1].items.length,
});
newList[newIndex + 1].items = newList[
newIndex + 1
].items.concat(items[index + 1].items);
index += 2;
} else {
// Adding items would exceed the number of columns.
// So, move new list pointer to the end. Hence, in next iteration,
// items will be added to a new list.
newIndex += 2;
}
} else {
// New list pointer was at the end of list so simply add new items to the list.
newList.push({
...currItem,
date: null,
dates: [
{
date: currItem.date,
span: items[index + 1].items.length,
},
],
});
newList.push(items[index + 1]);
index += 2;
}
} else {
// Merge cannot happen. Simply add all items to new list
// and set new list point to the end of list.
newList.push(currItem);
index++;
newIndex = newList.length;
}
}
for (let i = 0; i < newList.length; i++) {
const currItem = newList[i];
const nextItem = newList[i + 1];
if (currItem.itemType === ITEM_TYPE.TIME) {
if (currItem.dates.length > 1) {
currItem.groups = currItem.dates.map((item) => item.span);
nextItem.groups = currItem.groups;
}
}
}
return newList;
};
return ( return (
<> <>
{!isFirstLoad && files.length === 0 && !isInSearchMode ? ( {!isFirstLoad && files.length === 0 && !isInSearchMode ? (
@ -508,217 +423,22 @@ const PhotoFrame = ({
{constants.UPLOAD_FIRST_PHOTO} {constants.UPLOAD_FIRST_PHOTO}
</Button> </Button>
</EmptyScreen> </EmptyScreen>
) : filteredData.length ? ( ) : (
<Container> <Container>
<AutoSizer> <AutoSizer>
{({ height, width }) => { {({ height, width }) => (
let columns = Math.floor( <PhotoList
width / IMAGE_CONTAINER_MAX_WIDTH
);
let listItemHeight = IMAGE_CONTAINER_MAX_HEIGHT;
let skipMerge = false;
if (columns < MIN_COLUMNS) {
columns = MIN_COLUMNS;
listItemHeight = width / MIN_COLUMNS;
skipMerge = true;
}
let timeStampList: TimeStampListItem[] = [];
let listItemIndex = 0;
let currentDate = -1;
filteredData.forEach((item, index) => {
if (
!isSameDay(
new Date(
item.metadata.creationTime / 1000
),
new Date(currentDate)
)
) {
currentDate =
item.metadata.creationTime / 1000;
const dateTimeFormat =
new Intl.DateTimeFormat('en-IN', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
});
timeStampList.push({
itemType: ITEM_TYPE.TIME,
date: isSameDay(
new Date(currentDate),
new Date()
)
? 'Today'
: isSameDay(
new Date(currentDate),
new Date(Date.now() - A_DAY)
)
? 'Yesterday'
: dateTimeFormat.format(
currentDate
),
id: currentDate.toString(),
});
timeStampList.push({
itemType: ITEM_TYPE.TILE,
items: [item],
itemStartIndex: index,
});
listItemIndex = 1;
} else if (listItemIndex < columns) {
timeStampList[
timeStampList.length - 1
].items.push(item);
listItemIndex++;
} else {
listItemIndex = 1;
timeStampList.push({
itemType: ITEM_TYPE.TILE,
items: [item],
itemStartIndex: index,
});
}
});
if (!skipMerge) {
timeStampList = mergeTimeStampList(
timeStampList,
columns
);
}
const getItemSize = (index) => {
switch (timeStampList[index].itemType) {
case ITEM_TYPE.TIME:
return DATE_CONTAINER_HEIGHT;
case ITEM_TYPE.TILE:
return listItemHeight;
default:
return timeStampList[index].height;
}
};
const photoFrameHeight = (() => {
let sum = 0;
for (let i = 0; i < timeStampList.length; i++) {
sum += getItemSize(i);
}
return sum;
})();
files.length < 30 &&
!isInSearchMode &&
timeStampList.push({
itemType: ITEM_TYPE.BANNER,
banner: (
<BannerContainer span={columns}>
<p>
{constants.INSTALL_MOBILE_APP()}
</p>
</BannerContainer>
),
id: 'install-banner',
height: Math.max(
48,
height - photoFrameHeight
),
});
const extraRowsToRender = Math.ceil(
(NO_OF_PAGES * height) /
IMAGE_CONTAINER_MAX_HEIGHT
);
const generateKey = (index) => {
switch (timeStampList[index].itemType) {
case ITEM_TYPE.TILE:
return `${
timeStampList[index].items[0].id
}-${
timeStampList[index].items.slice(
-1
)[0].id
}`;
default:
return `${timeStampList[index].id}-${index}`;
}
};
const renderListItem = (
listItem: TimeStampListItem
) => {
switch (listItem.itemType) {
case ITEM_TYPE.TIME:
return listItem.dates ? (
listItem.dates.map((item) => (
<>
<DateContainer
key={item.date}
span={item.span}>
{item.date}
</DateContainer>
<div />
</>
))
) : (
<DateContainer span={columns}>
{listItem.date}
</DateContainer>
);
case ITEM_TYPE.BANNER:
return listItem.banner;
default: {
const ret = listItem.items.map(
(item, idx) =>
getThumbnail(
filteredData,
listItem.itemStartIndex +
idx
)
);
if (listItem.groups) {
let sum = 0;
for (
let i = 0;
i < listItem.groups.length - 1;
i++
) {
sum = sum + listItem.groups[i];
ret.splice(sum, 0, <div />);
sum += 1;
}
}
return ret;
}
}
};
return (
<List
key={`${columns}-${listItemHeight}-${activeCollection}`}
ref={listRef}
itemSize={getItemSize}
height={height}
width={width} width={width}
itemCount={timeStampList.length} height={height}
itemKey={generateKey} getThumbnail={getThumbnail}
overscanCount={extraRowsToRender}> filteredData={filteredData}
{({ index, style }) => ( activeCollection={activeCollection}
<ListItem style={style}> showBanner={
<ListContainer files.length < 30 && !isInSearchMode
columns={columns} }
groups={ resetFetching={resetFetching}
timeStampList[index].groups />
}>
{renderListItem(
timeStampList[index]
)} )}
</ListContainer>
</ListItem>
)}
</List>
);
}}
</AutoSizer> </AutoSizer>
<PhotoSwipe <PhotoSwipe
isOpen={open} isOpen={open}
@ -729,12 +449,9 @@ const PhotoFrame = ({
favItemIds={favItemIds} favItemIds={favItemIds}
loadingBar={loadingBar} loadingBar={loadingBar}
isSharedCollection={isSharedCollection} isSharedCollection={isSharedCollection}
isTrashCollection={activeCollection === TRASH_SECTION}
/> />
</Container> </Container>
) : (
<DeadCenter>
<div>{constants.NOTHING_HERE}</div>
</DeadCenter>
)} )}
</> </>
); );

View file

@ -0,0 +1,409 @@
import React, { useRef, useEffect } from 'react';
import { VariableSizeList as List } from 'react-window';
import styled from 'styled-components';
import { File } from 'services/fileService';
import {
IMAGE_CONTAINER_MAX_WIDTH,
IMAGE_CONTAINER_MAX_HEIGHT,
MIN_COLUMNS,
DATE_CONTAINER_HEIGHT,
GAP_BTW_TILES,
SPACE_BTW_DATES,
} from 'types';
import constants from 'utils/strings/constants';
const A_DAY = 24 * 60 * 60 * 1000;
const NO_OF_PAGES = 2;
enum ITEM_TYPE {
TIME = 'TIME',
TILE = 'TILE',
BANNER = 'BANNER',
}
interface TimeStampListItem {
itemType: ITEM_TYPE;
items?: File[];
itemStartIndex?: number;
date?: string;
dates?: {
date: string;
span: number;
}[];
groups?: number[];
banner?: any;
id?: string;
height?: number;
}
const ListItem = styled.div`
display: flex;
justify-content: center;
`;
const getTemplateColumns = (columns: number, groups?: number[]): string => {
if (groups) {
const sum = groups.reduce((acc, item) => acc + item, 0);
if (sum < columns) {
groups[groups.length - 1] += columns - sum;
}
return groups
.map((x) => `repeat(${x}, 1fr)`)
.join(` ${SPACE_BTW_DATES}px `);
} else {
return `repeat(${columns}, 1fr)`;
}
};
const ListContainer = styled.div<{ columns: number; groups?: number[] }>`
user-select: none;
display: grid;
grid-template-columns: ${({ columns, groups }) =>
getTemplateColumns(columns, groups)};
grid-column-gap: ${GAP_BTW_TILES}px;
padding: 0 24px;
width: 100%;
color: #fff;
@media (max-width: ${IMAGE_CONTAINER_MAX_WIDTH * 4}px) {
padding: 0 4px;
}
`;
const DateContainer = styled.div<{ span: number }>`
user-select: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
grid-column: span ${(props) => props.span};
display: flex;
align-items: center;
height: ${DATE_CONTAINER_HEIGHT}px;
`;
const BannerContainer = styled.div<{ span: number }>`
color: #979797;
text-align: center;
grid-column: span ${(props) => props.span};
display: flex;
justify-content: center;
align-items: flex-end;
`;
const NothingContainer = styled.div<{ span: number }>`
color: #979797;
text-align: center;
grid-column: span ${(props) => props.span};
display: flex;
justify-content: center;
align-items: center;
`;
interface Props {
height: number;
width: number;
filteredData: File[];
showBanner: boolean;
getThumbnail: (file: File[], index: number) => JSX.Element;
activeCollection: number;
resetFetching: () => void;
}
export function PhotoList({
height,
width,
filteredData,
showBanner,
getThumbnail,
activeCollection,
resetFetching,
}: Props) {
const timeStampListRef = useRef([]);
const timeStampList = timeStampListRef?.current ?? [];
const filteredDataCopyRef = useRef([]);
const filteredDataCopy = filteredDataCopyRef.current ?? [];
const listRef = useRef(null);
let columns = Math.floor(width / IMAGE_CONTAINER_MAX_WIDTH);
let listItemHeight = IMAGE_CONTAINER_MAX_HEIGHT;
let skipMerge = false;
if (columns < MIN_COLUMNS) {
columns = MIN_COLUMNS;
listItemHeight = width / MIN_COLUMNS;
skipMerge = true;
}
const refreshList = () => {
listRef.current?.resetAfterIndex(0);
resetFetching();
};
useEffect(() => {
let timeStampList: TimeStampListItem[] = [];
let listItemIndex = 0;
let currentDate = -1;
filteredData.forEach((item, index) => {
if (
!isSameDay(
new Date(item.metadata.creationTime / 1000),
new Date(currentDate)
)
) {
currentDate = item.metadata.creationTime / 1000;
const dateTimeFormat = new Intl.DateTimeFormat('en-IN', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
});
timeStampList.push({
itemType: ITEM_TYPE.TIME,
date: isSameDay(new Date(currentDate), new Date())
? 'Today'
: isSameDay(
new Date(currentDate),
new Date(Date.now() - A_DAY)
)
? 'Yesterday'
: dateTimeFormat.format(currentDate),
id: currentDate.toString(),
});
timeStampList.push({
itemType: ITEM_TYPE.TILE,
items: [item],
itemStartIndex: index,
});
listItemIndex = 1;
} else if (listItemIndex < columns) {
timeStampList[timeStampList.length - 1].items.push(item);
listItemIndex++;
} else {
listItemIndex = 1;
timeStampList.push({
itemType: ITEM_TYPE.TILE,
items: [item],
itemStartIndex: index,
});
}
});
if (!skipMerge) {
timeStampList = mergeTimeStampList(timeStampList, columns);
}
if (timeStampList.length === 0) {
timeStampList.push(getEmptyListItem());
}
if (showBanner) {
timeStampList.push(getBannerItem(timeStampList));
}
timeStampListRef.current = timeStampList;
filteredDataCopyRef.current = filteredData;
refreshList();
}, [width, height, filteredData, showBanner]);
const isSameDay = (first, second) =>
first.getFullYear() === second.getFullYear() &&
first.getMonth() === second.getMonth() &&
first.getDate() === second.getDate();
const getEmptyListItem = () => {
return {
itemType: ITEM_TYPE.BANNER,
banner: (
<NothingContainer span={columns}>
<div>{constants.NOTHING_HERE}</div>
</NothingContainer>
),
id: 'empty-list-banner',
height: height - 48,
};
};
const getBannerItem = (timeStampList) => {
const photoFrameHeight = (() => {
let sum = 0;
const getCurrentItemSize = getItemSize(timeStampList);
for (let i = 0; i < timeStampList.length; i++) {
sum += getCurrentItemSize(i);
}
return sum;
})();
return {
itemType: ITEM_TYPE.BANNER,
banner: (
<BannerContainer span={columns}>
<p>{constants.INSTALL_MOBILE_APP()}</p>
</BannerContainer>
),
id: 'install-banner',
height: Math.max(48, height - photoFrameHeight),
};
};
/**
* Checks and merge multiple dates into a single row.
*
* @param items
* @param columns
* @returns
*/
const mergeTimeStampList = (
items: TimeStampListItem[],
columns: number
): TimeStampListItem[] => {
const newList: TimeStampListItem[] = [];
let index = 0;
let newIndex = 0;
while (index < items.length) {
const currItem = items[index];
// If the current item is of type time, then it is not part of an ongoing date.
// So, there is a possibility of merge.
if (currItem.itemType === ITEM_TYPE.TIME) {
// If new list pointer is not at the end of list then
// we can add more items to the same list.
if (newList[newIndex]) {
// Check if items can be added to same list
if (
newList[newIndex + 1].items.length +
items[index + 1].items.length <=
columns
) {
newList[newIndex].dates.push({
date: currItem.date,
span: items[index + 1].items.length,
});
newList[newIndex + 1].items = newList[
newIndex + 1
].items.concat(items[index + 1].items);
index += 2;
} else {
// Adding items would exceed the number of columns.
// So, move new list pointer to the end. Hence, in next iteration,
// items will be added to a new list.
newIndex += 2;
}
} else {
// New list pointer was at the end of list so simply add new items to the list.
newList.push({
...currItem,
date: null,
dates: [
{
date: currItem.date,
span: items[index + 1].items.length,
},
],
});
newList.push(items[index + 1]);
index += 2;
}
} else {
// Merge cannot happen. Simply add all items to new list
// and set new list point to the end of list.
newList.push(currItem);
index++;
newIndex = newList.length;
}
}
for (let i = 0; i < newList.length; i++) {
const currItem = newList[i];
const nextItem = newList[i + 1];
if (currItem.itemType === ITEM_TYPE.TIME) {
if (currItem.dates.length > 1) {
currItem.groups = currItem.dates.map((item) => item.span);
nextItem.groups = currItem.groups;
}
}
}
return newList;
};
const getItemSize = (timeStampList) => (index) => {
switch (timeStampList[index].itemType) {
case ITEM_TYPE.TIME:
return DATE_CONTAINER_HEIGHT;
case ITEM_TYPE.TILE:
return listItemHeight;
default:
return timeStampList[index].height;
}
};
const extraRowsToRender = Math.ceil(
(NO_OF_PAGES * height) / IMAGE_CONTAINER_MAX_HEIGHT
);
const generateKey = (index) => {
switch (timeStampList[index].itemType) {
case ITEM_TYPE.TILE:
return `${timeStampList[index].items[0].id}-${
timeStampList[index].items.slice(-1)[0].id
}`;
default:
return `${timeStampList[index].id}-${index}`;
}
};
const renderListItem = (listItem: TimeStampListItem) => {
switch (listItem.itemType) {
case ITEM_TYPE.TIME:
return listItem.dates ? (
listItem.dates.map((item) => (
<>
<DateContainer key={item.date} span={item.span}>
{item.date}
</DateContainer>
<div />
</>
))
) : (
<DateContainer span={columns}>
{listItem.date}
</DateContainer>
);
case ITEM_TYPE.BANNER:
return listItem.banner;
default: {
const ret = listItem.items.map((item, idx) =>
getThumbnail(
filteredDataCopy,
listItem.itemStartIndex + idx
)
);
if (listItem.groups) {
let sum = 0;
for (let i = 0; i < listItem.groups.length - 1; i++) {
sum = sum + listItem.groups[i];
ret.splice(sum, 0, <div />);
sum += 1;
}
}
return ret;
}
}
};
return (
<List
key={`${activeCollection}`}
ref={listRef}
itemSize={getItemSize(timeStampList)}
height={height}
width={width}
itemCount={timeStampList.length}
itemKey={generateKey}
overscanCount={extraRowsToRender}>
{({ index, style }) => (
<ListItem style={style}>
<ListContainer
columns={columns}
groups={timeStampList[index].groups}>
{renderListItem(timeStampList[index])}
</ListContainer>
</ListItem>
)}
</List>
);
}

View file

@ -7,17 +7,35 @@ import {
addToFavorites, addToFavorites,
removeFromFavorites, removeFromFavorites,
} from 'services/collectionService'; } from 'services/collectionService';
import { File } from 'services/fileService'; import {
ALL_TIME,
File,
MAX_EDITED_CREATION_TIME,
MIN_EDITED_CREATION_TIME,
updatePublicMagicMetadata,
} from 'services/fileService';
import constants from 'utils/strings/constants'; import constants from 'utils/strings/constants';
import exifr from 'exifr'; import exifr from 'exifr';
import Modal from 'react-bootstrap/Modal'; import Modal from 'react-bootstrap/Modal';
import Button from 'react-bootstrap/Button'; import Button from 'react-bootstrap/Button';
import Form from 'react-bootstrap/Form';
import styled from 'styled-components'; import styled from 'styled-components';
import events from './events'; import events from './events';
import { downloadFile, formatDateTime } from 'utils/file'; import {
changeFileCreationTime,
downloadFile,
formatDateTime,
updateExistingFilePubMetadata,
} from 'utils/file';
import { FormCheck } from 'react-bootstrap'; import { FormCheck } from 'react-bootstrap';
import { prettyPrintExif } from 'utils/exif'; import { prettyPrintExif } from 'utils/exif';
import EditIcon from 'components/icons/EditIcon';
import { IconButton, Label, Row, Value } from 'components/Container';
import { logError } from 'utils/sentry';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import CloseIcon from 'components/icons/CloseIcon';
import TickIcon from 'components/icons/TickIcon';
interface Iprops { interface Iprops {
isOpen: boolean; isOpen: boolean;
@ -30,6 +48,7 @@ interface Iprops {
favItemIds: Set<number>; favItemIds: Set<number>;
loadingBar: any; loadingBar: any;
isSharedCollection: boolean; isSharedCollection: boolean;
isTrashCollection: boolean;
} }
const LegendContainer = styled.div` const LegendContainer = styled.div`
@ -49,16 +68,113 @@ const Pre = styled.pre`
`; `;
const renderInfoItem = (label: string, value: string | JSX.Element) => ( const renderInfoItem = (label: string, value: string | JSX.Element) => (
<> <Row>
<Form.Label column sm="4"> <Label width="30%">{label}</Label>
{label} <Value width="70%">{value}</Value>
</Form.Label> </Row>
<Form.Label column sm="8">
{value}
</Form.Label>
</>
); );
const isSameDay = (first, second) =>
first.getFullYear() === second.getFullYear() &&
first.getMonth() === second.getMonth() &&
first.getDate() === second.getDate();
function RenderCreationTime({
file,
scheduleUpdate,
}: {
file: File;
scheduleUpdate: () => void;
}) {
const originalCreationTime = new Date(file.metadata.creationTime / 1000);
const [isInEditMode, setIsInEditMode] = useState(false);
const [pickedTime, setPickedTime] = useState(originalCreationTime);
const openEditMode = () => setIsInEditMode(true);
const closeEditMode = () => setIsInEditMode(false);
const saveEdits = async () => {
try {
if (isInEditMode && file) {
const unixTimeInMicroSec = pickedTime.getTime() * 1000;
if (unixTimeInMicroSec === file.metadata.creationTime) {
return;
}
let updatedFile = await changeFileCreationTime(
file,
unixTimeInMicroSec
);
updatedFile = (
await updatePublicMagicMetadata([updatedFile])
)[0];
updateExistingFilePubMetadata(file, updatedFile);
scheduleUpdate();
}
} catch (e) {
logError(e, 'failed to update creationTime');
}
closeEditMode();
};
const discardEdits = () => {
setPickedTime(originalCreationTime);
closeEditMode();
};
const handleChange = (newDate) => {
if (newDate instanceof Date) {
setPickedTime(newDate);
}
};
return (
<>
<Row>
<Label width="30%">{constants.CREATION_TIME}</Label>
<Value width={isInEditMode ? '50%' : '60%'}>
{isInEditMode ? (
<DatePicker
open={isInEditMode}
selected={pickedTime}
onChange={handleChange}
timeInputLabel="Time:"
dateFormat="dd/MM/yyyy h:mm aa"
showTimeSelect
autoFocus
minDate={MIN_EDITED_CREATION_TIME}
maxDate={MAX_EDITED_CREATION_TIME}
maxTime={
isSameDay(pickedTime, new Date())
? MAX_EDITED_CREATION_TIME
: ALL_TIME
}
minTime={MIN_EDITED_CREATION_TIME}
fixedHeight
withPortal></DatePicker>
) : (
formatDateTime(pickedTime)
)}
</Value>
<Value
width={isInEditMode ? '20%' : '10%'}
style={{ cursor: 'pointer', marginLeft: '10px' }}>
{!isInEditMode ? (
<IconButton onClick={openEditMode}>
<EditIcon />
</IconButton>
) : (
<>
<IconButton onClick={saveEdits}>
<TickIcon />
</IconButton>
<IconButton onClick={discardEdits}>
<CloseIcon />
</IconButton>
</>
)}
</Value>
</Row>
</>
);
}
function ExifData(props: { exif: any }) { function ExifData(props: { exif: any }) {
const { exif } = props; const { exif } = props;
const [showAll, setShowAll] = useState(false); const [showAll, setShowAll] = useState(false);
@ -112,6 +228,67 @@ function ExifData(props: { exif: any }) {
); );
} }
function InfoModal({
showInfo,
handleCloseInfo,
items,
photoSwipe,
metadata,
exif,
scheduleUpdate,
}) {
return (
<Modal show={showInfo} onHide={handleCloseInfo}>
<Modal.Header closeButton>
<Modal.Title>{constants.INFO}</Modal.Title>
</Modal.Header>
<Modal.Body>
<div>
<Legend>{constants.METADATA}</Legend>
</div>
{renderInfoItem(
constants.FILE_ID,
items[photoSwipe?.getCurrentIndex()]?.id
)}
{metadata?.title &&
renderInfoItem(constants.FILE_NAME, metadata.title)}
{metadata?.creationTime && (
<RenderCreationTime
file={items[photoSwipe?.getCurrentIndex()]}
scheduleUpdate={scheduleUpdate}
/>
)}
{metadata?.modificationTime &&
renderInfoItem(
constants.UPDATED_ON,
formatDateTime(metadata.modificationTime / 1000)
)}
{metadata?.longitude > 0 &&
metadata?.longitude > 0 &&
renderInfoItem(
constants.LOCATION,
<a
href={`https://www.openstreetmap.org/?mlat=${metadata.latitude}&mlon=${metadata.longitude}#map=15/${metadata.latitude}/${metadata.longitude}`}
target="_blank"
rel="noopener noreferrer">
{constants.SHOW_MAP}
</a>
)}
{exif && (
<>
<ExifData exif={exif} />
</>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="outline-secondary" onClick={handleCloseInfo}>
{constants.CLOSE}
</Button>
</Modal.Footer>
</Modal>
);
}
function PhotoSwipe(props: Iprops) { function PhotoSwipe(props: Iprops) {
const pswpElement = useRef<HTMLDivElement>(); const pswpElement = useRef<HTMLDivElement>();
const [photoSwipe, setPhotoSwipe] = useState<Photoswipe<any>>(); const [photoSwipe, setPhotoSwipe] = useState<Photoswipe<any>>();
@ -140,6 +317,13 @@ function PhotoSwipe(props: Iprops) {
updateItems(items); updateItems(items);
}, [items]); }, [items]);
useEffect(() => {
if (photoSwipe) {
photoSwipe.options.arrowKeys = !showInfo;
photoSwipe.options.escKey = !showInfo;
}
}, [showInfo]);
function updateFavButton() { function updateFavButton() {
setIsFav(isInFav(this?.currItem)); setIsFav(isInFav(this?.currItem));
} }
@ -301,6 +485,7 @@ function PhotoSwipe(props: Iprops) {
await downloadFile(file); await downloadFile(file);
loadingBar.current.complete(); loadingBar.current.complete();
}; };
const scheduleUpdate = () => (needUpdate.current = true);
const { id } = props; const { id } = props;
let { className } = props; let { className } = props;
className = classnames(['pswp', className]).trim(); className = classnames(['pswp', className]).trim();
@ -345,7 +530,8 @@ function PhotoSwipe(props: Iprops) {
className="pswp__button pswp__button--zoom" className="pswp__button pswp__button--zoom"
title={constants.ZOOM_IN_OUT} title={constants.ZOOM_IN_OUT}
/> />
{!props.isSharedCollection && ( {!props.isSharedCollection &&
!props.isTrashCollection && (
<FavButton <FavButton
size={44} size={44}
isClick={isFav} isClick={isFav}
@ -379,64 +565,20 @@ function PhotoSwipe(props: Iprops) {
title={constants.NEXT} title={constants.NEXT}
/> />
<div className="pswp__caption"> <div className="pswp__caption">
<div className="pswp__caption__center" /> <div />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<Modal show={showInfo} onHide={handleCloseInfo}> <InfoModal
<Modal.Header closeButton> showInfo={showInfo}
<Modal.Title>{constants.INFO}</Modal.Title> handleCloseInfo={handleCloseInfo}
</Modal.Header> items={items}
<Modal.Body> photoSwipe={photoSwipe}
<Form.Group> metadata={metadata}
<div> exif={exif}
<Legend>{constants.METADATA}</Legend> scheduleUpdate={scheduleUpdate}
</div> />
{renderInfoItem(
constants.FILE_ID,
items[photoSwipe?.getCurrentIndex()]?.id
)}
{metadata?.title &&
renderInfoItem(constants.FILE_NAME, metadata.title)}
{metadata?.creationTime &&
renderInfoItem(
constants.CREATION_TIME,
formatDateTime(metadata.creationTime / 1000)
)}
{metadata?.modificationTime &&
renderInfoItem(
constants.UPDATED_ON,
formatDateTime(metadata.modificationTime / 1000)
)}
{metadata?.longitude > 0 &&
metadata?.longitude > 0 &&
renderInfoItem(
constants.LOCATION,
<a
href={`https://www.openstreetmap.org/?mlat=${metadata.latitude}&mlon=${metadata.longitude}#map=15/${metadata.latitude}/${metadata.longitude}`}
target="_blank"
rel="noopener noreferrer">
{constants.SHOW_MAP}
</a>
)}
{exif && (
<>
<br />
<br />
<ExifData exif={exif} />
</>
)}
</Form.Group>
</Modal.Body>
<Modal.Footer>
<Button
variant="outline-secondary"
onClick={handleCloseInfo}>
{constants.CLOSE}
</Button>
</Modal.Footer>
</Modal>
</> </>
); );
} }

View file

@ -18,12 +18,13 @@ import constants from 'utils/strings/constants';
import LocationIcon from './icons/LocationIcon'; import LocationIcon from './icons/LocationIcon';
import DateIcon from './icons/DateIcon'; import DateIcon from './icons/DateIcon';
import SearchIcon from './icons/SearchIcon'; import SearchIcon from './icons/SearchIcon';
import CrossIcon from './icons/CrossIcon'; import CloseIcon from './icons/CloseIcon';
import { Collection } from 'services/collectionService'; import { Collection } from 'services/collectionService';
import CollectionIcon from './icons/CollectionIcon'; import CollectionIcon from './icons/CollectionIcon';
import { File, FILE_TYPE } from 'services/fileService'; import { File, FILE_TYPE } from 'services/fileService';
import ImageIcon from './icons/ImageIcon'; import ImageIcon from './icons/ImageIcon';
import VideoIcon from './icons/VideoIcon'; import VideoIcon from './icons/VideoIcon';
import { IconButton } from './Container';
const Wrapper = styled.div<{ isDisabled: boolean; isOpen: boolean }>` const Wrapper = styled.div<{ isDisabled: boolean; isOpen: boolean }>`
position: fixed; position: fixed;
@ -346,15 +347,11 @@ export default function SearchBar(props: Props) {
noOptionsMessage={() => null} noOptionsMessage={() => null}
/> />
</div> </div>
<div style={{ width: '24px' }}>
{props.isOpen && ( {props.isOpen && (
<div <IconButton onClick={() => resetSearch()}>
style={{ cursor: 'pointer' }} <CloseIcon />
onClick={() => resetSearch()}> </IconButton>
<CrossIcon />
</div>
)} )}
</div>
</SearchInput> </SearchInput>
</Wrapper> </Wrapper>
<SearchButton <SearchButton

View file

@ -32,7 +32,11 @@ import InProgressIcon from './icons/InProgressIcon';
import exportService from 'services/exportService'; import exportService from 'services/exportService';
import { Subscription } from 'services/billingService'; import { Subscription } from 'services/billingService';
import { PAGES } from 'types'; import { PAGES } from 'types';
import { ARCHIVE_SECTION } from 'components/pages/gallery/Collections'; import {
ARCHIVE_SECTION,
TRASH_SECTION,
} from 'components/pages/gallery/Collections';
import FixLargeThumbnails from './FixLargeThumbnail';
interface Props { interface Props {
collections: Collection[]; collections: Collection[];
setDialogMessage: SetDialogMessage; setDialogMessage: SetDialogMessage;
@ -50,6 +54,7 @@ export default function Sidebar(props: Props) {
const [recoverModalView, setRecoveryModalView] = useState(false); const [recoverModalView, setRecoveryModalView] = useState(false);
const [twoFactorModalView, setTwoFactorModalView] = useState(false); const [twoFactorModalView, setTwoFactorModalView] = useState(false);
const [exportModalView, setExportModalView] = useState(false); const [exportModalView, setExportModalView] = useState(false);
const [fixLargeThumbsView, setFixLargeThumbsView] = useState(false);
const galleryContext = useContext(GalleryContext); const galleryContext = useContext(GalleryContext);
useEffect(() => { useEffect(() => {
const main = async () => { const main = async () => {
@ -221,6 +226,14 @@ export default function Sidebar(props: Props) {
}}> }}>
{constants.ARCHIVE} {constants.ARCHIVE}
</LinkButton> </LinkButton>
<LinkButton
style={{ marginTop: '30px' }}
onClick={() => {
galleryContext.setActiveCollection(TRASH_SECTION);
setIsOpen(false);
}}>
{constants.TRASH}
</LinkButton>
<> <>
<RecoveryKeyModal <RecoveryKeyModal
show={recoverModalView} show={recoverModalView}
@ -267,6 +280,18 @@ export default function Sidebar(props: Props) {
{constants.UPDATE_EMAIL} {constants.UPDATE_EMAIL}
</LinkButton> </LinkButton>
<Divider /> <Divider />
<>
<FixLargeThumbnails
isOpen={fixLargeThumbsView}
hide={() => setFixLargeThumbsView(false)}
show={() => setFixLargeThumbsView(true)}
/>
<LinkButton
style={{ marginTop: '30px' }}
onClick={() => setFixLargeThumbsView(true)}>
{constants.FIX_LARGE_THUMBNAILS}
</LinkButton>
</>
<LinkButton <LinkButton
style={{ marginTop: '30px' }} style={{ marginTop: '30px' }}
onClick={openFeedbackURL}> onClick={openFeedbackURL}>
@ -320,7 +345,7 @@ export default function Sidebar(props: Props) {
onClick={() => onClick={() =>
props.setDialogMessage({ props.setDialogMessage({
title: `${constants.DELETE_ACCOUNT}`, title: `${constants.DELETE_ACCOUNT}`,
content: constants.DELETE_MESSAGE(), content: constants.DELETE_ACCOUNT_MESSAGE(),
staticBackdrop: true, staticBackdrop: true,
proceed: { proceed: {
text: constants.DELETE_ACCOUNT, text: constants.DELETE_ACCOUNT,

View file

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
export default function DateIcon(props) { export default function CloseIcon(props) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -12,7 +12,7 @@ export default function DateIcon(props) {
); );
} }
DateIcon.defaultProps = { CloseIcon.defaultProps = {
height: 24, height: 24,
width: 24, width: 24,
viewBox: '0 0 24 24', viewBox: '0 0 24 24',

View file

@ -0,0 +1,20 @@
import React from 'react';
export default function EditIcon(props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height={props.height}
viewBox={props.viewBox}
width={props.width}
fill="currentColor">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
</svg>
);
}
EditIcon.defaultProps = {
height: 20,
width: 20,
viewBox: '0 0 24 24',
};

View file

@ -0,0 +1,20 @@
import React from 'react';
export default function RemoveIcon(props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height={props.height}
viewBox={props.viewBox}
width={props.width}
fill="currentColor">
<path d="M7 11v2h10v-2H7zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" />
</svg>
);
}
RemoveIcon.defaultProps = {
height: 24,
width: 24,
viewBox: '0 0 24 24',
};

View file

@ -0,0 +1,21 @@
import React from 'react';
export default function RestoreIcon(props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height={props.height}
viewBox={props.viewBox}
width={props.width}
fill="currentColor">
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M13 3c-4.97 0-9 4.03-9 9H1l4 3.99L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.25 2.52.77-1.28-3.52-2.09V8z" />
</svg>
);
}
RestoreIcon.defaultProps = {
height: 24,
width: 24,
viewBox: '0 0 24 24',
};

View file

@ -14,7 +14,7 @@ export default function TickIcon(props) {
} }
TickIcon.defaultProps = { TickIcon.defaultProps = {
height: 28, height: 20,
width: 20, width: 20,
viewBox: '0 0 24 24', viewBox: '0 0 24 24',
}; };

View file

@ -1,4 +1,4 @@
import { Label, Value } from 'components/Container'; import { Value } from 'components/Container';
import TickIcon from 'components/icons/TickIcon'; import TickIcon from 'components/icons/TickIcon';
import React from 'react'; import React from 'react';
import { ListGroup, Popover, Row } from 'react-bootstrap'; import { ListGroup, Popover, Row } from 'react-bootstrap';
@ -23,13 +23,13 @@ const SortByOptionCreator =
( (
<MenuItem> <MenuItem>
<Row> <Row>
<Label width="20px"> <Value width="20px">
{activeSortBy === props.sortBy && ( {activeSortBy === props.sortBy && (
<TickWrapper> <TickWrapper>
<TickIcon /> <TickIcon />
</TickWrapper> </TickWrapper>
)} )}
</Label> </Value>
<Value width="165px"> <Value width="165px">
<MenuLink <MenuLink
onClick={() => setCollectionSortBy(props.sortBy)} onClick={() => setCollectionSortBy(props.sortBy)}

View file

@ -24,6 +24,7 @@ import CollectionSort from './CollectionSort';
import OptionIcon, { OptionIconWrapper } from './OptionIcon'; import OptionIcon, { OptionIconWrapper } from './OptionIcon';
export const ARCHIVE_SECTION = -1; export const ARCHIVE_SECTION = -1;
export const TRASH_SECTION = -2;
export const ALL_SECTION = 0; export const ALL_SECTION = 0;
interface CollectionProps { interface CollectionProps {
@ -87,6 +88,22 @@ const Chip = styled.button<{ active: boolean }>`
} }
`; `;
const SectionChipCreater =
({ activeCollection, clickHandler }) =>
({ section, label }) =>
(
<Chip
active={activeCollection === section}
onClick={clickHandler(section)}>
{label}
<div
style={{
display: 'inline-block',
width: '24px',
}}
/>
</Chip>
);
const Hider = styled.div<{ hide: boolean }>` const Hider = styled.div<{ hide: boolean }>`
opacity: ${(props) => (props.hide ? '0' : '100')}; opacity: ${(props) => (props.hide ? '0' : '100')};
height: ${(props) => (props.hide ? '0' : 'auto')}; height: ${(props) => (props.hide ? '0' : 'auto')};
@ -124,7 +141,7 @@ export default function Collections(props: CollectionProps) {
useEffect(() => { useEffect(() => {
updateScrollObj(); updateScrollObj();
}, [collectionWrapperRef.current, props.isInSearchMode]); }, [collectionWrapperRef.current, props.isInSearchMode, collections]);
useEffect(() => { useEffect(() => {
if (!collectionWrapperRef?.current) { if (!collectionWrapperRef?.current) {
@ -146,10 +163,6 @@ export default function Collections(props: CollectionProps) {
const user: User = getData(LS_KEYS.USER); const user: User = getData(LS_KEYS.USER);
if (!collections || collections.length === 0) {
return null;
}
const collectionOptions = CollectionOptions({ const collectionOptions = CollectionOptions({
syncWithRemote: props.syncWithRemote, syncWithRemote: props.syncWithRemote,
setCollectionNamerAttributes: props.setCollectionNamerAttributes, setCollectionNamerAttributes: props.setCollectionNamerAttributes,
@ -188,6 +201,8 @@ export default function Collections(props: CollectionProps) {
); );
}; };
const SectionChip = SectionChipCreater({ activeCollection, clickHandler });
return ( return (
<Hider hide={props.isInSearchMode}> <Hider hide={props.isInSearchMode}>
<CollectionShare <CollectionShare
@ -210,17 +225,10 @@ export default function Collections(props: CollectionProps) {
<Wrapper <Wrapper
ref={collectionWrapperRef} ref={collectionWrapperRef}
onScroll={updateScrollObj}> onScroll={updateScrollObj}>
<Chip <SectionChip
active={activeCollection === ALL_SECTION} section={ALL_SECTION}
onClick={clickHandler(ALL_SECTION)}> label={constants.ALL}
{constants.ALL}
<div
style={{
display: 'inline-block',
width: '24px',
}}
/> />
</Chip>
{sortCollections( {sortCollections(
collections, collections,
props.collectionAndTheirLatestFile, props.collectionAndTheirLatestFile,
@ -262,17 +270,14 @@ export default function Collections(props: CollectionProps) {
</Chip> </Chip>
</OverlayTrigger> </OverlayTrigger>
))} ))}
<Chip <SectionChip
active={activeCollection === ARCHIVE_SECTION} section={ARCHIVE_SECTION}
onClick={clickHandler(ARCHIVE_SECTION)}> label={constants.ARCHIVE}
{constants.ARCHIVE} />
<div <SectionChip
style={{ section={TRASH_SECTION}
display: 'inline-block', label={constants.TRASH}
width: '24px',
}}
/> />
</Chip>
</Wrapper> </Wrapper>
{scrollObj.scrollLeft < {scrollObj.scrollLeft <
scrollObj.scrollWidth - scrollObj.clientWidth && ( scrollObj.scrollWidth - scrollObj.clientWidth && (

View file

@ -15,13 +15,18 @@ interface IProps {
selectable?: boolean; selectable?: boolean;
selected?: boolean; selected?: boolean;
onSelect?: (checked: boolean) => void; onSelect?: (checked: boolean) => void;
onHover?: () => void;
onRangeSelect?: () => void;
isRangeSelectActive?: boolean;
selectOnClick?: boolean; selectOnClick?: boolean;
isInsSelectRange?: boolean;
} }
const Check = styled.input` const Check = styled.input<{ active: boolean }>`
appearance: none; appearance: none;
position: absolute; position: absolute;
right: 0; z-index: 10;
left: 0;
opacity: 0; opacity: 0;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
@ -34,7 +39,7 @@ const Check = styled.input`
width: 16px; width: 16px;
height: 16px; height: 16px;
border: 2px solid #fff; border: 2px solid #fff;
background-color: rgba(0, 0, 0, 0.5); background-color: #ddd;
display: inline-block; display: inline-block;
border-radius: 50%; border-radius: 50%;
vertical-align: bottom; vertical-align: bottom;
@ -43,18 +48,19 @@ const Check = styled.input`
line-height: 16px; line-height: 16px;
transition: background-color 0.3s ease; transition: background-color 0.3s ease;
pointer-events: inherit; pointer-events: inherit;
color: #aaa;
} }
&::after { &::after {
content: ''; content: '';
width: 5px; width: 5px;
height: 10px; height: 10px;
border-right: 2px solid #fff; border-right: 2px solid #333;
border-bottom: 2px solid #fff; border-bottom: 2px solid #333;
transform: translate(-18px, 8px); transform: translate(-18px, 8px);
opacity: 0;
transition: transform 0.3s ease; transition: transform 0.3s ease;
position: absolute; position: absolute;
pointer-events: inherit; pointer-events: inherit;
transform: translate(-18px, 10px) rotate(45deg);
} }
/** checked */ /** checked */
@ -65,15 +71,50 @@ const Check = styled.input`
color: #fff; color: #fff;
} }
&:checked::after { &:checked::after {
opacity: 1; content: '';
transform: translate(-18px, 10px) rotate(45deg); border-right: 2px solid #ddd;
border-bottom: 2px solid #ddd;
} }
${(props) => props.active && 'opacity: 0.5 '};
&:checked { &:checked {
opacity: 1; opacity: 1 !important;
} }
`; `;
export const HoverOverlay = styled.div<{ checked: boolean }>`
opacity: 0;
left: 0;
top: 0;
outline: none;
height: 40%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
color: #fff;
font-weight: 900;
position: absolute;
${(props) =>
!props.checked &&
'background:linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0))'};
`;
export const InSelectRangeOverLay = styled.div<{ active: boolean }>`
opacity: ${(props) => (!props.active ? 0 : 1)});
left: 0;
top: 0;
outline: none;
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
color: #fff;
font-weight: 900;
position: absolute;
${(props) => props.active && 'background:rgba(81, 205, 124, 0.25)'};
`;
const Cont = styled.div<{ disabled: boolean; selected: boolean }>` const Cont = styled.div<{ disabled: boolean; selected: boolean }>`
background: #222; background: #222;
display: flex; display: flex;
@ -107,6 +148,9 @@ const Cont = styled.div<{ disabled: boolean; selected: boolean }>`
} }
&:hover ${Check} { &:hover ${Check} {
opacity: 0.5;
}
&:hover ${HoverOverlay} {
opacity: 1; opacity: 1;
} }
`; `;
@ -123,6 +167,10 @@ export default function PreviewCard(props: IProps) {
selected, selected,
onSelect, onSelect,
selectOnClick, selectOnClick,
onHover,
onRangeSelect,
isRangeSelectActive,
isInsSelectRange,
} = props; } = props;
const isMounted = useRef(true); const isMounted = useRef(true);
useLayoutEffect(() => { useLayoutEffect(() => {
@ -167,6 +215,9 @@ export default function PreviewCard(props: IProps) {
const handleClick = () => { const handleClick = () => {
if (selectOnClick) { if (selectOnClick) {
if (isRangeSelectActive) {
onRangeSelect();
}
onSelect?.(!selected); onSelect?.(!selected);
} else if (file?.msrc || imgSrc) { } else if (file?.msrc || imgSrc) {
onClick?.(); onClick?.();
@ -174,17 +225,25 @@ export default function PreviewCard(props: IProps) {
}; };
const handleSelect: React.ChangeEventHandler<HTMLInputElement> = (e) => { const handleSelect: React.ChangeEventHandler<HTMLInputElement> = (e) => {
if (isRangeSelectActive) {
onRangeSelect?.();
}
onSelect?.(e.target.checked); onSelect?.(e.target.checked);
}; };
const longPressCallback = () => { const longPressCallback = () => {
onSelect(!selected); onSelect(!selected);
}; };
const handleHover = () => {
if (selectOnClick) {
onHover();
}
};
return ( return (
<Cont <Cont
id={`thumb-${file?.id}`} id={`thumb-${file?.id}`}
onClick={handleClick} onClick={handleClick}
onMouseEnter={handleHover}
disabled={!forcedEnable && !file?.msrc && !imgSrc} disabled={!forcedEnable && !file?.msrc && !imgSrc}
selected={selected} selected={selected}
{...(selectable ? useLongPress(longPressCallback, 500) : {})}> {...(selectable ? useLongPress(longPressCallback, 500) : {})}>
@ -193,11 +252,16 @@ export default function PreviewCard(props: IProps) {
type="checkbox" type="checkbox"
checked={selected} checked={selected}
onChange={handleSelect} onChange={handleSelect}
active={isRangeSelectActive && isInsSelectRange}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
)} )}
{(file?.msrc || imgSrc) && <img src={file?.msrc || imgSrc} />} {(file?.msrc || imgSrc) && <img src={file?.msrc || imgSrc} />}
{file?.metadata.fileType === 1 && <PlayCircleOutline />} {file?.metadata.fileType === 1 && <PlayCircleOutline />}
<HoverOverlay checked={selected} />
<InSelectRangeOverLay
active={isRangeSelectActive && isInsSelectRange}
/>
</Cont> </Cont>
); );
} }

View file

@ -4,31 +4,29 @@ import { SetCollectionSelectorAttributes } from './CollectionSelector';
import styled from 'styled-components'; import styled from 'styled-components';
import Navbar from 'components/Navbar'; import Navbar from 'components/Navbar';
import DeleteIcon from 'components/icons/DeleteIcon'; import DeleteIcon from 'components/icons/DeleteIcon';
import CrossIcon from 'components/icons/CrossIcon'; import CloseIcon from 'components/icons/CloseIcon';
import AddIcon from 'components/icons/AddIcon'; import AddIcon from 'components/icons/AddIcon';
import { IconButton } from 'components/Container'; import { IconButton } from 'components/Container';
import constants from 'utils/strings/constants'; import constants from 'utils/strings/constants';
import Archive from 'components/icons/Archive'; import Archive from 'components/icons/Archive';
import MoveIcon from 'components/icons/MoveIcon'; import MoveIcon from 'components/icons/MoveIcon';
import { COLLECTION_OPS_TYPE } from 'utils/collection'; import { COLLECTION_OPS_TYPE } from 'utils/collection';
import { ALL_SECTION, ARCHIVE_SECTION } from './Collections'; import { ALL_SECTION, ARCHIVE_SECTION, TRASH_SECTION } from './Collections';
import UnArchive from 'components/icons/UnArchive'; import UnArchive from 'components/icons/UnArchive';
import { OverlayTrigger } from 'react-bootstrap'; import { OverlayTrigger } from 'react-bootstrap';
import { Collection } from 'services/collectionService'; import { Collection } from 'services/collectionService';
import RemoveIcon from 'components/icons/RemoveIcon';
import RestoreIcon from 'components/icons/RestoreIcon';
interface Props { interface Props {
addToCollectionHelper: ( addToCollectionHelper: (collection: Collection) => void;
collectionName: string, moveToCollectionHelper: (collection: Collection) => void;
collection: Collection restoreToCollectionHelper: (collection: Collection) => void;
) => void;
moveToCollectionHelper: (
collectionName: string,
collection: Collection
) => void;
showCreateCollectionModal: (opsType: COLLECTION_OPS_TYPE) => () => void; showCreateCollectionModal: (opsType: COLLECTION_OPS_TYPE) => () => void;
setDialogMessage: SetDialogMessage; setDialogMessage: SetDialogMessage;
setCollectionSelectorAttributes: SetCollectionSelectorAttributes; setCollectionSelectorAttributes: SetCollectionSelectorAttributes;
deleteFileHelper: () => void; deleteFileHelper: (permanent?: boolean) => void;
removeFromCollectionHelper: () => void;
count: number; count: number;
clearSelection: () => void; clearSelection: () => void;
archiveFilesHelper: () => void; archiveFilesHelper: () => void;
@ -52,7 +50,11 @@ const SelectionContainer = styled.div`
display: flex; display: flex;
`; `;
export const IconWithMessage = (props) => ( interface IconWithMessageProps {
children?: any;
message: string;
}
export const IconWithMessage = (props: IconWithMessageProps) => (
<OverlayTrigger <OverlayTrigger
placement="bottom" placement="bottom"
overlay={<p style={{ zIndex: 1002 }}>{props.message}</p>}> overlay={<p style={{ zIndex: 1002 }}>{props.message}</p>}>
@ -63,7 +65,9 @@ export const IconWithMessage = (props) => (
const SelectedFileOptions = ({ const SelectedFileOptions = ({
addToCollectionHelper, addToCollectionHelper,
moveToCollectionHelper, moveToCollectionHelper,
restoreToCollectionHelper,
showCreateCollectionModal, showCreateCollectionModal,
removeFromCollectionHelper,
setDialogMessage, setDialogMessage,
setCollectionSelectorAttributes, setCollectionSelectorAttributes,
deleteFileHelper, deleteFileHelper,
@ -76,28 +80,63 @@ const SelectedFileOptions = ({
}: Props) => { }: Props) => {
const addToCollection = () => const addToCollection = () =>
setCollectionSelectorAttributes({ setCollectionSelectorAttributes({
callback: (collection) => addToCollectionHelper(null, collection), callback: addToCollectionHelper,
showNextModal: showCreateCollectionModal(COLLECTION_OPS_TYPE.ADD), showNextModal: showCreateCollectionModal(COLLECTION_OPS_TYPE.ADD),
title: constants.ADD_TO_COLLECTION, title: constants.ADD_TO_COLLECTION,
fromCollection: activeCollection, fromCollection: activeCollection,
}); });
const deleteHandler = () => const trashHandler = () =>
setDialogMessage({ setDialogMessage({
title: constants.CONFIRM_DELETE_FILE, title: constants.CONFIRM_DELETE,
content: constants.DELETE_FILE_MESSAGE, content: constants.TRASH_MESSAGE,
staticBackdrop: true, staticBackdrop: true,
proceed: { proceed: {
action: deleteFileHelper, action: deleteFileHelper,
text: constants.MOVE_TO_TRASH,
variant: 'danger',
},
close: { text: constants.CANCEL },
});
const permanentlyDeleteHandler = () =>
setDialogMessage({
title: constants.CONFIRM_DELETE,
content: constants.DELETE_MESSAGE,
staticBackdrop: true,
proceed: {
action: () => deleteFileHelper(true),
text: constants.DELETE, text: constants.DELETE,
variant: 'danger', variant: 'danger',
}, },
close: { text: constants.CANCEL }, close: { text: constants.CANCEL },
}); });
const restoreHandler = () =>
setCollectionSelectorAttributes({
callback: restoreToCollectionHelper,
showNextModal: showCreateCollectionModal(
COLLECTION_OPS_TYPE.RESTORE
),
title: constants.RESTORE_TO_COLLECTION,
});
const removeFromCollectionHandler = () =>
setDialogMessage({
title: constants.CONFIRM_REMOVE,
content: constants.CONFIRM_REMOVE_MESSAGE(),
staticBackdrop: true,
proceed: {
action: removeFromCollectionHelper,
text: constants.REMOVE,
variant: 'danger',
},
close: { text: constants.CANCEL },
});
const moveToCollection = () => { const moveToCollection = () => {
setCollectionSelectorAttributes({ setCollectionSelectorAttributes({
callback: (collection) => moveToCollectionHelper(null, collection), callback: moveToCollectionHelper,
showNextModal: showCreateCollectionModal(COLLECTION_OPS_TYPE.MOVE), showNextModal: showCreateCollectionModal(COLLECTION_OPS_TYPE.MOVE),
title: constants.MOVE_TO_COLLECTION, title: constants.MOVE_TO_COLLECTION,
fromCollection: activeCollection, fromCollection: activeCollection,
@ -108,12 +147,27 @@ const SelectedFileOptions = ({
<SelectionBar> <SelectionBar>
<SelectionContainer> <SelectionContainer>
<IconButton onClick={clearSelection}> <IconButton onClick={clearSelection}>
<CrossIcon /> <CloseIcon />
</IconButton> </IconButton>
<div> <div>
{count} {constants.SELECTED} {count} {constants.SELECTED}
</div> </div>
</SelectionContainer> </SelectionContainer>
{activeCollection === TRASH_SECTION ? (
<>
<IconWithMessage message={constants.RESTORE}>
<IconButton onClick={restoreHandler}>
<RestoreIcon />
</IconButton>
</IconWithMessage>
<IconWithMessage message={constants.DELETE_PERMANENTLY}>
<IconButton onClick={permanentlyDeleteHandler}>
<DeleteIcon />
</IconButton>
</IconWithMessage>
</>
) : (
<>
{activeCollection === ARCHIVE_SECTION && ( {activeCollection === ARCHIVE_SECTION && (
<IconWithMessage message={constants.UNARCHIVE}> <IconWithMessage message={constants.UNARCHIVE}>
<IconButton onClick={unArchiveFilesHelper}> <IconButton onClick={unArchiveFilesHelper}>
@ -121,7 +175,6 @@ const SelectedFileOptions = ({
</IconButton> </IconButton>
</IconWithMessage> </IconWithMessage>
)} )}
{activeCollection === ALL_SECTION && ( {activeCollection === ALL_SECTION && (
<IconWithMessage message={constants.ARCHIVE}> <IconWithMessage message={constants.ARCHIVE}>
<IconButton onClick={archiveFilesHelper}> <IconButton onClick={archiveFilesHelper}>
@ -129,25 +182,36 @@ const SelectedFileOptions = ({
</IconButton> </IconButton>
</IconWithMessage> </IconWithMessage>
)} )}
{activeCollection !== ALL_SECTION &&
activeCollection !== ARCHIVE_SECTION &&
!isFavoriteCollection && (
<IconWithMessage message={constants.MOVE}>
<IconButton onClick={moveToCollection}>
<MoveIcon />
</IconButton>
</IconWithMessage>
)}
<IconWithMessage message={constants.ADD}> <IconWithMessage message={constants.ADD}>
<IconButton onClick={addToCollection}> <IconButton onClick={addToCollection}>
<AddIcon /> <AddIcon />
</IconButton> </IconButton>
</IconWithMessage> </IconWithMessage>
{activeCollection !== ALL_SECTION &&
activeCollection !== ARCHIVE_SECTION &&
!isFavoriteCollection && (
<>
<IconWithMessage message={constants.MOVE}>
<IconButton onClick={moveToCollection}>
<MoveIcon />
</IconButton>
</IconWithMessage>
<IconWithMessage message={constants.REMOVE}>
<IconButton
onClick={removeFromCollectionHandler}>
<RemoveIcon />
</IconButton>
</IconWithMessage>
</>
)}
<IconWithMessage message={constants.DELETE}> <IconWithMessage message={constants.DELETE}>
<IconButton onClick={deleteHandler}> <IconButton onClick={trashHandler}>
<DeleteIcon /> <DeleteIcon />
</IconButton> </IconButton>
</IconWithMessage> </IconWithMessage>
</>
)}
</SelectionBar> </SelectionBar>
); );
}; };

View file

@ -117,6 +117,12 @@ const GlobalStyles = createGlobalStyle`
.pswp__img { .pswp__img {
object-fit: contain; object-fit: contain;
} }
.pswp__caption{
font-size:20px;
height:10%;
padding-left:5%;
color:#eee;
}
.modal { .modal {
z-index: 2000; z-index: 2000;
@ -404,6 +410,42 @@ const GlobalStyles = createGlobalStyle`
.tooltip-inner{ .tooltip-inner{
padding:0px; padding:0px;
} }
.react-datepicker__input-container > input {
width:100%;
}
.react-datepicker__navigation{
top:14px;
}
.react-datepicker, .react-datepicker__header,.react-datepicker__time-container .react-datepicker__time,.react-datepicker-time__header{
background-color: #202020;
color:#fff;
border-color: #444;
}
.react-datepicker__current-month,.react-datepicker__day-name, .react-datepicker__day, .react-datepicker__time-name{
color:#fff;
}
.react-datepicker__day--disabled{
color:#5b5656;
}
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item:hover{
background-color:#686868
}
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--disabled :hover{
background-color: #202020;
}
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--disabled{
color:#5b5656;
}
.react-datepicker{
padding-bottom:10px;
}
.react-datepicker__day:hover {
background-color:#686868
}
.react-datepicker__day--disabled:hover {
background-color: #202020;
}
`; `;
export const LogoImage = styled.img` export const LogoImage = styled.img`

View file

@ -10,10 +10,11 @@ import { clearKeys, getKey, SESSION_KEYS } from 'utils/storage/sessionStorage';
import { import {
File, File,
getLocalFiles, getLocalFiles,
deleteFiles,
syncFiles, syncFiles,
updateMagicMetadata, updateMagicMetadata,
VISIBILITY_STATE, VISIBILITY_STATE,
trashFiles,
deleteFromTrash,
} from 'services/fileService'; } from 'services/fileService';
import styled from 'styled-components'; import styled from 'styled-components';
import LoadingBar from 'react-top-loading-bar'; import LoadingBar from 'react-top-loading-bar';
@ -25,6 +26,8 @@ import {
getFavItemIds, getFavItemIds,
getLocalCollections, getLocalCollections,
getNonEmptyCollections, getNonEmptyCollections,
createCollection,
CollectionType,
} from 'services/collectionService'; } from 'services/collectionService';
import constants from 'utils/strings/constants'; import constants from 'utils/strings/constants';
import billingService from 'services/billingService'; import billingService from 'services/billingService';
@ -47,7 +50,9 @@ import { LoadingOverlay } from 'components/LoadingOverlay';
import PhotoFrame from 'components/PhotoFrame'; import PhotoFrame from 'components/PhotoFrame';
import { import {
changeFilesVisibility, changeFilesVisibility,
getSelectedFileIds, getSelectedFiles,
mergeMetadata,
sortFiles,
sortFilesIntoCollections, sortFilesIntoCollections,
} from 'utils/file'; } from 'utils/file';
import SearchBar, { DateValue } from 'components/SearchBar'; import SearchBar, { DateValue } from 'components/SearchBar';
@ -66,17 +71,28 @@ import Upload from 'components/pages/gallery/Upload';
import Collections, { import Collections, {
ALL_SECTION, ALL_SECTION,
ARCHIVE_SECTION, ARCHIVE_SECTION,
TRASH_SECTION,
} from 'components/pages/gallery/Collections'; } from 'components/pages/gallery/Collections';
import { AppContext } from 'pages/_app'; import { AppContext } from 'pages/_app';
import { CustomError, ServerErrorCodes } from 'utils/common/errorUtil'; import { CustomError, ServerErrorCodes } from 'utils/common/errorUtil';
import { PAGES } from 'types'; import { PAGES } from 'types';
import { import {
copyOrMoveFromCollection,
COLLECTION_OPS_TYPE, COLLECTION_OPS_TYPE,
isSharedCollection, isSharedCollection,
handleCollectionOps,
getSelectedCollection,
isFavoriteCollection, isFavoriteCollection,
} from 'utils/collection'; } from 'utils/collection';
import { logError } from 'utils/sentry'; import { logError } from 'utils/sentry';
import {
clearLocalTrash,
emptyTrash,
getLocalTrash,
getTrashedFiles,
syncTrash,
Trash,
} from 'services/trashService';
import DeleteBtn from 'components/DeleteBtn';
export const DeadCenter = styled.div` export const DeadCenter = styled.div`
flex: 1; flex: 1;
@ -118,6 +134,7 @@ type GalleryContextType = {
files: Map<number, string>; files: Map<number, string>;
showPlanSelectorModal: () => void; showPlanSelectorModal: () => void;
setActiveCollection: (collection: number) => void; setActiveCollection: (collection: number) => void;
syncWithRemote: (force?: boolean, silent?: boolean) => Promise<void>;
}; };
const defaultGalleryContext: GalleryContextType = { const defaultGalleryContext: GalleryContextType = {
@ -125,6 +142,7 @@ const defaultGalleryContext: GalleryContextType = {
files: new Map(), files: new Map(),
showPlanSelectorModal: () => null, showPlanSelectorModal: () => null,
setActiveCollection: () => null, setActiveCollection: () => null,
syncWithRemote: () => null,
}; };
export const GalleryContext = createContext<GalleryContextType>( export const GalleryContext = createContext<GalleryContextType>(
@ -185,6 +203,7 @@ export default function Gallery() {
const [collectionFilesCount, setCollectionFilesCount] = const [collectionFilesCount, setCollectionFilesCount] =
useState<Map<number, number>>(); useState<Map<number, number>>();
const [activeCollection, setActiveCollection] = useState<number>(undefined); const [activeCollection, setActiveCollection] = useState<number>(undefined);
const [trash, setTrash] = useState<Trash>([]);
useEffect(() => { useEffect(() => {
const key = getKey(SESSION_KEYS.ENCRYPTION_KEY); const key = getKey(SESSION_KEYS.ENCRYPTION_KEY);
@ -200,9 +219,13 @@ export default function Gallery() {
setPlanModalView(true); setPlanModalView(true);
} }
setIsFirstLogin(false); setIsFirstLogin(false);
const files = await getLocalFiles(); const files = mergeMetadata(await getLocalFiles());
const collections = await getLocalCollections(); const collections = await getLocalCollections();
setFiles(files); const trash = await getLocalTrash();
const trashedFile = getTrashedFiles(trash);
setFiles(sortFiles([...files, ...trashedFile]));
setCollections(collections);
setTrash(trash);
await setDerivativeState(collections, files); await setDerivativeState(collections, files);
await checkSubscriptionPurchase( await checkSubscriptionPurchase(
setDialogMessage, setDialogMessage,
@ -237,6 +260,8 @@ export default function Gallery() {
collectionURL += '?collection='; collectionURL += '?collection=';
if (activeCollection === ARCHIVE_SECTION) { if (activeCollection === ARCHIVE_SECTION) {
collectionURL += constants.ARCHIVE; collectionURL += constants.ARCHIVE;
} else if (activeCollection === TRASH_SECTION) {
collectionURL += constants.TRASH;
} else { } else {
collectionURL += activeCollection; collectionURL += activeCollection;
} }
@ -260,8 +285,10 @@ export default function Gallery() {
await billingService.syncSubscription(); await billingService.syncSubscription();
const collections = await syncCollections(); const collections = await syncCollections();
setCollections(collections); setCollections(collections);
const { files } = await syncFiles(collections, setFiles); const files = await syncFiles(collections, setFiles);
await setDerivativeState(collections, files); await setDerivativeState(collections, files);
const trash = await syncTrash(collections, setFiles, files);
setTrash(trash);
} catch (e) { } catch (e) {
switch (e.message) { switch (e.message) {
case ServerErrorCodes.SESSION_EXPIRED: case ServerErrorCodes.SESSION_EXPIRED:
@ -297,21 +324,21 @@ export default function Gallery() {
collections: Collection[], collections: Collection[],
files: File[] files: File[]
) => { ) => {
const favItemIds = await getFavItemIds(files);
setFavItemIds(favItemIds);
const nonEmptyCollections = getNonEmptyCollections(collections, files); const nonEmptyCollections = getNonEmptyCollections(collections, files);
setCollections(nonEmptyCollections);
const collectionsAndTheirLatestFile = getCollectionsAndTheirLatestFile( const collectionsAndTheirLatestFile = getCollectionsAndTheirLatestFile(
nonEmptyCollections, nonEmptyCollections,
files files
); );
setCollectionsAndTheirLatestFile(collectionsAndTheirLatestFile);
const collectionWiseFiles = sortFilesIntoCollections(files); const collectionWiseFiles = sortFilesIntoCollections(files);
const collectionFilesCount = new Map<number, number>(); const collectionFilesCount = new Map<number, number>();
for (const [id, files] of collectionWiseFiles) { for (const [id, files] of collectionWiseFiles) {
collectionFilesCount.set(id, files.length); collectionFilesCount.set(id, files.length);
} }
setCollections(nonEmptyCollections);
setCollectionsAndTheirLatestFile(collectionsAndTheirLatestFile);
setCollectionFilesCount(collectionFilesCount); setCollectionFilesCount(collectionFilesCount);
const favItemIds = await getFavItemIds(files);
setFavItemIds(favItemIds);
}; };
const clearSelection = function () { const clearSelection = function () {
@ -321,24 +348,21 @@ export default function Gallery() {
if (!files) { if (!files) {
return <div />; return <div />;
} }
const addToCollectionHelper = async ( const collectionOpsHelper =
collectionName: string, (ops: COLLECTION_OPS_TYPE) => async (collection: Collection) => {
collection: Collection
) => {
loadingBar.current?.continuousStart(); loadingBar.current?.continuousStart();
try { try {
await copyOrMoveFromCollection( await handleCollectionOps(
COLLECTION_OPS_TYPE.ADD, ops,
setCollectionSelectorView, setCollectionSelectorView,
selected, selected,
files, files,
setActiveCollection, setActiveCollection,
collectionName,
collection collection
); );
clearSelection(); clearSelection();
} catch (e) { } catch (e) {
logError(e, 'collection ops failed', { ops });
setDialogMessage({ setDialogMessage({
title: constants.ERROR, title: constants.ERROR,
staticBackdrop: true, staticBackdrop: true,
@ -351,35 +375,6 @@ export default function Gallery() {
} }
}; };
const moveToCollectionHelper = async (
collectionName: string,
collection: Collection
) => {
loadingBar.current?.continuousStart();
try {
await copyOrMoveFromCollection(
COLLECTION_OPS_TYPE.MOVE,
setCollectionSelectorView,
selected,
files,
setActiveCollection,
collectionName,
collection
);
clearSelection();
} catch (e) {
setDialogMessage({
title: constants.ERROR,
staticBackdrop: true,
close: { variant: 'danger' },
content: constants.UNKNOWN_ERROR,
});
} finally {
await syncWithRemote(false, true);
loadingBar.current.complete();
}
};
const changeFilesVisibilityHelper = async ( const changeFilesVisibilityHelper = async (
visibility: VISIBILITY_STATE visibility: VISIBILITY_STATE
) => { ) => {
@ -393,6 +388,7 @@ export default function Gallery() {
await updateMagicMetadata(updatedFiles); await updateMagicMetadata(updatedFiles);
clearSelection(); clearSelection();
} catch (e) { } catch (e) {
logError(e, 'change file visibility failed');
switch (e.status?.toString()) { switch (e.status?.toString()) {
case ServerErrorCodes.FORBIDDEN: case ServerErrorCodes.FORBIDDEN:
setDialogMessage({ setDialogMessage({
@ -415,33 +411,18 @@ export default function Gallery() {
} }
}; };
const showCreateCollectionModal = (opsType: COLLECTION_OPS_TYPE) => { const showCreateCollectionModal = (ops: COLLECTION_OPS_TYPE) => {
const callback = async (collectionName: string) => {
try { try {
let callback = null; const collection = await createCollection(
switch (opsType) { collectionName,
case COLLECTION_OPS_TYPE.ADD: CollectionType.album,
callback = (collectionName: string) => collections
addToCollectionHelper(collectionName, null);
break;
case COLLECTION_OPS_TYPE.MOVE:
callback = (collectionName: string) =>
moveToCollectionHelper(collectionName, null);
break;
default:
throw Error(CustomError.INVALID_COLLECTION_OPERATION);
}
return () =>
setCollectionNamerAttributes({
title: constants.CREATE_COLLECTION,
buttonText: constants.CREATE,
autoFilledName: '',
callback,
});
} catch (e) {
logError(
e,
'showCreateCollectionModal called with incorrect attributes'
); );
await collectionOpsHelper(ops)(collection);
} catch (e) {
logError(e, 'create and collection ops failed');
setDialogMessage({ setDialogMessage({
title: constants.ERROR, title: constants.ERROR,
staticBackdrop: true, staticBackdrop: true,
@ -450,13 +431,28 @@ export default function Gallery() {
}); });
} }
}; };
return () =>
setCollectionNamerAttributes({
title: constants.CREATE_COLLECTION,
buttonText: constants.CREATE,
autoFilledName: '',
callback,
});
};
const deleteFileHelper = async () => { const deleteFileHelper = async (permanent?: boolean) => {
loadingBar.current?.continuousStart(); loadingBar.current?.continuousStart();
try { try {
const fileIds = getSelectedFileIds(selected); const selectedFiles = getSelectedFiles(selected, files);
await deleteFiles(fileIds); if (permanent) {
setDeleted([...deleted, ...fileIds]); await deleteFromTrash(selectedFiles.map((file) => file.id));
setDeleted([
...deleted,
...selectedFiles.map((file) => file.id),
]);
} else {
await trashFiles(selectedFiles);
}
clearSelection(); clearSelection();
} catch (e) { } catch (e) {
switch (e.status?.toString()) { switch (e.status?.toString()) {
@ -493,12 +489,47 @@ export default function Gallery() {
setCollectionSelectorView(false); setCollectionSelectorView(false);
}; };
const emptyTrashHandler = () =>
setDialogMessage({
title: constants.CONFIRM_EMPTY_TRASH,
content: constants.EMPTY_TRASH_MESSAGE,
staticBackdrop: true,
proceed: {
action: emptyTrashHelper,
text: constants.EMPTY_TRASH,
variant: 'danger',
},
close: { text: constants.CANCEL },
});
const emptyTrashHelper = async () => {
loadingBar.current?.continuousStart();
try {
await emptyTrash();
if (selected.collectionID === TRASH_SECTION) {
clearSelection();
}
await clearLocalTrash();
setActiveCollection(ALL_SECTION);
} catch (e) {
setDialogMessage({
title: constants.ERROR,
staticBackdrop: true,
close: { variant: 'danger' },
content: constants.UNKNOWN_ERROR,
});
} finally {
await syncWithRemote(false, true);
loadingBar.current.complete();
}
};
return ( return (
<GalleryContext.Provider <GalleryContext.Provider
value={{ value={{
...defaultGalleryContext, ...defaultGalleryContext,
showPlanSelectorModal: () => setPlanModalView(true), showPlanSelectorModal: () => setPlanModalView(true),
setActiveCollection, setActiveCollection,
syncWithRemote,
}}> }}>
<FullScreenDropZone <FullScreenDropZone
getRootProps={getRootProps} getRootProps={getRootProps}
@ -556,10 +587,7 @@ export default function Gallery() {
attributes={collectionNamerAttributes} attributes={collectionNamerAttributes}
/> />
<CollectionSelector <CollectionSelector
show={ show={collectionSelectorView}
collectionSelectorView &&
!(collectionsAndTheirLatestFile?.length === 0)
}
onHide={closeCollectionSelector} onHide={closeCollectionSelector}
collectionsAndTheirLatestFile={ collectionsAndTheirLatestFile={
collectionsAndTheirLatestFile collectionsAndTheirLatestFile
@ -622,7 +650,9 @@ export default function Gallery() {
{selected.count > 0 && {selected.count > 0 &&
selected.collectionID === activeCollection && ( selected.collectionID === activeCollection && (
<SelectedFileOptions <SelectedFileOptions
addToCollectionHelper={addToCollectionHelper} addToCollectionHelper={collectionOpsHelper(
COLLECTION_OPS_TYPE.ADD
)}
archiveFilesHelper={() => archiveFilesHelper={() =>
changeFilesVisibilityHelper( changeFilesVisibilityHelper(
VISIBILITY_STATE.ARCHIVED VISIBILITY_STATE.ARCHIVED
@ -633,7 +663,12 @@ export default function Gallery() {
VISIBILITY_STATE.VISIBLE VISIBILITY_STATE.VISIBLE
) )
} }
moveToCollectionHelper={moveToCollectionHelper} moveToCollectionHelper={collectionOpsHelper(
COLLECTION_OPS_TYPE.MOVE
)}
restoreToCollectionHelper={collectionOpsHelper(
COLLECTION_OPS_TYPE.RESTORE
)}
showCreateCollectionModal={ showCreateCollectionModal={
showCreateCollectionModal showCreateCollectionModal
} }
@ -642,6 +677,14 @@ export default function Gallery() {
setCollectionSelectorAttributes setCollectionSelectorAttributes
} }
deleteFileHelper={deleteFileHelper} deleteFileHelper={deleteFileHelper}
removeFromCollectionHelper={() =>
collectionOpsHelper(COLLECTION_OPS_TYPE.REMOVE)(
getSelectedCollection(
activeCollection,
collections
)
)
}
count={selected.count} count={selected.count}
clearSelection={clearSelection} clearSelection={clearSelection}
activeCollection={activeCollection} activeCollection={activeCollection}
@ -651,6 +694,9 @@ export default function Gallery() {
)} )}
/> />
)} )}
{activeCollection === TRASH_SECTION && trash?.length > 0 && (
<DeleteBtn onClick={emptyTrashHandler} />
)}
</FullScreenDropZone> </FullScreenDropZone>
</GalleryContext.Provider> </GalleryContext.Provider>
); );

View file

@ -75,6 +75,11 @@ export enum COLLECTION_SORT_BY {
NAME, NAME,
} }
interface RemoveFromCollectionRequest {
collectionID: number;
fileIDs: number[];
}
const getCollectionWithSecrets = async ( const getCollectionWithSecrets = async (
collection: Collection, collection: Collection,
masterKey: string masterKey: string
@ -212,8 +217,28 @@ export const syncCollections = async () => {
return collections; return collections;
}; };
export const setLocalCollection = async (collections: Collection[]) => { export const getCollection = async (
await localForage.setItem(COLLECTIONS, collections); collectionID: number
): Promise<Collection> => {
try {
const token = getToken();
if (!token) {
return;
}
const resp = await HTTPService.get(
`${ENDPOINT}/collections/${collectionID}`,
null,
{ 'X-Auth-Token': token }
);
const key = await getActualKey();
const collectionWithSecrets = await getCollectionWithSecrets(
resp.data?.collection,
key
);
return collectionWithSecrets;
} catch (e) {
logError(e, 'failed to get collection', { collectionID });
}
}; };
export const getCollectionsAndTheirLatestFile = ( export const getCollectionsAndTheirLatestFile = (
@ -389,6 +414,33 @@ export const addToCollection = async (
throw e; throw e;
} }
}; };
export const restoreToCollection = async (
collection: Collection,
files: File[]
) => {
try {
const token = getToken();
const fileKeysEncryptedWithNewCollection =
await encryptWithNewCollectionKey(collection, files);
const requestBody: AddToCollectionRequest = {
collectionID: collection.id,
files: fileKeysEncryptedWithNewCollection,
};
await HTTPService.post(
`${ENDPOINT}/collections/restore-files`,
requestBody,
null,
{
'X-Auth-Token': token,
}
);
} catch (e) {
logError(e, 'restore to collection Failed ');
throw e;
}
};
export const moveToCollection = async ( export const moveToCollection = async (
fromCollectionID: number, fromCollectionID: number,
toCollection: Collection, toCollection: Collection,
@ -440,22 +492,20 @@ const encryptWithNewCollectionKey = async (
} }
return fileKeysEncryptedWithNewCollection; return fileKeysEncryptedWithNewCollection;
}; };
const removeFromCollection = async (collection: Collection, files: File[]) => { export const removeFromCollection = async (
collection: Collection,
files: File[]
) => {
try { try {
const params = {};
const token = getToken(); const token = getToken();
params['collectionID'] = collection.id; const request: RemoveFromCollectionRequest = {
await Promise.all( collectionID: collection.id,
files.map(async (file) => { fileIDs: files.map((file) => file.id),
if (params['fileIDs'] === undefined) { };
params['fileIDs'] = [];
}
params['fileIDs'].push(file.id);
})
);
await HTTPService.post( await HTTPService.post(
`${ENDPOINT}/collections/remove-files`, `${ENDPOINT}/collections/v2/remove-files`,
params, request,
null, null,
{ 'X-Auth-Token': token } { 'X-Auth-Token': token }
); );
@ -475,7 +525,7 @@ export const deleteCollection = async (
const token = getToken(); const token = getToken();
await HTTPService.delete( await HTTPService.delete(
`${ENDPOINT}/collections/${collectionID}`, `${ENDPOINT}/collections/v2/${collectionID}`,
null, null,
null, null,
{ 'X-Auth-Token': token } { 'X-Auth-Token': token }

View file

@ -44,6 +44,19 @@ class DownloadManager {
thumbnailCache: Cache, thumbnailCache: Cache,
file: File file: File
) => { ) => {
const thumb = await this.getThumbnail(token, file);
try {
await thumbnailCache.put(
file.id.toString(),
new Response(new Blob([thumb]))
);
} catch (e) {
// TODO: handle storage full exception.
}
return URL.createObjectURL(new Blob([thumb]));
};
getThumbnail = async (token: string, file: File) => {
const resp = await HTTPService.get( const resp = await HTTPService.get(
getThumbnailUrl(file.id), getThumbnailUrl(file.id),
null, null,
@ -51,20 +64,12 @@ class DownloadManager {
{ responseType: 'arraybuffer' } { responseType: 'arraybuffer' }
); );
const worker = await new CryptoWorker(); const worker = await new CryptoWorker();
const decrypted: any = await worker.decryptThumbnail( const decrypted: Uint8Array = await worker.decryptThumbnail(
new Uint8Array(resp.data), new Uint8Array(resp.data),
await worker.fromB64(file.thumbnail.decryptionHeader), await worker.fromB64(file.thumbnail.decryptionHeader),
file.key file.key
); );
try { return decrypted;
await thumbnailCache.put(
file.id.toString(),
new Response(new Blob([decrypted]))
);
} catch (e) {
// TODO: handle storage full exception.
}
return URL.createObjectURL(new Blob([decrypted]));
}; };
getFile = async (file: File, forPreview = false) => { getFile = async (file: File, forPreview = false) => {

View file

@ -2,16 +2,24 @@ import { getEndpoint } from 'utils/common/apiUtil';
import localForage from 'utils/storage/localForage'; import localForage from 'utils/storage/localForage';
import { getToken } from 'utils/common/key'; import { getToken } from 'utils/common/key';
import { DataStream, MetadataObject } from './upload/uploadService'; import {
DataStream,
EncryptionResult,
MetadataObject,
} from './upload/uploadService';
import { Collection } from './collectionService'; import { Collection } from './collectionService';
import HTTPService from './HTTPService'; import HTTPService from './HTTPService';
import { logError } from 'utils/sentry'; import { logError } from 'utils/sentry';
import { decryptFile, sortFiles } from 'utils/file'; import { decryptFile, mergeMetadata, sortFiles } from 'utils/file';
import CryptoWorker from 'utils/crypto';
const ENDPOINT = getEndpoint(); const ENDPOINT = getEndpoint();
const DIFF_LIMIT: number = 1000;
const FILES = 'files'; const FILES_TABLE = 'files';
export const MIN_EDITED_CREATION_TIME = new Date(1800, 0, 1);
export const MAX_EDITED_CREATION_TIME = new Date();
export const ALL_TIME = new Date(1800, 0, 1, 23, 59, 59);
export interface fileAttribute { export interface fileAttribute {
encryptedData?: DataStream | Uint8Array; encryptedData?: DataStream | Uint8Array;
@ -41,15 +49,35 @@ export enum VISIBILITY_STATE {
VISIBLE, VISIBLE,
ARCHIVED, ARCHIVED,
} }
export interface MagicMetadataCore {
version: number;
count: number;
header: string;
data: Record<string, any>;
}
export interface EncryptedMagicMetadataCore
extends Omit<MagicMetadataCore, 'data'> {
data: string;
}
export interface MagicMetadataProps { export interface MagicMetadataProps {
visibility?: VISIBILITY_STATE; visibility?: VISIBILITY_STATE;
} }
export interface MagicMetadata {
version: number; export interface MagicMetadata extends Omit<MagicMetadataCore, 'data'> {
count: number; data: MagicMetadataProps;
data: string | MagicMetadataProps;
header: string;
} }
export interface PublicMagicMetadataProps {
editedTime?: number;
}
export interface PublicMagicMetadata extends Omit<MagicMetadataCore, 'data'> {
data: PublicMagicMetadataProps;
}
export interface File { export interface File {
id: number; id: number;
collectionID: number; collectionID: number;
@ -58,6 +86,7 @@ export interface File {
thumbnail: fileAttribute; thumbnail: fileAttribute;
metadata: MetadataObject; metadata: MetadataObject;
magicMetadata: MagicMetadata; magicMetadata: MagicMetadata;
pubMagicMetadata: PublicMagicMetadata;
encryptedKey: string; encryptedKey: string;
keyDecryptionNonce: string; keyDecryptionNonce: string;
key: string; key: string;
@ -67,6 +96,8 @@ export interface File {
w: number; w: number;
h: number; h: number;
isDeleted: boolean; isDeleted: boolean;
isTrashed?: boolean;
deleteBy?: number;
dataIndex: number; dataIndex: number;
updationTime: number; updationTime: number;
} }
@ -74,23 +105,40 @@ export interface File {
interface UpdateMagicMetadataRequest { interface UpdateMagicMetadataRequest {
metadataList: UpdateMagicMetadata[]; metadataList: UpdateMagicMetadata[];
} }
interface UpdateMagicMetadata { interface UpdateMagicMetadata {
id: number; id: number;
magicMetadata: MagicMetadata; magicMetadata: EncryptedMagicMetadataCore;
} }
export const NEW_MAGIC_METADATA: MagicMetadata = { export const NEW_MAGIC_METADATA: MagicMetadataCore = {
version: 0, version: 0,
data: {}, data: {},
header: null, header: null,
count: 0, count: 0,
}; };
interface TrashRequest {
items: TrashRequestItems[];
}
interface TrashRequestItems {
fileID: number;
collectionID: number;
}
export const getLocalFiles = async () => { export const getLocalFiles = async () => {
const files: Array<File> = (await localForage.getItem<File[]>(FILES)) || []; const files: Array<File> =
(await localForage.getItem<File[]>(FILES_TABLE)) || [];
return files; return files;
}; };
export const setLocalFiles = async (files: File[]) => {
await localForage.setItem(FILES_TABLE, files);
};
const getCollectionLastSyncTime = async (collection: Collection) =>
(await localForage.getItem<number>(`${collection.id}-time`)) ?? 0;
export const syncFiles = async ( export const syncFiles = async (
collections: Collection[], collections: Collection[],
setFiles: (files: File[]) => void setFiles: (files: File[]) => void
@ -98,26 +146,19 @@ export const syncFiles = async (
const localFiles = await getLocalFiles(); const localFiles = await getLocalFiles();
let files = await removeDeletedCollectionFiles(collections, localFiles); let files = await removeDeletedCollectionFiles(collections, localFiles);
if (files.length !== localFiles.length) { if (files.length !== localFiles.length) {
await localForage.setItem('files', files); await setLocalFiles(files);
setFiles(files); setFiles(sortFiles(mergeMetadata(files)));
} }
for (const collection of collections) { for (const collection of collections) {
if (!getToken()) { if (!getToken()) {
continue; continue;
} }
const lastSyncTime = const lastSyncTime = await getCollectionLastSyncTime(collection);
(await localForage.getItem<number>(`${collection.id}-time`)) ?? 0;
if (collection.updationTime === lastSyncTime) { if (collection.updationTime === lastSyncTime) {
continue; continue;
} }
const fetchedFiles = const fetchedFiles =
(await getFiles( (await getFiles(collection, lastSyncTime, files, setFiles)) ?? [];
collection,
lastSyncTime,
DIFF_LIMIT,
files,
setFiles
)) ?? [];
files.push(...fetchedFiles); files.push(...fetchedFiles);
const latestVersionFiles = new Map<string, File>(); const latestVersionFiles = new Map<string, File>();
files.forEach((file) => { files.forEach((file) => {
@ -137,42 +178,25 @@ export const syncFiles = async (
} }
files.push(file); files.push(file);
} }
files = sortFiles(files); await setLocalFiles(files);
await localForage.setItem('files', files);
await localForage.setItem( await localForage.setItem(
`${collection.id}-time`, `${collection.id}-time`,
collection.updationTime collection.updationTime
); );
setFiles( setFiles(sortFiles(mergeMetadata(files)));
files.map((item) => ({
...item,
w: window.innerWidth,
h: window.innerHeight,
}))
);
} }
return { return mergeMetadata(files);
files: files.map((item) => ({
...item,
w: window.innerWidth,
h: window.innerHeight,
})),
};
}; };
export const getFiles = async ( export const getFiles = async (
collection: Collection, collection: Collection,
sinceTime: number, sinceTime: number,
limit: number,
files: File[], files: File[],
setFiles: (files: File[]) => void setFiles: (files: File[]) => void
): Promise<File[]> => { ): Promise<File[]> => {
try { try {
const decryptedFiles: File[] = []; const decryptedFiles: File[] = [];
let time = let time = sinceTime;
sinceTime ||
(await localForage.getItem<number>(`${collection.id}-time`)) ||
0;
let resp; let resp;
do { do {
const token = getToken(); const token = getToken();
@ -180,11 +204,10 @@ export const getFiles = async (
break; break;
} }
resp = await HTTPService.get( resp = await HTTPService.get(
`${ENDPOINT}/collections/diff`, `${ENDPOINT}/collections/v2/diff`,
{ {
collectionID: collection.id, collectionID: collection.id,
sinceTime: time, sinceTime: time,
limit,
}, },
{ {
'X-Auth-Token': token, 'X-Auth-Token': token,
@ -206,14 +229,15 @@ export const getFiles = async (
time = resp.data.diff.slice(-1)[0].updationTime; time = resp.data.diff.slice(-1)[0].updationTime;
} }
setFiles( setFiles(
[...(files || []), ...decryptedFiles] sortFiles(
.filter((item) => !item.isDeleted) mergeMetadata(
.sort( [...(files || []), ...decryptedFiles].filter(
(a, b) => (item) => !item.isDeleted
b.metadata.creationTime - a.metadata.creationTime )
)
) )
); );
} while (resp.data.diff.length === limit); } while (resp.data.hasMore);
return decryptedFiles; return decryptedFiles;
} catch (e) { } catch (e) {
logError(e, 'Get files failed'); logError(e, 'Get files failed');
@ -232,14 +256,35 @@ const removeDeletedCollectionFiles = async (
return files; return files;
}; };
export const deleteFiles = async (filesToDelete: number[]) => { export const trashFiles = async (filesToTrash: File[]) => {
try {
const token = getToken();
if (!token) {
return;
}
const trashRequest: TrashRequest = {
items: filesToTrash.map((file) => ({
fileID: file.id,
collectionID: file.collectionID,
})),
};
await HTTPService.post(`${ENDPOINT}/files/trash`, trashRequest, null, {
'X-Auth-Token': token,
});
} catch (e) {
logError(e, 'trash file failed');
throw e;
}
};
export const deleteFromTrash = async (filesToDelete: number[]) => {
try { try {
const token = getToken(); const token = getToken();
if (!token) { if (!token) {
return; return;
} }
await HTTPService.post( await HTTPService.post(
`${ENDPOINT}/files/delete`, `${ENDPOINT}/trash/delete`,
{ fileIDs: filesToDelete }, { fileIDs: filesToDelete },
null, null,
{ {
@ -247,7 +292,7 @@ export const deleteFiles = async (filesToDelete: number[]) => {
} }
); );
} catch (e) { } catch (e) {
logError(e, 'delete failed'); logError(e, 'delete from trash failed');
throw e; throw e;
} }
}; };
@ -258,13 +303,69 @@ export const updateMagicMetadata = async (files: File[]) => {
return; return;
} }
const reqBody: UpdateMagicMetadataRequest = { metadataList: [] }; const reqBody: UpdateMagicMetadataRequest = { metadataList: [] };
const worker = await new CryptoWorker();
for (const file of files) { for (const file of files) {
const { file: encryptedMagicMetadata }: EncryptionResult =
await worker.encryptMetadata(file.magicMetadata.data, file.key);
reqBody.metadataList.push({ reqBody.metadataList.push({
id: file.id, id: file.id,
magicMetadata: file.magicMetadata, magicMetadata: {
version: file.magicMetadata.version,
count: file.magicMetadata.count,
data: encryptedMagicMetadata.encryptedData as unknown as string,
header: encryptedMagicMetadata.decryptionHeader,
},
}); });
} }
await HTTPService.put(`${ENDPOINT}/files/magic-metadata`, reqBody, null, { await HTTPService.put(`${ENDPOINT}/files/magic-metadata`, reqBody, null, {
'X-Auth-Token': token, 'X-Auth-Token': token,
}); });
return files.map(
(file): File => ({
...file,
magicMetadata: {
...file.magicMetadata,
version: file.magicMetadata.version + 1,
},
})
);
};
export const updatePublicMagicMetadata = async (files: File[]) => {
const token = getToken();
if (!token) {
return;
}
const reqBody: UpdateMagicMetadataRequest = { metadataList: [] };
const worker = await new CryptoWorker();
for (const file of files) {
const { file: encryptedPubMagicMetadata }: EncryptionResult =
await worker.encryptMetadata(file.pubMagicMetadata.data, file.key);
reqBody.metadataList.push({
id: file.id,
magicMetadata: {
version: file.pubMagicMetadata.version,
count: file.pubMagicMetadata.count,
data: encryptedPubMagicMetadata.encryptedData as unknown as string,
header: encryptedPubMagicMetadata.decryptionHeader,
},
});
}
await HTTPService.put(
`${ENDPOINT}/files/public-magic-metadata`,
reqBody,
null,
{
'X-Auth-Token': token,
}
);
return files.map(
(file): File => ({
...file,
pubMagicMetadata: {
...file.pubMagicMetadata,
version: file.pubMagicMetadata.version + 1,
},
})
);
}; };

View file

@ -0,0 +1,147 @@
import downloadManager from 'services/downloadManager';
import { fileAttribute, getLocalFiles } from 'services/fileService';
import { generateThumbnail } from 'services/upload/thumbnailService';
import { getToken } from 'utils/common/key';
import { logError } from 'utils/sentry';
import { getEndpoint } from 'utils/common/apiUtil';
import HTTPService from 'services/HTTPService';
import CryptoWorker from 'utils/crypto';
import uploadHttpClient from 'services/upload/uploadHttpClient';
import { EncryptionResult, UploadURL } from 'services/upload/uploadService';
import { SetProgressTracker } from 'components/FixLargeThumbnail';
import { getFileType } from './upload/readFileService';
import { getLocalTrash, getTrashedFiles } from './trashService';
const ENDPOINT = getEndpoint();
const REPLACE_THUMBNAIL_THRESHOLD = 500 * 1024; // 500KB
export async function getLargeThumbnailFiles() {
try {
const token = getToken();
if (!token) {
return;
}
const resp = await HTTPService.get(
`${ENDPOINT}/files/large-thumbnails`,
{
threshold: REPLACE_THUMBNAIL_THRESHOLD,
},
{
'X-Auth-Token': token,
}
);
return resp.data.largeThumbnailFiles as number[];
} catch (e) {
logError(e, 'failed to get large thumbnail files');
throw e;
}
}
export async function replaceThumbnail(
setProgressTracker: SetProgressTracker,
largeThumbnailFileIDs: Set<number>
) {
let completedWithError = false;
try {
const token = getToken();
const worker = await new CryptoWorker();
const files = await getLocalFiles();
const trash = await getLocalTrash();
const trashFiles = getTrashedFiles(trash);
const largeThumbnailFiles = [...files, ...trashFiles].filter((file) =>
largeThumbnailFileIDs.has(file.id)
);
if (largeThumbnailFileIDs.size !== largeThumbnailFiles.length) {
logError(Error(), 'all large thumbnail files not found locally');
}
if (largeThumbnailFiles.length === 0) {
return completedWithError;
}
setProgressTracker({ current: 0, total: largeThumbnailFiles.length });
const uploadURLs: UploadURL[] = [];
uploadHttpClient.fetchUploadURLs(
largeThumbnailFiles.length,
uploadURLs
);
for (const [idx, file] of largeThumbnailFiles.entries()) {
try {
setProgressTracker({
current: idx,
total: largeThumbnailFiles.length,
});
const originalThumbnail = await downloadManager.getThumbnail(
token,
file
);
const dummyImageFile = new globalThis.File(
[originalThumbnail],
file.metadata.title
);
const fileTypeInfo = await getFileType(worker, dummyImageFile);
const { thumbnail: newThumbnail } = await generateThumbnail(
worker,
dummyImageFile,
fileTypeInfo
);
const newUploadedThumbnail = await uploadThumbnail(
worker,
file.key,
newThumbnail,
uploadURLs.pop()
);
await updateThumbnail(file.id, newUploadedThumbnail);
} catch (e) {
logError(e, 'failed to replace a thumbnail');
completedWithError = true;
}
}
} catch (e) {
logError(e, 'replace Thumbnail function failed');
completedWithError = true;
}
return completedWithError;
}
export async function uploadThumbnail(
worker,
fileKey: string,
updatedThumbnail: Uint8Array,
uploadURL: UploadURL
): Promise<fileAttribute> {
const { file: encryptedThumbnail }: EncryptionResult =
await worker.encryptThumbnail(updatedThumbnail, fileKey);
const thumbnailObjectKey = await uploadHttpClient.putFile(
uploadURL,
encryptedThumbnail.encryptedData as Uint8Array,
() => {}
);
return {
objectKey: thumbnailObjectKey,
decryptionHeader: encryptedThumbnail.decryptionHeader,
};
}
export async function updateThumbnail(
fileID: number,
newThumbnail: fileAttribute
) {
try {
const token = getToken();
if (!token) {
return;
}
await HTTPService.put(
`${ENDPOINT}/files/thumbnail`,
{
fileID: fileID,
thumbnail: newThumbnail,
},
null,
{
'X-Auth-Token': token,
}
);
} catch (e) {
logError(e, 'failed to update thumbnail');
throw e;
}
}

View file

@ -0,0 +1,198 @@
import { SetFiles } from 'pages/gallery';
import { getEndpoint } from 'utils/common/apiUtil';
import { getToken } from 'utils/common/key';
import { decryptFile, mergeMetadata, sortFiles } from 'utils/file';
import { logError } from 'utils/sentry';
import localForage from 'utils/storage/localForage';
import { Collection, getCollection } from './collectionService';
import { File } from './fileService';
import HTTPService from './HTTPService';
const TRASH = 'file-trash';
const TRASH_TIME = 'trash-time';
const DELETED_COLLECTION = 'deleted-collection';
const ENDPOINT = getEndpoint();
export interface TrashItem {
file: File;
isDeleted: boolean;
isRestored: boolean;
deleteBy: number;
createdAt: number;
updatedAt: number;
}
export type Trash = TrashItem[];
export async function getLocalTrash() {
const trash = (await localForage.getItem<Trash>(TRASH)) || [];
return trash;
}
export async function getLocalDeletedCollections() {
const trashedCollections: Array<Collection> =
(await localForage.getItem<Collection[]>(DELETED_COLLECTION)) || [];
return trashedCollections;
}
export async function cleanTrashCollections(fileTrash: Trash) {
const trashedCollections = await getLocalDeletedCollections();
const neededTrashCollections = new Set<number>(
fileTrash.map((item) => item.file.collectionID)
);
const filterCollections = trashedCollections.filter((item) =>
neededTrashCollections.has(item.id)
);
await localForage.setItem(DELETED_COLLECTION, filterCollections);
}
async function getLastSyncTime() {
return (await localForage.getItem<number>(TRASH_TIME)) ?? 0;
}
export async function syncTrash(
collections: Collection[],
setFiles: SetFiles,
files: File[]
): Promise<Trash> {
const trash = await getLocalTrash();
collections = [...collections, ...(await getLocalDeletedCollections())];
const collectionMap = new Map<number, Collection>(
collections.map((collection) => [collection.id, collection])
);
if (!getToken()) {
return trash;
}
const lastSyncTime = await getLastSyncTime();
const updatedTrash = await updateTrash(
collectionMap,
lastSyncTime,
setFiles,
files,
trash
);
cleanTrashCollections(updatedTrash);
return updatedTrash;
}
export const updateTrash = async (
collections: Map<number, Collection>,
sinceTime: number,
setFiles: SetFiles,
files: File[],
currentTrash: Trash
): Promise<Trash> => {
try {
let updatedTrash: Trash = [...currentTrash];
let time = sinceTime;
let resp;
do {
const token = getToken();
if (!token) {
break;
}
resp = await HTTPService.get(
`${ENDPOINT}/trash/diff`,
{
sinceTime: time,
},
{
'X-Auth-Token': token,
}
);
for (const trashItem of resp.data.diff as TrashItem[]) {
const collectionID = trashItem.file.collectionID;
let collection = collections.get(collectionID);
if (!collection) {
collection = await getCollection(collectionID);
collections.set(collectionID, collection);
localForage.setItem(DELETED_COLLECTION, [
...collections.values(),
]);
}
if (!trashItem.isDeleted && !trashItem.isRestored) {
trashItem.file = await decryptFile(
trashItem.file,
collection
);
}
updatedTrash.push(trashItem);
}
if (resp.data.diff.length) {
time = resp.data.diff.slice(-1)[0].updatedAt;
}
updatedTrash = removeDuplicates(updatedTrash);
updatedTrash = removeRestoredOrDeletedTrashItems(updatedTrash);
setFiles(
sortFiles([...(files ?? []), ...getTrashedFiles(updatedTrash)])
);
await localForage.setItem(TRASH, updatedTrash);
await localForage.setItem(TRASH_TIME, time);
} while (resp.data.hasMore);
return updatedTrash;
} catch (e) {
logError(e, 'Get trash files failed');
}
};
function removeDuplicates(trash: Trash) {
const latestVersionTrashItems = new Map<number, TrashItem>();
trash.forEach(({ file, updatedAt, ...rest }) => {
if (
!latestVersionTrashItems.has(file.id) ||
latestVersionTrashItems.get(file.id).updatedAt < updatedAt
) {
latestVersionTrashItems.set(file.id, { file, updatedAt, ...rest });
}
});
trash = [];
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const [_, trashedFile] of latestVersionTrashItems) {
trash.push(trashedFile);
}
return trash;
}
function removeRestoredOrDeletedTrashItems(trash: Trash) {
return trash.filter((item) => !item.isDeleted && !item.isRestored);
}
export function getTrashedFiles(trash: Trash) {
return mergeMetadata(
trash.map((trashedFile) => ({
...trashedFile.file,
updationTime: trashedFile.updatedAt,
isTrashed: true,
deleteBy: trashedFile.deleteBy,
}))
);
}
export const emptyTrash = async () => {
try {
const token = getToken();
if (!token) {
return;
}
const lastUpdatedAt = await getLastSyncTime();
await HTTPService.post(
`${ENDPOINT}/trash/empty`,
{ lastUpdatedAt },
null,
{
'X-Auth-Token': token,
}
);
} catch (e) {
logError(e, 'empty trash failed');
throw e;
}
};
export const clearLocalTrash = async () => {
await localForage.setItem(TRASH, []);
};

View file

@ -1,6 +1,5 @@
import exifr from 'exifr'; import exifr from 'exifr';
import { logError } from 'utils/sentry';
import { NULL_LOCATION, Location } from './metadataService'; import { NULL_LOCATION, Location } from './metadataService';
const EXIF_TAGS_NEEDED = [ const EXIF_TAGS_NEEDED = [
@ -20,7 +19,6 @@ interface ParsedEXIFData {
export async function getExifData( export async function getExifData(
receivedFile: globalThis.File receivedFile: globalThis.File
): Promise<ParsedEXIFData> { ): Promise<ParsedEXIFData> {
try {
const exifData = await exifr.parse(receivedFile, EXIF_TAGS_NEEDED); const exifData = await exifr.parse(receivedFile, EXIF_TAGS_NEEDED);
if (!exifData) { if (!exifData) {
return { location: NULL_LOCATION, creationTime: null }; return { location: NULL_LOCATION, creationTime: null };
@ -30,10 +28,6 @@ export async function getExifData(
creationTime: getUNIXTime(exifData), creationTime: getUNIXTime(exifData),
}; };
return parsedEXIFData; return parsedEXIFData;
} catch (e) {
logError(e, 'error reading exif data');
// ignore exif parsing errors
}
} }
function getUNIXTime(exifData: any) { function getUNIXTime(exifData: any) {

View file

@ -34,7 +34,14 @@ export async function extractMetadata(
) { ) {
let exifData = null; let exifData = null;
if (fileTypeInfo.fileType === FILE_TYPE.IMAGE) { if (fileTypeInfo.fileType === FILE_TYPE.IMAGE) {
try {
exifData = await getExifData(receivedFile); exifData = await getExifData(receivedFile);
} catch (e) {
logError(e, 'file missing exif data ', {
fileType: fileTypeInfo.exactType,
});
// ignore exif parsing errors
}
} }
const extractedMetadata: MetadataObject = { const extractedMetadata: MetadataObject = {

View file

@ -58,7 +58,7 @@ export async function getFileType(
logError(e, CustomError.TYPE_DETECTION_FAILED, { logError(e, CustomError.TYPE_DETECTION_FAILED, {
fileFormat, fileFormat,
}); });
return { fileType: FILE_TYPE.OTHERS, exactType: null }; return { fileType: FILE_TYPE.OTHERS, exactType: fileFormat };
} }
} }

View file

@ -4,6 +4,8 @@ import { logError } from 'utils/sentry';
import { BLACK_THUMBNAIL_BASE64 } from '../../../public/images/black-thumbnail-b64'; import { BLACK_THUMBNAIL_BASE64 } from '../../../public/images/black-thumbnail-b64';
import FFmpegService from 'services/ffmpegService'; import FFmpegService from 'services/ffmpegService';
import { convertToHumanReadable } from 'utils/billingUtil'; import { convertToHumanReadable } from 'utils/billingUtil';
import { fileIsHEIC } from 'utils/file';
import { FileTypeInfo } from './readFileService';
const MAX_THUMBNAIL_DIMENSION = 720; const MAX_THUMBNAIL_DIMENSION = 720;
const MIN_COMPRESSION_PERCENTAGE_SIZE_DIFF = 10; const MIN_COMPRESSION_PERCENTAGE_SIZE_DIFF = 10;
@ -21,15 +23,15 @@ interface Dimension {
export async function generateThumbnail( export async function generateThumbnail(
worker, worker,
file: globalThis.File, file: globalThis.File,
fileType: FILE_TYPE, fileTypeInfo: FileTypeInfo
isHEIC: boolean
): Promise<{ thumbnail: Uint8Array; hasStaticThumbnail: boolean }> { ): Promise<{ thumbnail: Uint8Array; hasStaticThumbnail: boolean }> {
try { try {
let hasStaticThumbnail = false; let hasStaticThumbnail = false;
let canvas = document.createElement('canvas'); let canvas = document.createElement('canvas');
let thumbnail: Uint8Array; let thumbnail: Uint8Array;
try { try {
if (fileType === FILE_TYPE.IMAGE) { if (fileTypeInfo.fileType === FILE_TYPE.IMAGE) {
const isHEIC = fileIsHEIC(fileTypeInfo.exactType);
canvas = await generateImageThumbnail(worker, file, isHEIC); canvas = await generateImageThumbnail(worker, file, isHEIC);
} else { } else {
try { try {
@ -38,9 +40,12 @@ export async function generateThumbnail(
canvas = await generateImageThumbnail( canvas = await generateImageThumbnail(
worker, worker,
dummyImageFile, dummyImageFile,
isHEIC false
); );
} catch (e) { } catch (e) {
logError(e, 'failed to generate thumbnail using ffmpeg', {
type: fileTypeInfo.exactType,
});
canvas = await generateVideoThumbnail(file); canvas = await generateVideoThumbnail(file);
} }
} }
@ -50,7 +55,9 @@ export async function generateThumbnail(
throw Error('EMPTY THUMBNAIL'); throw Error('EMPTY THUMBNAIL');
} }
} catch (e) { } catch (e) {
logError(e, 'uploading static thumbnail'); logError(e, 'uploading static thumbnail', {
type: fileTypeInfo.exactType,
});
thumbnail = Uint8Array.from(atob(BLACK_THUMBNAIL_BASE64), (c) => thumbnail = Uint8Array.from(atob(BLACK_THUMBNAIL_BASE64), (c) =>
c.charCodeAt(0) c.charCodeAt(0)
); );
@ -116,14 +123,7 @@ export async function generateImageThumbnail(
} }
}; };
timeout = setTimeout( timeout = setTimeout(
() => () => reject(Error(CustomError.WAIT_TIME_EXCEEDED)),
reject(
Error(
`wait time exceeded for format ${
file.name.split('.').slice(-1)[0]
}`
)
),
WAIT_TIME_THUMBNAIL_GENERATION WAIT_TIME_THUMBNAIL_GENERATION
); );
}); });
@ -176,14 +176,7 @@ export async function generateVideoThumbnail(file: globalThis.File) {
video.preload = 'metadata'; video.preload = 'metadata';
video.src = videoURL; video.src = videoURL;
timeout = setTimeout( timeout = setTimeout(
() => () => reject(Error(CustomError.WAIT_TIME_EXCEEDED)),
reject(
Error(
`wait time exceeded for format ${
file.name.split('.').slice(-1)[0]
}`
)
),
WAIT_TIME_THUMBNAIL_GENERATION WAIT_TIME_THUMBNAIL_GENERATION
); );
}); });

View file

@ -1,4 +1,4 @@
import { File, getLocalFiles } from '../fileService'; import { File, getLocalFiles, setLocalFiles } from '../fileService';
import { Collection, getLocalCollections } from '../collectionService'; import { Collection, getLocalCollections } from '../collectionService';
import { SetFiles } from 'pages/gallery'; import { SetFiles } from 'pages/gallery';
import { ComlinkWorker, getDedicatedCryptoWorker } from 'utils/crypto'; import { ComlinkWorker, getDedicatedCryptoWorker } from 'utils/crypto';
@ -8,7 +8,6 @@ import {
removeUnnecessaryFileProps, removeUnnecessaryFileProps,
} from 'utils/file'; } from 'utils/file';
import { logError } from 'utils/sentry'; import { logError } from 'utils/sentry';
import localForage from 'utils/storage/localForage';
import { import {
getMetadataMapKey, getMetadataMapKey,
ParsedMetaDataJSON, ParsedMetaDataJSON,
@ -185,8 +184,7 @@ class UploadManager {
if (fileUploadResult === FileUploadResults.UPLOADED) { if (fileUploadResult === FileUploadResults.UPLOADED) {
this.existingFiles.push(file); this.existingFiles.push(file);
this.existingFiles = sortFiles(this.existingFiles); this.existingFiles = sortFiles(this.existingFiles);
await localForage.setItem( await setLocalFiles(
'files',
removeUnnecessaryFileProps(this.existingFiles) removeUnnecessaryFileProps(this.existingFiles)
); );
this.setFiles(this.existingFiles); this.setFiles(this.existingFiles);

View file

@ -19,7 +19,6 @@ import { uploadStreamUsingMultipart } from './multiPartUploadService';
import UIService from './uiService'; import UIService from './uiService';
import { handleUploadError } from 'utils/common/errorUtil'; import { handleUploadError } from 'utils/common/errorUtil';
import { MetadataMap } from './uploadManager'; import { MetadataMap } from './uploadManager';
import { fileIsHEIC } from 'utils/file';
// this is the chunk size of the un-encrypted file which is read and encrypted before uploading it as a single part. // this is the chunk size of the un-encrypted file which is read and encrypted before uploading it as a single part.
export const MULTIPART_PART_SIZE = 20 * 1024 * 1024; export const MULTIPART_PART_SIZE = 20 * 1024 * 1024;
@ -108,13 +107,10 @@ class UploadService {
rawFile: globalThis.File, rawFile: globalThis.File,
fileTypeInfo: FileTypeInfo fileTypeInfo: FileTypeInfo
): Promise<FileInMemory> { ): Promise<FileInMemory> {
const isHEIC = fileIsHEIC(fileTypeInfo.exactType);
const { thumbnail, hasStaticThumbnail } = await generateThumbnail( const { thumbnail, hasStaticThumbnail } = await generateThumbnail(
worker, worker,
rawFile, rawFile,
fileTypeInfo.fileType, fileTypeInfo
isHEIC
); );
const filedata = await getFileData(worker, rawFile); const filedata = await getFileData(worker, rawFile);

View file

@ -102,9 +102,9 @@ export default async function uploader(
file: decryptedFile, file: decryptedFile,
}; };
} catch (e) { } catch (e) {
const fileFormat = logError(e, 'file upload failed', {
fileTypeInfo.exactType ?? rawFile.name.split('.').pop(); fileFormat: fileTypeInfo.exactType,
logError(e, 'file upload failed', { fileFormat }); });
const error = handleUploadError(e); const error = handleUploadError(e);
switch (error.message) { switch (error.message) {
case CustomError.ETAG_MISSING: case CustomError.ETAG_MISSING:

View file

@ -2,8 +2,9 @@ import {
addToCollection, addToCollection,
Collection, Collection,
CollectionType, CollectionType,
createCollection,
moveToCollection, moveToCollection,
removeFromCollection,
restoreToCollection,
} from 'services/collectionService'; } from 'services/collectionService';
import { getSelectedFiles } from 'utils/file'; import { getSelectedFiles } from 'utils/file';
import { File } from 'services/fileService'; import { File } from 'services/fileService';
@ -15,26 +16,18 @@ import { getData, LS_KEYS } from 'utils/storage/localStorage';
export enum COLLECTION_OPS_TYPE { export enum COLLECTION_OPS_TYPE {
ADD, ADD,
MOVE, MOVE,
REMOVE,
RESTORE,
} }
export async function copyOrMoveFromCollection( export async function handleCollectionOps(
type: COLLECTION_OPS_TYPE, type: COLLECTION_OPS_TYPE,
setCollectionSelectorView: (value: boolean) => void, setCollectionSelectorView: (value: boolean) => void,
selected: SelectedState, selected: SelectedState,
files: File[], files: File[],
setActiveCollection: (id: number) => void, setActiveCollection: (id: number) => void,
collectionName: string, collection: Collection
existingCollection: Collection
) { ) {
setCollectionSelectorView(false); setCollectionSelectorView(false);
let collection: Collection;
if (!existingCollection) {
collection = await createCollection(
collectionName,
CollectionType.album
);
} else {
collection = existingCollection;
}
const selectedFiles = getSelectedFiles(selected, files); const selectedFiles = getSelectedFiles(selected, files);
switch (type) { switch (type) {
case COLLECTION_OPS_TYPE.ADD: case COLLECTION_OPS_TYPE.ADD:
@ -47,6 +40,12 @@ export async function copyOrMoveFromCollection(
selectedFiles selectedFiles
); );
break; break;
case COLLECTION_OPS_TYPE.REMOVE:
await removeFromCollection(collection, selectedFiles);
break;
case COLLECTION_OPS_TYPE.RESTORE:
await restoreToCollection(collection, selectedFiles);
break;
default: default:
throw Error(CustomError.INVALID_COLLECTION_OPERATION); throw Error(CustomError.INVALID_COLLECTION_OPERATION);
} }

View file

@ -27,6 +27,7 @@ export enum CustomError {
SIGNUP_FAILED = 'signup failed', SIGNUP_FAILED = 'signup failed',
FAV_COLLECTION_MISSING = 'favorite collection missing', FAV_COLLECTION_MISSING = 'favorite collection missing',
INVALID_COLLECTION_OPERATION = 'invalid collection operation', INVALID_COLLECTION_OPERATION = 'invalid collection operation',
WAIT_TIME_EXCEEDED = 'thumbnail generation wait time exceeded',
} }
function parseUploadError(error: AxiosResponse) { function parseUploadError(error: AxiosResponse) {

View file

@ -6,11 +6,11 @@ import {
FILE_TYPE, FILE_TYPE,
MagicMetadataProps, MagicMetadataProps,
NEW_MAGIC_METADATA, NEW_MAGIC_METADATA,
PublicMagicMetadataProps,
VISIBILITY_STATE, VISIBILITY_STATE,
} from 'services/fileService'; } from 'services/fileService';
import { decodeMotionPhoto } from 'services/motionPhotoService'; import { decodeMotionPhoto } from 'services/motionPhotoService';
import { getMimeTypeFromBlob } from 'services/upload/readFileService'; import { getMimeTypeFromBlob } from 'services/upload/readFileService';
import { EncryptionResult } from 'services/upload/uploadService';
import DownloadManger from 'services/downloadManager'; import DownloadManger from 'services/downloadManager';
import { logError } from 'utils/sentry'; import { logError } from 'utils/sentry';
import { User } from 'services/userService'; import { User } from 'services/userService';
@ -69,7 +69,7 @@ export function sortFilesIntoCollections(files: File[]) {
return collectionWiseFiles; return collectionWiseFiles;
} }
export function getSelectedFileIds(selectedFiles: SelectedState) { function getSelectedFileIds(selectedFiles: SelectedState) {
const filesIDs: number[] = []; const filesIDs: number[] = [];
for (const [key, val] of Object.entries(selectedFiles)) { for (const [key, val] of Object.entries(selectedFiles)) {
if (typeof val === 'boolean' && val) { if (typeof val === 'boolean' && val) {
@ -79,17 +79,19 @@ export function getSelectedFileIds(selectedFiles: SelectedState) {
return filesIDs; return filesIDs;
} }
export function getSelectedFiles( export function getSelectedFiles(
selectedFiles: SelectedState, selected: SelectedState,
files: File[] files: File[]
): File[] { ): File[] {
const filesIDs = new Set(getSelectedFileIds(selectedFiles)); const filesIDs = new Set(getSelectedFileIds(selected));
const filesToDelete: File[] = []; const selectedFiles: File[] = [];
const foundFiles = new Set<number>();
for (const file of files) { for (const file of files) {
if (filesIDs.has(file.id)) { if (filesIDs.has(file.id) && !foundFiles.has(file.id)) {
filesToDelete.push(file); selectedFiles.push(file);
foundFiles.add(file.id);
} }
} }
return filesToDelete; return selectedFiles;
} }
export function checkFileFormatSupport(name: string) { export function checkFileFormatSupport(name: string) {
@ -123,6 +125,31 @@ export function formatDateTime(date: number | Date) {
return `${dateTimeFormat.format(date)} ${timeFormat.format(date)}`; return `${dateTimeFormat.format(date)} ${timeFormat.format(date)}`;
} }
export function formatDateRelative(date: number) {
const units = {
year: 24 * 60 * 60 * 1000 * 365,
month: (24 * 60 * 60 * 1000 * 365) / 12,
day: 24 * 60 * 60 * 1000,
hour: 60 * 60 * 1000,
minute: 60 * 1000,
second: 1000,
};
const relativeDateFormat = new Intl.RelativeTimeFormat('en-IN', {
localeMatcher: 'best fit',
numeric: 'always',
style: 'long',
});
const elapsed = date - Date.now();
// "Math.abs" accounts for both "past" & "future" scenarios
for (const u in units)
if (Math.abs(elapsed) > units[u] || u === 'second')
return relativeDateFormat.format(
Math.round(elapsed / units[u]),
u as Intl.RelativeTimeFormatUnit
);
}
export function sortFiles(files: File[]) { export function sortFiles(files: File[]) {
// sort according to modification time first // sort according to modification time first
files = files.sort((a, b) => { files = files.sort((a, b) => {
@ -172,9 +199,17 @@ export async function decryptFile(file: File, collection: Collection) {
file.key file.key
); );
} }
if (file.pubMagicMetadata?.data) {
file.pubMagicMetadata.data = await worker.decryptMetadata(
file.pubMagicMetadata.data,
file.pubMagicMetadata.header,
file.key
);
}
return file; return file;
} catch (e) { } catch (e) {
logError(e, 'file decryption failed'); logError(e, 'file decryption failed');
throw e;
} }
} }
@ -245,15 +280,12 @@ export function fileIsArchived(file: File) {
return file.magicMetadata.data.visibility === VISIBILITY_STATE.ARCHIVED; return file.magicMetadata.data.visibility === VISIBILITY_STATE.ARCHIVED;
} }
export async function changeFilesVisibility( export async function updateMagicMetadataProps(
files: File[], file: File,
selected: SelectedState, magicMetadataUpdates: MagicMetadataProps
visibility: VISIBILITY_STATE
) { ) {
const worker = await new CryptoWorker(); const worker = await new CryptoWorker();
const selectedFiles = getSelectedFiles(selected, files);
const updatedFiles: File[] = [];
for (const file of selectedFiles) {
if (!file.magicMetadata) { if (!file.magicMetadata) {
file.magicMetadata = NEW_MAGIC_METADATA; file.magicMetadata = NEW_MAGIC_METADATA;
} }
@ -264,27 +296,91 @@ export async function changeFilesVisibility(
file.key file.key
)) as MagicMetadataProps; )) as MagicMetadataProps;
} }
if (magicMetadataUpdates) {
// copies the existing magic metadata properties of the files and updates the visibility value // copies the existing magic metadata properties of the files and updates the visibility value
// The expected behaviour while updating magic metadata is to let the existing property as it is and update/add the property you want // The expected behaviour while updating magic metadata is to let the existing property as it is and update/add the property you want
const updatedMagicMetadataProps: MagicMetadataProps = { const magicMetadataProps: MagicMetadataProps = {
...file.magicMetadata.data, ...file.magicMetadata.data,
visibility, ...magicMetadataUpdates,
}; };
const encryptedMagicMetadata: EncryptionResult =
await worker.encryptMetadata(updatedMagicMetadataProps, file.key); return {
updatedFiles.push({
...file, ...file,
magicMetadata: { magicMetadata: {
version: file.magicMetadata.version, ...file.magicMetadata,
count: Object.keys(updatedMagicMetadataProps).length, data: magicMetadataProps,
data: encryptedMagicMetadata.file count: Object.keys(file.magicMetadata.data).length,
.encryptedData as unknown as string,
header: encryptedMagicMetadata.file.decryptionHeader,
}, },
}); };
} else {
return file;
}
}
export async function updatePublicMagicMetadataProps(
file: File,
publicMetadataUpdates: PublicMagicMetadataProps
) {
const worker = await new CryptoWorker();
if (!file.pubMagicMetadata) {
file.pubMagicMetadata = NEW_MAGIC_METADATA;
}
if (typeof file.pubMagicMetadata.data === 'string') {
file.pubMagicMetadata.data = (await worker.decryptMetadata(
file.pubMagicMetadata.data,
file.pubMagicMetadata.header,
file.key
)) as PublicMagicMetadataProps;
}
if (publicMetadataUpdates) {
const publicMetadataProps = {
...file.pubMagicMetadata.data,
...publicMetadataUpdates,
};
return {
...file,
pubMagicMetadata: {
...file.pubMagicMetadata,
data: publicMetadataProps,
count: Object.keys(file.pubMagicMetadata.data).length,
},
};
} else {
return file;
}
}
export async function changeFilesVisibility(
files: File[],
selected: SelectedState,
visibility: VISIBILITY_STATE
) {
const selectedFiles = getSelectedFiles(selected, files);
const updatedFiles: File[] = [];
for (const file of selectedFiles) {
const updatedMagicMetadataProps: MagicMetadataProps = {
visibility,
};
updatedFiles.push(
await updateMagicMetadataProps(file, updatedMagicMetadataProps)
);
} }
return updatedFiles; return updatedFiles;
} }
export async function changeFileCreationTime(file: File, editedTime: number) {
const updatedPublicMagicMetadataProps: PublicMagicMetadataProps = {
editedTime,
};
return await updatePublicMagicMetadataProps(
file,
updatedPublicMagicMetadataProps
);
}
export function isSharedFile(file: File) { export function isSharedFile(file: File) {
const user: User = getData(LS_KEYS.USER); const user: User = getData(LS_KEYS.USER);
@ -293,3 +389,28 @@ export function isSharedFile(file: File) {
} }
return file.ownerID !== user.id; return file.ownerID !== user.id;
} }
export function mergeMetadata(files: File[]): File[] {
return files.map((file) => ({
...file,
metadata: {
...file.metadata,
...(file.pubMagicMetadata?.data
? {
...(file.pubMagicMetadata?.data.editedTime && {
creationTime: file.pubMagicMetadata.data.editedTime,
}),
}
: {}),
...(file.magicMetadata?.data ? file.magicMetadata.data : {}),
},
}));
}
export function updateExistingFilePubMetadata(
existingFile: File,
updatedFile: File
) {
existingFile.pubMagicMetadata = updatedFile.pubMagicMetadata;
existingFile.metadata = mergeMetadata([existingFile])[0].metadata;
}

View file

@ -3,13 +3,13 @@ import { errorWithContext } from 'utils/common/errorUtil';
import { getUserAnonymizedID } from 'utils/user'; import { getUserAnonymizedID } from 'utils/user';
export const logError = ( export const logError = (
e: any, error: any,
msg: string, msg: string,
info?: Record<string, unknown> info?: Record<string, unknown>
) => { ) => {
const err = errorWithContext(e, msg); const err = errorWithContext(error, msg);
if (!process.env.NEXT_PUBLIC_SENTRY_ENV) { if (!process.env.NEXT_PUBLIC_SENTRY_ENV) {
console.log(e); console.log({ error, msg, info });
} }
Sentry.captureException(err, { Sentry.captureException(err, {
level: Sentry.Severity.Info, level: Sentry.Severity.Info,
@ -18,7 +18,7 @@ export const logError = (
...(info && { ...(info && {
info: info, info: info,
}), }),
rootCause: { message: e?.message }, rootCause: { message: error?.message },
}, },
}); });
}; };

View file

@ -12,6 +12,7 @@ export enum LS_KEYS {
SHOW_BACK_BUTTON = 'showBackButton', SHOW_BACK_BUTTON = 'showBackButton',
EXPORT = 'export', EXPORT = 'export',
AnonymizeUserID = 'anonymizedUserID', AnonymizeUserID = 'anonymizedUserID',
THUMBNAIL_FIX_STATE = 'thumbnailFixState',
} }
export const setData = (key: LS_KEYS, value: object) => { export const setData = (key: LS_KEYS, value: object) => {

View file

@ -159,8 +159,8 @@ const englishConstants = {
UPLOAD_FIRST_PHOTO_DESCRIPTION: 'preserve your first memory with ente', UPLOAD_FIRST_PHOTO_DESCRIPTION: 'preserve your first memory with ente',
UPLOAD_FIRST_PHOTO: 'preserve', UPLOAD_FIRST_PHOTO: 'preserve',
UPLOAD_DROPZONE_MESSAGE: 'drop to backup your files', UPLOAD_DROPZONE_MESSAGE: 'drop to backup your files',
CONFIRM_DELETE_FILE: 'confirm file deletion', CONFIRM_DELETE: 'confirm deletion',
DELETE_FILE_MESSAGE: 'sure you want to delete selected files?', DELETE_MESSAGE: `the selected files will be permanently deleted and can't be restored `,
DELETE_FILE: 'delete files', DELETE_FILE: 'delete files',
DELETE: 'delete', DELETE: 'delete',
MULTI_FOLDER_UPLOAD: 'multiple folders detected', MULTI_FOLDER_UPLOAD: 'multiple folders detected',
@ -210,7 +210,7 @@ const englishConstants = {
CANCEL: 'cancel', CANCEL: 'cancel',
LOGOUT: 'logout', LOGOUT: 'logout',
DELETE_ACCOUNT: 'delete account', DELETE_ACCOUNT: 'delete account',
DELETE_MESSAGE: () => ( DELETE_ACCOUNT_MESSAGE: () => (
<> <>
<p> <p>
please send an email to{' '} please send an email to{' '}
@ -359,8 +359,7 @@ const englishConstants = {
<> <>
<p>are you sure you want to delete this album?</p> <p>are you sure you want to delete this album?</p>
<p> <p>
all files that are unique to this album will be permanently all files that are unique to this album will be moved to trash
deleted
</p> </p>
</> </>
), ),
@ -549,9 +548,54 @@ const englishConstants = {
MOVE: 'move', MOVE: 'move',
ADD: 'add', ADD: 'add',
SORT: 'sort', SORT: 'sort',
REMOVE: 'remove',
CONFIRM_REMOVE: 'confirm removal',
TRASH: 'trash',
MOVE_TO_TRASH: 'move to trash',
TRASH_MESSAGE:
'the selected files will be removed from all albums and moved to trash ',
DELETE_PERMANENTLY: 'delete permanently',
RESTORE: 'restore',
CONFIRM_RESTORE: 'confirm restoration',
RESTORE_MESSAGE: 'restore selected files ?',
RESTORE_TO_COLLECTION: 'restore to album',
AUTOMATIC_BIN_DELETE_MESSAGE: (relativeTime: string) =>
`permanently deleted ${relativeTime}`,
EMPTY_TRASH: 'empty trash',
CONFIRM_EMPTY_TRASH: 'empty trash?',
EMPTY_TRASH_MESSAGE:
'all files will be permanently removed from your ente account',
CONFIRM_REMOVE_MESSAGE: () => (
<>
<p>are you sure you want to remove these files from the album?</p>
<p>
all files that are unique to this album will be moved to trash
</p>
</>
),
SORT_BY_LATEST_PHOTO: 'recent photo', SORT_BY_LATEST_PHOTO: 'recent photo',
SORT_BY_MODIFICATION_TIME: 'last updated', SORT_BY_MODIFICATION_TIME: 'last updated',
SORT_BY_COLLECTION_NAME: 'album name', SORT_BY_COLLECTION_NAME: 'album name',
FIX_LARGE_THUMBNAILS: 'compress thumbnails',
THUMBNAIL_REPLACED: 'thumbnails compressed',
FIX: 'compress',
FIX_LATER: 'compress later',
REPLACE_THUMBNAIL_NOT_STARTED: () => (
<>
some of your videos thumbnails can be compressed to save space.
would you like ente to compress them?
</>
),
REPLACE_THUMBNAIL_COMPLETED: () => (
<>successfully compressed all thumbnails</>
),
REPLACE_THUMBNAIL_NOOP: () => (
<>you have no thumbnails that can be compressed further</>
),
REPLACE_THUMBNAIL_COMPLETED_WITH_ERROR: () => (
<>could not compress some of your thumbnails, please retry</>
),
}; };
export default englishConstants; export default englishConstants;

1977
yarn.lock

File diff suppressed because it is too large Load diff