commit
9344acb1d2
|
@ -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",
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
228
src/components/FixLargeThumbnail.tsx
Normal file
228
src/components/FixLargeThumbnail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
409
src/components/PhotoList.tsx
Normal file
409
src/components/PhotoList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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',
|
20
src/components/icons/EditIcon.tsx
Normal file
20
src/components/icons/EditIcon.tsx
Normal 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',
|
||||||
|
};
|
20
src/components/icons/RemoveIcon.tsx
Normal file
20
src/components/icons/RemoveIcon.tsx
Normal 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',
|
||||||
|
};
|
21
src/components/icons/RestoreIcon.tsx
Normal file
21
src/components/icons/RestoreIcon.tsx
Normal 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',
|
||||||
|
};
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
|
@ -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)}
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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`
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
147
src/services/migrateThumbnailService.ts
Normal file
147
src/services/migrateThumbnailService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
198
src/services/trashService.ts
Normal file
198
src/services/trashService.ts
Normal 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, []);
|
||||||
|
};
|
|
@ -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) {
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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 },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in a new issue