udpate photoswipe info dialog

This commit is contained in:
Abhinav 2022-06-04 16:51:06 +05:30
parent 5ee5b5f890
commit ed60c290ce
17 changed files with 979 additions and 926 deletions

View file

@ -41,20 +41,19 @@ export const IconButton = styled.button`
export const Row = styled.div`
display: flex;
align-items: center;
margin-bottom: 20px;
margin-bottom: ${({ theme }) => theme.spacing(2)};
flex: 1;
`;
export const Label = styled.div<{ width?: string }>`
width: ${(props) => props.width ?? '70%'};
color: ${(props) => props.theme.palette.text.secondary};
`;
export const Value = styled.div<{ width?: string }>`
display: flex;
justify-content: flex-start;
align-items: center;
width: ${(props) => props.width ?? '30%'};
color: #ddd;
`;
export const FlexWrapper = styled(Box)`

View file

@ -6,7 +6,7 @@ import styled from 'styled-components';
import DownloadManager from 'services/downloadManager';
import constants from 'utils/strings/constants';
import AutoSizer from 'react-virtualized-auto-sizer';
import PhotoSwipe from 'components/PhotoSwipe/PhotoSwipe';
import PhotoSwipe from 'components/PhotoSwipe';
import { formatDateRelative } from 'utils/file';
import {
ALL_SECTION,

View file

@ -0,0 +1,63 @@
import React, { useState } from 'react';
import constants from 'utils/strings/constants';
import { FormCheck } from 'react-bootstrap';
import { RenderInfoItem } from './RenderInfoItem';
import { LegendContainer } from '../styledComponents/LegendContainer';
import { Pre } from '../styledComponents/Pre';
import { Typography } from '@mui/material';
export function ExifData(props: { exif: any }) {
const { exif } = props;
const [showAll, setShowAll] = useState(false);
const changeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
setShowAll(e.target.checked);
};
const renderAllValues = () => <Pre>{exif.raw}</Pre>;
const renderSelectedValues = () => (
<>
{exif?.Make &&
exif?.Model &&
RenderInfoItem(constants.DEVICE, `${exif.Make} ${exif.Model}`)}
{exif?.ImageWidth &&
exif?.ImageHeight &&
RenderInfoItem(
constants.IMAGE_SIZE,
`${exif.ImageWidth} x ${exif.ImageHeight}`
)}
{exif?.Flash && RenderInfoItem(constants.FLASH, exif.Flash)}
{exif?.FocalLength &&
RenderInfoItem(
constants.FOCAL_LENGTH,
exif.FocalLength.toString()
)}
{exif?.ApertureValue &&
RenderInfoItem(
constants.APERTURE,
exif.ApertureValue.toString()
)}
{exif?.ISOSpeedRatings &&
RenderInfoItem(constants.ISO, exif.ISOSpeedRatings.toString())}
</>
);
return (
<>
<LegendContainer>
<Typography variant="subtitle" mb={1}>
{constants.EXIF}
</Typography>
<FormCheck>
<FormCheck.Label>
<FormCheck.Input onChange={changeHandler} />
{constants.SHOW_ALL}
</FormCheck.Label>
</FormCheck>
</LegendContainer>
{showAll ? renderAllValues() : renderSelectedValues()}
</>
);
}

View file

@ -0,0 +1,99 @@
import React, { useState } from 'react';
import constants from 'utils/strings/constants';
import { Col, Form, FormControl } from 'react-bootstrap';
import { FlexWrapper, IconButton, Value } from 'components/Container';
import CloseIcon from '@mui/icons-material/Close';
import TickIcon from '@mui/icons-material/Done';
import { Formik } from 'formik';
import * as Yup from 'yup';
import { MAX_EDITED_FILE_NAME_LENGTH } from 'constants/file';
import { SmallLoadingSpinner } from '../styledComponents/SmallLoadingSpinner';
export interface formValues {
filename: string;
}
export const FileNameEditForm = ({
filename,
saveEdits,
discardEdits,
extension,
}) => {
const [loading, setLoading] = useState(false);
const onSubmit = async (values: formValues) => {
try {
setLoading(true);
await saveEdits(values.filename);
} finally {
setLoading(false);
}
};
return (
<Formik<formValues>
initialValues={{ filename }}
validationSchema={Yup.object().shape({
filename: Yup.string()
.required(constants.REQUIRED)
.max(
MAX_EDITED_FILE_NAME_LENGTH,
constants.FILE_NAME_CHARACTER_LIMIT
),
})}
validateOnBlur={false}
onSubmit={onSubmit}>
{({ values, errors, handleChange, handleSubmit }) => (
<Form noValidate onSubmit={handleSubmit}>
<Form.Row>
<Form.Group
bsPrefix="ente-form-group"
as={Col}
xs={extension ? 7 : 8}>
<Form.Control
as="textarea"
placeholder={constants.FILE_NAME}
value={values.filename}
onChange={handleChange('filename')}
isInvalid={Boolean(errors.filename)}
autoFocus
disabled={loading}
/>
<FormControl.Feedback
type="invalid"
style={{ textAlign: 'center' }}>
{errors.filename}
</FormControl.Feedback>
</Form.Group>
{extension && (
<Form.Group
bsPrefix="ente-form-group"
as={Col}
xs={1}
controlId="formHorizontalFileName">
<FlexWrapper style={{ padding: '5px' }}>
{`.${extension}`}
</FlexWrapper>
</Form.Group>
)}
<Form.Group bsPrefix="ente-form-group" as={Col} xs={2}>
<Value width={'16.67%'}>
<IconButton type="submit" disabled={loading}>
{loading ? (
<SmallLoadingSpinner />
) : (
<TickIcon />
)}
</IconButton>
<IconButton
onClick={discardEdits}
disabled={loading}>
<CloseIcon />
</IconButton>
</Value>
</Form.Group>
</Form.Row>
</Form>
)}
</Formik>
);
};

View file

@ -0,0 +1,121 @@
import React, { useState } from 'react';
import { updateFilePublicMagicMetadata } from 'services/fileService';
import { EnteFile } from 'types/file';
import constants from 'utils/strings/constants';
import {
changeFileCreationTime,
formatDateTime,
updateExistingFilePubMetadata,
} from 'utils/file';
import EditIcon from 'components/icons/EditIcon';
import { IconButton, Label, Row, Value } from 'components/Container';
import { logError } from 'utils/sentry';
import CloseIcon from '@mui/icons-material/Close';
import TickIcon from '@mui/icons-material/Done';
import EnteDateTimePicker from 'components/EnteDateTimePicker';
import { SmallLoadingSpinner } from '../styledComponents/SmallLoadingSpinner';
export function RenderCreationTime({
shouldDisableEdits,
file,
scheduleUpdate,
}: {
shouldDisableEdits: boolean;
file: EnteFile;
scheduleUpdate: () => void;
}) {
const [loading, setLoading] = useState(false);
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 {
setLoading(true);
if (isInEditMode && file) {
const unixTimeInMicroSec = pickedTime.getTime() * 1000;
if (unixTimeInMicroSec === file?.metadata.creationTime) {
closeEditMode();
return;
}
let updatedFile = await changeFileCreationTime(
file,
unixTimeInMicroSec
);
updatedFile = (
await updateFilePublicMagicMetadata([updatedFile])
)[0];
updateExistingFilePubMetadata(file, updatedFile);
scheduleUpdate();
}
} catch (e) {
logError(e, 'failed to update creationTime');
} finally {
closeEditMode();
setLoading(false);
}
};
const discardEdits = () => {
setPickedTime(originalCreationTime);
closeEditMode();
};
const handleChange = (newDate: Date) => {
if (newDate instanceof Date) {
setPickedTime(newDate);
}
};
return (
<>
<Row>
<Label width="30%">{constants.CREATION_TIME}</Label>
<Value
width={
!shouldDisableEdits
? isInEditMode
? '50%'
: '60%'
: '70%'
}>
{isInEditMode ? (
<EnteDateTimePicker
loading={loading}
isInEditMode={isInEditMode}
pickedTime={pickedTime}
handleChange={handleChange}
/>
) : (
formatDateTime(pickedTime)
)}
</Value>
{!shouldDisableEdits && (
<Value
width={isInEditMode ? '20%' : '10%'}
style={{ cursor: 'pointer', marginLeft: '10px' }}>
{!isInEditMode ? (
<IconButton onClick={openEditMode}>
<EditIcon />
</IconButton>
) : (
<>
<IconButton onClick={saveEdits}>
{loading ? (
<SmallLoadingSpinner />
) : (
<TickIcon />
)}
</IconButton>
<IconButton onClick={discardEdits}>
<CloseIcon />
</IconButton>
</>
)}
</Value>
)}
</Row>
</>
);
}

View file

@ -0,0 +1,103 @@
import React, { useState } from 'react';
import { updateFilePublicMagicMetadata } from 'services/fileService';
import { EnteFile } from 'types/file';
import constants from 'utils/strings/constants';
import {
changeFileName,
splitFilenameAndExtension,
updateExistingFilePubMetadata,
} from 'utils/file';
import EditIcon from 'components/icons/EditIcon';
import {
FreeFlowText,
IconButton,
Label,
Row,
Value,
} from 'components/Container';
import { logError } from 'utils/sentry';
import { FileNameEditForm } from './FileNameEditForm';
export const getFileTitle = (filename, extension) => {
if (extension) {
return filename + '.' + extension;
} else {
return filename;
}
};
export function RenderFileName({
shouldDisableEdits,
file,
scheduleUpdate,
}: {
shouldDisableEdits: boolean;
file: EnteFile;
scheduleUpdate: () => void;
}) {
const originalTitle = file?.metadata.title;
const [isInEditMode, setIsInEditMode] = useState(false);
const [originalFileName, extension] =
splitFilenameAndExtension(originalTitle);
const [filename, setFilename] = useState(originalFileName);
const openEditMode = () => setIsInEditMode(true);
const closeEditMode = () => setIsInEditMode(false);
const saveEdits = async (newFilename: string) => {
try {
if (file) {
if (filename === newFilename) {
closeEditMode();
return;
}
setFilename(newFilename);
const newTitle = getFileTitle(newFilename, extension);
let updatedFile = await changeFileName(file, newTitle);
updatedFile = (
await updateFilePublicMagicMetadata([updatedFile])
)[0];
updateExistingFilePubMetadata(file, updatedFile);
scheduleUpdate();
}
} catch (e) {
logError(e, 'failed to update file name');
} finally {
closeEditMode();
}
};
return (
<>
<Row>
<Label width="30%">{constants.FILE_NAME}</Label>
{!isInEditMode ? (
<>
<Value width={!shouldDisableEdits ? '60%' : '70%'}>
<FreeFlowText>
{getFileTitle(filename, extension)}
</FreeFlowText>
</Value>
{!shouldDisableEdits && (
<Value
width="10%"
style={{
cursor: 'pointer',
marginLeft: '10px',
}}>
<IconButton onClick={openEditMode}>
<EditIcon />
</IconButton>
</Value>
)}
</>
) : (
<FileNameEditForm
extension={extension}
filename={filename}
saveEdits={saveEdits}
discardEdits={closeEditMode}
/>
)}
</Row>
</>
);
}

View file

@ -0,0 +1,9 @@
import React from 'react';
import { Label, Row, Value } from 'components/Container';
export const RenderInfoItem = (label: string, value: string | JSX.Element) => (
<Row>
<Label width="30%">{label}</Label>
<Value width="70%">{value}</Value>
</Row>
);

View file

@ -0,0 +1,77 @@
import React from 'react';
import constants from 'utils/strings/constants';
import { formatDateTime } from 'utils/file';
import { RenderFileName } from './RenderFileName';
import { ExifData } from './ExifData';
import { RenderCreationTime } from './RenderCreationTime';
import { RenderInfoItem } from './RenderInfoItem';
import DialogBoxBase from 'components/DialogBox/base';
import DialogTitleWithCloseButton from 'components/DialogBox/titleWithCloseButton';
import { DialogContent, Typography } from '@mui/material';
export function InfoModal({
shouldDisableEdits,
showInfo,
handleCloseInfo,
items,
photoSwipe,
metadata,
exif,
scheduleUpdate,
}) {
return (
<DialogBoxBase
sx={{ zIndex: '1501' }}
open={showInfo}
onClose={handleCloseInfo}>
<DialogTitleWithCloseButton onClose={handleCloseInfo}>
{constants.INFO}
</DialogTitleWithCloseButton>
<DialogContent>
<Typography variant="subtitle" mb={1}>
{constants.METADATA}
</Typography>
{RenderInfoItem(
constants.FILE_ID,
items[photoSwipe?.getCurrentIndex()]?.id
)}
{metadata?.title && (
<RenderFileName
shouldDisableEdits={shouldDisableEdits}
file={items[photoSwipe?.getCurrentIndex()]}
scheduleUpdate={scheduleUpdate}
/>
)}
{metadata?.creationTime && (
<RenderCreationTime
shouldDisableEdits={shouldDisableEdits}
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} />
</>
)}
</DialogContent>
</DialogBoxBase>
);
}

View file

@ -1,908 +0,0 @@
import React, { useContext, useEffect, useRef, useState } from 'react';
import Photoswipe from 'photoswipe';
import PhotoswipeUIDefault from 'photoswipe/dist/photoswipe-ui-default';
import classnames from 'classnames';
import FavButton from 'components/FavButton';
import {
addToFavorites,
removeFromFavorites,
} from 'services/collectionService';
import { updateFilePublicMagicMetadata } from 'services/fileService';
import { EnteFile } from 'types/file';
import constants from 'utils/strings/constants';
import exifr from 'exifr';
import Modal from 'react-bootstrap/Modal';
import Button from 'react-bootstrap/Button';
import styled from 'styled-components';
import events from './events';
import {
changeFileCreationTime,
changeFileName,
downloadFile,
formatDateTime,
splitFilenameAndExtension,
updateExistingFilePubMetadata,
} from 'utils/file';
import { Col, Form, FormCheck, FormControl } from 'react-bootstrap';
import { prettyPrintExif } from 'utils/exif';
import EditIcon from 'components/icons/EditIcon';
import {
FlexWrapper,
FreeFlowText,
IconButton,
Label,
Row,
Value,
} from 'components/Container';
import { livePhotoBtnHTML } from 'components/LivePhotoBtn';
import { logError } from 'utils/sentry';
import CloseIcon from '@mui/icons-material/Close';
import TickIcon from '@mui/icons-material/Done';
import { Formik } from 'formik';
import * as Yup from 'yup';
import EnteSpinner from 'components/EnteSpinner';
import EnteDateTimePicker from 'components/EnteDateTimePicker';
import { MAX_EDITED_FILE_NAME_LENGTH, FILE_TYPE } from 'constants/file';
import { sleep } from 'utils/common';
import { playVideo, pauseVideo } from 'utils/photoFrame';
import { PublicCollectionGalleryContext } from 'utils/publicCollectionGallery';
import { GalleryContext } from 'pages/gallery';
import { AppContext } from 'pages/_app';
const SmallLoadingSpinner = () => (
<EnteSpinner
style={{
width: '20px',
height: '20px',
}}
/>
);
interface Iprops {
isOpen: boolean;
items: any[];
currentIndex?: number;
onClose?: (needUpdate: boolean) => void;
gettingData: (instance: any, index: number, item: EnteFile) => void;
id?: string;
className?: string;
favItemIds: Set<number>;
isSharedCollection: boolean;
isTrashCollection: boolean;
enableDownload: boolean;
isSourceLoaded: boolean;
}
const LegendContainer = styled.div`
display: flex;
justify-content: space-between;
`;
const Legend = styled.span`
font-size: 20px;
color: #ddd;
display: inline;
`;
const Pre = styled.pre`
color: #aaa;
padding: 7px 15px;
`;
const LivePhotoBtn = styled.button`
position: absolute;
bottom: 6vh;
right: 6vh;
height: 40px;
width: 80px;
background: #d7d7d7;
outline: none;
border: none;
border-radius: 10%;
z-index: 10;
cursor: ${(props) => (props.disabled ? 'not-allowed' : 'pointer')};
}
`;
const livePhotoDefaultOptions = {
click: () => {},
hide: () => {},
show: () => {},
loading: false,
visible: false,
};
const renderInfoItem = (label: string, value: string | JSX.Element) => (
<Row>
<Label width="30%">{label}</Label>
<Value width="70%">{value}</Value>
</Row>
);
function RenderCreationTime({
shouldDisableEdits,
file,
scheduleUpdate,
}: {
shouldDisableEdits: boolean;
file: EnteFile;
scheduleUpdate: () => void;
}) {
const [loading, setLoading] = useState(false);
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 {
setLoading(true);
if (isInEditMode && file) {
const unixTimeInMicroSec = pickedTime.getTime() * 1000;
if (unixTimeInMicroSec === file?.metadata.creationTime) {
closeEditMode();
return;
}
let updatedFile = await changeFileCreationTime(
file,
unixTimeInMicroSec
);
updatedFile = (
await updateFilePublicMagicMetadata([updatedFile])
)[0];
updateExistingFilePubMetadata(file, updatedFile);
scheduleUpdate();
}
} catch (e) {
logError(e, 'failed to update creationTime');
} finally {
closeEditMode();
setLoading(false);
}
};
const discardEdits = () => {
setPickedTime(originalCreationTime);
closeEditMode();
};
const handleChange = (newDate: Date) => {
if (newDate instanceof Date) {
setPickedTime(newDate);
}
};
return (
<>
<Row>
<Label width="30%">{constants.CREATION_TIME}</Label>
<Value
width={
!shouldDisableEdits
? isInEditMode
? '50%'
: '60%'
: '70%'
}>
{isInEditMode ? (
<EnteDateTimePicker
loading={loading}
isInEditMode={isInEditMode}
pickedTime={pickedTime}
handleChange={handleChange}
/>
) : (
formatDateTime(pickedTime)
)}
</Value>
{!shouldDisableEdits && (
<Value
width={isInEditMode ? '20%' : '10%'}
style={{ cursor: 'pointer', marginLeft: '10px' }}>
{!isInEditMode ? (
<IconButton onClick={openEditMode}>
<EditIcon />
</IconButton>
) : (
<>
<IconButton onClick={saveEdits}>
{loading ? (
<SmallLoadingSpinner />
) : (
<TickIcon />
)}
</IconButton>
<IconButton onClick={discardEdits}>
<CloseIcon />
</IconButton>
</>
)}
</Value>
)}
</Row>
</>
);
}
const getFileTitle = (filename, extension) => {
if (extension) {
return filename + '.' + extension;
} else {
return filename;
}
};
interface formValues {
filename: string;
}
const FileNameEditForm = ({ filename, saveEdits, discardEdits, extension }) => {
const [loading, setLoading] = useState(false);
const onSubmit = async (values: formValues) => {
try {
setLoading(true);
await saveEdits(values.filename);
} finally {
setLoading(false);
}
};
return (
<Formik<formValues>
initialValues={{ filename }}
validationSchema={Yup.object().shape({
filename: Yup.string()
.required(constants.REQUIRED)
.max(
MAX_EDITED_FILE_NAME_LENGTH,
constants.FILE_NAME_CHARACTER_LIMIT
),
})}
validateOnBlur={false}
onSubmit={onSubmit}>
{({ values, errors, handleChange, handleSubmit }) => (
<Form noValidate onSubmit={handleSubmit}>
<Form.Row>
<Form.Group
bsPrefix="ente-form-group"
as={Col}
xs={extension ? 7 : 8}>
<Form.Control
as="textarea"
placeholder={constants.FILE_NAME}
value={values.filename}
onChange={handleChange('filename')}
isInvalid={Boolean(errors.filename)}
autoFocus
disabled={loading}
/>
<FormControl.Feedback
type="invalid"
style={{ textAlign: 'center' }}>
{errors.filename}
</FormControl.Feedback>
</Form.Group>
{extension && (
<Form.Group
bsPrefix="ente-form-group"
as={Col}
xs={1}
controlId="formHorizontalFileName">
<FlexWrapper style={{ padding: '5px' }}>
{`.${extension}`}
</FlexWrapper>
</Form.Group>
)}
<Form.Group bsPrefix="ente-form-group" as={Col} xs={2}>
<Value width={'16.67%'}>
<IconButton type="submit" disabled={loading}>
{loading ? (
<SmallLoadingSpinner />
) : (
<TickIcon />
)}
</IconButton>
<IconButton
onClick={discardEdits}
disabled={loading}>
<CloseIcon />
</IconButton>
</Value>
</Form.Group>
</Form.Row>
</Form>
)}
</Formik>
);
};
function RenderFileName({
shouldDisableEdits,
file,
scheduleUpdate,
}: {
shouldDisableEdits: boolean;
file: EnteFile;
scheduleUpdate: () => void;
}) {
const originalTitle = file?.metadata.title;
const [isInEditMode, setIsInEditMode] = useState(false);
const [originalFileName, extension] =
splitFilenameAndExtension(originalTitle);
const [filename, setFilename] = useState(originalFileName);
const openEditMode = () => setIsInEditMode(true);
const closeEditMode = () => setIsInEditMode(false);
const saveEdits = async (newFilename: string) => {
try {
if (file) {
if (filename === newFilename) {
closeEditMode();
return;
}
setFilename(newFilename);
const newTitle = getFileTitle(newFilename, extension);
let updatedFile = await changeFileName(file, newTitle);
updatedFile = (
await updateFilePublicMagicMetadata([updatedFile])
)[0];
updateExistingFilePubMetadata(file, updatedFile);
scheduleUpdate();
}
} catch (e) {
logError(e, 'failed to update file name');
} finally {
closeEditMode();
}
};
return (
<>
<Row>
<Label width="30%">{constants.FILE_NAME}</Label>
{!isInEditMode ? (
<>
<Value width={!shouldDisableEdits ? '60%' : '70%'}>
<FreeFlowText>
{getFileTitle(filename, extension)}
</FreeFlowText>
</Value>
{!shouldDisableEdits && (
<Value
width="10%"
style={{
cursor: 'pointer',
marginLeft: '10px',
}}>
<IconButton onClick={openEditMode}>
<EditIcon />
</IconButton>
</Value>
)}
</>
) : (
<FileNameEditForm
extension={extension}
filename={filename}
saveEdits={saveEdits}
discardEdits={closeEditMode}
/>
)}
</Row>
</>
);
}
function ExifData(props: { exif: any }) {
const { exif } = props;
const [showAll, setShowAll] = useState(false);
const changeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
setShowAll(e.target.checked);
};
const renderAllValues = () => <Pre>{exif.raw}</Pre>;
const renderSelectedValues = () => (
<>
{exif?.Make &&
exif?.Model &&
renderInfoItem(constants.DEVICE, `${exif.Make} ${exif.Model}`)}
{exif?.ImageWidth &&
exif?.ImageHeight &&
renderInfoItem(
constants.IMAGE_SIZE,
`${exif.ImageWidth} x ${exif.ImageHeight}`
)}
{exif?.Flash && renderInfoItem(constants.FLASH, exif.Flash)}
{exif?.FocalLength &&
renderInfoItem(
constants.FOCAL_LENGTH,
exif.FocalLength.toString()
)}
{exif?.ApertureValue &&
renderInfoItem(
constants.APERTURE,
exif.ApertureValue.toString()
)}
{exif?.ISOSpeedRatings &&
renderInfoItem(constants.ISO, exif.ISOSpeedRatings.toString())}
</>
);
return (
<>
<LegendContainer>
<Legend>{constants.EXIF}</Legend>
<FormCheck>
<FormCheck.Label>
<FormCheck.Input onChange={changeHandler} />
{constants.SHOW_ALL}
</FormCheck.Label>
</FormCheck>
</LegendContainer>
{showAll ? renderAllValues() : renderSelectedValues()}
</>
);
}
function InfoModal({
shouldDisableEdits,
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 && (
<RenderFileName
shouldDisableEdits={shouldDisableEdits}
file={items[photoSwipe?.getCurrentIndex()]}
scheduleUpdate={scheduleUpdate}
/>
)}
{metadata?.creationTime && (
<RenderCreationTime
shouldDisableEdits={shouldDisableEdits}
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<Photoswipe.Options>>();
const { isOpen, items, isSourceLoaded } = props;
const [isFav, setIsFav] = useState(false);
const [showInfo, setShowInfo] = useState(false);
const [metadata, setMetaData] = useState<EnteFile['metadata']>(null);
const [exif, setExif] = useState<any>(null);
const [livePhotoBtnOptions, setLivePhotoBtnOptions] = useState(
livePhotoDefaultOptions
);
const needUpdate = useRef(false);
const publicCollectionGalleryContext = useContext(
PublicCollectionGalleryContext
);
const galleryContext = useContext(GalleryContext);
const appContext = useContext(AppContext);
useEffect(() => {
if (!pswpElement) return;
if (isOpen) {
openPhotoSwipe();
}
if (!isOpen) {
closePhotoSwipe();
}
return () => {
closePhotoSwipe();
};
}, [isOpen]);
useEffect(() => {
updateItems(items);
}, [items]);
useEffect(() => {
if (photoSwipe) {
photoSwipe.options.arrowKeys = !showInfo;
photoSwipe.options.escKey = !showInfo;
}
}, [showInfo]);
useEffect(() => {
if (!isOpen) return;
const item = items[photoSwipe?.getCurrentIndex()];
if (item && item.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
const getVideoAndImage = () => {
const video = document.getElementById(
`live-photo-video-${item.id}`
);
const image = document.getElementById(
`live-photo-image-${item.id}`
);
return { video, image };
};
const { video, image } = getVideoAndImage();
if (video && image) {
setLivePhotoBtnOptions({
click: async () => {
await playVideo(video, image);
},
hide: async () => {
await pauseVideo(video, image);
},
show: async () => {
await playVideo(video, image);
},
visible: true,
loading: false,
});
} else {
setLivePhotoBtnOptions({
...livePhotoDefaultOptions,
visible: true,
loading: true,
});
}
const downloadLivePhotoBtn = document.getElementById(
`download-btn-${item.id}`
) as HTMLButtonElement;
if (downloadLivePhotoBtn) {
const downloadLivePhoto = () => {
downloadFileHelper(photoSwipe.currItem);
};
downloadLivePhotoBtn.addEventListener(
'click',
downloadLivePhoto
);
return () => {
downloadLivePhotoBtn.removeEventListener(
'click',
downloadLivePhoto
);
setLivePhotoBtnOptions(livePhotoDefaultOptions);
};
}
return () => {
setLivePhotoBtnOptions(livePhotoDefaultOptions);
};
}
}, [photoSwipe?.currItem, isOpen, isSourceLoaded]);
function updateFavButton() {
setIsFav(isInFav(this?.currItem));
}
const openPhotoSwipe = () => {
const { items, currentIndex } = props;
const options = {
history: false,
maxSpreadZoom: 5,
index: currentIndex,
showHideOpacity: true,
getDoubleTapZoom(isMouseClick, item) {
if (isMouseClick) {
return 2.5;
}
// zoom to original if initial zoom is less than 0.7x,
// otherwise to 1.5x, to make sure that double-tap gesture always zooms image
return item.initialZoomLevel < 0.7 ? 1 : 1.5;
},
getThumbBoundsFn: (index) => {
try {
const file = items[index];
const ele = document.getElementById(`thumb-${file.id}`);
if (ele) {
const rect = ele.getBoundingClientRect();
const pageYScroll =
window.pageYOffset ||
document.documentElement.scrollTop;
return {
x: rect.left,
y: rect.top + pageYScroll,
w: rect.width,
};
}
return null;
} catch (e) {
return null;
}
},
};
const photoSwipe = new Photoswipe(
pswpElement.current,
PhotoswipeUIDefault,
items,
options
);
events.forEach((event) => {
const callback = props[event];
if (callback || event === 'destroy') {
photoSwipe.listen(event, function (...args) {
if (callback) {
args.unshift(this);
callback(...args);
}
if (event === 'destroy') {
handleClose();
}
if (event === 'close') {
handleClose();
}
});
}
});
photoSwipe.listen('beforeChange', function () {
updateInfo.call(this);
updateFavButton.call(this);
});
photoSwipe.listen('resize', checkExifAvailable);
photoSwipe.init();
needUpdate.current = false;
setPhotoSwipe(photoSwipe);
};
const closePhotoSwipe = () => {
if (photoSwipe) photoSwipe.close();
};
const handleClose = () => {
const { onClose } = props;
if (typeof onClose === 'function') {
onClose(needUpdate.current);
}
const videoTags = document.getElementsByTagName('video');
for (const videoTag of videoTags) {
videoTag.pause();
}
handleCloseInfo();
// BE_AWARE: this will clear any notification set, even if they were not set in/by the photoswipe component
galleryContext.setNotificationAttributes(null);
};
const isInFav = (file) => {
const { favItemIds } = props;
if (favItemIds && file) {
return favItemIds.has(file.id);
}
return false;
};
const onFavClick = async (file) => {
const { favItemIds } = props;
if (!isInFav(file)) {
favItemIds.add(file.id);
addToFavorites(file);
setIsFav(true);
} else {
favItemIds.delete(file.id);
removeFromFavorites(file);
setIsFav(false);
}
needUpdate.current = true;
};
const updateItems = (items = []) => {
if (photoSwipe) {
photoSwipe.items.length = 0;
items.forEach((item) => {
photoSwipe.items.push(item);
});
photoSwipe.invalidateCurrItems();
// photoSwipe.updateSize(true);
}
};
const checkExifAvailable = async () => {
setExif(null);
await sleep(100);
try {
const img: HTMLImageElement = document.querySelector(
'.pswp__img:not(.pswp__img--placeholder)'
);
if (img) {
const exifData = await exifr.parse(img);
if (!exifData) {
return;
}
exifData.raw = prettyPrintExif(exifData);
setExif(exifData);
}
} catch (e) {
logError(e, 'exifr parsing failed');
}
};
function updateInfo() {
const file: EnteFile = this?.currItem;
if (file?.metadata) {
setMetaData(file.metadata);
setExif(null);
checkExifAvailable();
}
}
const handleCloseInfo = () => {
setShowInfo(false);
};
const handleOpenInfo = () => {
setShowInfo(true);
};
const downloadFileHelper = async (file) => {
appContext.startLoading();
await downloadFile(
file,
publicCollectionGalleryContext.accessedThroughSharedURL,
publicCollectionGalleryContext.token,
publicCollectionGalleryContext.passwordToken
);
appContext.finishLoading();
};
const scheduleUpdate = () => (needUpdate.current = true);
const { id } = props;
let { className } = props;
className = classnames(['pswp', className]).trim();
return (
<>
<div
id={id}
className={className}
tabIndex={Number('-1')}
role="dialog"
aria-hidden="true"
ref={pswpElement}>
<div className="pswp__bg" />
<div className="pswp__scroll-wrap">
<LivePhotoBtn
onClick={livePhotoBtnOptions.click}
onMouseEnter={livePhotoBtnOptions.show}
onMouseLeave={livePhotoBtnOptions.hide}
disabled={livePhotoBtnOptions.loading}
style={{
display: livePhotoBtnOptions.visible
? 'block'
: 'none',
}}>
{livePhotoBtnHTML} {constants.LIVE}
</LivePhotoBtn>
<div className="pswp__container">
<div className="pswp__item" />
<div className="pswp__item" />
<div className="pswp__item" />
</div>
<div className="pswp__ui pswp__ui--hidden">
<div className="pswp__top-bar">
<div className="pswp__counter" />
<button
className="pswp__button pswp__button--close"
title={constants.CLOSE}
/>
{props.enableDownload && (
<button
className="pswp-custom download-btn"
title={constants.DOWNLOAD}
onClick={() =>
downloadFileHelper(photoSwipe.currItem)
}
/>
)}
<button
className="pswp__button pswp__button--fs"
title={constants.TOGGLE_FULLSCREEN}
/>
<button
className="pswp__button pswp__button--zoom"
title={constants.ZOOM_IN_OUT}
/>
{!props.isSharedCollection &&
!props.isTrashCollection && (
<FavButton
size={44}
isClick={isFav}
onClick={() => {
onFavClick(photoSwipe?.currItem);
}}
/>
)}
{!props.isSharedCollection && (
<button
className="pswp-custom info-btn"
title={constants.INFO}
onClick={handleOpenInfo}
/>
)}
<div className="pswp__preloader">
<div className="pswp__preloader__icn">
<div className="pswp__preloader__cut">
<div className="pswp__preloader__donut" />
</div>
</div>
</div>
</div>
<div className="pswp__share-modal pswp__share-modal--hidden pswp__single-tap">
<div className="pswp__share-tooltip" />
</div>
<button
className="pswp__button pswp__button--arrow--left"
title={constants.PREVIOUS}
/>
<button
className="pswp__button pswp__button--arrow--right"
title={constants.NEXT}
/>
<div className="pswp__caption">
<div />
</div>
</div>
</div>
</div>
{!props.isSharedCollection && (
<InfoModal
shouldDisableEdits={props.isSharedCollection}
showInfo={showInfo}
handleCloseInfo={handleCloseInfo}
items={items}
photoSwipe={photoSwipe}
metadata={metadata}
exif={exif}
scheduleUpdate={scheduleUpdate}
/>
)}
</>
);
}
export default PhotoSwipe;

View file

@ -0,0 +1,437 @@
import React, { useContext, useEffect, useRef, useState } from 'react';
import Photoswipe from 'photoswipe';
import PhotoswipeUIDefault from 'photoswipe/dist/photoswipe-ui-default';
import classnames from 'classnames';
import FavButton from 'components/FavButton';
import {
addToFavorites,
removeFromFavorites,
} from 'services/collectionService';
import { EnteFile } from 'types/file';
import constants from 'utils/strings/constants';
import exifr from 'exifr';
import events from './events';
import { downloadFile } from 'utils/file';
import { prettyPrintExif } from 'utils/exif';
import { livePhotoBtnHTML } from 'components/LivePhotoBtn';
import { logError } from 'utils/sentry';
import { FILE_TYPE } from 'constants/file';
import { sleep } from 'utils/common';
import { playVideo, pauseVideo } from 'utils/photoFrame';
import { PublicCollectionGalleryContext } from 'utils/publicCollectionGallery';
import { GalleryContext } from 'pages/gallery';
import { AppContext } from 'pages/_app';
import { InfoModal } from './InfoDialog';
import { defaultLivePhotoDefaultOptions } from 'constants/photoswipe';
import { LivePhotoBtn } from './styledComponents/LivePhotoBtn';
interface Iprops {
isOpen: boolean;
items: any[];
currentIndex?: number;
onClose?: (needUpdate: boolean) => void;
gettingData: (instance: any, index: number, item: EnteFile) => void;
id?: string;
className?: string;
favItemIds: Set<number>;
isSharedCollection: boolean;
isTrashCollection: boolean;
enableDownload: boolean;
isSourceLoaded: boolean;
}
function PhotoSwipe(props: Iprops) {
const pswpElement = useRef<HTMLDivElement>();
const [photoSwipe, setPhotoSwipe] =
useState<Photoswipe<Photoswipe.Options>>();
const { isOpen, items, isSourceLoaded } = props;
const [isFav, setIsFav] = useState(false);
const [showInfo, setShowInfo] = useState(false);
const [metadata, setMetaData] = useState<EnteFile['metadata']>(null);
const [exif, setExif] = useState<any>(null);
const [livePhotoBtnOptions, setLivePhotoBtnOptions] = useState(
defaultLivePhotoDefaultOptions
);
const needUpdate = useRef(false);
const publicCollectionGalleryContext = useContext(
PublicCollectionGalleryContext
);
const galleryContext = useContext(GalleryContext);
const appContext = useContext(AppContext);
useEffect(() => {
if (!pswpElement) return;
if (isOpen) {
openPhotoSwipe();
}
if (!isOpen) {
closePhotoSwipe();
}
return () => {
closePhotoSwipe();
};
}, [isOpen]);
useEffect(() => {
updateItems(items);
}, [items]);
useEffect(() => {
if (photoSwipe) {
photoSwipe.options.arrowKeys = !showInfo;
photoSwipe.options.escKey = !showInfo;
}
}, [showInfo]);
useEffect(() => {
if (!isOpen) return;
const item = items[photoSwipe?.getCurrentIndex()];
if (item && item.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
const getVideoAndImage = () => {
const video = document.getElementById(
`live-photo-video-${item.id}`
);
const image = document.getElementById(
`live-photo-image-${item.id}`
);
return { video, image };
};
const { video, image } = getVideoAndImage();
if (video && image) {
setLivePhotoBtnOptions({
click: async () => {
await playVideo(video, image);
},
hide: async () => {
await pauseVideo(video, image);
},
show: async () => {
await playVideo(video, image);
},
visible: true,
loading: false,
});
} else {
setLivePhotoBtnOptions({
...defaultLivePhotoDefaultOptions,
visible: true,
loading: true,
});
}
const downloadLivePhotoBtn = document.getElementById(
`download-btn-${item.id}`
) as HTMLButtonElement;
if (downloadLivePhotoBtn) {
const downloadLivePhoto = () => {
downloadFileHelper(photoSwipe.currItem);
};
downloadLivePhotoBtn.addEventListener(
'click',
downloadLivePhoto
);
return () => {
downloadLivePhotoBtn.removeEventListener(
'click',
downloadLivePhoto
);
setLivePhotoBtnOptions(defaultLivePhotoDefaultOptions);
};
}
return () => {
setLivePhotoBtnOptions(defaultLivePhotoDefaultOptions);
};
}
}, [photoSwipe?.currItem, isOpen, isSourceLoaded]);
function updateFavButton() {
setIsFav(isInFav(this?.currItem));
}
const openPhotoSwipe = () => {
const { items, currentIndex } = props;
const options = {
history: false,
maxSpreadZoom: 5,
index: currentIndex,
showHideOpacity: true,
getDoubleTapZoom(isMouseClick, item) {
if (isMouseClick) {
return 2.5;
}
// zoom to original if initial zoom is less than 0.7x,
// otherwise to 1.5x, to make sure that double-tap gesture always zooms image
return item.initialZoomLevel < 0.7 ? 1 : 1.5;
},
getThumbBoundsFn: (index) => {
try {
const file = items[index];
const ele = document.getElementById(`thumb-${file.id}`);
if (ele) {
const rect = ele.getBoundingClientRect();
const pageYScroll =
window.pageYOffset ||
document.documentElement.scrollTop;
return {
x: rect.left,
y: rect.top + pageYScroll,
w: rect.width,
};
}
return null;
} catch (e) {
return null;
}
},
};
const photoSwipe = new Photoswipe(
pswpElement.current,
PhotoswipeUIDefault,
items,
options
);
events.forEach((event) => {
const callback = props[event];
if (callback || event === 'destroy') {
photoSwipe.listen(event, function (...args) {
if (callback) {
args.unshift(this);
callback(...args);
}
if (event === 'destroy') {
handleClose();
}
if (event === 'close') {
handleClose();
}
});
}
});
photoSwipe.listen('beforeChange', function () {
updateInfo.call(this);
updateFavButton.call(this);
});
photoSwipe.listen('resize', checkExifAvailable);
photoSwipe.init();
needUpdate.current = false;
setPhotoSwipe(photoSwipe);
};
const closePhotoSwipe = () => {
if (photoSwipe) photoSwipe.close();
};
const handleClose = () => {
const { onClose } = props;
if (typeof onClose === 'function') {
onClose(needUpdate.current);
}
const videoTags = document.getElementsByTagName('video');
for (const videoTag of videoTags) {
videoTag.pause();
}
handleCloseInfo();
// BE_AWARE: this will clear any notification set, even if they were not set in/by the photoswipe component
galleryContext.setNotificationAttributes(null);
};
const isInFav = (file) => {
const { favItemIds } = props;
if (favItemIds && file) {
return favItemIds.has(file.id);
}
return false;
};
const onFavClick = async (file) => {
const { favItemIds } = props;
if (!isInFav(file)) {
favItemIds.add(file.id);
addToFavorites(file);
setIsFav(true);
} else {
favItemIds.delete(file.id);
removeFromFavorites(file);
setIsFav(false);
}
needUpdate.current = true;
};
const updateItems = (items = []) => {
if (photoSwipe) {
photoSwipe.items.length = 0;
items.forEach((item) => {
photoSwipe.items.push(item);
});
photoSwipe.invalidateCurrItems();
// photoSwipe.updateSize(true);
}
};
const checkExifAvailable = async () => {
setExif(null);
await sleep(100);
try {
const img: HTMLImageElement = document.querySelector(
'.pswp__img:not(.pswp__img--placeholder)'
);
if (img) {
const exifData = await exifr.parse(img);
if (!exifData) {
return;
}
exifData.raw = prettyPrintExif(exifData);
setExif(exifData);
}
} catch (e) {
logError(e, 'exifr parsing failed');
}
};
function updateInfo() {
const file: EnteFile = this?.currItem;
if (file?.metadata) {
setMetaData(file.metadata);
setExif(null);
checkExifAvailable();
}
}
const handleCloseInfo = () => {
setShowInfo(false);
};
const handleOpenInfo = () => {
setShowInfo(true);
};
const downloadFileHelper = async (file) => {
appContext.startLoading();
await downloadFile(
file,
publicCollectionGalleryContext.accessedThroughSharedURL,
publicCollectionGalleryContext.token,
publicCollectionGalleryContext.passwordToken
);
appContext.finishLoading();
};
const scheduleUpdate = () => (needUpdate.current = true);
const { id } = props;
let { className } = props;
className = classnames(['pswp', className]).trim();
return (
<>
<div
id={id}
className={className}
tabIndex={Number('-1')}
role="dialog"
aria-hidden="true"
ref={pswpElement}>
<div className="pswp__bg" />
<div className="pswp__scroll-wrap">
<LivePhotoBtn
onClick={livePhotoBtnOptions.click}
onMouseEnter={livePhotoBtnOptions.show}
onMouseLeave={livePhotoBtnOptions.hide}
disabled={livePhotoBtnOptions.loading}
style={{
display: livePhotoBtnOptions.visible
? 'block'
: 'none',
}}>
{livePhotoBtnHTML} {constants.LIVE}
</LivePhotoBtn>
<div className="pswp__container">
<div className="pswp__item" />
<div className="pswp__item" />
<div className="pswp__item" />
</div>
<div className="pswp__ui pswp__ui--hidden">
<div className="pswp__top-bar">
<div className="pswp__counter" />
<button
className="pswp__button pswp__button--close"
title={constants.CLOSE}
/>
{props.enableDownload && (
<button
className="pswp-custom download-btn"
title={constants.DOWNLOAD}
onClick={() =>
downloadFileHelper(photoSwipe.currItem)
}
/>
)}
<button
className="pswp__button pswp__button--fs"
title={constants.TOGGLE_FULLSCREEN}
/>
<button
className="pswp__button pswp__button--zoom"
title={constants.ZOOM_IN_OUT}
/>
{!props.isSharedCollection &&
!props.isTrashCollection && (
<FavButton
size={44}
isClick={isFav}
onClick={() => {
onFavClick(photoSwipe?.currItem);
}}
/>
)}
{!props.isSharedCollection && (
<button
className="pswp-custom info-btn"
title={constants.INFO}
onClick={handleOpenInfo}
/>
)}
<div className="pswp__preloader">
<div className="pswp__preloader__icn">
<div className="pswp__preloader__cut">
<div className="pswp__preloader__donut" />
</div>
</div>
</div>
</div>
<div className="pswp__share-modal pswp__share-modal--hidden pswp__single-tap">
<div className="pswp__share-tooltip" />
</div>
<button
className="pswp__button pswp__button--arrow--left"
title={constants.PREVIOUS}
/>
<button
className="pswp__button pswp__button--arrow--right"
title={constants.NEXT}
/>
<div className="pswp__caption">
<div />
</div>
</div>
</div>
</div>
<InfoModal
shouldDisableEdits={props.isSharedCollection}
showInfo={showInfo}
handleCloseInfo={handleCloseInfo}
items={items}
photoSwipe={photoSwipe}
metadata={metadata}
exif={exif}
scheduleUpdate={scheduleUpdate}
/>
</>
);
}
export default PhotoSwipe;

View file

@ -0,0 +1,7 @@
import styled from 'styled-components';
export const Legend = styled.span`
font-size: 20px;
color: #ddd;
display: inline;
`;

View file

@ -0,0 +1,6 @@
import styled from 'styled-components';
export const LegendContainer = styled.div`
display: flex;
justify-content: space-between;
`;

View file

@ -0,0 +1,16 @@
import styled from 'styled-components';
export const LivePhotoBtn = styled.button`
position: absolute;
bottom: 6vh;
right: 6vh;
height: 40px;
width: 80px;
background: #d7d7d7;
outline: none;
border: none;
border-radius: 10%;
z-index: 10;
cursor: ${(props) => (props.disabled ? 'not-allowed' : 'pointer')};
}
`;

View file

@ -0,0 +1,6 @@
import styled from 'styled-components';
export const Pre = styled.pre`
color: #aaa;
padding: 7px 15px;
`;

View file

@ -0,0 +1,11 @@
import React from 'react';
import EnteSpinner from 'components/EnteSpinner';
export const SmallLoadingSpinner = () => (
<EnteSpinner
style={{
width: '20px',
height: '20px',
}}
/>
);

View file

@ -0,0 +1,7 @@
export const defaultLivePhotoDefaultOptions = {
click: () => {},
hide: () => {},
show: () => {},
loading: false,
visible: false,
};

View file

@ -463,21 +463,21 @@ const englishConstants = {
VIDEO_PLAYBACK_FAILED: 'video format not supported',
VIDEO_PLAYBACK_FAILED_DOWNLOAD_INSTEAD:
'this video cannot be played on your browser',
METADATA: 'metadata',
INFO: 'information',
FILE_ID: 'file id',
FILE_NAME: 'file name',
CREATION_TIME: 'creation time',
UPDATED_ON: 'updated on',
LOCATION: 'location',
METADATA: 'Metadata',
INFO: 'Info',
FILE_ID: 'File id',
FILE_NAME: 'File name',
CREATION_TIME: 'Creation time',
UPDATED_ON: 'Updated on',
LOCATION: 'Location',
SHOW_MAP: 'show on map',
EXIF: 'exif',
DEVICE: 'device',
IMAGE_SIZE: 'image size',
FLASH: 'flash',
FOCAL_LENGTH: 'focal length',
APERTURE: 'aperture',
ISO: 'iso',
EXIF: 'Exif',
DEVICE: 'Device',
IMAGE_SIZE: 'Image size',
FLASH: 'Flash',
FOCAL_LENGTH: 'Focal length',
APERTURE: 'Aperture',
ISO: 'ISO',
SHOW_ALL: 'show all',
LOGIN_TO_UPLOAD_FILES: (count: number) =>
count === 1