Warn before throwing out uncommitted form changes
This commit is contained in:
parent
71da1bda01
commit
7421256cb6
@ -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); }
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
30
src/utility/usePreventNavigation.ts
Normal file
30
src/utility/usePreventNavigation.ts
Normal 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]);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user