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