commit
9344acb1d2
|
@ -44,6 +44,7 @@
|
|||
"react-bootstrap": "^1.3.0",
|
||||
"react-burger-menu": "^3.0.4",
|
||||
"react-collapse": "^5.1.0",
|
||||
"react-datepicker": "^4.3.0",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-dropzone": "^11.2.4",
|
||||
"react-otp-input": "^2.3.1",
|
||||
|
@ -70,6 +71,7 @@
|
|||
"@types/photoswipe": "^4.1.1",
|
||||
"@types/react": "^16.9.49",
|
||||
"@types/react-collapse": "^5.0.1",
|
||||
"@types/react-datepicker": "^4.1.7",
|
||||
"@types/react-select": "^4.0.15",
|
||||
"@types/react-window": "^1.8.2",
|
||||
"@types/react-window-infinite-loader": "^1.0.3",
|
||||
|
|
|
@ -22,8 +22,6 @@ export const IconButton = styled.button`
|
|||
background: none;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 5px;
|
||||
color: inherit;
|
||||
margin: 0 10px;
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import constants from 'utils/strings/constants';
|
||||
import { IconWithMessage } from './pages/gallery/SelectedFileOptions';
|
||||
|
||||
const Wrapper = styled.button`
|
||||
border: none;
|
||||
background-color: #ff6666;
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
bottom: 30px;
|
||||
right: 30px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
|
@ -15,6 +17,7 @@ const Wrapper = styled.button`
|
|||
`;
|
||||
export default function DeleteBtn(props) {
|
||||
return (
|
||||
<IconWithMessage message={constants.EMPTY_TRASH}>
|
||||
<Wrapper onClick={props.onClick}>
|
||||
<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" />
|
||||
</svg>
|
||||
</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 styled from 'styled-components';
|
||||
import constants from 'utils/strings/constants';
|
||||
import CrossIcon from './icons/CrossIcon';
|
||||
import CloseIcon from './icons/CloseIcon';
|
||||
|
||||
const CloseButtonWrapper = styled.div`
|
||||
position: absolute;
|
||||
|
@ -62,7 +62,7 @@ export default function FullScreenDropZone(props: Props) {
|
|||
{isDragActive && (
|
||||
<Overlay onDrop={onDragLeave} onDragLeave={onDragLeave}>
|
||||
<CloseButtonWrapper onClick={onDragLeave}>
|
||||
<CrossIcon />
|
||||
<CloseIcon />
|
||||
</CloseButtonWrapper>
|
||||
{constants.UPLOAD_DROPZONE_MESSAGE}
|
||||
</Overlay>
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import {
|
||||
DeadCenter,
|
||||
GalleryContext,
|
||||
Search,
|
||||
SelectedState,
|
||||
|
@ -14,40 +13,18 @@ import styled from 'styled-components';
|
|||
import DownloadManager from 'services/downloadManager';
|
||||
import constants from 'utils/strings/constants';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { VariableSizeList as List } from 'react-window';
|
||||
import PhotoSwipe from 'components/PhotoSwipe/PhotoSwipe';
|
||||
import { isInsideBox, isSameDay as isSameDayAnyYear } from 'utils/search';
|
||||
import { SetDialogMessage } from './MessageDialog';
|
||||
import { fileIsArchived, formatDateRelative } from 'utils/file';
|
||||
import {
|
||||
GAP_BTW_TILES,
|
||||
DATE_CONTAINER_HEIGHT,
|
||||
IMAGE_CONTAINER_MAX_HEIGHT,
|
||||
IMAGE_CONTAINER_MAX_WIDTH,
|
||||
MIN_COLUMNS,
|
||||
SPACE_BTW_DATES,
|
||||
} from 'types';
|
||||
import { fileIsArchived } from 'utils/file';
|
||||
import { ALL_SECTION, ARCHIVE_SECTION } from './pages/gallery/Collections';
|
||||
ALL_SECTION,
|
||||
ARCHIVE_SECTION,
|
||||
TRASH_SECTION,
|
||||
} from './pages/gallery/Collections';
|
||||
import { isSharedFile } from 'utils/file';
|
||||
import { isPlaybackPossible } from 'utils/photoFrame';
|
||||
|
||||
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;
|
||||
}
|
||||
import { PhotoList } from './PhotoList';
|
||||
|
||||
const Container = styled.div`
|
||||
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`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
@ -127,12 +52,6 @@ const EmptyScreen = styled.div`
|
|||
}
|
||||
`;
|
||||
|
||||
enum ITEM_TYPE {
|
||||
TIME = 'TIME',
|
||||
TILE = 'TILE',
|
||||
BANNER = 'BANNER',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
files: File[];
|
||||
setFiles: SetFiles;
|
||||
|
@ -176,7 +95,29 @@ const PhotoFrame = ({
|
|||
const [fetching, setFetching] = useState<{ [k: number]: boolean }>({});
|
||||
const startTime = Date.now();
|
||||
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(() => {
|
||||
if (isInSearchMode) {
|
||||
|
@ -195,10 +136,80 @@ const PhotoFrame = ({
|
|||
}
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.resetAfterIndex(0);
|
||||
const resetFetching = () => {
|
||||
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) => {
|
||||
files[index] = {
|
||||
|
@ -272,10 +283,14 @@ const PhotoFrame = ({
|
|||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleSelect = (id: number) => (checked: boolean) => {
|
||||
const handleSelect = (id: number, index?: number) => (checked: boolean) => {
|
||||
if (selected.collectionID !== activeCollection) {
|
||||
setSelected({ count: 0, collectionID: 0 });
|
||||
}
|
||||
if (checked) {
|
||||
setRangeStart(index);
|
||||
}
|
||||
|
||||
setSelected((selected) => ({
|
||||
...selected,
|
||||
[id]: checked,
|
||||
|
@ -283,19 +298,50 @@ const PhotoFrame = ({
|
|||
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) => (
|
||||
<PreviewCard
|
||||
key={`tile-${file[index].id}`}
|
||||
key={`tile-${file[index].id}-selected-${
|
||||
selected[file[index].id] ?? false
|
||||
}`}
|
||||
file={file[index]}
|
||||
updateUrl={updateUrl(file[index].dataIndex)}
|
||||
onClick={onThumbnailClick(index)}
|
||||
selectable={!isSharedCollection}
|
||||
onSelect={handleSelect(file[index].id)}
|
||||
onSelect={handleSelect(file[index].id, index)}
|
||||
selected={
|
||||
selected.collectionID === activeCollection &&
|
||||
selected[file[index].id]
|
||||
}
|
||||
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 (
|
||||
<>
|
||||
{!isFirstLoad && files.length === 0 && !isInSearchMode ? (
|
||||
|
@ -508,217 +423,22 @@ const PhotoFrame = ({
|
|||
{constants.UPLOAD_FIRST_PHOTO}
|
||||
</Button>
|
||||
</EmptyScreen>
|
||||
) : filteredData.length ? (
|
||||
) : (
|
||||
<Container>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => {
|
||||
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;
|
||||
}
|
||||
|
||||
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}
|
||||
{({ height, width }) => (
|
||||
<PhotoList
|
||||
width={width}
|
||||
itemCount={timeStampList.length}
|
||||
itemKey={generateKey}
|
||||
overscanCount={extraRowsToRender}>
|
||||
{({ index, style }) => (
|
||||
<ListItem style={style}>
|
||||
<ListContainer
|
||||
columns={columns}
|
||||
groups={
|
||||
timeStampList[index].groups
|
||||
}>
|
||||
{renderListItem(
|
||||
timeStampList[index]
|
||||
height={height}
|
||||
getThumbnail={getThumbnail}
|
||||
filteredData={filteredData}
|
||||
activeCollection={activeCollection}
|
||||
showBanner={
|
||||
files.length < 30 && !isInSearchMode
|
||||
}
|
||||
resetFetching={resetFetching}
|
||||
/>
|
||||
)}
|
||||
</ListContainer>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
<PhotoSwipe
|
||||
isOpen={open}
|
||||
|
@ -729,12 +449,9 @@ const PhotoFrame = ({
|
|||
favItemIds={favItemIds}
|
||||
loadingBar={loadingBar}
|
||||
isSharedCollection={isSharedCollection}
|
||||
isTrashCollection={activeCollection === TRASH_SECTION}
|
||||
/>
|
||||
</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,
|
||||
removeFromFavorites,
|
||||
} 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 exifr from 'exifr';
|
||||
import Modal from 'react-bootstrap/Modal';
|
||||
import Button from 'react-bootstrap/Button';
|
||||
import Form from 'react-bootstrap/Form';
|
||||
import styled from 'styled-components';
|
||||
import events from './events';
|
||||
import { downloadFile, formatDateTime } from 'utils/file';
|
||||
import {
|
||||
changeFileCreationTime,
|
||||
downloadFile,
|
||||
formatDateTime,
|
||||
updateExistingFilePubMetadata,
|
||||
} from 'utils/file';
|
||||
import { FormCheck } from 'react-bootstrap';
|
||||
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 {
|
||||
isOpen: boolean;
|
||||
|
@ -30,6 +48,7 @@ interface Iprops {
|
|||
favItemIds: Set<number>;
|
||||
loadingBar: any;
|
||||
isSharedCollection: boolean;
|
||||
isTrashCollection: boolean;
|
||||
}
|
||||
|
||||
const LegendContainer = styled.div`
|
||||
|
@ -49,16 +68,113 @@ const Pre = styled.pre`
|
|||
`;
|
||||
|
||||
const renderInfoItem = (label: string, value: string | JSX.Element) => (
|
||||
<>
|
||||
<Form.Label column sm="4">
|
||||
{label}
|
||||
</Form.Label>
|
||||
<Form.Label column sm="8">
|
||||
{value}
|
||||
</Form.Label>
|
||||
</>
|
||||
<Row>
|
||||
<Label width="30%">{label}</Label>
|
||||
<Value width="70%">{value}</Value>
|
||||
</Row>
|
||||
);
|
||||
|
||||
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 }) {
|
||||
const { exif } = props;
|
||||
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) {
|
||||
const pswpElement = useRef<HTMLDivElement>();
|
||||
const [photoSwipe, setPhotoSwipe] = useState<Photoswipe<any>>();
|
||||
|
@ -140,6 +317,13 @@ function PhotoSwipe(props: Iprops) {
|
|||
updateItems(items);
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (photoSwipe) {
|
||||
photoSwipe.options.arrowKeys = !showInfo;
|
||||
photoSwipe.options.escKey = !showInfo;
|
||||
}
|
||||
}, [showInfo]);
|
||||
|
||||
function updateFavButton() {
|
||||
setIsFav(isInFav(this?.currItem));
|
||||
}
|
||||
|
@ -301,6 +485,7 @@ function PhotoSwipe(props: Iprops) {
|
|||
await downloadFile(file);
|
||||
loadingBar.current.complete();
|
||||
};
|
||||
const scheduleUpdate = () => (needUpdate.current = true);
|
||||
const { id } = props;
|
||||
let { className } = props;
|
||||
className = classnames(['pswp', className]).trim();
|
||||
|
@ -345,7 +530,8 @@ function PhotoSwipe(props: Iprops) {
|
|||
className="pswp__button pswp__button--zoom"
|
||||
title={constants.ZOOM_IN_OUT}
|
||||
/>
|
||||
{!props.isSharedCollection && (
|
||||
{!props.isSharedCollection &&
|
||||
!props.isTrashCollection && (
|
||||
<FavButton
|
||||
size={44}
|
||||
isClick={isFav}
|
||||
|
@ -379,64 +565,20 @@ function PhotoSwipe(props: Iprops) {
|
|||
title={constants.NEXT}
|
||||
/>
|
||||
<div className="pswp__caption">
|
||||
<div className="pswp__caption__center" />
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal show={showInfo} onHide={handleCloseInfo}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{constants.INFO}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form.Group>
|
||||
<div>
|
||||
<Legend>{constants.METADATA}</Legend>
|
||||
</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>
|
||||
<InfoModal
|
||||
showInfo={showInfo}
|
||||
handleCloseInfo={handleCloseInfo}
|
||||
items={items}
|
||||
photoSwipe={photoSwipe}
|
||||
metadata={metadata}
|
||||
exif={exif}
|
||||
scheduleUpdate={scheduleUpdate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -18,12 +18,13 @@ import constants from 'utils/strings/constants';
|
|||
import LocationIcon from './icons/LocationIcon';
|
||||
import DateIcon from './icons/DateIcon';
|
||||
import SearchIcon from './icons/SearchIcon';
|
||||
import CrossIcon from './icons/CrossIcon';
|
||||
import CloseIcon from './icons/CloseIcon';
|
||||
import { Collection } from 'services/collectionService';
|
||||
import CollectionIcon from './icons/CollectionIcon';
|
||||
import { File, FILE_TYPE } from 'services/fileService';
|
||||
import ImageIcon from './icons/ImageIcon';
|
||||
import VideoIcon from './icons/VideoIcon';
|
||||
import { IconButton } from './Container';
|
||||
|
||||
const Wrapper = styled.div<{ isDisabled: boolean; isOpen: boolean }>`
|
||||
position: fixed;
|
||||
|
@ -346,15 +347,11 @@ export default function SearchBar(props: Props) {
|
|||
noOptionsMessage={() => null}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: '24px' }}>
|
||||
{props.isOpen && (
|
||||
<div
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => resetSearch()}>
|
||||
<CrossIcon />
|
||||
</div>
|
||||
<IconButton onClick={() => resetSearch()}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</SearchInput>
|
||||
</Wrapper>
|
||||
<SearchButton
|
||||
|
|
|
@ -32,7 +32,11 @@ import InProgressIcon from './icons/InProgressIcon';
|
|||
import exportService from 'services/exportService';
|
||||
import { Subscription } from 'services/billingService';
|
||||
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 {
|
||||
collections: Collection[];
|
||||
setDialogMessage: SetDialogMessage;
|
||||
|
@ -50,6 +54,7 @@ export default function Sidebar(props: Props) {
|
|||
const [recoverModalView, setRecoveryModalView] = useState(false);
|
||||
const [twoFactorModalView, setTwoFactorModalView] = useState(false);
|
||||
const [exportModalView, setExportModalView] = useState(false);
|
||||
const [fixLargeThumbsView, setFixLargeThumbsView] = useState(false);
|
||||
const galleryContext = useContext(GalleryContext);
|
||||
useEffect(() => {
|
||||
const main = async () => {
|
||||
|
@ -221,6 +226,14 @@ export default function Sidebar(props: Props) {
|
|||
}}>
|
||||
{constants.ARCHIVE}
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
style={{ marginTop: '30px' }}
|
||||
onClick={() => {
|
||||
galleryContext.setActiveCollection(TRASH_SECTION);
|
||||
setIsOpen(false);
|
||||
}}>
|
||||
{constants.TRASH}
|
||||
</LinkButton>
|
||||
<>
|
||||
<RecoveryKeyModal
|
||||
show={recoverModalView}
|
||||
|
@ -267,6 +280,18 @@ export default function Sidebar(props: Props) {
|
|||
{constants.UPDATE_EMAIL}
|
||||
</LinkButton>
|
||||
<Divider />
|
||||
<>
|
||||
<FixLargeThumbnails
|
||||
isOpen={fixLargeThumbsView}
|
||||
hide={() => setFixLargeThumbsView(false)}
|
||||
show={() => setFixLargeThumbsView(true)}
|
||||
/>
|
||||
<LinkButton
|
||||
style={{ marginTop: '30px' }}
|
||||
onClick={() => setFixLargeThumbsView(true)}>
|
||||
{constants.FIX_LARGE_THUMBNAILS}
|
||||
</LinkButton>
|
||||
</>
|
||||
<LinkButton
|
||||
style={{ marginTop: '30px' }}
|
||||
onClick={openFeedbackURL}>
|
||||
|
@ -320,7 +345,7 @@ export default function Sidebar(props: Props) {
|
|||
onClick={() =>
|
||||
props.setDialogMessage({
|
||||
title: `${constants.DELETE_ACCOUNT}`,
|
||||
content: constants.DELETE_MESSAGE(),
|
||||
content: constants.DELETE_ACCOUNT_MESSAGE(),
|
||||
staticBackdrop: true,
|
||||
proceed: {
|
||||
text: constants.DELETE_ACCOUNT,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function DateIcon(props) {
|
||||
export default function CloseIcon(props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
@ -12,7 +12,7 @@ export default function DateIcon(props) {
|
|||
);
|
||||
}
|
||||
|
||||
DateIcon.defaultProps = {
|
||||
CloseIcon.defaultProps = {
|
||||
height: 24,
|
||||
width: 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 = {
|
||||
height: 28,
|
||||
height: 20,
|
||||
width: 20,
|
||||
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 React from 'react';
|
||||
import { ListGroup, Popover, Row } from 'react-bootstrap';
|
||||
|
@ -23,13 +23,13 @@ const SortByOptionCreator =
|
|||
(
|
||||
<MenuItem>
|
||||
<Row>
|
||||
<Label width="20px">
|
||||
<Value width="20px">
|
||||
{activeSortBy === props.sortBy && (
|
||||
<TickWrapper>
|
||||
<TickIcon />
|
||||
</TickWrapper>
|
||||
)}
|
||||
</Label>
|
||||
</Value>
|
||||
<Value width="165px">
|
||||
<MenuLink
|
||||
onClick={() => setCollectionSortBy(props.sortBy)}
|
||||
|
|
|
@ -24,6 +24,7 @@ import CollectionSort from './CollectionSort';
|
|||
import OptionIcon, { OptionIconWrapper } from './OptionIcon';
|
||||
|
||||
export const ARCHIVE_SECTION = -1;
|
||||
export const TRASH_SECTION = -2;
|
||||
export const ALL_SECTION = 0;
|
||||
|
||||
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 }>`
|
||||
opacity: ${(props) => (props.hide ? '0' : '100')};
|
||||
height: ${(props) => (props.hide ? '0' : 'auto')};
|
||||
|
@ -124,7 +141,7 @@ export default function Collections(props: CollectionProps) {
|
|||
|
||||
useEffect(() => {
|
||||
updateScrollObj();
|
||||
}, [collectionWrapperRef.current, props.isInSearchMode]);
|
||||
}, [collectionWrapperRef.current, props.isInSearchMode, collections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!collectionWrapperRef?.current) {
|
||||
|
@ -146,10 +163,6 @@ export default function Collections(props: CollectionProps) {
|
|||
|
||||
const user: User = getData(LS_KEYS.USER);
|
||||
|
||||
if (!collections || collections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const collectionOptions = CollectionOptions({
|
||||
syncWithRemote: props.syncWithRemote,
|
||||
setCollectionNamerAttributes: props.setCollectionNamerAttributes,
|
||||
|
@ -188,6 +201,8 @@ export default function Collections(props: CollectionProps) {
|
|||
);
|
||||
};
|
||||
|
||||
const SectionChip = SectionChipCreater({ activeCollection, clickHandler });
|
||||
|
||||
return (
|
||||
<Hider hide={props.isInSearchMode}>
|
||||
<CollectionShare
|
||||
|
@ -210,17 +225,10 @@ export default function Collections(props: CollectionProps) {
|
|||
<Wrapper
|
||||
ref={collectionWrapperRef}
|
||||
onScroll={updateScrollObj}>
|
||||
<Chip
|
||||
active={activeCollection === ALL_SECTION}
|
||||
onClick={clickHandler(ALL_SECTION)}>
|
||||
{constants.ALL}
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '24px',
|
||||
}}
|
||||
<SectionChip
|
||||
section={ALL_SECTION}
|
||||
label={constants.ALL}
|
||||
/>
|
||||
</Chip>
|
||||
{sortCollections(
|
||||
collections,
|
||||
props.collectionAndTheirLatestFile,
|
||||
|
@ -262,17 +270,14 @@ export default function Collections(props: CollectionProps) {
|
|||
</Chip>
|
||||
</OverlayTrigger>
|
||||
))}
|
||||
<Chip
|
||||
active={activeCollection === ARCHIVE_SECTION}
|
||||
onClick={clickHandler(ARCHIVE_SECTION)}>
|
||||
{constants.ARCHIVE}
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '24px',
|
||||
}}
|
||||
<SectionChip
|
||||
section={ARCHIVE_SECTION}
|
||||
label={constants.ARCHIVE}
|
||||
/>
|
||||
<SectionChip
|
||||
section={TRASH_SECTION}
|
||||
label={constants.TRASH}
|
||||
/>
|
||||
</Chip>
|
||||
</Wrapper>
|
||||
{scrollObj.scrollLeft <
|
||||
scrollObj.scrollWidth - scrollObj.clientWidth && (
|
||||
|
|
|
@ -15,13 +15,18 @@ interface IProps {
|
|||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
onSelect?: (checked: boolean) => void;
|
||||
onHover?: () => void;
|
||||
onRangeSelect?: () => void;
|
||||
isRangeSelectActive?: boolean;
|
||||
selectOnClick?: boolean;
|
||||
isInsSelectRange?: boolean;
|
||||
}
|
||||
|
||||
const Check = styled.input`
|
||||
const Check = styled.input<{ active: boolean }>`
|
||||
appearance: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
|
@ -34,7 +39,7 @@ const Check = styled.input`
|
|||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #fff;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
background-color: #ddd;
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
vertical-align: bottom;
|
||||
|
@ -43,18 +48,19 @@ const Check = styled.input`
|
|||
line-height: 16px;
|
||||
transition: background-color 0.3s ease;
|
||||
pointer-events: inherit;
|
||||
color: #aaa;
|
||||
}
|
||||
&::after {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 10px;
|
||||
border-right: 2px solid #fff;
|
||||
border-bottom: 2px solid #fff;
|
||||
border-right: 2px solid #333;
|
||||
border-bottom: 2px solid #333;
|
||||
transform: translate(-18px, 8px);
|
||||
opacity: 0;
|
||||
transition: transform 0.3s ease;
|
||||
position: absolute;
|
||||
pointer-events: inherit;
|
||||
transform: translate(-18px, 10px) rotate(45deg);
|
||||
}
|
||||
|
||||
/** checked */
|
||||
|
@ -65,15 +71,50 @@ const Check = styled.input`
|
|||
color: #fff;
|
||||
}
|
||||
&:checked::after {
|
||||
opacity: 1;
|
||||
transform: translate(-18px, 10px) rotate(45deg);
|
||||
content: '';
|
||||
border-right: 2px solid #ddd;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
|
||||
${(props) => props.active && 'opacity: 0.5 '};
|
||||
&: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 }>`
|
||||
background: #222;
|
||||
display: flex;
|
||||
|
@ -107,6 +148,9 @@ const Cont = styled.div<{ disabled: boolean; selected: boolean }>`
|
|||
}
|
||||
|
||||
&:hover ${Check} {
|
||||
opacity: 0.5;
|
||||
}
|
||||
&:hover ${HoverOverlay} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
@ -123,6 +167,10 @@ export default function PreviewCard(props: IProps) {
|
|||
selected,
|
||||
onSelect,
|
||||
selectOnClick,
|
||||
onHover,
|
||||
onRangeSelect,
|
||||
isRangeSelectActive,
|
||||
isInsSelectRange,
|
||||
} = props;
|
||||
const isMounted = useRef(true);
|
||||
useLayoutEffect(() => {
|
||||
|
@ -167,6 +215,9 @@ export default function PreviewCard(props: IProps) {
|
|||
|
||||
const handleClick = () => {
|
||||
if (selectOnClick) {
|
||||
if (isRangeSelectActive) {
|
||||
onRangeSelect();
|
||||
}
|
||||
onSelect?.(!selected);
|
||||
} else if (file?.msrc || imgSrc) {
|
||||
onClick?.();
|
||||
|
@ -174,17 +225,25 @@ export default function PreviewCard(props: IProps) {
|
|||
};
|
||||
|
||||
const handleSelect: React.ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
if (isRangeSelectActive) {
|
||||
onRangeSelect?.();
|
||||
}
|
||||
onSelect?.(e.target.checked);
|
||||
};
|
||||
|
||||
const longPressCallback = () => {
|
||||
onSelect(!selected);
|
||||
};
|
||||
|
||||
const handleHover = () => {
|
||||
if (selectOnClick) {
|
||||
onHover();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Cont
|
||||
id={`thumb-${file?.id}`}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleHover}
|
||||
disabled={!forcedEnable && !file?.msrc && !imgSrc}
|
||||
selected={selected}
|
||||
{...(selectable ? useLongPress(longPressCallback, 500) : {})}>
|
||||
|
@ -193,11 +252,16 @@ export default function PreviewCard(props: IProps) {
|
|||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={handleSelect}
|
||||
active={isRangeSelectActive && isInsSelectRange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
{(file?.msrc || imgSrc) && <img src={file?.msrc || imgSrc} />}
|
||||
{file?.metadata.fileType === 1 && <PlayCircleOutline />}
|
||||
<HoverOverlay checked={selected} />
|
||||
<InSelectRangeOverLay
|
||||
active={isRangeSelectActive && isInsSelectRange}
|
||||
/>
|
||||
</Cont>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,31 +4,29 @@ import { SetCollectionSelectorAttributes } from './CollectionSelector';
|
|||
import styled from 'styled-components';
|
||||
import Navbar from 'components/Navbar';
|
||||
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 { IconButton } from 'components/Container';
|
||||
import constants from 'utils/strings/constants';
|
||||
import Archive from 'components/icons/Archive';
|
||||
import MoveIcon from 'components/icons/MoveIcon';
|
||||
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 { OverlayTrigger } from 'react-bootstrap';
|
||||
import { Collection } from 'services/collectionService';
|
||||
import RemoveIcon from 'components/icons/RemoveIcon';
|
||||
import RestoreIcon from 'components/icons/RestoreIcon';
|
||||
|
||||
interface Props {
|
||||
addToCollectionHelper: (
|
||||
collectionName: string,
|
||||
collection: Collection
|
||||
) => void;
|
||||
moveToCollectionHelper: (
|
||||
collectionName: string,
|
||||
collection: Collection
|
||||
) => void;
|
||||
addToCollectionHelper: (collection: Collection) => void;
|
||||
moveToCollectionHelper: (collection: Collection) => void;
|
||||
restoreToCollectionHelper: (collection: Collection) => void;
|
||||
showCreateCollectionModal: (opsType: COLLECTION_OPS_TYPE) => () => void;
|
||||
setDialogMessage: SetDialogMessage;
|
||||
setCollectionSelectorAttributes: SetCollectionSelectorAttributes;
|
||||
deleteFileHelper: () => void;
|
||||
deleteFileHelper: (permanent?: boolean) => void;
|
||||
removeFromCollectionHelper: () => void;
|
||||
count: number;
|
||||
clearSelection: () => void;
|
||||
archiveFilesHelper: () => void;
|
||||
|
@ -52,7 +50,11 @@ const SelectionContainer = styled.div`
|
|||
display: flex;
|
||||
`;
|
||||
|
||||
export const IconWithMessage = (props) => (
|
||||
interface IconWithMessageProps {
|
||||
children?: any;
|
||||
message: string;
|
||||
}
|
||||
export const IconWithMessage = (props: IconWithMessageProps) => (
|
||||
<OverlayTrigger
|
||||
placement="bottom"
|
||||
overlay={<p style={{ zIndex: 1002 }}>{props.message}</p>}>
|
||||
|
@ -63,7 +65,9 @@ export const IconWithMessage = (props) => (
|
|||
const SelectedFileOptions = ({
|
||||
addToCollectionHelper,
|
||||
moveToCollectionHelper,
|
||||
restoreToCollectionHelper,
|
||||
showCreateCollectionModal,
|
||||
removeFromCollectionHelper,
|
||||
setDialogMessage,
|
||||
setCollectionSelectorAttributes,
|
||||
deleteFileHelper,
|
||||
|
@ -76,28 +80,63 @@ const SelectedFileOptions = ({
|
|||
}: Props) => {
|
||||
const addToCollection = () =>
|
||||
setCollectionSelectorAttributes({
|
||||
callback: (collection) => addToCollectionHelper(null, collection),
|
||||
callback: addToCollectionHelper,
|
||||
showNextModal: showCreateCollectionModal(COLLECTION_OPS_TYPE.ADD),
|
||||
title: constants.ADD_TO_COLLECTION,
|
||||
fromCollection: activeCollection,
|
||||
});
|
||||
|
||||
const deleteHandler = () =>
|
||||
const trashHandler = () =>
|
||||
setDialogMessage({
|
||||
title: constants.CONFIRM_DELETE_FILE,
|
||||
content: constants.DELETE_FILE_MESSAGE,
|
||||
title: constants.CONFIRM_DELETE,
|
||||
content: constants.TRASH_MESSAGE,
|
||||
staticBackdrop: true,
|
||||
proceed: {
|
||||
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,
|
||||
variant: 'danger',
|
||||
},
|
||||
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 = () => {
|
||||
setCollectionSelectorAttributes({
|
||||
callback: (collection) => moveToCollectionHelper(null, collection),
|
||||
callback: moveToCollectionHelper,
|
||||
showNextModal: showCreateCollectionModal(COLLECTION_OPS_TYPE.MOVE),
|
||||
title: constants.MOVE_TO_COLLECTION,
|
||||
fromCollection: activeCollection,
|
||||
|
@ -108,12 +147,27 @@ const SelectedFileOptions = ({
|
|||
<SelectionBar>
|
||||
<SelectionContainer>
|
||||
<IconButton onClick={clearSelection}>
|
||||
<CrossIcon />
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<div>
|
||||
{count} {constants.SELECTED}
|
||||
</div>
|
||||
</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 && (
|
||||
<IconWithMessage message={constants.UNARCHIVE}>
|
||||
<IconButton onClick={unArchiveFilesHelper}>
|
||||
|
@ -121,7 +175,6 @@ const SelectedFileOptions = ({
|
|||
</IconButton>
|
||||
</IconWithMessage>
|
||||
)}
|
||||
|
||||
{activeCollection === ALL_SECTION && (
|
||||
<IconWithMessage message={constants.ARCHIVE}>
|
||||
<IconButton onClick={archiveFilesHelper}>
|
||||
|
@ -129,25 +182,36 @@ const SelectedFileOptions = ({
|
|||
</IconButton>
|
||||
</IconWithMessage>
|
||||
)}
|
||||
{activeCollection !== ALL_SECTION &&
|
||||
activeCollection !== ARCHIVE_SECTION &&
|
||||
!isFavoriteCollection && (
|
||||
<IconWithMessage message={constants.MOVE}>
|
||||
<IconButton onClick={moveToCollection}>
|
||||
<MoveIcon />
|
||||
</IconButton>
|
||||
</IconWithMessage>
|
||||
)}
|
||||
<IconWithMessage message={constants.ADD}>
|
||||
<IconButton onClick={addToCollection}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</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}>
|
||||
<IconButton onClick={deleteHandler}>
|
||||
<IconButton onClick={trashHandler}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</IconWithMessage>
|
||||
</>
|
||||
)}
|
||||
</SelectionBar>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -117,6 +117,12 @@ const GlobalStyles = createGlobalStyle`
|
|||
.pswp__img {
|
||||
object-fit: contain;
|
||||
}
|
||||
.pswp__caption{
|
||||
font-size:20px;
|
||||
height:10%;
|
||||
padding-left:5%;
|
||||
color:#eee;
|
||||
}
|
||||
|
||||
.modal {
|
||||
z-index: 2000;
|
||||
|
@ -404,6 +410,42 @@ const GlobalStyles = createGlobalStyle`
|
|||
.tooltip-inner{
|
||||
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`
|
||||
|
|
|
@ -10,10 +10,11 @@ import { clearKeys, getKey, SESSION_KEYS } from 'utils/storage/sessionStorage';
|
|||
import {
|
||||
File,
|
||||
getLocalFiles,
|
||||
deleteFiles,
|
||||
syncFiles,
|
||||
updateMagicMetadata,
|
||||
VISIBILITY_STATE,
|
||||
trashFiles,
|
||||
deleteFromTrash,
|
||||
} from 'services/fileService';
|
||||
import styled from 'styled-components';
|
||||
import LoadingBar from 'react-top-loading-bar';
|
||||
|
@ -25,6 +26,8 @@ import {
|
|||
getFavItemIds,
|
||||
getLocalCollections,
|
||||
getNonEmptyCollections,
|
||||
createCollection,
|
||||
CollectionType,
|
||||
} from 'services/collectionService';
|
||||
import constants from 'utils/strings/constants';
|
||||
import billingService from 'services/billingService';
|
||||
|
@ -47,7 +50,9 @@ import { LoadingOverlay } from 'components/LoadingOverlay';
|
|||
import PhotoFrame from 'components/PhotoFrame';
|
||||
import {
|
||||
changeFilesVisibility,
|
||||
getSelectedFileIds,
|
||||
getSelectedFiles,
|
||||
mergeMetadata,
|
||||
sortFiles,
|
||||
sortFilesIntoCollections,
|
||||
} from 'utils/file';
|
||||
import SearchBar, { DateValue } from 'components/SearchBar';
|
||||
|
@ -66,17 +71,28 @@ import Upload from 'components/pages/gallery/Upload';
|
|||
import Collections, {
|
||||
ALL_SECTION,
|
||||
ARCHIVE_SECTION,
|
||||
TRASH_SECTION,
|
||||
} from 'components/pages/gallery/Collections';
|
||||
import { AppContext } from 'pages/_app';
|
||||
import { CustomError, ServerErrorCodes } from 'utils/common/errorUtil';
|
||||
import { PAGES } from 'types';
|
||||
import {
|
||||
copyOrMoveFromCollection,
|
||||
COLLECTION_OPS_TYPE,
|
||||
isSharedCollection,
|
||||
handleCollectionOps,
|
||||
getSelectedCollection,
|
||||
isFavoriteCollection,
|
||||
} from 'utils/collection';
|
||||
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`
|
||||
flex: 1;
|
||||
|
@ -118,6 +134,7 @@ type GalleryContextType = {
|
|||
files: Map<number, string>;
|
||||
showPlanSelectorModal: () => void;
|
||||
setActiveCollection: (collection: number) => void;
|
||||
syncWithRemote: (force?: boolean, silent?: boolean) => Promise<void>;
|
||||
};
|
||||
|
||||
const defaultGalleryContext: GalleryContextType = {
|
||||
|
@ -125,6 +142,7 @@ const defaultGalleryContext: GalleryContextType = {
|
|||
files: new Map(),
|
||||
showPlanSelectorModal: () => null,
|
||||
setActiveCollection: () => null,
|
||||
syncWithRemote: () => null,
|
||||
};
|
||||
|
||||
export const GalleryContext = createContext<GalleryContextType>(
|
||||
|
@ -185,6 +203,7 @@ export default function Gallery() {
|
|||
const [collectionFilesCount, setCollectionFilesCount] =
|
||||
useState<Map<number, number>>();
|
||||
const [activeCollection, setActiveCollection] = useState<number>(undefined);
|
||||
const [trash, setTrash] = useState<Trash>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const key = getKey(SESSION_KEYS.ENCRYPTION_KEY);
|
||||
|
@ -200,9 +219,13 @@ export default function Gallery() {
|
|||
setPlanModalView(true);
|
||||
}
|
||||
setIsFirstLogin(false);
|
||||
const files = await getLocalFiles();
|
||||
const files = mergeMetadata(await getLocalFiles());
|
||||
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 checkSubscriptionPurchase(
|
||||
setDialogMessage,
|
||||
|
@ -237,6 +260,8 @@ export default function Gallery() {
|
|||
collectionURL += '?collection=';
|
||||
if (activeCollection === ARCHIVE_SECTION) {
|
||||
collectionURL += constants.ARCHIVE;
|
||||
} else if (activeCollection === TRASH_SECTION) {
|
||||
collectionURL += constants.TRASH;
|
||||
} else {
|
||||
collectionURL += activeCollection;
|
||||
}
|
||||
|
@ -260,8 +285,10 @@ export default function Gallery() {
|
|||
await billingService.syncSubscription();
|
||||
const collections = await syncCollections();
|
||||
setCollections(collections);
|
||||
const { files } = await syncFiles(collections, setFiles);
|
||||
const files = await syncFiles(collections, setFiles);
|
||||
await setDerivativeState(collections, files);
|
||||
const trash = await syncTrash(collections, setFiles, files);
|
||||
setTrash(trash);
|
||||
} catch (e) {
|
||||
switch (e.message) {
|
||||
case ServerErrorCodes.SESSION_EXPIRED:
|
||||
|
@ -297,21 +324,21 @@ export default function Gallery() {
|
|||
collections: Collection[],
|
||||
files: File[]
|
||||
) => {
|
||||
const favItemIds = await getFavItemIds(files);
|
||||
setFavItemIds(favItemIds);
|
||||
const nonEmptyCollections = getNonEmptyCollections(collections, files);
|
||||
setCollections(nonEmptyCollections);
|
||||
const collectionsAndTheirLatestFile = getCollectionsAndTheirLatestFile(
|
||||
nonEmptyCollections,
|
||||
files
|
||||
);
|
||||
setCollectionsAndTheirLatestFile(collectionsAndTheirLatestFile);
|
||||
const collectionWiseFiles = sortFilesIntoCollections(files);
|
||||
const collectionFilesCount = new Map<number, number>();
|
||||
for (const [id, files] of collectionWiseFiles) {
|
||||
collectionFilesCount.set(id, files.length);
|
||||
}
|
||||
setCollections(nonEmptyCollections);
|
||||
setCollectionsAndTheirLatestFile(collectionsAndTheirLatestFile);
|
||||
setCollectionFilesCount(collectionFilesCount);
|
||||
const favItemIds = await getFavItemIds(files);
|
||||
setFavItemIds(favItemIds);
|
||||
};
|
||||
|
||||
const clearSelection = function () {
|
||||
|
@ -321,24 +348,21 @@ export default function Gallery() {
|
|||
if (!files) {
|
||||
return <div />;
|
||||
}
|
||||
const addToCollectionHelper = async (
|
||||
collectionName: string,
|
||||
collection: Collection
|
||||
) => {
|
||||
const collectionOpsHelper =
|
||||
(ops: COLLECTION_OPS_TYPE) => async (collection: Collection) => {
|
||||
loadingBar.current?.continuousStart();
|
||||
try {
|
||||
await copyOrMoveFromCollection(
|
||||
COLLECTION_OPS_TYPE.ADD,
|
||||
await handleCollectionOps(
|
||||
ops,
|
||||
setCollectionSelectorView,
|
||||
selected,
|
||||
files,
|
||||
|
||||
setActiveCollection,
|
||||
collectionName,
|
||||
collection
|
||||
);
|
||||
clearSelection();
|
||||
} catch (e) {
|
||||
logError(e, 'collection ops failed', { ops });
|
||||
setDialogMessage({
|
||||
title: constants.ERROR,
|
||||
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 (
|
||||
visibility: VISIBILITY_STATE
|
||||
) => {
|
||||
|
@ -393,6 +388,7 @@ export default function Gallery() {
|
|||
await updateMagicMetadata(updatedFiles);
|
||||
clearSelection();
|
||||
} catch (e) {
|
||||
logError(e, 'change file visibility failed');
|
||||
switch (e.status?.toString()) {
|
||||
case ServerErrorCodes.FORBIDDEN:
|
||||
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 {
|
||||
let callback = null;
|
||||
switch (opsType) {
|
||||
case COLLECTION_OPS_TYPE.ADD:
|
||||
callback = (collectionName: string) =>
|
||||
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'
|
||||
const collection = await createCollection(
|
||||
collectionName,
|
||||
CollectionType.album,
|
||||
collections
|
||||
);
|
||||
|
||||
await collectionOpsHelper(ops)(collection);
|
||||
} catch (e) {
|
||||
logError(e, 'create and collection ops failed');
|
||||
setDialogMessage({
|
||||
title: constants.ERROR,
|
||||
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();
|
||||
try {
|
||||
const fileIds = getSelectedFileIds(selected);
|
||||
await deleteFiles(fileIds);
|
||||
setDeleted([...deleted, ...fileIds]);
|
||||
const selectedFiles = getSelectedFiles(selected, files);
|
||||
if (permanent) {
|
||||
await deleteFromTrash(selectedFiles.map((file) => file.id));
|
||||
setDeleted([
|
||||
...deleted,
|
||||
...selectedFiles.map((file) => file.id),
|
||||
]);
|
||||
} else {
|
||||
await trashFiles(selectedFiles);
|
||||
}
|
||||
clearSelection();
|
||||
} catch (e) {
|
||||
switch (e.status?.toString()) {
|
||||
|
@ -493,12 +489,47 @@ export default function Gallery() {
|
|||
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 (
|
||||
<GalleryContext.Provider
|
||||
value={{
|
||||
...defaultGalleryContext,
|
||||
showPlanSelectorModal: () => setPlanModalView(true),
|
||||
setActiveCollection,
|
||||
syncWithRemote,
|
||||
}}>
|
||||
<FullScreenDropZone
|
||||
getRootProps={getRootProps}
|
||||
|
@ -556,10 +587,7 @@ export default function Gallery() {
|
|||
attributes={collectionNamerAttributes}
|
||||
/>
|
||||
<CollectionSelector
|
||||
show={
|
||||
collectionSelectorView &&
|
||||
!(collectionsAndTheirLatestFile?.length === 0)
|
||||
}
|
||||
show={collectionSelectorView}
|
||||
onHide={closeCollectionSelector}
|
||||
collectionsAndTheirLatestFile={
|
||||
collectionsAndTheirLatestFile
|
||||
|
@ -622,7 +650,9 @@ export default function Gallery() {
|
|||
{selected.count > 0 &&
|
||||
selected.collectionID === activeCollection && (
|
||||
<SelectedFileOptions
|
||||
addToCollectionHelper={addToCollectionHelper}
|
||||
addToCollectionHelper={collectionOpsHelper(
|
||||
COLLECTION_OPS_TYPE.ADD
|
||||
)}
|
||||
archiveFilesHelper={() =>
|
||||
changeFilesVisibilityHelper(
|
||||
VISIBILITY_STATE.ARCHIVED
|
||||
|
@ -633,7 +663,12 @@ export default function Gallery() {
|
|||
VISIBILITY_STATE.VISIBLE
|
||||
)
|
||||
}
|
||||
moveToCollectionHelper={moveToCollectionHelper}
|
||||
moveToCollectionHelper={collectionOpsHelper(
|
||||
COLLECTION_OPS_TYPE.MOVE
|
||||
)}
|
||||
restoreToCollectionHelper={collectionOpsHelper(
|
||||
COLLECTION_OPS_TYPE.RESTORE
|
||||
)}
|
||||
showCreateCollectionModal={
|
||||
showCreateCollectionModal
|
||||
}
|
||||
|
@ -642,6 +677,14 @@ export default function Gallery() {
|
|||
setCollectionSelectorAttributes
|
||||
}
|
||||
deleteFileHelper={deleteFileHelper}
|
||||
removeFromCollectionHelper={() =>
|
||||
collectionOpsHelper(COLLECTION_OPS_TYPE.REMOVE)(
|
||||
getSelectedCollection(
|
||||
activeCollection,
|
||||
collections
|
||||
)
|
||||
)
|
||||
}
|
||||
count={selected.count}
|
||||
clearSelection={clearSelection}
|
||||
activeCollection={activeCollection}
|
||||
|
@ -651,6 +694,9 @@ export default function Gallery() {
|
|||
)}
|
||||
/>
|
||||
)}
|
||||
{activeCollection === TRASH_SECTION && trash?.length > 0 && (
|
||||
<DeleteBtn onClick={emptyTrashHandler} />
|
||||
)}
|
||||
</FullScreenDropZone>
|
||||
</GalleryContext.Provider>
|
||||
);
|
||||
|
|
|
@ -75,6 +75,11 @@ export enum COLLECTION_SORT_BY {
|
|||
NAME,
|
||||
}
|
||||
|
||||
interface RemoveFromCollectionRequest {
|
||||
collectionID: number;
|
||||
fileIDs: number[];
|
||||
}
|
||||
|
||||
const getCollectionWithSecrets = async (
|
||||
collection: Collection,
|
||||
masterKey: string
|
||||
|
@ -212,8 +217,28 @@ export const syncCollections = async () => {
|
|||
return collections;
|
||||
};
|
||||
|
||||
export const setLocalCollection = async (collections: Collection[]) => {
|
||||
await localForage.setItem(COLLECTIONS, collections);
|
||||
export const getCollection = async (
|
||||
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 = (
|
||||
|
@ -389,6 +414,33 @@ export const addToCollection = async (
|
|||
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 (
|
||||
fromCollectionID: number,
|
||||
toCollection: Collection,
|
||||
|
@ -440,22 +492,20 @@ const encryptWithNewCollectionKey = async (
|
|||
}
|
||||
return fileKeysEncryptedWithNewCollection;
|
||||
};
|
||||
const removeFromCollection = async (collection: Collection, files: File[]) => {
|
||||
export const removeFromCollection = async (
|
||||
collection: Collection,
|
||||
files: File[]
|
||||
) => {
|
||||
try {
|
||||
const params = {};
|
||||
const token = getToken();
|
||||
params['collectionID'] = collection.id;
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
if (params['fileIDs'] === undefined) {
|
||||
params['fileIDs'] = [];
|
||||
}
|
||||
params['fileIDs'].push(file.id);
|
||||
})
|
||||
);
|
||||
const request: RemoveFromCollectionRequest = {
|
||||
collectionID: collection.id,
|
||||
fileIDs: files.map((file) => file.id),
|
||||
};
|
||||
|
||||
await HTTPService.post(
|
||||
`${ENDPOINT}/collections/remove-files`,
|
||||
params,
|
||||
`${ENDPOINT}/collections/v2/remove-files`,
|
||||
request,
|
||||
null,
|
||||
{ 'X-Auth-Token': token }
|
||||
);
|
||||
|
@ -475,7 +525,7 @@ export const deleteCollection = async (
|
|||
const token = getToken();
|
||||
|
||||
await HTTPService.delete(
|
||||
`${ENDPOINT}/collections/${collectionID}`,
|
||||
`${ENDPOINT}/collections/v2/${collectionID}`,
|
||||
null,
|
||||
null,
|
||||
{ 'X-Auth-Token': token }
|
||||
|
|
|
@ -44,6 +44,19 @@ class DownloadManager {
|
|||
thumbnailCache: Cache,
|
||||
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(
|
||||
getThumbnailUrl(file.id),
|
||||
null,
|
||||
|
@ -51,20 +64,12 @@ class DownloadManager {
|
|||
{ responseType: 'arraybuffer' }
|
||||
);
|
||||
const worker = await new CryptoWorker();
|
||||
const decrypted: any = await worker.decryptThumbnail(
|
||||
const decrypted: Uint8Array = await worker.decryptThumbnail(
|
||||
new Uint8Array(resp.data),
|
||||
await worker.fromB64(file.thumbnail.decryptionHeader),
|
||||
file.key
|
||||
);
|
||||
try {
|
||||
await thumbnailCache.put(
|
||||
file.id.toString(),
|
||||
new Response(new Blob([decrypted]))
|
||||
);
|
||||
} catch (e) {
|
||||
// TODO: handle storage full exception.
|
||||
}
|
||||
return URL.createObjectURL(new Blob([decrypted]));
|
||||
return decrypted;
|
||||
};
|
||||
|
||||
getFile = async (file: File, forPreview = false) => {
|
||||
|
|
|
@ -2,16 +2,24 @@ import { getEndpoint } from 'utils/common/apiUtil';
|
|||
import localForage from 'utils/storage/localForage';
|
||||
|
||||
import { getToken } from 'utils/common/key';
|
||||
import { DataStream, MetadataObject } from './upload/uploadService';
|
||||
import {
|
||||
DataStream,
|
||||
EncryptionResult,
|
||||
MetadataObject,
|
||||
} from './upload/uploadService';
|
||||
import { Collection } from './collectionService';
|
||||
import HTTPService from './HTTPService';
|
||||
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 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 {
|
||||
encryptedData?: DataStream | Uint8Array;
|
||||
|
@ -41,15 +49,35 @@ export enum VISIBILITY_STATE {
|
|||
VISIBLE,
|
||||
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 {
|
||||
visibility?: VISIBILITY_STATE;
|
||||
}
|
||||
export interface MagicMetadata {
|
||||
version: number;
|
||||
count: number;
|
||||
data: string | MagicMetadataProps;
|
||||
header: string;
|
||||
|
||||
export interface MagicMetadata extends Omit<MagicMetadataCore, 'data'> {
|
||||
data: MagicMetadataProps;
|
||||
}
|
||||
|
||||
export interface PublicMagicMetadataProps {
|
||||
editedTime?: number;
|
||||
}
|
||||
|
||||
export interface PublicMagicMetadata extends Omit<MagicMetadataCore, 'data'> {
|
||||
data: PublicMagicMetadataProps;
|
||||
}
|
||||
|
||||
export interface File {
|
||||
id: number;
|
||||
collectionID: number;
|
||||
|
@ -58,6 +86,7 @@ export interface File {
|
|||
thumbnail: fileAttribute;
|
||||
metadata: MetadataObject;
|
||||
magicMetadata: MagicMetadata;
|
||||
pubMagicMetadata: PublicMagicMetadata;
|
||||
encryptedKey: string;
|
||||
keyDecryptionNonce: string;
|
||||
key: string;
|
||||
|
@ -67,6 +96,8 @@ export interface File {
|
|||
w: number;
|
||||
h: number;
|
||||
isDeleted: boolean;
|
||||
isTrashed?: boolean;
|
||||
deleteBy?: number;
|
||||
dataIndex: number;
|
||||
updationTime: number;
|
||||
}
|
||||
|
@ -74,23 +105,40 @@ export interface File {
|
|||
interface UpdateMagicMetadataRequest {
|
||||
metadataList: UpdateMagicMetadata[];
|
||||
}
|
||||
|
||||
interface UpdateMagicMetadata {
|
||||
id: number;
|
||||
magicMetadata: MagicMetadata;
|
||||
magicMetadata: EncryptedMagicMetadataCore;
|
||||
}
|
||||
|
||||
export const NEW_MAGIC_METADATA: MagicMetadata = {
|
||||
export const NEW_MAGIC_METADATA: MagicMetadataCore = {
|
||||
version: 0,
|
||||
data: {},
|
||||
header: null,
|
||||
count: 0,
|
||||
};
|
||||
|
||||
interface TrashRequest {
|
||||
items: TrashRequestItems[];
|
||||
}
|
||||
|
||||
interface TrashRequestItems {
|
||||
fileID: number;
|
||||
collectionID: number;
|
||||
}
|
||||
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;
|
||||
};
|
||||
|
||||
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 (
|
||||
collections: Collection[],
|
||||
setFiles: (files: File[]) => void
|
||||
|
@ -98,26 +146,19 @@ export const syncFiles = async (
|
|||
const localFiles = await getLocalFiles();
|
||||
let files = await removeDeletedCollectionFiles(collections, localFiles);
|
||||
if (files.length !== localFiles.length) {
|
||||
await localForage.setItem('files', files);
|
||||
setFiles(files);
|
||||
await setLocalFiles(files);
|
||||
setFiles(sortFiles(mergeMetadata(files)));
|
||||
}
|
||||
for (const collection of collections) {
|
||||
if (!getToken()) {
|
||||
continue;
|
||||
}
|
||||
const lastSyncTime =
|
||||
(await localForage.getItem<number>(`${collection.id}-time`)) ?? 0;
|
||||
const lastSyncTime = await getCollectionLastSyncTime(collection);
|
||||
if (collection.updationTime === lastSyncTime) {
|
||||
continue;
|
||||
}
|
||||
const fetchedFiles =
|
||||
(await getFiles(
|
||||
collection,
|
||||
lastSyncTime,
|
||||
DIFF_LIMIT,
|
||||
files,
|
||||
setFiles
|
||||
)) ?? [];
|
||||
(await getFiles(collection, lastSyncTime, files, setFiles)) ?? [];
|
||||
files.push(...fetchedFiles);
|
||||
const latestVersionFiles = new Map<string, File>();
|
||||
files.forEach((file) => {
|
||||
|
@ -137,42 +178,25 @@ export const syncFiles = async (
|
|||
}
|
||||
files.push(file);
|
||||
}
|
||||
files = sortFiles(files);
|
||||
await localForage.setItem('files', files);
|
||||
await setLocalFiles(files);
|
||||
await localForage.setItem(
|
||||
`${collection.id}-time`,
|
||||
collection.updationTime
|
||||
);
|
||||
setFiles(
|
||||
files.map((item) => ({
|
||||
...item,
|
||||
w: window.innerWidth,
|
||||
h: window.innerHeight,
|
||||
}))
|
||||
);
|
||||
setFiles(sortFiles(mergeMetadata(files)));
|
||||
}
|
||||
return {
|
||||
files: files.map((item) => ({
|
||||
...item,
|
||||
w: window.innerWidth,
|
||||
h: window.innerHeight,
|
||||
})),
|
||||
};
|
||||
return mergeMetadata(files);
|
||||
};
|
||||
|
||||
export const getFiles = async (
|
||||
collection: Collection,
|
||||
sinceTime: number,
|
||||
limit: number,
|
||||
files: File[],
|
||||
setFiles: (files: File[]) => void
|
||||
): Promise<File[]> => {
|
||||
try {
|
||||
const decryptedFiles: File[] = [];
|
||||
let time =
|
||||
sinceTime ||
|
||||
(await localForage.getItem<number>(`${collection.id}-time`)) ||
|
||||
0;
|
||||
let time = sinceTime;
|
||||
let resp;
|
||||
do {
|
||||
const token = getToken();
|
||||
|
@ -180,11 +204,10 @@ export const getFiles = async (
|
|||
break;
|
||||
}
|
||||
resp = await HTTPService.get(
|
||||
`${ENDPOINT}/collections/diff`,
|
||||
`${ENDPOINT}/collections/v2/diff`,
|
||||
{
|
||||
collectionID: collection.id,
|
||||
sinceTime: time,
|
||||
limit,
|
||||
},
|
||||
{
|
||||
'X-Auth-Token': token,
|
||||
|
@ -206,14 +229,15 @@ export const getFiles = async (
|
|||
time = resp.data.diff.slice(-1)[0].updationTime;
|
||||
}
|
||||
setFiles(
|
||||
[...(files || []), ...decryptedFiles]
|
||||
.filter((item) => !item.isDeleted)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.metadata.creationTime - a.metadata.creationTime
|
||||
sortFiles(
|
||||
mergeMetadata(
|
||||
[...(files || []), ...decryptedFiles].filter(
|
||||
(item) => !item.isDeleted
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
} while (resp.data.diff.length === limit);
|
||||
} while (resp.data.hasMore);
|
||||
return decryptedFiles;
|
||||
} catch (e) {
|
||||
logError(e, 'Get files failed');
|
||||
|
@ -232,14 +256,35 @@ const removeDeletedCollectionFiles = async (
|
|||
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 {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
await HTTPService.post(
|
||||
`${ENDPOINT}/files/delete`,
|
||||
`${ENDPOINT}/trash/delete`,
|
||||
{ fileIDs: filesToDelete },
|
||||
null,
|
||||
{
|
||||
|
@ -247,7 +292,7 @@ export const deleteFiles = async (filesToDelete: number[]) => {
|
|||
}
|
||||
);
|
||||
} catch (e) {
|
||||
logError(e, 'delete failed');
|
||||
logError(e, 'delete from trash failed');
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
@ -258,13 +303,69 @@ export const updateMagicMetadata = async (files: File[]) => {
|
|||
return;
|
||||
}
|
||||
const reqBody: UpdateMagicMetadataRequest = { metadataList: [] };
|
||||
const worker = await new CryptoWorker();
|
||||
for (const file of files) {
|
||||
const { file: encryptedMagicMetadata }: EncryptionResult =
|
||||
await worker.encryptMetadata(file.magicMetadata.data, file.key);
|
||||
reqBody.metadataList.push({
|
||||
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, {
|
||||
'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 { logError } from 'utils/sentry';
|
||||
import { NULL_LOCATION, Location } from './metadataService';
|
||||
|
||||
const EXIF_TAGS_NEEDED = [
|
||||
|
@ -20,7 +19,6 @@ interface ParsedEXIFData {
|
|||
export async function getExifData(
|
||||
receivedFile: globalThis.File
|
||||
): Promise<ParsedEXIFData> {
|
||||
try {
|
||||
const exifData = await exifr.parse(receivedFile, EXIF_TAGS_NEEDED);
|
||||
if (!exifData) {
|
||||
return { location: NULL_LOCATION, creationTime: null };
|
||||
|
@ -30,10 +28,6 @@ export async function getExifData(
|
|||
creationTime: getUNIXTime(exifData),
|
||||
};
|
||||
return parsedEXIFData;
|
||||
} catch (e) {
|
||||
logError(e, 'error reading exif data');
|
||||
// ignore exif parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
function getUNIXTime(exifData: any) {
|
||||
|
|
|
@ -34,7 +34,14 @@ export async function extractMetadata(
|
|||
) {
|
||||
let exifData = null;
|
||||
if (fileTypeInfo.fileType === FILE_TYPE.IMAGE) {
|
||||
try {
|
||||
exifData = await getExifData(receivedFile);
|
||||
} catch (e) {
|
||||
logError(e, 'file missing exif data ', {
|
||||
fileType: fileTypeInfo.exactType,
|
||||
});
|
||||
// ignore exif parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
const extractedMetadata: MetadataObject = {
|
||||
|
|
|
@ -58,7 +58,7 @@ export async function getFileType(
|
|||
logError(e, CustomError.TYPE_DETECTION_FAILED, {
|
||||
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 FFmpegService from 'services/ffmpegService';
|
||||
import { convertToHumanReadable } from 'utils/billingUtil';
|
||||
import { fileIsHEIC } from 'utils/file';
|
||||
import { FileTypeInfo } from './readFileService';
|
||||
|
||||
const MAX_THUMBNAIL_DIMENSION = 720;
|
||||
const MIN_COMPRESSION_PERCENTAGE_SIZE_DIFF = 10;
|
||||
|
@ -21,15 +23,15 @@ interface Dimension {
|
|||
export async function generateThumbnail(
|
||||
worker,
|
||||
file: globalThis.File,
|
||||
fileType: FILE_TYPE,
|
||||
isHEIC: boolean
|
||||
fileTypeInfo: FileTypeInfo
|
||||
): Promise<{ thumbnail: Uint8Array; hasStaticThumbnail: boolean }> {
|
||||
try {
|
||||
let hasStaticThumbnail = false;
|
||||
let canvas = document.createElement('canvas');
|
||||
let thumbnail: Uint8Array;
|
||||
try {
|
||||
if (fileType === FILE_TYPE.IMAGE) {
|
||||
if (fileTypeInfo.fileType === FILE_TYPE.IMAGE) {
|
||||
const isHEIC = fileIsHEIC(fileTypeInfo.exactType);
|
||||
canvas = await generateImageThumbnail(worker, file, isHEIC);
|
||||
} else {
|
||||
try {
|
||||
|
@ -38,9 +40,12 @@ export async function generateThumbnail(
|
|||
canvas = await generateImageThumbnail(
|
||||
worker,
|
||||
dummyImageFile,
|
||||
isHEIC
|
||||
false
|
||||
);
|
||||
} catch (e) {
|
||||
logError(e, 'failed to generate thumbnail using ffmpeg', {
|
||||
type: fileTypeInfo.exactType,
|
||||
});
|
||||
canvas = await generateVideoThumbnail(file);
|
||||
}
|
||||
}
|
||||
|
@ -50,7 +55,9 @@ export async function generateThumbnail(
|
|||
throw Error('EMPTY THUMBNAIL');
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e, 'uploading static thumbnail');
|
||||
logError(e, 'uploading static thumbnail', {
|
||||
type: fileTypeInfo.exactType,
|
||||
});
|
||||
thumbnail = Uint8Array.from(atob(BLACK_THUMBNAIL_BASE64), (c) =>
|
||||
c.charCodeAt(0)
|
||||
);
|
||||
|
@ -116,14 +123,7 @@ export async function generateImageThumbnail(
|
|||
}
|
||||
};
|
||||
timeout = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
Error(
|
||||
`wait time exceeded for format ${
|
||||
file.name.split('.').slice(-1)[0]
|
||||
}`
|
||||
)
|
||||
),
|
||||
() => reject(Error(CustomError.WAIT_TIME_EXCEEDED)),
|
||||
WAIT_TIME_THUMBNAIL_GENERATION
|
||||
);
|
||||
});
|
||||
|
@ -176,14 +176,7 @@ export async function generateVideoThumbnail(file: globalThis.File) {
|
|||
video.preload = 'metadata';
|
||||
video.src = videoURL;
|
||||
timeout = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
Error(
|
||||
`wait time exceeded for format ${
|
||||
file.name.split('.').slice(-1)[0]
|
||||
}`
|
||||
)
|
||||
),
|
||||
() => reject(Error(CustomError.WAIT_TIME_EXCEEDED)),
|
||||
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 { SetFiles } from 'pages/gallery';
|
||||
import { ComlinkWorker, getDedicatedCryptoWorker } from 'utils/crypto';
|
||||
|
@ -8,7 +8,6 @@ import {
|
|||
removeUnnecessaryFileProps,
|
||||
} from 'utils/file';
|
||||
import { logError } from 'utils/sentry';
|
||||
import localForage from 'utils/storage/localForage';
|
||||
import {
|
||||
getMetadataMapKey,
|
||||
ParsedMetaDataJSON,
|
||||
|
@ -185,8 +184,7 @@ class UploadManager {
|
|||
if (fileUploadResult === FileUploadResults.UPLOADED) {
|
||||
this.existingFiles.push(file);
|
||||
this.existingFiles = sortFiles(this.existingFiles);
|
||||
await localForage.setItem(
|
||||
'files',
|
||||
await setLocalFiles(
|
||||
removeUnnecessaryFileProps(this.existingFiles)
|
||||
);
|
||||
this.setFiles(this.existingFiles);
|
||||
|
|
|
@ -19,7 +19,6 @@ import { uploadStreamUsingMultipart } from './multiPartUploadService';
|
|||
import UIService from './uiService';
|
||||
import { handleUploadError } from 'utils/common/errorUtil';
|
||||
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.
|
||||
export const MULTIPART_PART_SIZE = 20 * 1024 * 1024;
|
||||
|
@ -108,13 +107,10 @@ class UploadService {
|
|||
rawFile: globalThis.File,
|
||||
fileTypeInfo: FileTypeInfo
|
||||
): Promise<FileInMemory> {
|
||||
const isHEIC = fileIsHEIC(fileTypeInfo.exactType);
|
||||
|
||||
const { thumbnail, hasStaticThumbnail } = await generateThumbnail(
|
||||
worker,
|
||||
rawFile,
|
||||
fileTypeInfo.fileType,
|
||||
isHEIC
|
||||
fileTypeInfo
|
||||
);
|
||||
|
||||
const filedata = await getFileData(worker, rawFile);
|
||||
|
|
|
@ -102,9 +102,9 @@ export default async function uploader(
|
|||
file: decryptedFile,
|
||||
};
|
||||
} catch (e) {
|
||||
const fileFormat =
|
||||
fileTypeInfo.exactType ?? rawFile.name.split('.').pop();
|
||||
logError(e, 'file upload failed', { fileFormat });
|
||||
logError(e, 'file upload failed', {
|
||||
fileFormat: fileTypeInfo.exactType,
|
||||
});
|
||||
const error = handleUploadError(e);
|
||||
switch (error.message) {
|
||||
case CustomError.ETAG_MISSING:
|
||||
|
|
|
@ -2,8 +2,9 @@ import {
|
|||
addToCollection,
|
||||
Collection,
|
||||
CollectionType,
|
||||
createCollection,
|
||||
moveToCollection,
|
||||
removeFromCollection,
|
||||
restoreToCollection,
|
||||
} from 'services/collectionService';
|
||||
import { getSelectedFiles } from 'utils/file';
|
||||
import { File } from 'services/fileService';
|
||||
|
@ -15,26 +16,18 @@ import { getData, LS_KEYS } from 'utils/storage/localStorage';
|
|||
export enum COLLECTION_OPS_TYPE {
|
||||
ADD,
|
||||
MOVE,
|
||||
REMOVE,
|
||||
RESTORE,
|
||||
}
|
||||
export async function copyOrMoveFromCollection(
|
||||
export async function handleCollectionOps(
|
||||
type: COLLECTION_OPS_TYPE,
|
||||
setCollectionSelectorView: (value: boolean) => void,
|
||||
selected: SelectedState,
|
||||
files: File[],
|
||||
setActiveCollection: (id: number) => void,
|
||||
collectionName: string,
|
||||
existingCollection: Collection
|
||||
collection: Collection
|
||||
) {
|
||||
setCollectionSelectorView(false);
|
||||
let collection: Collection;
|
||||
if (!existingCollection) {
|
||||
collection = await createCollection(
|
||||
collectionName,
|
||||
CollectionType.album
|
||||
);
|
||||
} else {
|
||||
collection = existingCollection;
|
||||
}
|
||||
const selectedFiles = getSelectedFiles(selected, files);
|
||||
switch (type) {
|
||||
case COLLECTION_OPS_TYPE.ADD:
|
||||
|
@ -47,6 +40,12 @@ export async function copyOrMoveFromCollection(
|
|||
selectedFiles
|
||||
);
|
||||
break;
|
||||
case COLLECTION_OPS_TYPE.REMOVE:
|
||||
await removeFromCollection(collection, selectedFiles);
|
||||
break;
|
||||
case COLLECTION_OPS_TYPE.RESTORE:
|
||||
await restoreToCollection(collection, selectedFiles);
|
||||
break;
|
||||
default:
|
||||
throw Error(CustomError.INVALID_COLLECTION_OPERATION);
|
||||
}
|
||||
|
|
|
@ -27,6 +27,7 @@ export enum CustomError {
|
|||
SIGNUP_FAILED = 'signup failed',
|
||||
FAV_COLLECTION_MISSING = 'favorite collection missing',
|
||||
INVALID_COLLECTION_OPERATION = 'invalid collection operation',
|
||||
WAIT_TIME_EXCEEDED = 'thumbnail generation wait time exceeded',
|
||||
}
|
||||
|
||||
function parseUploadError(error: AxiosResponse) {
|
||||
|
|
|
@ -6,11 +6,11 @@ import {
|
|||
FILE_TYPE,
|
||||
MagicMetadataProps,
|
||||
NEW_MAGIC_METADATA,
|
||||
PublicMagicMetadataProps,
|
||||
VISIBILITY_STATE,
|
||||
} from 'services/fileService';
|
||||
import { decodeMotionPhoto } from 'services/motionPhotoService';
|
||||
import { getMimeTypeFromBlob } from 'services/upload/readFileService';
|
||||
import { EncryptionResult } from 'services/upload/uploadService';
|
||||
import DownloadManger from 'services/downloadManager';
|
||||
import { logError } from 'utils/sentry';
|
||||
import { User } from 'services/userService';
|
||||
|
@ -69,7 +69,7 @@ export function sortFilesIntoCollections(files: File[]) {
|
|||
return collectionWiseFiles;
|
||||
}
|
||||
|
||||
export function getSelectedFileIds(selectedFiles: SelectedState) {
|
||||
function getSelectedFileIds(selectedFiles: SelectedState) {
|
||||
const filesIDs: number[] = [];
|
||||
for (const [key, val] of Object.entries(selectedFiles)) {
|
||||
if (typeof val === 'boolean' && val) {
|
||||
|
@ -79,17 +79,19 @@ export function getSelectedFileIds(selectedFiles: SelectedState) {
|
|||
return filesIDs;
|
||||
}
|
||||
export function getSelectedFiles(
|
||||
selectedFiles: SelectedState,
|
||||
selected: SelectedState,
|
||||
files: File[]
|
||||
): File[] {
|
||||
const filesIDs = new Set(getSelectedFileIds(selectedFiles));
|
||||
const filesToDelete: File[] = [];
|
||||
const filesIDs = new Set(getSelectedFileIds(selected));
|
||||
const selectedFiles: File[] = [];
|
||||
const foundFiles = new Set<number>();
|
||||
for (const file of files) {
|
||||
if (filesIDs.has(file.id)) {
|
||||
filesToDelete.push(file);
|
||||
if (filesIDs.has(file.id) && !foundFiles.has(file.id)) {
|
||||
selectedFiles.push(file);
|
||||
foundFiles.add(file.id);
|
||||
}
|
||||
}
|
||||
return filesToDelete;
|
||||
return selectedFiles;
|
||||
}
|
||||
|
||||
export function checkFileFormatSupport(name: string) {
|
||||
|
@ -123,6 +125,31 @@ export function formatDateTime(date: number | 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[]) {
|
||||
// sort according to modification time first
|
||||
files = files.sort((a, b) => {
|
||||
|
@ -172,9 +199,17 @@ export async function decryptFile(file: File, collection: Collection) {
|
|||
file.key
|
||||
);
|
||||
}
|
||||
if (file.pubMagicMetadata?.data) {
|
||||
file.pubMagicMetadata.data = await worker.decryptMetadata(
|
||||
file.pubMagicMetadata.data,
|
||||
file.pubMagicMetadata.header,
|
||||
file.key
|
||||
);
|
||||
}
|
||||
return file;
|
||||
} catch (e) {
|
||||
logError(e, 'file decryption failed');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -245,15 +280,12 @@ export function fileIsArchived(file: File) {
|
|||
return file.magicMetadata.data.visibility === VISIBILITY_STATE.ARCHIVED;
|
||||
}
|
||||
|
||||
export async function changeFilesVisibility(
|
||||
files: File[],
|
||||
selected: SelectedState,
|
||||
visibility: VISIBILITY_STATE
|
||||
export async function updateMagicMetadataProps(
|
||||
file: File,
|
||||
magicMetadataUpdates: MagicMetadataProps
|
||||
) {
|
||||
const worker = await new CryptoWorker();
|
||||
const selectedFiles = getSelectedFiles(selected, files);
|
||||
const updatedFiles: File[] = [];
|
||||
for (const file of selectedFiles) {
|
||||
|
||||
if (!file.magicMetadata) {
|
||||
file.magicMetadata = NEW_MAGIC_METADATA;
|
||||
}
|
||||
|
@ -264,27 +296,91 @@ export async function changeFilesVisibility(
|
|||
file.key
|
||||
)) as MagicMetadataProps;
|
||||
}
|
||||
if (magicMetadataUpdates) {
|
||||
// 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
|
||||
const updatedMagicMetadataProps: MagicMetadataProps = {
|
||||
const magicMetadataProps: MagicMetadataProps = {
|
||||
...file.magicMetadata.data,
|
||||
visibility,
|
||||
...magicMetadataUpdates,
|
||||
};
|
||||
const encryptedMagicMetadata: EncryptionResult =
|
||||
await worker.encryptMetadata(updatedMagicMetadataProps, file.key);
|
||||
updatedFiles.push({
|
||||
|
||||
return {
|
||||
...file,
|
||||
magicMetadata: {
|
||||
version: file.magicMetadata.version,
|
||||
count: Object.keys(updatedMagicMetadataProps).length,
|
||||
data: encryptedMagicMetadata.file
|
||||
.encryptedData as unknown as string,
|
||||
header: encryptedMagicMetadata.file.decryptionHeader,
|
||||
...file.magicMetadata,
|
||||
data: magicMetadataProps,
|
||||
count: Object.keys(file.magicMetadata.data).length,
|
||||
},
|
||||
});
|
||||
};
|
||||
} 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;
|
||||
}
|
||||
|
||||
export async function changeFileCreationTime(file: File, editedTime: number) {
|
||||
const updatedPublicMagicMetadataProps: PublicMagicMetadataProps = {
|
||||
editedTime,
|
||||
};
|
||||
|
||||
return await updatePublicMagicMetadataProps(
|
||||
file,
|
||||
updatedPublicMagicMetadataProps
|
||||
);
|
||||
}
|
||||
|
||||
export function isSharedFile(file: File) {
|
||||
const user: User = getData(LS_KEYS.USER);
|
||||
|
||||
|
@ -293,3 +389,28 @@ export function isSharedFile(file: File) {
|
|||
}
|
||||
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';
|
||||
|
||||
export const logError = (
|
||||
e: any,
|
||||
error: any,
|
||||
msg: string,
|
||||
info?: Record<string, unknown>
|
||||
) => {
|
||||
const err = errorWithContext(e, msg);
|
||||
const err = errorWithContext(error, msg);
|
||||
if (!process.env.NEXT_PUBLIC_SENTRY_ENV) {
|
||||
console.log(e);
|
||||
console.log({ error, msg, info });
|
||||
}
|
||||
Sentry.captureException(err, {
|
||||
level: Sentry.Severity.Info,
|
||||
|
@ -18,7 +18,7 @@ export const logError = (
|
|||
...(info && {
|
||||
info: info,
|
||||
}),
|
||||
rootCause: { message: e?.message },
|
||||
rootCause: { message: error?.message },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
@ -12,6 +12,7 @@ export enum LS_KEYS {
|
|||
SHOW_BACK_BUTTON = 'showBackButton',
|
||||
EXPORT = 'export',
|
||||
AnonymizeUserID = 'anonymizedUserID',
|
||||
THUMBNAIL_FIX_STATE = 'thumbnailFixState',
|
||||
}
|
||||
|
||||
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: 'preserve',
|
||||
UPLOAD_DROPZONE_MESSAGE: 'drop to backup your files',
|
||||
CONFIRM_DELETE_FILE: 'confirm file deletion',
|
||||
DELETE_FILE_MESSAGE: 'sure you want to delete selected files?',
|
||||
CONFIRM_DELETE: 'confirm deletion',
|
||||
DELETE_MESSAGE: `the selected files will be permanently deleted and can't be restored `,
|
||||
DELETE_FILE: 'delete files',
|
||||
DELETE: 'delete',
|
||||
MULTI_FOLDER_UPLOAD: 'multiple folders detected',
|
||||
|
@ -210,7 +210,7 @@ const englishConstants = {
|
|||
CANCEL: 'cancel',
|
||||
LOGOUT: 'logout',
|
||||
DELETE_ACCOUNT: 'delete account',
|
||||
DELETE_MESSAGE: () => (
|
||||
DELETE_ACCOUNT_MESSAGE: () => (
|
||||
<>
|
||||
<p>
|
||||
please send an email to{' '}
|
||||
|
@ -359,8 +359,7 @@ const englishConstants = {
|
|||
<>
|
||||
<p>are you sure you want to delete this album?</p>
|
||||
<p>
|
||||
all files that are unique to this album will be permanently
|
||||
deleted
|
||||
all files that are unique to this album will be moved to trash
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
|
@ -549,9 +548,54 @@ const englishConstants = {
|
|||
MOVE: 'move',
|
||||
ADD: 'add',
|
||||
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_MODIFICATION_TIME: 'last updated',
|
||||
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;
|
||||
|
|
Loading…
Reference in a new issue