diff --git a/src/app/admin/uploads/[uploadPath]/page.tsx b/src/app/admin/uploads/[uploadPath]/page.tsx
index a2faee67..1092ed7d 100644
--- a/src/app/admin/uploads/[uploadPath]/page.tsx
+++ b/src/app/admin/uploads/[uploadPath]/page.tsx
@@ -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); }
diff --git a/src/components/FieldSetWithStatus.tsx b/src/components/FieldSetWithStatus.tsx
index 3c7bdd7f..6e123e97 100644
--- a/src/components/FieldSetWithStatus.tsx
+++ b/src/components/FieldSetWithStatus.tsx
@@ -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({
({note})
}
+ {isModified &&
+
+ *
+ }
{error &&
{error}
diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx
index 54b72d49..b0af5f7b 100644
--- a/src/photo/form/PhotoForm.tsx
+++ b/src/photo/form/PhotoForm.tsx
@@ -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();
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 */}
{type === 'create' ? 'Create' : 'Update'}
+
diff --git a/src/photo/form/index.ts b/src/photo/form/index.ts
index 63ab8698..5c772333 100644
--- a/src/photo/form/index.ts
+++ b/src/photo/form/index.ts
@@ -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,
+ current: Partial,
+) => {
+ return Object
+ .keys(current)
+ .filter(key =>
+ (original[key as keyof PhotoFormData] ?? '') !==
+ (current[key as keyof PhotoFormData] ?? '')
+ ) as (keyof PhotoFormData)[];
+};
+
+export const generateTakenAtFields = (
+ form?: Partial
+): { takenAt: string, takenAtNaive: string } => ({
+ takenAt: form?.takenAt || generateLocalPostgresString(),
+ takenAtNaive: form?.takenAtNaive || generateLocalNaivePostgresString(),
+});
diff --git a/src/photo/server.ts b/src/photo/server.ts
index ce40fe79..3a606a06 100644
--- a/src/photo/server.ts
+++ b/src/photo/server.ts
@@ -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
@@ -55,9 +56,14 @@ export const extractExifDataFromBlobPath = async (
blobId,
...exifData && {
photoFormExif: {
+ ...includeInitialPhotoFields && {
+ ...generateTakenAtFields(),
+ hidden: 'false',
+ favorite: 'false',
+ extension,
+ url,
+ },
...convertExifToFormData(exifData, filmSimulation),
- extension,
- url,
},
},
};
diff --git a/src/site/globals.css b/src/site/globals.css
index 0c7a0614..f5cb2d5c 100644
--- a/src/site/globals.css
+++ b/src/site/globals.css
@@ -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
diff --git a/src/utility/usePreventNavigation.ts b/src/utility/usePreventNavigation.ts
new file mode 100644
index 00000000..bbf44e97
--- /dev/null
+++ b/src/utility/usePreventNavigation.ts
@@ -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]);
+}