Warn before throwing out uncommitted form changes

This commit is contained in:
Sam Becker 2024-04-08 21:51:18 -05:00
parent 71da1bda01
commit 7421256cb6
7 changed files with 102 additions and 32 deletions

View File

@ -16,7 +16,7 @@ export default async function UploadPage({ params: { uploadPath } }: Params) {
const {
blobId,
photoFormExif,
} = await extractExifDataFromBlobPath(uploadPath);
} = await extractExifDataFromBlobPath(uploadPath, true);
if (!photoFormExif) { redirect(PATH_ADMIN); }

View File

@ -13,6 +13,7 @@ export default function FieldSetWithStatus({
note,
error,
value,
isModified,
onChange,
selectOptions,
selectOptionsDefaultLabel,
@ -31,6 +32,7 @@ export default function FieldSetWithStatus({
note?: string
error?: string
value: string
isModified?: boolean
onChange?: (value: string) => void
selectOptions?: { value: string, label: string }[]
selectOptionsDefaultLabel?: string
@ -57,6 +59,12 @@ export default function FieldSetWithStatus({
<span className="text-gray-400 dark:text-gray-600">
({note})
</span>}
{isModified &&
<span className={clsx(
'text-main font-medium text-[0.9rem] -ml-1.5 translate-y-[-1px]'
)}>
*
</span>}
{error &&
<span className="text-error">
{error}

View File

@ -1,11 +1,12 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
FORM_METADATA_ENTRIES,
PhotoFormData,
convertFormKeysToLabels,
formHasTextContent,
getChangedFormFields,
getFormErrors,
isFormValid,
} from '.';
@ -16,10 +17,6 @@ import Link from 'next/link';
import { clsx } from 'clsx/lite';
import CanvasBlurCapture from '@/components/CanvasBlurCapture';
import { PATH_ADMIN_PHOTOS, PATH_ADMIN_UPLOADS } from '@/site/paths';
import {
generateLocalNaivePostgresString,
generateLocalPostgresString,
} from '@/utility/date';
import { toastSuccess, toastWarning } from '@/toast';
import { getDimensionsFromSize } from '@/utility/size';
import ImageBlurFallback from '@/components/ImageBlurFallback';
@ -31,6 +28,7 @@ import AiButton from '../ai/AiButton';
import Spinner from '@/components/Spinner';
import { getNextImageUrlForRequest } from '@/services/next-image';
import useDelay from '@/utility/useDelay';
import usePreventNavigation from '@/utility/usePreventNavigation';
const THUMBNAIL_SIZE = 300;
@ -63,6 +61,21 @@ export default function PhotoForm({
const [blurError, setBlurError] =
useState<string>();
const [hasBlurData, setHasBlurData] = useState(false);
const changedFormKeys = useMemo(() =>
getChangedFormFields(initialPhotoForm, formData),
[initialPhotoForm, formData]);
const formHasChanged = changedFormKeys.length > 0;
const onlyChangedFieldIsBlurData =
changedFormKeys.length === 1 &&
changedFormKeys[0] === 'blurData';
usePreventNavigation(formHasChanged && !onlyChangedFieldIsBlurData);
const canFormBeSubmitted =
(type === 'create' || formHasChanged) &&
isFormValid(formData) &&
!aiContent?.isLoading;
const didLoad1000msAgo = useDelay(1000);
@ -112,22 +125,6 @@ export default function PhotoForm({
height,
} = getDimensionsFromSize(THUMBNAIL_SIZE, formData.aspectRatio);
// Generate local date strings when
// none can be extracted from EXIF
useEffect(() => {
if (!formData.takenAt || !formData.takenAtNaive) {
setFormData(data => ({
...data,
...!formData.takenAt && {
takenAt: generateLocalPostgresString(),
},
...!formData.takenAtNaive && {
takenAtNaive: generateLocalNaivePostgresString(),
},
}));
}
}, [formData.takenAt, formData.takenAtNaive]);
const url = formData.url ?? '';
const updateBlurData = useCallback((blurData: string) => {
@ -321,6 +318,7 @@ export default function PhotoForm({
note={note}
error={formErrors[key]}
value={formData[key] ?? ''}
isModified={changedFormKeys.includes(key)}
onChange={value => {
const formUpdated = { ...formData, [key]: value };
setFormData(formUpdated);
@ -357,10 +355,7 @@ export default function PhotoForm({
{/* Actions */}
<div className={clsx(
'flex gap-3 sticky bottom-0',
'pb-4 md:pb-8 pt-10',
'bg-gradient-to-t from-60%',
'from-white/90',
'dark:from-black/95',
'pb-4 md:pb-8 mt-12',
)}>
<Link
className="button"
@ -369,12 +364,19 @@ export default function PhotoForm({
Cancel
</Link>
<SubmitButtonWithStatus
disabled={!isFormValid(formData) || aiContent?.isLoading}
disabled={!canFormBeSubmitted}
onFormStatusChange={onFormStatusChange}
primary
>
{type === 'create' ? 'Create' : 'Update'}
</SubmitButtonWithStatus>
<div className={clsx(
'absolute -top-16 -left-2 right-0 bottom-0 -z-10',
'pointer-events-none',
'bg-gradient-to-t',
'from-white/90 from-60%',
'dark:from-black/90 dark:from-50%',
)} />
</div>
</form>
</div>

View File

@ -3,6 +3,8 @@ import { Photo, PhotoDbInsert, PhotoExif } from '..';
import {
convertTimestampToNaivePostgresString,
convertTimestampWithOffsetToPostgresString,
generateLocalNaivePostgresString,
generateLocalPostgresString,
} from '@/utility/date';
import { getAspectRatioFromExif, getOffsetFromExif } from '@/utility/exif';
import { toFixedNumber } from '@/utility/number';
@ -284,5 +286,25 @@ export const convertFormDataToPhotoDbInsert = (
? parseFloat(photoForm.priorityOrder)
: undefined,
hidden: photoForm.hidden === 'true',
...generateTakenAtFields(photoForm),
};
};
export const getChangedFormFields = (
original: Partial<PhotoFormData>,
current: Partial<PhotoFormData>,
) => {
return Object
.keys(current)
.filter(key =>
(original[key as keyof PhotoFormData] ?? '') !==
(current[key as keyof PhotoFormData] ?? '')
) as (keyof PhotoFormData)[];
};
export const generateTakenAtFields = (
form?: Partial<PhotoFormData>
): { takenAt: string, takenAtNaive: string } => ({
takenAt: form?.takenAt || generateLocalPostgresString(),
takenAtNaive: form?.takenAtNaive || generateLocalNaivePostgresString(),
});

View File

@ -2,7 +2,7 @@ import {
getExtensionFromStorageUrl,
getIdFromStorageUrl,
} from '@/services/storage';
import { convertExifToFormData } from '@/photo/form';
import { convertExifToFormData, generateTakenAtFields } from '@/photo/form';
import {
getFujifilmSimulationFromMakerNote,
isExifForFujifilm,
@ -12,7 +12,8 @@ import { PhotoFormData } from './form';
import { FilmSimulation } from '@/simulation';
export const extractExifDataFromBlobPath = async (
blobPath: string
blobPath: string,
includeInitialPhotoFields?: boolean,
): Promise<{
blobId?: string
photoFormExif?: Partial<PhotoFormData>
@ -55,9 +56,14 @@ export const extractExifDataFromBlobPath = async (
blobId,
...exifData && {
photoFormExif: {
...includeInitialPhotoFields && {
...generateTakenAtFields(),
hidden: 'false',
favorite: 'false',
extension,
url,
},
...convertExifToFormData(exifData, filmSimulation),
extension,
url,
},
},
};

View File

@ -96,7 +96,9 @@
@apply
text-invert
bg-gray-900 dark:bg-gray-100
disabled:bg-gray-900 disabled:dark:bg-gray-100
disabled:text-gray-300 disabled:dark:text-gray-700
disabled:bg-white disabled:dark:bg-black
disabled:border-gray-200 disabled:dark:border-gray-700
border-gray-900 dark:border-gray-100
active:bg-gray-700 active:border-gray-700
active:dark:bg-gray-300 active:dark:border-gray-300

View File

@ -0,0 +1,30 @@
import { useEffect } from 'react';
export default function usePreventNavigation(
enabled?: boolean,
// eslint-disable-next-line max-len
confirmation = 'Are you sure you want to leave this page? Any unsaved changes will be lost.',
includeButtons?: boolean,
) {
useEffect(() => {
const callback = (e: MouseEvent) => {
const target = e.target as HTMLElement | undefined;
const parent = target?.parentElement as HTMLElement | undefined;
const grandParent = parent?.parentElement as HTMLElement | undefined;
const targets = [target, parent, grandParent];
if (
targets.some(target => target?.tagName === 'A') && (
!includeButtons ||
targets.some(target => target?.tagName === 'BUTTON')
)
) {
if (enabled && !confirm(confirmation)) {
e.stopPropagation();
e.preventDefault();
}
}
};
document.addEventListener('click', callback, true);
return () => document.removeEventListener('click', callback, true);
}, [enabled, confirmation, includeButtons]);
}