[web] Start using new Exif library during date modifications (#2604)

This commit is contained in:
Manav Rathi
2024-08-03 10:15:31 +05:30
committed by GitHub
12 changed files with 507 additions and 404 deletions

View File

@@ -7,16 +7,13 @@
"@/base": "*",
"@/media": "*",
"@/new": "*",
"@date-io/date-fns": "^2.14.0",
"@ente/eslint-config": "*",
"@ente/shared": "*",
"@mui/x-date-pickers": "^5.0.0-alpha.6",
"@stripe/stripe-js": "^1.13.2",
"@xmldom/xmldom": "^0.8.10",
"bip39": "^3.0.4",
"bs58": "^5.0.0",
"chrono-node": "^2.2.6",
"date-fns": "^2",
"debounce": "^2.0.0",
"exifr": "^7.1.3",
"exifreader": "^4",

View File

@@ -1,67 +0,0 @@
import { useState } from "react";
import {
LocalizationProvider,
MobileDateTimePicker,
} from "@mui/x-date-pickers";
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
const MIN_EDITED_CREATION_TIME = new Date(1800, 0, 1);
const MAX_EDITED_CREATION_TIME = new Date();
interface Props {
initialValue?: Date;
disabled?: boolean;
label?: string;
onSubmit: (date: Date) => void;
onClose?: () => void;
}
const EnteDateTimePicker = ({
initialValue,
disabled,
onSubmit,
onClose,
}: Props) => {
const [open, setOpen] = useState(true);
const [value, setValue] = useState(initialValue ?? new Date());
const handleClose = () => {
setOpen(false);
onClose?.();
};
return (
<LocalizationProvider dateAdapter={AdapterDateFns}>
<MobileDateTimePicker
value={value}
onChange={setValue}
open={open}
onClose={handleClose}
onOpen={() => setOpen(true)}
maxDateTime={MAX_EDITED_CREATION_TIME}
minDateTime={MIN_EDITED_CREATION_TIME}
disabled={disabled}
onAccept={onSubmit}
DialogProps={{
sx: {
zIndex: "1502",
".MuiPickersToolbar-penIconButton": {
display: "none",
},
".MuiDialog-paper": { width: "320px" },
".MuiClockPicker-root": {
position: "relative",
minHeight: "292px",
},
".PrivatePickersSlideTransition-root": {
minHeight: "200px",
},
},
}}
renderInput={() => <></>}
/>
</LocalizationProvider>
);
};
export default EnteDateTimePicker;

View File

@@ -1,4 +1,11 @@
import log from "@/base/log";
import type { ParsedMetadataDate } from "@/media/file-metadata";
import { FileType } from "@/media/file-type";
import { PhotoDateTimePicker } from "@/new/photos/components/PhotoDateTimePicker";
import downloadManager from "@/new/photos/services/download";
import { extractExifDates } from "@/new/photos/services/exif";
import { EnteFile } from "@/new/photos/types/file";
import { fileLogID } from "@/new/photos/utils/file";
import DialogBox from "@ente/shared/components/DialogBox/";
import {
Button,
@@ -14,41 +21,42 @@ import { useFormik } from "formik";
import { t } from "i18next";
import { GalleryContext } from "pages/gallery";
import React, { useContext, useEffect, useState } from "react";
import { updateCreationTimeWithExif } from "services/fix-exif";
import EnteDateTimePicker from "./EnteDateTimePicker";
import {
changeFileCreationTime,
updateExistingFilePubMetadata,
} from "utils/file";
export interface FixCreationTimeAttributes {
files: EnteFile[];
}
type Step = "running" | "completed" | "completed-with-errors";
/** The current state of the fixing process. */
type Status = "running" | "completed" | "completed-with-errors";
export type FixOption =
| "date-time-original"
| "date-time-digitized"
| "metadata-date"
| "custom-time";
| "custom";
interface FormValues {
option: FixOption;
/**
* Date.toISOString()
*
* Formik doesn't have native support for JS dates, so we instead keep the
* corresponding date's ISO string representation as the form state.
*/
customTimeString: string;
/* Only valid when {@link option} is "custom-time". */
customDate: ParsedMetadataDate | undefined;
}
export interface FixCreationTimeAttributes {
files: EnteFile[];
}
interface FixCreationTimeProps {
isOpen: boolean;
show: () => void;
hide: () => void;
attributes: FixCreationTimeAttributes;
}
const FixCreationTime: React.FC<FixCreationTimeProps> = (props) => {
const [step, setStep] = useState<Step | undefined>();
const FixCreationTime: React.FC<FixCreationTimeProps> = ({
isOpen,
hide,
attributes,
}) => {
const [status, setStatus] = useState<Status | undefined>();
const [progressTracker, setProgressTracker] = useState({
current: 0,
total: 0,
@@ -58,39 +66,36 @@ const FixCreationTime: React.FC<FixCreationTimeProps> = (props) => {
useEffect(() => {
// TODO (MR): Not sure why this is needed
if (props.attributes && props.isOpen && step !== "running") {
setStep(undefined);
}
}, [props.isOpen]);
if (attributes && isOpen && status !== "running") setStatus(undefined);
}, [isOpen]);
const onSubmit = async (values: FormValues) => {
console.log({ values });
setStep("running");
const completedWithErrors = await updateCreationTimeWithExif(
props.attributes.files,
setStatus("running");
const completedWithErrors = await updateFiles(
attributes.files,
values.option,
new Date(values.customTimeString),
values.customDate,
setProgressTracker,
);
setStep(completedWithErrors ? "completed-with-errors" : "completed");
setStatus(completedWithErrors ? "completed-with-errors" : "completed");
await galleryContext.syncWithRemote();
};
const title =
step === "running"
status == "running"
? t("FIX_CREATION_TIME_IN_PROGRESS")
: t("FIX_CREATION_TIME");
const message = messageForStep(step);
const message = messageForStatus(status);
if (!props.attributes) {
if (!attributes) {
return <></>;
}
return (
<DialogBox
open={props.isOpen}
onClose={props.hide}
open={isOpen}
onClose={hide}
attributes={{ title, nonClosable: true }}
>
<div
@@ -98,16 +103,12 @@ const FixCreationTime: React.FC<FixCreationTimeProps> = (props) => {
marginBottom: "10px",
display: "flex",
flexDirection: "column",
...(step === "running" ? { alignItems: "center" } : {}),
...(status == "running" ? { alignItems: "center" } : {}),
}}
>
{message && <div>{message}</div>}
{step === "running" && (
<FixCreationTimeRunning {...{ progressTracker }} />
)}
<OptionsForm {...{ step, onSubmit }} hide={props.hide} />
{status === "running" && <Progress {...{ progressTracker }} />}
<OptionsForm {...{ step: status, onSubmit }} hide={hide} />
</div>
</DialogBox>
);
@@ -115,7 +116,7 @@ const FixCreationTime: React.FC<FixCreationTimeProps> = (props) => {
export default FixCreationTime;
const messageForStep = (step?: Step) => {
const messageForStatus = (step?: Status) => {
switch (step) {
case undefined:
return undefined;
@@ -128,113 +129,7 @@ const messageForStep = (step?: Step) => {
}
};
interface OptionsFormProps {
step?: Step;
onSubmit: (values: FormValues) => void | Promise<any>;
hide: () => void;
}
const OptionsForm: React.FC<OptionsFormProps> = ({ step, onSubmit, hide }) => {
const { values, handleChange, handleSubmit } = useFormik({
initialValues: {
option: "date-time-original",
customTimeString: new Date().toISOString(),
},
validateOnBlur: false,
onSubmit,
});
return (
<>
{(step === undefined || step === "completed-with-errors") && (
<div style={{ marginTop: "10px" }}>
<form onSubmit={handleSubmit}>
<FormControl>
<FormLabel>
{t("UPDATE_CREATION_TIME_NOT_STARTED")}
</FormLabel>
</FormControl>
<RadioGroup name={"option"} onChange={handleChange}>
<FormControlLabel
value={"date-time-original"}
control={<Radio size="small" />}
label={t("DATE_TIME_ORIGINAL")}
/>
<FormControlLabel
value={"date-time-digitized"}
control={<Radio size="small" />}
label={t("DATE_TIME_DIGITIZED")}
/>
<FormControlLabel
value={"metadata-date"}
control={<Radio size="small" />}
label={t("METADATA_DATE")}
/>
<FormControlLabel
value={"custom-time"}
control={<Radio size="small" />}
label={t("CUSTOM_TIME")}
/>
</RadioGroup>
{values.option === "custom-time" && (
<EnteDateTimePicker
onSubmit={(d: Date) =>
handleChange("customTimeString")(
d.toISOString(),
)
}
/>
)}
</form>
</div>
)}
<Footer step={step} startFix={handleSubmit} hide={hide} />
</>
);
};
const Footer = ({ step, startFix, ...props }) => {
return (
step !== "running" && (
<div
style={{
width: "100%",
display: "flex",
marginTop: "30px",
justifyContent: "space-around",
}}
>
{(step === undefined || step === "completed-with-errors") && (
<Button
color="secondary"
size="large"
onClick={() => {
props.hide();
}}
>
{t("cancel")}
</Button>
)}
{step === "completed" && (
<Button color="primary" size="large" onClick={props.hide}>
{t("CLOSE")}
</Button>
)}
{(step === undefined || step === "completed-with-errors") && (
<>
<div style={{ width: "30px" }} />
<Button color="accent" size="large" onClick={startFix}>
{t("FIX_CREATION_TIME")}
</Button>
</>
)}
</div>
)
);
};
const FixCreationTimeRunning = ({ progressTracker }) => {
const Progress = ({ progressTracker }) => {
const progress = Math.round(
(progressTracker.current * 100) / progressTracker.total,
);
@@ -262,3 +157,198 @@ const FixCreationTimeRunning = ({ progressTracker }) => {
</>
);
};
interface OptionsFormProps {
step?: Status;
onSubmit: (values: FormValues) => Promise<void>;
hide: () => void;
}
const OptionsForm: React.FC<OptionsFormProps> = ({ step, onSubmit, hide }) => {
const { values, handleChange, setValues, handleSubmit } =
useFormik<FormValues>({
initialValues: {
option: "date-time-original",
customDate: undefined,
},
validateOnBlur: false,
onSubmit,
});
return (
<>
{(step === undefined || step === "completed-with-errors") && (
<div style={{ marginTop: "10px" }}>
<form onSubmit={handleSubmit}>
<FormControl>
<FormLabel>
{t("UPDATE_CREATION_TIME_NOT_STARTED")}
</FormLabel>
</FormControl>
<RadioGroup
name={"option"}
value={values.option}
onChange={handleChange}
>
<FormControlLabel
value={"date-time-original"}
control={<Radio size="small" />}
label={t("DATE_TIME_ORIGINAL")}
/>
<FormControlLabel
value={"date-time-digitized"}
control={<Radio size="small" />}
label={t("DATE_TIME_DIGITIZED")}
/>
<FormControlLabel
value={"metadata-date"}
control={<Radio size="small" />}
label={t("METADATA_DATE")}
/>
<FormControlLabel
value={"custom"}
control={<Radio size="small" />}
label={t("CUSTOM_TIME")}
/>
</RadioGroup>
{values.option == "custom" && (
<PhotoDateTimePicker
onAccept={(customDate) =>
setValues({ option: "custom", customDate })
}
/>
)}
</form>
</div>
)}
<Footer step={step} startFix={handleSubmit} hide={hide} />
</>
);
};
const Footer = ({ step, startFix, ...props }) => {
return (
step != "running" && (
<div
style={{
width: "100%",
display: "flex",
marginTop: "30px",
justifyContent: "space-around",
}}
>
{(!step || step == "completed-with-errors") && (
<Button
color="secondary"
size="large"
onClick={() => {
props.hide();
}}
>
{t("cancel")}
</Button>
)}
{step == "completed" && (
<Button color="primary" size="large" onClick={props.hide}>
{t("CLOSE")}
</Button>
)}
{(!step || step == "completed-with-errors") && (
<>
<div style={{ width: "30px" }} />
<Button color="accent" size="large" onClick={startFix}>
{t("FIX_CREATION_TIME")}
</Button>
</>
)}
</div>
)
);
};
type SetProgressTracker = React.Dispatch<
React.SetStateAction<{
current: number;
total: number;
}>
>;
const updateFiles = async (
enteFiles: EnteFile[],
fixOption: FixOption,
customDate: ParsedMetadataDate,
setProgressTracker: SetProgressTracker,
) => {
setProgressTracker({ current: 0, total: enteFiles.length });
let hadErrors = false;
for (const [i, enteFile] of enteFiles.entries()) {
try {
await updateEnteFileDate(enteFile, fixOption, customDate);
} catch (e) {
log.error(`Failed to update date of ${fileLogID(enteFile)}`, e);
hadErrors = true;
} finally {
setProgressTracker({ current: i + 1, total: enteFiles.length });
}
}
return hadErrors;
};
/**
* Update the date associated with a given {@link enteFile}.
*
* This is generally treated as the creation date of the underlying asset
* (photo, video, live photo) that this file stores.
*
* - For images, this function allows us to update this date from the Exif and
* other metadata embedded in the file.
*
* - For all types of files (including images), this function allows us to
* update this date to an explicitly provided value.
*
* If an Exif-involving {@link fixOption} is passed for an non-image file, then
* that file is just skipped over. Similarly, if an Exif-involving
* {@link fixOption} is provided, but the given underlying image for the given
* {@link enteFile} does not have a corresponding Exif (or related) value, then
* that file is skipped.
*
* Note that metadata associated with an {@link EnteFile} is immutable, and we
* instead modify the mutable metadata section associated with the file. See
* [Note: Metadatum] for more details.
*/
export const updateEnteFileDate = async (
enteFile: EnteFile,
fixOption: FixOption,
customDate: ParsedMetadataDate,
) => {
let newDate: ParsedMetadataDate | undefined;
if (fixOption === "custom") {
newDate = customDate;
} else if (enteFile.metadata.fileType == FileType.image) {
const stream = await downloadManager.getFile(enteFile);
const blob = await new Response(stream).blob();
const file = new File([blob], enteFile.metadata.title);
const { DateTimeOriginal, DateTimeDigitized, MetadataDate, DateTime } =
await extractExifDates(file);
switch (fixOption) {
case "date-time-original":
newDate = DateTimeOriginal ?? DateTime;
break;
case "date-time-digitized":
newDate = DateTimeDigitized;
break;
case "metadata-date":
newDate = MetadataDate;
break;
}
}
if (newDate && newDate.timestamp !== enteFile.metadata.creationTime) {
const updatedFile = await changeFileCreationTime(
enteFile,
newDate.timestamp,
);
updateExistingFilePubMetadata(enteFile, updatedFile);
}
};

View File

@@ -1,9 +1,10 @@
import log from "@/base/log";
import type { ParsedMetadataDate } from "@/media/file-metadata";
import { PhotoDateTimePicker } from "@/new/photos/components/PhotoDateTimePicker";
import { EnteFile } from "@/new/photos/types/file";
import { FlexWrapper } from "@ente/shared/components/Container";
import { formatDate, formatTime } from "@ente/shared/time/format";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
import EnteDateTimePicker from "components/EnteDateTimePicker";
import { useState } from "react";
import {
changeFileCreationTime,
@@ -27,11 +28,11 @@ export function RenderCreationTime({
const openEditMode = () => setIsInEditMode(true);
const closeEditMode = () => setIsInEditMode(false);
const saveEdits = async (pickedTime: Date) => {
const saveEdits = async (pickedTime: ParsedMetadataDate) => {
try {
setLoading(true);
if (isInEditMode && file) {
const unixTimeInMicroSec = pickedTime.getTime() * 1000;
const unixTimeInMicroSec = pickedTime.timestamp;
if (unixTimeInMicroSec === file?.metadata.creationTime) {
closeEditMode();
return;
@@ -63,10 +64,10 @@ export function RenderCreationTime({
hideEditOption={shouldDisableEdits || isInEditMode}
/>
{isInEditMode && (
<EnteDateTimePicker
<PhotoDateTimePicker
initialValue={originalCreationTime}
disabled={loading}
onSubmit={saveEdits}
onAccept={saveEdits}
onClose={closeEditMode}
/>
)}

View File

@@ -4,6 +4,7 @@ import { nameAndExtension } from "@/base/file";
import type { ParsedMetadata } from "@/media/file-metadata";
import { FileType } from "@/media/file-type";
import { UnidentifiedFaces } from "@/new/photos/components/PeopleList";
import { photoSwipeZIndex } from "@/new/photos/components/PhotoViewer";
import { tagNumericValue, type RawExifTags } from "@/new/photos/services/exif";
import { isMLEnabled } from "@/new/photos/services/ml";
import { EnteFile } from "@/new/photos/types/file";
@@ -339,7 +340,7 @@ const parseExifInfo = (
const FileInfoSidebar = styled((props: DialogProps) => (
<EnteDrawer {...props} anchor="right" />
))({
zIndex: 1501,
zIndex: photoSwipeZIndex + 1,
"& .MuiPaper-root": {
padding: 8,
},

View File

@@ -1066,7 +1066,6 @@ export default function Gallery() {
<FixCreationTime
isOpen={fixCreationTimeView}
hide={() => setFixCreationTimeView(false)}
show={() => setFixCreationTimeView(true)}
attributes={fixCreationTimeAttributes}
/>
<GalleryNavbar

View File

@@ -1,105 +0,0 @@
import log from "@/base/log";
import { FileType } from "@/media/file-type";
import downloadManager from "@/new/photos/services/download";
import { EnteFile } from "@/new/photos/types/file";
import { detectFileTypeInfo } from "@/new/photos/utils/detect-type";
import { validateAndGetCreationUnixTimeInMicroSeconds } from "@ente/shared/time";
import { getParsedExifData } from "@ente/shared/utils/exif-old";
import type { FixOption } from "components/FixCreationTime";
import {
changeFileCreationTime,
updateExistingFilePubMetadata,
} from "utils/file";
const EXIF_TIME_TAGS = [
"DateTimeOriginal",
"CreateDate",
"ModifyDate",
"DateCreated",
"MetadataDate",
];
export type SetProgressTracker = React.Dispatch<
React.SetStateAction<{
current: number;
total: number;
}>
>;
export async function updateCreationTimeWithExif(
filesToBeUpdated: EnteFile[],
fixOption: FixOption,
customTime: Date,
setProgressTracker: SetProgressTracker,
) {
let completedWithError = false;
try {
if (filesToBeUpdated.length === 0) {
return completedWithError;
}
setProgressTracker({ current: 0, total: filesToBeUpdated.length });
for (const [index, file] of filesToBeUpdated.entries()) {
try {
let correctCreationTime: number;
if (fixOption === "custom-time") {
correctCreationTime = customTime.getTime() * 1000;
} else {
if (file.metadata.fileType !== FileType.image) {
continue;
}
const fileStream = await downloadManager.getFile(file);
const fileBlob = await new Response(fileStream).blob();
const fileObject = new File(
[fileBlob],
file.metadata.title,
);
const fileTypeInfo = await detectFileTypeInfo(fileObject);
const exifData = await getParsedExifData(
fileObject,
fileTypeInfo,
EXIF_TIME_TAGS,
);
if (fixOption === "date-time-original") {
correctCreationTime =
validateAndGetCreationUnixTimeInMicroSeconds(
exifData?.DateTimeOriginal ??
exifData?.DateCreated,
);
} else if (fixOption === "date-time-digitized") {
correctCreationTime =
validateAndGetCreationUnixTimeInMicroSeconds(
exifData?.CreateDate,
);
} else if (fixOption === "metadata-date") {
correctCreationTime =
validateAndGetCreationUnixTimeInMicroSeconds(
exifData?.MetadataDate,
);
}
}
if (
correctCreationTime &&
correctCreationTime !== file.metadata.creationTime
) {
const updatedFile = await changeFileCreationTime(
file,
correctCreationTime,
);
updateExistingFilePubMetadata(file, updatedFile);
}
} catch (e) {
log.error("failed to updated a CreationTime With Exif", e);
completedWithError = true;
} finally {
setProgressTracker({
current: index + 1,
total: filesToBeUpdated.length,
});
}
}
} catch (e) {
log.error("update CreationTime With Exif failed", e);
completedWithError = true;
}
return completedWithError;
}

View File

@@ -148,6 +148,15 @@ specifying the import map as mentioned
but that disables the SWC integration altogether, so we live with this
infelicity for now.
### Date pickers
[@mui/x-date-pickers](https://mui.com/x/react-date-pickers/getting-started/) is
used to get a date/time picker component. This is the community version of the
DateTimePicker component provided by MUI.
[dayjs](https://github.com/iamkun/dayjs) is used as the date library that that
`@mui/x-date-pickers` will internally use to manipulate dates.
### Translations
For showing the app's UI in multiple languages, we use the

View File

@@ -6,6 +6,8 @@
"@/base": "*",
"@/utils": "*",
"@ente/shared": "*",
"@mui/x-date-pickers": "^7.12.0",
"dayjs": "^1.11.12",
"formik": "^2.4",
"idb": "^8",
"zod": "^3"

View File

@@ -0,0 +1,167 @@
import type { ParsedMetadataDate } from "@/media/file-metadata";
import { photoSwipeZIndex } from "@/new/photos/components/PhotoViewer";
import {
LocalizationProvider,
MobileDateTimePicker,
} from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs, { Dayjs } from "dayjs";
import React, { useState } from "react";
interface PhotoDateTimePickerProps {
/**
* The initial date to preselect in the date/time picker.
*
* If not provided, the current date/time is used.
*/
initialValue?: Date;
/**
* If true, then the picker shows provided date/time but doesn't allow
* editing it.
*/
disabled?: boolean;
/**
* Callback invoked when the user makes and confirms a date/time.
*/
onAccept: (date: ParsedMetadataDate) => void;
/**
* Optional callback invoked when the picker has been closed.
*/
onClose?: () => void;
}
/**
* A customized version of MUI DateTimePicker suitable for use in selecting and
* modifying the date/time for a photo.
*
* On success, it returns a {@link ParsedMetadataDate} which contains the a
* local date/time string representation of the selected date, the current UTC
* offset, and an epoch timestamp. The idea is that the user is picking a
* date/time in the hypothetical timezone of where the photo was taken.
*
* We return local (current) UTC offset, but this might be different from what
* the user is imagining when they're picking a date. So it should be taken as
* an advisory, and only used if the photo does not already have an associated
* UTC offset. For more discussion of the caveats and nuances around this, see
* [Note: Photos are always in local date/time].
*/
export const PhotoDateTimePicker: React.FC<PhotoDateTimePickerProps> = ({
initialValue,
disabled,
onAccept,
onClose,
}) => {
const [open, setOpen] = useState(true);
const [value, setValue] = useState<Dayjs | null>(dayjs(initialValue));
const handleAccept = (d: Dayjs | null) => {
if (!dayjs.isDayjs(d))
throw new Error(`Unexpected non-dayjs result ${typeof d}`);
onAccept(parseMetadataDateFromDayjs(d));
};
const handleClose = () => {
setOpen(false);
onClose?.();
};
return (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<MobileDateTimePicker
value={value}
onChange={(d) => setValue(d)}
open={open}
onClose={handleClose}
onOpen={() => setOpen(true)}
disabled={disabled}
disableFuture={true}
/* The dialog grows too big on the default portrait mode with
our theme customizations. So we instead use the landscape
layout. This works great on desktop since we have sufficient
width. MUI omits the sidebar on mobile devices (using the
pointer:fine media query), so it remains functional on mobile
devices too. */
orientation="landscape"
onAccept={handleAccept}
slots={{ field: EmptyField }}
slotProps={{
/* The time picker has a smaller height than the calendar,
which causes an ungainly layout shift. Prevent this by
giving a minimum height to the picker.
The constant 336px will likely change in the future when
MUI gets updated, so this solution is fragile. However
MUI is anyways intending to replace the TimeClock with a
DigitalTimePicker that has a better UX. */
layout: {
sx: {
".MuiTimeClock-root": {
minHeight: "336px",
},
},
},
/* We also get opened from the info drawer in the photo
viewer, so give our dialog a higher z-index than both the
photo viewer and the info drawer */
dialog: {
sx: {
zIndex: photoSwipeZIndex + 2,
},
},
}}
/>
</LocalizationProvider>
);
};
/**
* We don't wish to render any UI for the MUI DateTimePicker when it is closed,
* and instead only wish to use it as a dialog that we trigger ourselves.
*
* To achieve this we provide this nop-DOM element as the "field" slot to the
* date/time picker.
*
* See: https://mui.com/x/react-date-pickers/custom-field/
*/
const EmptyField: React.FC = () => <></>;
/**
* A variant of {@link parseMetadataDate} that does the same thing, but for
* {@link Dayjs} instances.
*/
const parseMetadataDateFromDayjs = (d: Dayjs): ParsedMetadataDate => {
// `Dayjs.format` returns an ISO 8601 string of the form
// 2020-04-02T08:02:17-05:00'.
//
// https://day.js.org/docs/en/display/format
//
// This is different from the JavaScript `Date.toISOString` which also
// returns an ISO 8601 string, but with the time zone descriptor always set
// to UTC Zulu ("Z").
//
// The behaviour of Dayjs.format is more convenient for us, since it does
// both things we wish for:
// - Display the date in the local timezone
// - Include the timezone offset.
const s = d.format();
let dateTime: string;
let offsetTime: string | undefined;
// Check to see if there is a time-zone descriptor of the form "Z" or
// "±05:30" or "±0530" at the end of s.
const m = s.match(/Z|[+-]\d\d:?\d\d$/);
if (m?.index) {
dateTime = s.substring(0, m.index);
offsetTime = s.substring(m.index);
} else {
throw new Error(
`Dayjs.format returned a string "${s}" without a timezone offset`,
);
}
const timestamp = d.valueOf() * 1000;
return { dateTime, offsetTime, timestamp };
};

View File

@@ -0,0 +1,5 @@
/**
* PhotoSwipe sets the zIndex of its "pswp" class to 1500. We need to go higher
* than that for our drawers and dialogs to show above it.
*/
export const photoSwipeZIndex = 1500;

View File

@@ -183,7 +183,7 @@
dependencies:
"@babel/helper-plugin-utils" "^7.24.7"
"@babel/runtime@^7.0.0", "@babel/runtime@^7.12.0", "@babel/runtime@^7.18.9":
"@babel/runtime@^7.0.0", "@babel/runtime@^7.12.0":
version "7.23.9"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.9.tgz#47791a15e4603bb5f905bc0753801cf21d6345f7"
integrity sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==
@@ -204,6 +204,13 @@
dependencies:
regenerator-runtime "^0.14.0"
"@babel/runtime@^7.25.0":
version "7.25.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.0.tgz#3af9a91c1b739c569d5d80cc917280919c544ecb"
integrity sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==
dependencies:
regenerator-runtime "^0.14.0"
"@babel/template@^7.24.7":
version "7.24.7"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.7.tgz#02efcee317d0609d2c07117cb70ef8fb17ab7315"
@@ -238,39 +245,6 @@
"@babel/helper-validator-identifier" "^7.24.7"
to-fast-properties "^2.0.0"
"@date-io/core@^2.15.0", "@date-io/core@^2.17.0":
version "2.17.0"
resolved "https://registry.yarnpkg.com/@date-io/core/-/core-2.17.0.tgz#360a4d0641f069776ed22e457876e8a8a58c205e"
integrity sha512-+EQE8xZhRM/hsY0CDTVyayMDDY5ihc4MqXCrPxooKw19yAzUIC6uUqsZeaOFNL9YKTNxYKrJP5DFgE8o5xRCOw==
"@date-io/date-fns@^2.14.0", "@date-io/date-fns@^2.15.0":
version "2.17.0"
resolved "https://registry.yarnpkg.com/@date-io/date-fns/-/date-fns-2.17.0.tgz#1d9d0a02e0137524331819c9576a4e8e19a6142b"
integrity sha512-L0hWZ/mTpy3Gx/xXJ5tq5CzHo0L7ry6KEO9/w/JWiFWFLZgiNVo3ex92gOl3zmzjHqY/3Ev+5sehAr8UnGLEng==
dependencies:
"@date-io/core" "^2.17.0"
"@date-io/dayjs@^2.15.0":
version "2.17.0"
resolved "https://registry.yarnpkg.com/@date-io/dayjs/-/dayjs-2.17.0.tgz#ec3e2384136c028971ca2f78800a6877b9fdbe62"
integrity sha512-Iq1wjY5XzBh0lheFA0it6Dsyv94e8mTiNR8vuTai+KopxDkreL3YjwTmZHxkgB7/vd0RMIACStzVgWvPATnDCA==
dependencies:
"@date-io/core" "^2.17.0"
"@date-io/luxon@^2.15.0":
version "2.17.0"
resolved "https://registry.yarnpkg.com/@date-io/luxon/-/luxon-2.17.0.tgz#76e1f001aaa38fe7f0049f010fe356db1bb517d2"
integrity sha512-l712Vdm/uTddD2XWt9TlQloZUiTiRQtY5TCOG45MQ/8u0tu8M17BD6QYHar/3OrnkGybALAMPzCy1r5D7+0HBg==
dependencies:
"@date-io/core" "^2.17.0"
"@date-io/moment@^2.15.0":
version "2.17.0"
resolved "https://registry.yarnpkg.com/@date-io/moment/-/moment-2.17.0.tgz#04d2487d9d15d468b2e7903b87268fa1c89b56cb"
integrity sha512-e4nb4CDZU4k0WRVhz1Wvl7d+hFsedObSauDHKtZwU9kt7gdYEAzKgnrSCTHsEaXrDumdrkCYTeZ0Tmyk7uV4tw==
dependencies:
"@date-io/core" "^2.17.0"
"@discoveryjs/json-ext@0.5.7":
version "0.5.7"
resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70"
@@ -683,6 +657,15 @@
"@mui/utils" "^5.16.0"
prop-types "^15.8.1"
"@mui/private-theming@^5.16.6":
version "5.16.6"
resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-5.16.6.tgz#547671e7ae3f86b68d1289a0b90af04dfcc1c8c9"
integrity sha512-rAk+Rh8Clg7Cd7shZhyt2HGTTE5wYKNSJ5sspf28Fqm/PZ69Er9o6KX25g03/FG2dfpg5GCwZh/xOojiTfm3hw==
dependencies:
"@babel/runtime" "^7.23.9"
"@mui/utils" "^5.16.6"
prop-types "^15.8.1"
"@mui/styled-engine@^5.15.14":
version "5.15.14"
resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.15.14.tgz#168b154c4327fa4ccc1933a498331d53f61c0de2"
@@ -693,6 +676,16 @@
csstype "^3.1.3"
prop-types "^15.8.1"
"@mui/styled-engine@^5.16.6":
version "5.16.6"
resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.16.6.tgz#60110c106dd482dfdb7e2aa94fd6490a0a3f8852"
integrity sha512-zaThmS67ZmtHSWToTiHslbI8jwrmITcN93LQaR2lKArbvS7Z3iLkwRoiikNWutx9MBs8Q6okKvbZq1RQYB3v7g==
dependencies:
"@babel/runtime" "^7.23.9"
"@emotion/cache" "^11.11.0"
csstype "^3.1.3"
prop-types "^15.8.1"
"@mui/system@^5.16.0":
version "5.16.0"
resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.16.0.tgz#e5b4cfbdfbc0ee9859f6b168e8b07d750303b7a0"
@@ -707,20 +700,29 @@
csstype "^3.1.3"
prop-types "^15.8.1"
"@mui/system@^5.16.5":
version "5.16.6"
resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.16.6.tgz#2dabe63d2e45816ce611c40d6e3f79b9c2ccbcd7"
integrity sha512-5xgyJjBIMPw8HIaZpfbGAaFYPwImQn7Nyh+wwKWhvkoIeDosQ1ZMVrbTclefi7G8hNmqhip04duYwYpbBFnBgw==
dependencies:
"@babel/runtime" "^7.23.9"
"@mui/private-theming" "^5.16.6"
"@mui/styled-engine" "^5.16.6"
"@mui/types" "^7.2.15"
"@mui/utils" "^5.16.6"
clsx "^2.1.0"
csstype "^3.1.3"
prop-types "^15.8.1"
"@mui/types@^7.2.14":
version "7.2.14"
resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.14.tgz#8a02ac129b70f3d82f2f9b76ded2c8d48e3fc8c9"
integrity sha512-MZsBZ4q4HfzBsywtXgM1Ksj6HDThtiwmOKUXH1pKYISI9gAVXCNHNpo7TlGoGrBaYWZTdNoirIN7JsQcQUjmQQ==
"@mui/utils@^5.10.3":
version "5.15.11"
resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.15.11.tgz#a71804d6d6025783478fd1aca9afbf83d9b789c7"
integrity sha512-D6bwqprUa9Stf8ft0dcMqWyWDKEo7D+6pB1k8WajbqlYIRA8J8Kw9Ra7PSZKKePGBGWO+/xxrX1U8HpG/aXQCw==
dependencies:
"@babel/runtime" "^7.23.9"
"@types/prop-types" "^15.7.11"
prop-types "^15.8.1"
react-is "^18.2.0"
"@mui/types@^7.2.15":
version "7.2.15"
resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.15.tgz#dadd232fe9a70be0d526630675dff3b110f30b53"
integrity sha512-nbo7yPhtKJkdf9kcVOF8JZHPZTmqXjJ/tI0bdWgHg5tp9AnIN4Y7f7wm9T+0SyGYJk76+GYZ8Q5XaTYAsUHN0Q==
"@mui/utils@^5.15.14", "@mui/utils@^5.16.0":
version "5.16.0"
@@ -732,23 +734,30 @@
prop-types "^15.8.1"
react-is "^18.2.0"
"@mui/x-date-pickers@^5.0.0-alpha.6":
version "5.0.20"
resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-5.0.20.tgz#7b4e5b5a214a8095937ba7d82bb82acd6f270d72"
integrity sha512-ERukSeHIoNLbI1C2XRhF9wRhqfsr+Q4B1SAw2ZlU7CWgcG8UBOxgqRKDEOVAIoSWL+DWT6GRuQjOKvj6UXZceA==
"@mui/utils@^5.16.5", "@mui/utils@^5.16.6":
version "5.16.6"
resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.16.6.tgz#905875bbc58d3dcc24531c3314a6807aba22a711"
integrity sha512-tWiQqlhxAt3KENNiSRL+DIn9H5xNVK6Jjf70x3PnfQPz1MPBdh7yyIcAyVBT9xiw7hP3SomRhPR7hzBMBCjqEA==
dependencies:
"@babel/runtime" "^7.18.9"
"@date-io/core" "^2.15.0"
"@date-io/date-fns" "^2.15.0"
"@date-io/dayjs" "^2.15.0"
"@date-io/luxon" "^2.15.0"
"@date-io/moment" "^2.15.0"
"@mui/utils" "^5.10.3"
"@types/react-transition-group" "^4.4.5"
clsx "^1.2.1"
prop-types "^15.7.2"
"@babel/runtime" "^7.23.9"
"@mui/types" "^7.2.15"
"@types/prop-types" "^15.7.12"
clsx "^2.1.1"
prop-types "^15.8.1"
react-is "^18.3.1"
"@mui/x-date-pickers@^7.12.0":
version "7.12.0"
resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-7.12.0.tgz#189acc9e3d2a5be260fab9faf5e6add516e59b09"
integrity sha512-WU5C7QNfSpJ9cP8vl2sY7q35NW+0TUMgEy+sl98fcPhLckq3cgV1wnVxoZnQZ3BxVQAtx+7ag/MpefU03vJcVw==
dependencies:
"@babel/runtime" "^7.25.0"
"@mui/system" "^5.16.5"
"@mui/utils" "^5.16.5"
"@types/react-transition-group" "^4.4.10"
clsx "^2.1.1"
prop-types "^15.8.1"
react-transition-group "^4.4.5"
rifm "^0.12.1"
"@next/bundle-analyzer@^14.1":
version "14.2.4"
@@ -1076,7 +1085,7 @@
resolved "https://registry.yarnpkg.com/@types/photoswipe/-/photoswipe-4.1.6.tgz#41d1e0a54c1b27628688e8abf9b95baab472a247"
integrity sha512-6kN4KYjNF4sg79fSwZ46s4Pron4+YJxoW0DQOcHveUZc/3cWd8Q4B9OLlDmEYw9iI6fODU8kyyq8ZBy+8F/+zQ==
"@types/prop-types@*", "@types/prop-types@^15.7.11":
"@types/prop-types@*", "@types/prop-types@^15.7.11", "@types/prop-types@^15.7.12":
version "15.7.12"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.12.tgz#12bb1e2be27293c1406acb6af1c3f3a1481d98c6"
integrity sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==
@@ -1105,7 +1114,7 @@
"@types/react-dom" "*"
"@types/react-transition-group" "*"
"@types/react-transition-group@*", "@types/react-transition-group@^4.4.10", "@types/react-transition-group@^4.4.5":
"@types/react-transition-group@*", "@types/react-transition-group@^4.4.10":
version "4.4.10"
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.10.tgz#6ee71127bdab1f18f11ad8fb3322c6da27c327ac"
integrity sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==
@@ -1684,12 +1693,7 @@ cliui@^8.0.1:
strip-ansi "^6.0.1"
wrap-ansi "^7.0.0"
clsx@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
clsx@^2.1.0:
clsx@^2.1.0, clsx@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
@@ -1827,7 +1831,7 @@ data-view-byte-offset@^1.0.0:
es-errors "^1.3.0"
is-data-view "^1.0.1"
date-fns@^2, date-fns@^2.30.0:
date-fns@^2.30.0:
version "2.30.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0"
integrity sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==
@@ -1839,6 +1843,11 @@ dayjs@^1.10.0:
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0"
integrity sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==
dayjs@^1.11.12:
version "1.11.12"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.12.tgz#5245226cc7f40a15bf52e0b99fd2a04669ccac1d"
integrity sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg==
debounce@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
@@ -3895,7 +3904,7 @@ process-nextick-args@~2.0.0:
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@@ -3977,7 +3986,7 @@ react-is@^16.13.1, react-is@^16.7.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-is@^18.2.0:
react-is@^18.2.0, react-is@^18.3.1:
version "18.3.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
@@ -4140,11 +4149,6 @@ reusify@^1.0.4:
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
rifm@^0.12.1:
version "0.12.1"
resolved "https://registry.yarnpkg.com/rifm/-/rifm-0.12.1.tgz#8fa77f45b7f1cda2a0068787ac821f0593967ac4"
integrity sha512-OGA1Bitg/dSJtI/c4dh90svzaUPt228kzFsUkJbtA2c964IqEAwWXeL9ZJi86xWv3j5SMqRvGULl7bA6cK0Bvg==
rimraf@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"