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 { const {
blobId, blobId,
photoFormExif, photoFormExif,
} = await extractExifDataFromBlobPath(uploadPath); } = await extractExifDataFromBlobPath(uploadPath, true);
if (!photoFormExif) { redirect(PATH_ADMIN); } if (!photoFormExif) { redirect(PATH_ADMIN); }

View File

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

View File

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

View File

@ -3,6 +3,8 @@ import { Photo, PhotoDbInsert, PhotoExif } from '..';
import { import {
convertTimestampToNaivePostgresString, convertTimestampToNaivePostgresString,
convertTimestampWithOffsetToPostgresString, convertTimestampWithOffsetToPostgresString,
generateLocalNaivePostgresString,
generateLocalPostgresString,
} from '@/utility/date'; } from '@/utility/date';
import { getAspectRatioFromExif, getOffsetFromExif } from '@/utility/exif'; import { getAspectRatioFromExif, getOffsetFromExif } from '@/utility/exif';
import { toFixedNumber } from '@/utility/number'; import { toFixedNumber } from '@/utility/number';
@ -284,5 +286,25 @@ export const convertFormDataToPhotoDbInsert = (
? parseFloat(photoForm.priorityOrder) ? parseFloat(photoForm.priorityOrder)
: undefined, : undefined,
hidden: photoForm.hidden === 'true', 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, getExtensionFromStorageUrl,
getIdFromStorageUrl, getIdFromStorageUrl,
} from '@/services/storage'; } from '@/services/storage';
import { convertExifToFormData } from '@/photo/form'; import { convertExifToFormData, generateTakenAtFields } from '@/photo/form';
import { import {
getFujifilmSimulationFromMakerNote, getFujifilmSimulationFromMakerNote,
isExifForFujifilm, isExifForFujifilm,
@ -12,7 +12,8 @@ import { PhotoFormData } from './form';
import { FilmSimulation } from '@/simulation'; import { FilmSimulation } from '@/simulation';
export const extractExifDataFromBlobPath = async ( export const extractExifDataFromBlobPath = async (
blobPath: string blobPath: string,
includeInitialPhotoFields?: boolean,
): Promise<{ ): Promise<{
blobId?: string blobId?: string
photoFormExif?: Partial<PhotoFormData> photoFormExif?: Partial<PhotoFormData>
@ -55,9 +56,14 @@ export const extractExifDataFromBlobPath = async (
blobId, blobId,
...exifData && { ...exifData && {
photoFormExif: { photoFormExif: {
...includeInitialPhotoFields && {
...generateTakenAtFields(),
hidden: 'false',
favorite: 'false',
extension,
url,
},
...convertExifToFormData(exifData, filmSimulation), ...convertExifToFormData(exifData, filmSimulation),
extension,
url,
}, },
}, },
}; };

View File

@ -96,7 +96,9 @@
@apply @apply
text-invert text-invert
bg-gray-900 dark:bg-gray-100 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 border-gray-900 dark:border-gray-100
active:bg-gray-700 active:border-gray-700 active:bg-gray-700 active:border-gray-700
active:dark:bg-gray-300 active:dark:border-gray-300 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]);
}