mirror of
https://github.com/ente-io/ente.git
synced 2025-07-04 14:36:53 +00:00
1059 lines
34 KiB
TypeScript
1059 lines
34 KiB
TypeScript
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
|
/* TODO: Audit this file
|
|
Plan of action:
|
|
- Move common components into FileInfoComponents.tsx
|
|
|
|
- Move the rest out to files in the apps themeselves: albums/SharedFileInfo
|
|
and photos/FileInfo to deal with the @/new/photos imports here.
|
|
*/
|
|
|
|
import { assertionFailed } from "@/base/assert";
|
|
import { LinkButtonUndecorated } from "@/base/components/LinkButton";
|
|
import { type ButtonishProps } from "@/base/components/mui";
|
|
import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator";
|
|
import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer";
|
|
import { SingleInputForm } from "@/base/components/SingleInputForm";
|
|
import { Titlebar } from "@/base/components/Titlebar";
|
|
import { EllipsizedTypography } from "@/base/components/Typography";
|
|
import {
|
|
useModalVisibility,
|
|
type ModalVisibilityProps,
|
|
} from "@/base/components/utils/modal";
|
|
import { useBaseContext } from "@/base/context";
|
|
import { haveWindow } from "@/base/env";
|
|
import { nameAndExtension } from "@/base/file-name";
|
|
import { formattedDate, formattedTime } from "@/base/i18n-date";
|
|
import log from "@/base/log";
|
|
import type { Location } from "@/base/types";
|
|
import { CopyButton } from "@/gallery/components/FileInfoComponents";
|
|
import { tagNumericValue, type RawExifTags } from "@/gallery/services/exif";
|
|
import {
|
|
changeCaption,
|
|
changeFileName,
|
|
updateExistingFilePubMetadata,
|
|
} from "@/gallery/services/file";
|
|
import { formattedByteSize } from "@/gallery/utils/units";
|
|
import { type EnteFile } from "@/media/file";
|
|
import {
|
|
fileCreationPhotoDate,
|
|
fileLocation,
|
|
filePublicMagicMetadata,
|
|
updateRemotePublicMagicMetadata,
|
|
type ParsedMetadata,
|
|
type ParsedMetadataDate,
|
|
} from "@/media/file-metadata";
|
|
import { FileType } from "@/media/file-type";
|
|
import { FileDateTimePicker } from "@/new/photos/components/FileDateTimePicker";
|
|
import { FilePeopleList } from "@/new/photos/components/PeopleList";
|
|
import {
|
|
confirmDisableMapsDialogAttributes,
|
|
confirmEnableMapsDialogAttributes,
|
|
} from "@/new/photos/components/utils/dialog";
|
|
import { useSettingsSnapshot } from "@/new/photos/components/utils/use-snapshot";
|
|
import {
|
|
aboveFileViewerContentZ,
|
|
fileInfoDrawerZ,
|
|
} from "@/new/photos/components/utils/z-index";
|
|
import {
|
|
getAnnotatedFacesForFile,
|
|
isMLEnabled,
|
|
type AnnotatedFaceID,
|
|
} from "@/new/photos/services/ml";
|
|
import { updateMapEnabled } from "@/new/photos/services/settings";
|
|
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
|
|
import CameraOutlinedIcon from "@mui/icons-material/CameraOutlined";
|
|
import CloseIcon from "@mui/icons-material/Close";
|
|
import DoneIcon from "@mui/icons-material/Done";
|
|
import EditIcon from "@mui/icons-material/Edit";
|
|
import FaceRetouchingNaturalIcon from "@mui/icons-material/FaceRetouchingNatural";
|
|
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
|
|
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
|
|
import PhotoOutlinedIcon from "@mui/icons-material/PhotoOutlined";
|
|
import TextSnippetOutlinedIcon from "@mui/icons-material/TextSnippetOutlined";
|
|
import VideocamOutlinedIcon from "@mui/icons-material/VideocamOutlined";
|
|
import {
|
|
Box,
|
|
Button,
|
|
CircularProgress,
|
|
Dialog,
|
|
DialogContent,
|
|
DialogTitle,
|
|
IconButton,
|
|
InputAdornment,
|
|
Link,
|
|
Stack,
|
|
styled,
|
|
TextField,
|
|
Typography,
|
|
type ButtonProps,
|
|
type DialogProps,
|
|
} from "@mui/material";
|
|
import { useFormik } from "formik";
|
|
import { t } from "i18next";
|
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
// Re-uses images from ~leaflet package.
|
|
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.webpack.css";
|
|
import "leaflet/dist/leaflet.css";
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unused-expressions
|
|
haveWindow() && require("leaflet-defaulticon-compatibility");
|
|
const leaflet = haveWindow()
|
|
? // eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
(require("leaflet") as typeof import("leaflet"))
|
|
: null;
|
|
|
|
/**
|
|
* Exif data for a file, in a form suitable for use by {@link FileInfo}.
|
|
*
|
|
* TODO: Indicate missing exif (e.g. videos) better, both in the data type, and
|
|
* in the UI (e.g. by omitting the entire row).
|
|
*/
|
|
export interface FileInfoExif {
|
|
tags: RawExifTags | undefined;
|
|
parsed: ParsedMetadata | undefined;
|
|
}
|
|
|
|
export type FileInfoProps = ModalVisibilityProps & {
|
|
/**
|
|
* The file whose information we are showing.
|
|
*/
|
|
file: EnteFile | undefined;
|
|
/**
|
|
* Exif information for {@link file}.
|
|
*/
|
|
exif: FileInfoExif | undefined;
|
|
/**
|
|
* If set, then controls to edit the file's metadata (name, date, caption)
|
|
* will be shown.
|
|
*/
|
|
allowEdits?: boolean;
|
|
/**
|
|
* If set, then an inline map will be shown (if the user has enabled it)
|
|
* using the file's location.
|
|
*/
|
|
allowMap?: boolean;
|
|
/**
|
|
* If set, then a clickable chip will be shown for each collection that this
|
|
* file is a part of.
|
|
*
|
|
* Uses {@link fileCollectionIDs}, {@link allCollectionsNameByID} and
|
|
* {@link onSelectCollection}, so all of those props should also be set for
|
|
* this to have an effect.
|
|
*/
|
|
showCollections?: boolean;
|
|
/**
|
|
* A map from file IDs to the IDs of the collections that they're a part of.
|
|
*
|
|
* Used when {@link showCollections} is set.
|
|
*/
|
|
fileCollectionIDs?: Map<number, number[]>;
|
|
/**
|
|
* A map from collection IDs to their name.
|
|
*
|
|
* Used when {@link showCollections} is set.
|
|
*/
|
|
allCollectionsNameByID?: Map<number, string>;
|
|
scheduleUpdate: () => void;
|
|
refreshPhotoswipe: () => void;
|
|
/**
|
|
* Called when the user selects a collection from among the collections that
|
|
* the file belongs to.
|
|
*/
|
|
onSelectCollection?: (collectionID: number) => void;
|
|
/**
|
|
* Called when the user selects a person in the file info panel.
|
|
*/
|
|
onSelectPerson?: (personID: string) => void;
|
|
};
|
|
|
|
export const FileInfo: React.FC<FileInfoProps> = ({
|
|
open,
|
|
onClose,
|
|
file,
|
|
exif,
|
|
allowEdits,
|
|
allowMap,
|
|
showCollections,
|
|
fileCollectionIDs,
|
|
allCollectionsNameByID,
|
|
scheduleUpdate,
|
|
refreshPhotoswipe,
|
|
onSelectCollection,
|
|
onSelectPerson,
|
|
}) => {
|
|
const { showMiniDialog } = useBaseContext();
|
|
|
|
const { mapEnabled } = useSettingsSnapshot();
|
|
|
|
const [annotatedFaces, setAnnotatedFaces] = useState<AnnotatedFaceID[]>([]);
|
|
|
|
const { show: showRawExif, props: rawExifVisibilityProps } =
|
|
useModalVisibility();
|
|
|
|
const location = useMemo(
|
|
// Prefer the location in the EnteFile, then fall back to Exif.
|
|
() => (file ? fileLocation(file) : undefined) ?? exif?.parsed?.location,
|
|
[file, exif],
|
|
);
|
|
|
|
const annotatedExif = useMemo(() => annotateExif(exif), [exif]);
|
|
|
|
useEffect(() => {
|
|
if (!file) return;
|
|
if (!isMLEnabled()) return;
|
|
|
|
let didCancel = false;
|
|
|
|
void getAnnotatedFacesForFile(file).then(
|
|
(faces) => !didCancel && setAnnotatedFaces(faces),
|
|
);
|
|
|
|
return () => {
|
|
didCancel = true;
|
|
};
|
|
}, [file]);
|
|
|
|
const openEnableMapConfirmationDialog = () =>
|
|
showMiniDialog(
|
|
confirmEnableMapsDialogAttributes(() => updateMapEnabled(true)),
|
|
);
|
|
|
|
const openDisableMapConfirmationDialog = () =>
|
|
showMiniDialog(
|
|
confirmDisableMapsDialogAttributes(() => updateMapEnabled(false)),
|
|
);
|
|
|
|
const handleSelectFace = ({ personID }: AnnotatedFaceID) =>
|
|
onSelectPerson?.(personID);
|
|
|
|
if (!file) {
|
|
if (open) assertionFailed();
|
|
return <></>;
|
|
}
|
|
|
|
return (
|
|
<FileInfoSidebar {...{ open, onClose }}>
|
|
<Titlebar onClose={onClose} title={t("info")} backIsClose />
|
|
<Stack sx={{ pt: 1, pb: 3, gap: "20px" }}>
|
|
<Caption
|
|
{...{
|
|
file,
|
|
allowEdits,
|
|
scheduleUpdate,
|
|
refreshPhotoswipe,
|
|
}}
|
|
/>
|
|
<CreationTime {...{ file, allowEdits, scheduleUpdate }} />
|
|
<FileName
|
|
{...{ file, annotatedExif, allowEdits, scheduleUpdate }}
|
|
/>
|
|
|
|
{annotatedExif?.takenOnDevice && (
|
|
<InfoItem
|
|
icon={<CameraOutlinedIcon />}
|
|
title={annotatedExif.takenOnDevice}
|
|
caption={createMultipartCaption(
|
|
annotatedExif.fNumber,
|
|
annotatedExif.exposureTime,
|
|
annotatedExif.iso,
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{location && (
|
|
<>
|
|
<InfoItem
|
|
icon={<LocationOnOutlinedIcon />}
|
|
title={t("location")}
|
|
caption={
|
|
!mapEnabled || !allowMap ? (
|
|
<Link
|
|
href={openStreetMapLink(location)}
|
|
target="_blank"
|
|
rel="noopener"
|
|
sx={{ fontWeight: "medium" }}
|
|
>
|
|
{t("view_on_map")}
|
|
</Link>
|
|
) : (
|
|
<LinkButtonUndecorated
|
|
onClick={
|
|
openDisableMapConfirmationDialog
|
|
}
|
|
>
|
|
{t("disable_map")}
|
|
</LinkButtonUndecorated>
|
|
)
|
|
}
|
|
trailingButton={
|
|
<CopyButton
|
|
size="medium"
|
|
text={openStreetMapLink(location)}
|
|
/>
|
|
}
|
|
/>
|
|
{allowMap && (
|
|
<MapBox
|
|
location={location}
|
|
mapEnabled={mapEnabled}
|
|
openUpdateMapConfirmationDialog={
|
|
openEnableMapConfirmationDialog
|
|
}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
<InfoItem
|
|
icon={<TextSnippetOutlinedIcon />}
|
|
title={t("details")}
|
|
caption={
|
|
!exif ? (
|
|
<ActivityIndicator size={12} />
|
|
) : !exif.tags ? (
|
|
t("no_exif")
|
|
) : (
|
|
<LinkButtonUndecorated onClick={showRawExif}>
|
|
{t("view_exif")}
|
|
</LinkButtonUndecorated>
|
|
)
|
|
}
|
|
/>
|
|
{annotatedFaces.length > 0 && (
|
|
<InfoItem icon={<FaceRetouchingNaturalIcon />}>
|
|
<FilePeopleList
|
|
file={file}
|
|
annotatedFaceIDs={annotatedFaces}
|
|
onSelectFace={handleSelectFace}
|
|
/>
|
|
</InfoItem>
|
|
)}
|
|
{showCollections &&
|
|
fileCollectionIDs &&
|
|
allCollectionsNameByID &&
|
|
onSelectCollection && (
|
|
<Albums
|
|
{...{
|
|
file,
|
|
fileCollectionIDs,
|
|
allCollectionsNameByID,
|
|
onSelectCollection,
|
|
}}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
<RawExif
|
|
{...rawExifVisibilityProps}
|
|
onInfoClose={onClose}
|
|
tags={exif?.tags}
|
|
fileName={file.metadata.title}
|
|
/>
|
|
</FileInfoSidebar>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Some immediate fields of interest, in the form that we want to display on the
|
|
* info panel for a file.
|
|
*/
|
|
type AnnotatedExif = Required<FileInfoExif> & {
|
|
resolution?: string;
|
|
megaPixels?: string;
|
|
takenOnDevice?: string;
|
|
fNumber?: string;
|
|
exposureTime?: string;
|
|
iso?: string;
|
|
};
|
|
|
|
const annotateExif = (
|
|
fileInfoExif: FileInfoExif | undefined,
|
|
): AnnotatedExif | undefined => {
|
|
if (!fileInfoExif || !fileInfoExif.tags || !fileInfoExif.parsed)
|
|
return undefined;
|
|
|
|
const info: AnnotatedExif = { ...fileInfoExif };
|
|
|
|
const { width, height } = fileInfoExif.parsed;
|
|
if (width && height) {
|
|
info.resolution = `${width} x ${height}`;
|
|
const mp = Math.round((width * height) / 1000000);
|
|
if (mp) info.megaPixels = `${mp}MP`;
|
|
}
|
|
|
|
const { tags } = fileInfoExif;
|
|
const { exif } = tags;
|
|
|
|
if (exif) {
|
|
if (exif.Make && exif.Model)
|
|
info.takenOnDevice = `${exif.Make.description} ${exif.Model.description}`;
|
|
|
|
if (exif.FNumber)
|
|
info.fNumber = exif.FNumber.description; /* e.g. "f/16" */
|
|
|
|
if (exif.ExposureTime)
|
|
info.exposureTime = exif.ExposureTime.description; /* "1/10" */
|
|
|
|
if (exif.ISOSpeedRatings)
|
|
info.iso = `ISO${tagNumericValue(exif.ISOSpeedRatings)}`;
|
|
}
|
|
|
|
return info;
|
|
};
|
|
|
|
const FileInfoSidebar = styled(
|
|
(props: Pick<DialogProps, "open" | "onClose" | "children">) => (
|
|
<SidebarDrawer
|
|
{...props}
|
|
anchor="right"
|
|
// See: [Note: Overzealous Chrome? Complicated ARIA?], but this time
|
|
// with a different workaround.
|
|
//
|
|
// https://github.com/mui/material-ui/issues/43106#issuecomment-2514637251
|
|
disableRestoreFocus={true}
|
|
closeAfterTransition={true}
|
|
/>
|
|
),
|
|
)(({ theme }) => ({
|
|
zIndex: fileInfoDrawerZ,
|
|
// [Note: Lighter backdrop for overlays on photo viewer]
|
|
//
|
|
// The default backdrop color we use for the drawer in light mode is too
|
|
// "white" when used in the image gallery because unlike the rest of the app
|
|
// the gallery retains a black background irrespective of the mode. So use a
|
|
// lighter scrim when overlaying content directly atop the image gallery.
|
|
//
|
|
// We don't need to add this special casing for nested overlays (e.g.
|
|
// dialogs initiated from the file info drawer itself) since now there is
|
|
// enough "white" on the screen to warrant the stronger (default) backdrop.
|
|
...theme.applyStyles("light", {
|
|
".MuiBackdrop-root": {
|
|
backgroundColor: theme.vars.palette.backdrop.faint,
|
|
},
|
|
}),
|
|
}));
|
|
|
|
interface InfoItemProps {
|
|
/**
|
|
* The icon associated with the info entry.
|
|
*/
|
|
icon: React.ReactNode;
|
|
/**
|
|
* The primary content / title of the info entry.
|
|
*
|
|
* Only used if {@link children} are not specified.
|
|
*/
|
|
title?: string;
|
|
/**
|
|
* The secondary information / subtext associated with the info entry.
|
|
*
|
|
* Only used if {@link children} are not specified.
|
|
*/
|
|
caption?: React.ReactNode;
|
|
/**
|
|
* A component, usually a button (e.g. an "edit button"), shown at the
|
|
* trailing edge of the info entry.
|
|
*/
|
|
trailingButton?: React.ReactNode;
|
|
}
|
|
|
|
/**
|
|
* An entry in the file info panel listing.
|
|
*/
|
|
const InfoItem: React.FC<React.PropsWithChildren<InfoItemProps>> = ({
|
|
icon,
|
|
title,
|
|
caption,
|
|
trailingButton,
|
|
children,
|
|
}) => (
|
|
<Stack
|
|
direction="row"
|
|
sx={{ alignItems: "flex-start", flex: 1, gap: "12px" }}
|
|
>
|
|
<InfoItemIconContainer>{icon}</InfoItemIconContainer>
|
|
{children ? (
|
|
<Box sx={{ flex: 1, mt: "4px" }}>{children}</Box>
|
|
) : (
|
|
<Stack sx={{ flex: 1, mt: "4px", gap: "4px" }}>
|
|
<Typography sx={{ wordBreak: "break-all" }}>{title}</Typography>
|
|
<Typography
|
|
variant="small"
|
|
{...(typeof caption == "string"
|
|
? {}
|
|
: { component: "div" })}
|
|
sx={{ color: "text.muted" }}
|
|
>
|
|
{caption}
|
|
</Typography>
|
|
</Stack>
|
|
)}
|
|
{trailingButton}
|
|
</Stack>
|
|
);
|
|
|
|
const InfoItemIconContainer = styled("div")(
|
|
({ theme }) => `
|
|
width: 48px;
|
|
aspect-ratio: 1;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
color: ${theme.vars.palette.stroke.muted}
|
|
`,
|
|
);
|
|
|
|
type EditButtonProps = ButtonishProps & {
|
|
/**
|
|
* If true, then an activity indicator is shown in place of the edit icon.
|
|
*/
|
|
loading?: boolean;
|
|
};
|
|
|
|
const EditButton: React.FC<EditButtonProps> = ({ onClick, loading }) => (
|
|
<IconButton onClick={onClick} disabled={!!loading} color="secondary">
|
|
{!loading ? (
|
|
<EditIcon />
|
|
) : (
|
|
<CircularProgress size={"24px"} color="inherit" />
|
|
)}
|
|
</IconButton>
|
|
);
|
|
|
|
type CaptionProps = Pick<
|
|
FileInfoProps,
|
|
"allowEdits" | "scheduleUpdate" | "refreshPhotoswipe"
|
|
> & {
|
|
/* TODO(PS): This is DisplayFile, but that's meant to be removed */
|
|
file: EnteFile & {
|
|
title?: string;
|
|
};
|
|
};
|
|
|
|
const Caption: React.FC<CaptionProps> = ({
|
|
file,
|
|
allowEdits,
|
|
scheduleUpdate,
|
|
refreshPhotoswipe,
|
|
}) => {
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
const caption = file.pubMagicMetadata?.data.caption ?? "";
|
|
|
|
const formik = useFormik<{ caption: string }>({
|
|
initialValues: { caption },
|
|
validate: ({ caption }) =>
|
|
caption.length > 5000
|
|
? { caption: t("caption_character_limit") }
|
|
: {},
|
|
onSubmit: async ({ caption: newCaption }, { setFieldError }) => {
|
|
if (newCaption == caption) return;
|
|
setIsSaving(true);
|
|
try {
|
|
const updatedFile = await changeCaption(file, newCaption);
|
|
updateExistingFilePubMetadata(file, updatedFile);
|
|
// @ts-ignore
|
|
file.title = file.pubMagicMetadata.data.caption;
|
|
} catch (e) {
|
|
log.error("Failed to update caption", e);
|
|
setFieldError("caption", t("generic_error"));
|
|
}
|
|
refreshPhotoswipe();
|
|
scheduleUpdate();
|
|
setIsSaving(false);
|
|
},
|
|
});
|
|
|
|
const { values, errors, handleChange, handleSubmit, resetForm } = formik;
|
|
|
|
if (!caption.length && !allowEdits) {
|
|
return <></>;
|
|
}
|
|
|
|
return (
|
|
<CaptionForm onSubmit={handleSubmit}>
|
|
<TextField
|
|
id="caption"
|
|
name="caption"
|
|
type="text"
|
|
multiline
|
|
aria-label={t("description")}
|
|
hiddenLabel
|
|
fullWidth
|
|
placeholder={t("caption_placeholder")}
|
|
value={values.caption}
|
|
onChange={handleChange("caption")}
|
|
error={!!errors.caption}
|
|
helperText={errors.caption}
|
|
disabled={!allowEdits || isSaving}
|
|
/>
|
|
{values.caption != caption && (
|
|
<Stack direction="row" sx={{ justifyContent: "flex-end" }}>
|
|
<IconButton
|
|
type="submit"
|
|
disabled={isSaving}
|
|
// Prevent layout shift when we're showing progress.
|
|
sx={{ minWidth: "48px" }}
|
|
>
|
|
{isSaving ? (
|
|
<CircularProgress size="18px" color="inherit" />
|
|
) : (
|
|
<DoneIcon />
|
|
)}
|
|
</IconButton>
|
|
<IconButton onClick={() => resetForm()} disabled={isSaving}>
|
|
<CloseIcon />
|
|
</IconButton>
|
|
</Stack>
|
|
)}
|
|
</CaptionForm>
|
|
);
|
|
};
|
|
|
|
const CaptionForm = styled("form")(({ theme }) => ({
|
|
padding: theme.spacing(1),
|
|
}));
|
|
|
|
type CreationTimeProps = Pick<
|
|
FileInfoProps,
|
|
"allowEdits" | "scheduleUpdate"
|
|
> & {
|
|
file: EnteFile;
|
|
};
|
|
|
|
const CreationTime: React.FC<CreationTimeProps> = ({
|
|
file,
|
|
allowEdits,
|
|
scheduleUpdate,
|
|
}) => {
|
|
const { onGenericError } = useBaseContext();
|
|
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
const originalDate = fileCreationPhotoDate(
|
|
file,
|
|
filePublicMagicMetadata(file),
|
|
);
|
|
|
|
const saveEdits = async (pickedTime: ParsedMetadataDate) => {
|
|
setIsEditing(false);
|
|
|
|
const { dateTime, timestamp: editedTime } = pickedTime;
|
|
if (editedTime == originalDate.getTime()) {
|
|
// Same as before.
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
try {
|
|
// [Note: Don't modify offsetTime when editing date via picker]
|
|
//
|
|
// Use the updated date time (both in its canonical dateTime form,
|
|
// and also as in the epoch timestamp), but don't use the offset.
|
|
//
|
|
// The offset here will be the offset of the computer where this
|
|
// user is making this edit, not the offset of the place where the
|
|
// photo was taken. In a future iteration of the date time editor,
|
|
// we can provide functionality for the user to edit the associated
|
|
// offset, but right now it is not even surfaced, so don't also
|
|
// potentially overwrite it.
|
|
await updateRemotePublicMagicMetadata(file, {
|
|
dateTime,
|
|
editedTime,
|
|
});
|
|
} catch (e) {
|
|
onGenericError(e);
|
|
}
|
|
scheduleUpdate();
|
|
setIsSaving(false);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<InfoItem
|
|
icon={<CalendarTodayIcon />}
|
|
title={formattedDate(originalDate)}
|
|
caption={formattedTime(originalDate)}
|
|
trailingButton={
|
|
allowEdits && (
|
|
<EditButton
|
|
onClick={() => setIsEditing(true)}
|
|
loading={isSaving}
|
|
/>
|
|
)
|
|
}
|
|
/>
|
|
{isEditing && (
|
|
<FileDateTimePicker
|
|
initialValue={originalDate}
|
|
onAccept={saveEdits}
|
|
onDidClose={() => setIsEditing(false)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
type FileNameProps = Pick<FileInfoProps, "allowEdits" | "scheduleUpdate"> & {
|
|
file: EnteFile;
|
|
annotatedExif: AnnotatedExif | undefined;
|
|
};
|
|
|
|
const FileName: React.FC<FileNameProps> = ({
|
|
file,
|
|
annotatedExif,
|
|
allowEdits,
|
|
scheduleUpdate,
|
|
}) => {
|
|
const { show: showRename, props: renameVisibilityProps } =
|
|
useModalVisibility();
|
|
|
|
const fileName = file.metadata.title;
|
|
|
|
const handleRename = async (newFileName: string) => {
|
|
const updatedFile = await changeFileName(file, newFileName);
|
|
updateExistingFilePubMetadata(file, updatedFile);
|
|
scheduleUpdate();
|
|
};
|
|
|
|
const icon =
|
|
file.metadata.fileType === FileType.video ? (
|
|
<VideocamOutlinedIcon />
|
|
) : (
|
|
<PhotoOutlinedIcon />
|
|
);
|
|
|
|
const fileSize = file.info?.fileSize;
|
|
const caption = createMultipartCaption(
|
|
annotatedExif?.megaPixels,
|
|
annotatedExif?.resolution,
|
|
fileSize ? formattedByteSize(fileSize) : undefined,
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<InfoItem
|
|
icon={icon}
|
|
title={fileName}
|
|
caption={caption}
|
|
trailingButton={
|
|
allowEdits && <EditButton onClick={showRename} />
|
|
}
|
|
/>
|
|
<RenameFileDialog
|
|
{...renameVisibilityProps}
|
|
fileName={fileName}
|
|
onRename={handleRename}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const createMultipartCaption = (
|
|
p1: string | undefined,
|
|
p2: string | undefined,
|
|
p3: string | undefined,
|
|
) => (
|
|
<Stack direction="row" sx={{ gap: 1 }}>
|
|
{p1 && <div>{p1}</div>}
|
|
{p2 && <div>{p2}</div>}
|
|
{p3 && <div>{p3}</div>}
|
|
</Stack>
|
|
);
|
|
|
|
type RenameFileDialogProps = ModalVisibilityProps & {
|
|
/**
|
|
* The current name of the file.
|
|
*/
|
|
fileName: string;
|
|
/**
|
|
* Called when the user makes a change to the existing name and activates the
|
|
* rename button on the dialog.
|
|
*
|
|
* @param newFileName The changed name. The extension currently cannot be
|
|
* modified, but it is guaranteed the name component of {@link newFileName}
|
|
* will be different from that of the {@link fileName} prop of the dialog.
|
|
*
|
|
* Until the promise settles, the dialog will show an activity indicator. If
|
|
* the promise rejects, it will also show an error. If the promise is
|
|
* fulfilled, then the dialog will also be closed.
|
|
*
|
|
* The dialog will also be closed if the user activates the rename button
|
|
* without changing the name.
|
|
*/
|
|
onRename: (newFileName: string) => Promise<void>;
|
|
};
|
|
|
|
const RenameFileDialog: React.FC<RenameFileDialogProps> = ({
|
|
open,
|
|
onClose,
|
|
fileName,
|
|
onRename,
|
|
}) => {
|
|
const [name, extension] = nameAndExtension(fileName);
|
|
|
|
const handleSubmit = async (newName: string) => {
|
|
const newFileName = [newName, extension].filter((x) => !!x).join(".");
|
|
if (newFileName != fileName) {
|
|
await onRename(newFileName);
|
|
}
|
|
onClose();
|
|
};
|
|
|
|
return (
|
|
<Dialog
|
|
{...{ open, onClose }}
|
|
sx={{ zIndex: aboveFileViewerContentZ }}
|
|
fullWidth
|
|
maxWidth="xs"
|
|
>
|
|
<DialogTitle sx={{ "&&&": { paddingBlock: "26px 0px" } }}>
|
|
{t("rename_file")}
|
|
</DialogTitle>
|
|
<DialogContent>
|
|
<SingleInputForm
|
|
label={t("file_name")}
|
|
placeholder={t("file_name")}
|
|
autoFocus
|
|
initialValue={name}
|
|
submitButtonTitle={t("rename")}
|
|
onSubmit={handleSubmit}
|
|
onCancel={onClose}
|
|
slotProps={{
|
|
input: {
|
|
// Align the adornment text to the input text.
|
|
sx: { alignItems: "baseline" },
|
|
endAdornment: extension && (
|
|
<InputAdornment position="end">
|
|
{`.${extension}`}
|
|
</InputAdornment>
|
|
),
|
|
},
|
|
}}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
const openStreetMapLink = ({ latitude, longitude }: Location) =>
|
|
`https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=15/${latitude}/${longitude}`;
|
|
|
|
interface MapBoxProps {
|
|
location: Location;
|
|
mapEnabled: boolean;
|
|
openUpdateMapConfirmationDialog: () => void;
|
|
}
|
|
|
|
const MapBox: React.FC<MapBoxProps> = ({
|
|
location,
|
|
mapEnabled,
|
|
openUpdateMapConfirmationDialog,
|
|
}) => {
|
|
const urlTemplate = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
|
|
const attribution =
|
|
'© <a target="_blank" rel="noopener" href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
|
const zoom = 16;
|
|
|
|
const mapBoxContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const mapContainer = mapBoxContainerRef.current;
|
|
if (mapEnabled) {
|
|
const position: L.LatLngTuple = [
|
|
location.latitude,
|
|
location.longitude,
|
|
];
|
|
if (mapContainer && !mapContainer.hasChildNodes()) {
|
|
// @ts-ignore
|
|
const map = leaflet.map(mapContainer).setView(position, zoom);
|
|
// @ts-ignore
|
|
leaflet
|
|
.tileLayer(urlTemplate, {
|
|
attribution,
|
|
})
|
|
.addTo(map);
|
|
// @ts-ignore
|
|
leaflet.marker(position).addTo(map).openPopup();
|
|
}
|
|
} else {
|
|
if (mapContainer?.hasChildNodes()) {
|
|
if (mapContainer.firstChild) {
|
|
mapContainer.removeChild(mapContainer.firstChild);
|
|
}
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [mapEnabled]);
|
|
|
|
return mapEnabled ? (
|
|
<MapBoxContainer ref={mapBoxContainerRef} />
|
|
) : (
|
|
<MapBoxEnableContainer>
|
|
<ChipButton onClick={openUpdateMapConfirmationDialog}>
|
|
{t("enable_map")}
|
|
</ChipButton>
|
|
</MapBoxEnableContainer>
|
|
);
|
|
};
|
|
|
|
const MapBoxContainer = styled("div")`
|
|
height: 200px;
|
|
width: 100%;
|
|
`;
|
|
|
|
const MapBoxEnableContainer = styled(MapBoxContainer)(
|
|
({ theme }) => `
|
|
position: relative;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
background-color: ${theme.vars.palette.fill.fainter};
|
|
`,
|
|
);
|
|
|
|
interface RawExifProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onInfoClose: () => void;
|
|
tags: RawExifTags | undefined;
|
|
fileName: string;
|
|
}
|
|
|
|
const RawExif: React.FC<RawExifProps> = ({
|
|
open,
|
|
onClose,
|
|
onInfoClose,
|
|
tags,
|
|
fileName,
|
|
}) => {
|
|
if (!tags) {
|
|
return <></>;
|
|
}
|
|
|
|
const handleRootClose = () => {
|
|
onClose();
|
|
onInfoClose();
|
|
};
|
|
|
|
const items: (readonly [string, string, string, string])[] = Object.entries(
|
|
tags,
|
|
)
|
|
.map(([namespace, namespaceTags]) => {
|
|
return Object.entries(namespaceTags).map(([tagName, tag]) => {
|
|
const key = `${namespace}:${tagName}`;
|
|
let description = "<...>";
|
|
if (typeof tag == "string") {
|
|
description = tag;
|
|
} else if (typeof tag == "number") {
|
|
description = `${tag}`;
|
|
} else if (
|
|
tag &&
|
|
typeof tag == "object" &&
|
|
"description" in tag &&
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
typeof tag.description == "string"
|
|
) {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
|
description = tag.description;
|
|
}
|
|
return [key, namespace, tagName, description] as const;
|
|
});
|
|
})
|
|
.flat()
|
|
.filter(([, , , description]) => description);
|
|
|
|
return (
|
|
<FileInfoSidebar open={open} onClose={onClose}>
|
|
<Titlebar
|
|
onClose={onClose}
|
|
title={t("exif")}
|
|
caption={fileName}
|
|
onRootClose={handleRootClose}
|
|
actionButton={
|
|
<CopyButton size="small" text={JSON.stringify(tags)} />
|
|
}
|
|
/>
|
|
<Stack sx={{ gap: 2, py: 3, px: 1 }}>
|
|
{items.map(([key, namespace, tagName, description]) => (
|
|
<ExifItem key={key}>
|
|
<Stack direction="row" sx={{ gap: 1 }}>
|
|
<Typography
|
|
variant="small"
|
|
sx={{ color: "text.muted" }}
|
|
>
|
|
{tagName}
|
|
</Typography>
|
|
<Typography
|
|
variant="tiny"
|
|
sx={{ color: "text.faint" }}
|
|
>
|
|
{namespace}
|
|
</Typography>
|
|
</Stack>
|
|
<EllipsizedTypography sx={{ width: "100%" }}>
|
|
{description}
|
|
</EllipsizedTypography>
|
|
</ExifItem>
|
|
))}
|
|
</Stack>
|
|
</FileInfoSidebar>
|
|
);
|
|
};
|
|
|
|
const ExifItem = styled("div")`
|
|
padding-left: 8px;
|
|
padding-right: 8px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
`;
|
|
|
|
type AlbumsProps = Required<
|
|
Pick<
|
|
FileInfoProps,
|
|
"fileCollectionIDs" | "allCollectionsNameByID" | "onSelectCollection"
|
|
>
|
|
> & {
|
|
file: EnteFile;
|
|
};
|
|
|
|
const Albums: React.FC<AlbumsProps> = ({
|
|
file,
|
|
fileCollectionIDs,
|
|
allCollectionsNameByID,
|
|
onSelectCollection,
|
|
}) => (
|
|
<InfoItem icon={<FolderOutlinedIcon />}>
|
|
<Stack
|
|
direction="row"
|
|
sx={{
|
|
gap: 1,
|
|
flexWrap: "wrap",
|
|
justifyContent: "flex-start",
|
|
alignItems: "flex-start",
|
|
}}
|
|
>
|
|
{fileCollectionIDs
|
|
.get(file.id)
|
|
?.filter((collectionID) =>
|
|
allCollectionsNameByID.has(collectionID),
|
|
)
|
|
.map((collectionID) => (
|
|
<ChipButton
|
|
key={collectionID}
|
|
onClick={() => onSelectCollection(collectionID)}
|
|
>
|
|
{allCollectionsNameByID.get(collectionID)}
|
|
</ChipButton>
|
|
))}
|
|
</Stack>
|
|
</InfoItem>
|
|
);
|
|
|
|
const ChipButton = styled((props: ButtonProps) => (
|
|
<Button color="secondary" {...props} />
|
|
))(({ theme }) => ({
|
|
...theme.typography.small,
|
|
padding: "8px",
|
|
}));
|