From d6adce8e27db379a79a13730a867cbbe89eba629 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Mon, 30 Oct 2023 16:38:13 -0500 Subject: [PATCH 1/6] Refactor photo edit page data handling --- .../admin/photos/[photoId]/edit/page.tsx | 25 +++----- .../admin/uploads/[uploadPath]/page.tsx | 56 +++-------------- src/components/AdminChildPage.tsx | 50 +++++++++------ src/photo/PhotoEditPageClient.tsx | 31 ++++++++++ src/photo/PhotoForm.tsx | 15 +++-- src/photo/form.ts | 7 ++- src/photo/server.ts | 61 +++++++++++++++++++ src/vendors/fujifilm/index.ts | 39 ++++++++---- 8 files changed, 176 insertions(+), 108 deletions(-) create mode 100644 src/photo/PhotoEditPageClient.tsx create mode 100644 src/photo/server.ts diff --git a/src/app/(auth-state)/admin/photos/[photoId]/edit/page.tsx b/src/app/(auth-state)/admin/photos/[photoId]/edit/page.tsx index a5fc1881..cd065d81 100644 --- a/src/app/(auth-state)/admin/photos/[photoId]/edit/page.tsx +++ b/src/app/(auth-state)/admin/photos/[photoId]/edit/page.tsx @@ -1,31 +1,20 @@ -import PhotoForm from '@/photo/PhotoForm'; -import { convertPhotoToFormData } from '@/photo/form'; -import AdminChildPage from '@/components/AdminChildPage'; import { redirect } from 'next/navigation'; import { getPhotoCached } from '@/cache'; -import { PATH_ADMIN, PATH_ADMIN_PHOTOS } from '@/site/paths'; +import { PATH_ADMIN } from '@/site/paths'; +import PhotoEditPageClient from '@/photo/PhotoEditPageClient'; export const runtime = 'edge'; -interface Props { +export default async function PhotoEditPage({ + params: { photoId }, +}: { params: { photoId: string } -} - -export default async function PhotoPageEdit({ params: { photoId } }: Props) { +}) { const photo = await getPhotoCached(photoId); if (!photo) { redirect(PATH_ADMIN); } return ( - - - + ); }; diff --git a/src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx b/src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx index a908701f..8852e404 100644 --- a/src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx +++ b/src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx @@ -1,66 +1,26 @@ import PhotoForm from '@/photo/PhotoForm'; -import { ExifData, ExifParserFactory } from 'ts-exif-parser'; -import { convertExifToFormData } from '@/photo/form'; import AdminChildPage from '@/components/AdminChildPage'; -import { getExtensionFromBlobUrl, getIdFromBlobUrl } from '@/services/blob'; import { PATH_ADMIN_UPLOADS } from '@/site/paths'; -import { - FujifilmSimulation, - getFujifilmSimulationFromMakerNote, - isExifForFujifilm, -} from '@/vendors/fujifilm'; +import { extractFormDataFromUploadPath } from '@/photo/server'; interface Params { params: { uploadPath: string } } export default async function UploadPage({ params: { uploadPath } }: Params) { - const url = decodeURIComponent(uploadPath); - - const extension = getExtensionFromBlobUrl(url); - - const fileBytes = uploadPath - ? await fetch(url) - .then(res => res.arrayBuffer()) - : undefined; - - let exifDataForm: ExifData | undefined; - let filmSimulation: FujifilmSimulation | undefined; - - if (fileBytes) { - const parser = ExifParserFactory.create(Buffer.from(fileBytes)); - - // Data for form - parser.enableBinaryFields(false); - exifDataForm = parser.parse(); - - // Capture film simulation for Fujifilm cameras - if (isExifForFujifilm(exifDataForm)) { - // Parse exif data again with binary fields - // in order to access MakerNote tag - parser.enableBinaryFields(true); - const exifDataBinary = parser.parse(); - const makerNote = exifDataBinary.tags?.MakerNote; - if (Buffer.isBuffer(makerNote)) { - filmSimulation = getFujifilmSimulationFromMakerNote(makerNote); - } - } - } + const { + blobId, + photoForm, + } = await extractFormDataFromUploadPath(uploadPath); return ( - {exifDataForm - ? + {photoForm + ? : null} ); diff --git a/src/components/AdminChildPage.tsx b/src/components/AdminChildPage.tsx index b2fc46f9..57ee3a85 100644 --- a/src/components/AdminChildPage.tsx +++ b/src/components/AdminChildPage.tsx @@ -8,39 +8,49 @@ function AdminChildPage({ backPath, backLabel, breadcrumb, + accessory, children, }: { backPath?: string backLabel?: string breadcrumb?: ReactNode + accessory?: ReactNode children: ReactNode, }) { return ( - {backPath && + {(backPath || breadcrumb || accessory) &&
- - - {backLabel || 'Back'} - - {breadcrumb && - <> - / - - {breadcrumb} - - } +
+ {backPath && + + + {backLabel || 'Back'} + } + {breadcrumb && + <> + / + + {breadcrumb} + + } +
+ {accessory && +
{accessory}
}
}
{children} diff --git a/src/photo/PhotoEditPageClient.tsx b/src/photo/PhotoEditPageClient.tsx new file mode 100644 index 00000000..d6be09b2 --- /dev/null +++ b/src/photo/PhotoEditPageClient.tsx @@ -0,0 +1,31 @@ +'use client'; + +import AdminChildPage from '@/components/AdminChildPage'; +import { Photo } from '.'; +import { PATH_ADMIN_PHOTOS } from '@/site/paths'; +import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; +import { BiRefresh } from 'react-icons/bi'; +import { convertPhotoToFormData } from './form'; +import PhotoForm from './PhotoForm'; + +export default function PhotoEditPageClient({ + photo, +}: { + photo: Photo +}) { + return ( + }> + Refresh EXIF + } + > + + + ); +}; diff --git a/src/photo/PhotoForm.tsx b/src/photo/PhotoForm.tsx index 9f680de0..c2c7055f 100644 --- a/src/photo/PhotoForm.tsx +++ b/src/photo/PhotoForm.tsx @@ -12,7 +12,7 @@ import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import Link from 'next/link'; import { cc } from '@/utility/css'; import CanvasBlurCapture from '@/components/CanvasBlurCapture'; -import { PATH_ADMIN_PHOTOS } from '@/site/paths'; +import { PATH_ADMIN_PHOTOS, PATH_ADMIN_UPLOADS } from '@/site/paths'; import { generateLocalNaivePostgresString, generateLocalPostgresString, @@ -126,13 +126,12 @@ export default function PhotoForm({ type={checkbox ? 'checkbox' : undefined} />)}
- {type === 'edit' && - - Cancel - } + + Cancel + diff --git a/src/photo/form.ts b/src/photo/form.ts index 37e98342..6c1ea947 100644 --- a/src/photo/form.ts +++ b/src/photo/form.ts @@ -1,4 +1,4 @@ -import { ExifData } from 'ts-exif-parser'; +import type { ExifData } from 'ts-exif-parser'; import { Photo, PhotoDbInsert, PhotoExif } from '.'; import { convertTimestampToNaivePostgresString, @@ -11,6 +11,7 @@ import { generateNanoid } from '@/utility/nanoid'; import { FILM_SIMULATION_FORM_INPUT_OPTIONS, FujifilmSimulation, + MAKE_FUJIFILM, } from '@/vendors/fujifilm'; export type PhotoFormData = Record; @@ -48,7 +49,7 @@ const FORM_METADATA: Record = { label: 'fujifilm simulation', options: FILM_SIMULATION_FORM_INPUT_OPTIONS, optionsDefaultLabel: 'Unknown', - hideBasedOnCamera: make => make !== 'FUJIFILM', + hideBasedOnCamera: make => make !== MAKE_FUJIFILM, }, focalLength: { label: 'focal length' }, focalLengthIn35MmFormat: { label: 'focal length 35mm-equivalent' }, @@ -59,9 +60,9 @@ const FORM_METADATA: Record = { locationName: { label: 'location name', hideTemporarily: true }, latitude: { label: 'latitude' }, longitude: { label: 'longitude' }, - priorityOrder: { label: 'priority order' }, takenAt: { label: 'taken at' }, takenAtNaive: { label: 'taken at (naive)' }, + priorityOrder: { label: 'priority order' }, hidden: { label: 'hidden', checkbox: true }, }; diff --git a/src/photo/server.ts b/src/photo/server.ts new file mode 100644 index 00000000..cf8969e5 --- /dev/null +++ b/src/photo/server.ts @@ -0,0 +1,61 @@ +import { getExtensionFromBlobUrl, getIdFromBlobUrl } from '@/services/blob'; +import { convertExifToFormData } from '@/photo/form'; +import { + FujifilmSimulation, + getFujifilmSimulationFromMakerNote, + isExifForFujifilm, +} from '@/vendors/fujifilm'; +import { ExifData, ExifParserFactory } from 'ts-exif-parser'; +import { PhotoFormData } from './form'; + +export const extractFormDataFromUploadPath = async ( + uploadPath: string +): Promise<{ + blobId?: string + photoForm?: Partial +}> => { + const url = decodeURIComponent(uploadPath); + + const blobId = getIdFromBlobUrl(url); + + const extension = getExtensionFromBlobUrl(url); + + const fileBytes = uploadPath + ? await fetch(url) + .then(res => res.arrayBuffer()) + : undefined; + + let exifDataForm: ExifData | undefined; + let filmSimulation: FujifilmSimulation | undefined; + + if (fileBytes) { + const parser = ExifParserFactory.create(Buffer.from(fileBytes)); + + // Data for form + parser.enableBinaryFields(false); + exifDataForm = parser.parse(); + + // Capture film simulation for Fujifilm cameras + if (isExifForFujifilm(exifDataForm)) { + // Parse exif data again with binary fields + // in order to access MakerNote tag + parser.enableBinaryFields(true); + const exifDataBinary = parser.parse(); + const makerNote = exifDataBinary.tags?.MakerNote; + if (Buffer.isBuffer(makerNote)) { + filmSimulation = getFujifilmSimulationFromMakerNote(makerNote); + } + } + } + + return { + blobId, + ...exifDataForm && { + photoForm: { + ...convertExifToFormData(exifDataForm, filmSimulation), + extension, + url: decodeURIComponent(uploadPath), + }, + }, + }; +}; diff --git a/src/vendors/fujifilm/index.ts b/src/vendors/fujifilm/index.ts index edef8813..bcc80cc3 100644 --- a/src/vendors/fujifilm/index.ts +++ b/src/vendors/fujifilm/index.ts @@ -3,11 +3,13 @@ import type { ExifData } from 'ts-exif-parser'; -const MAKE_FUJIFILM = 'FUJIFILM'; +export const MAKE_FUJIFILM = 'FUJIFILM'; +const BYTE_INDEX_TAG_COUNT = 12; const BYTE_INDEX_FIRST_TAG = 14; const BYTES_PER_TAG = 12; -const BYTE_OFFSET_FOR_INT_VALUE = 8; +const BYTE_OFFSET_TAG_TYPE = 2; +const BYTE_OFFSET_TAG_VALUE = 8; const TAG_ID_SATURATION = 0x1003; const TAG_ID_FILM_MODE = 0x1401; @@ -233,16 +235,31 @@ export const getLabelForFilmSimulation = ( const parseFujifilmMakerNote = ( bytes: Buffer, - valueForTag: (tag: number, value: number) => void + valueForTagUInt: (tagId: number, value: number) => void ) => { - for ( - let i = BYTE_INDEX_FIRST_TAG; - i + BYTES_PER_TAG < bytes.length; - i += BYTES_PER_TAG - ) { - const tag = bytes.readUInt16LE(i); - const value = bytes.readUInt16LE(i + BYTE_OFFSET_FOR_INT_VALUE); - valueForTag(tag, value); + const tagCount = bytes.readUint16LE(BYTE_INDEX_TAG_COUNT); + for (let i = 0; i < tagCount; i++) { + const index = BYTE_INDEX_FIRST_TAG + i * BYTES_PER_TAG; + if (index + BYTES_PER_TAG < bytes.length) { + const tagId = bytes.readUInt16LE(index); + const tagType = bytes.readUInt16LE(index + BYTE_OFFSET_TAG_TYPE); + switch (tagType) { + // UInt16 + case 3: + valueForTagUInt( + tagId, + bytes.readUInt16LE(index + BYTE_OFFSET_TAG_VALUE), + ); + break; + // UInt32 + case 4: + valueForTagUInt( + tagId, + bytes.readUInt32LE(index + BYTE_OFFSET_TAG_VALUE), + ); + break; + } + } } }; From 8bb5c2990b66e2778a3d5f4b7aadc4e78d4b903e Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Wed, 1 Nov 2023 00:10:42 -0500 Subject: [PATCH 2/6] Add server actions to get/override EXIF data --- src/components/FieldSetWithStatus.tsx | 1 - src/components/SubmitButtonWithStatus.tsx | 1 - src/photo/PhotoEditPageClient.tsx | 22 +++++++++-- src/photo/PhotoForm.tsx | 10 +++++ src/photo/actions.ts | 48 +++++++++++++++++++++-- src/photo/form.ts | 19 ++++++++- 6 files changed, 90 insertions(+), 11 deletions(-) diff --git a/src/components/FieldSetWithStatus.tsx b/src/components/FieldSetWithStatus.tsx index bdc62717..3bf31245 100644 --- a/src/components/FieldSetWithStatus.tsx +++ b/src/components/FieldSetWithStatus.tsx @@ -1,7 +1,6 @@ 'use client'; import { LegacyRef } from 'react'; -// @ts-ignore import { useFormStatus } from 'react-dom'; import Spinner from './Spinner'; import { cc } from '@/utility/css'; diff --git a/src/components/SubmitButtonWithStatus.tsx b/src/components/SubmitButtonWithStatus.tsx index bb9d661e..60026d46 100644 --- a/src/components/SubmitButtonWithStatus.tsx +++ b/src/components/SubmitButtonWithStatus.tsx @@ -1,7 +1,6 @@ 'use client'; import { HTMLProps } from 'react'; -// @ts-ignore import { useFormStatus } from 'react-dom'; import Spinner from './Spinner'; import { cc } from '@/utility/css'; diff --git a/src/photo/PhotoEditPageClient.tsx b/src/photo/PhotoEditPageClient.tsx index d6be09b2..ffb9f157 100644 --- a/src/photo/PhotoEditPageClient.tsx +++ b/src/photo/PhotoEditPageClient.tsx @@ -5,26 +5,40 @@ import { Photo } from '.'; import { PATH_ADMIN_PHOTOS } from '@/site/paths'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import { BiRefresh } from 'react-icons/bi'; -import { convertPhotoToFormData } from './form'; +import { PhotoFormData, convertPhotoToFormData } from './form'; import PhotoForm from './PhotoForm'; +import { useFormState } from 'react-dom'; +import { getExifDataAction } from './actions'; export default function PhotoEditPageClient({ photo, }: { photo: Photo }) { + const [updatedExifData, action] = useFormState>( + getExifDataAction, + { url: photo.url}, + ); + return ( }> - Refresh EXIF - } + accessory={ +
+ + } + > + Refresh EXIF + +
} > ); diff --git a/src/photo/PhotoForm.tsx b/src/photo/PhotoForm.tsx index c2c7055f..36816bbd 100644 --- a/src/photo/PhotoForm.tsx +++ b/src/photo/PhotoForm.tsx @@ -23,16 +23,26 @@ const THUMBNAIL_HEIGHT = 200; export default function PhotoForm({ initialPhotoForm, + updatedExifData, type = 'create', debugBlur, }: { initialPhotoForm: Partial + updatedExifData?: Partial type?: 'create' | 'edit' debugBlur?: boolean }) { const [formData, setFormData] = useState>(initialPhotoForm); + useEffect(() => { + // Update form when EXIF data is refreshed by parent + setFormData(currentForm => ({ + ...currentForm, + ...updatedExifData, + })); + }, [updatedExifData]); + // Generate local date strings when // none can be harvested from EXIF useEffect(() => { diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 811be878..7223beaf 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -6,8 +6,14 @@ import { sqlDeletePhotoTagGlobally, sqlUpdatePhoto, sqlRenamePhotoTagGlobally, + getPhoto, } from '@/services/postgres'; -import { convertFormDataToPhoto } from './form'; +import { + PhotoFormData, + convertFormDataToPhotoDbInsert, + convertPhotoFormDataToPhotoDbInsert, + convertPhotoToFormData, +} from './form'; import { redirect } from 'next/navigation'; import { convertUploadToPhoto, @@ -20,9 +26,10 @@ import { revalidatePhotosKey, } from '@/cache'; import { PATH_ADMIN_PHOTOS, PATH_ADMIN_TAGS } from '@/site/paths'; +import { extractFormDataFromUploadPath } from './server'; export async function createPhotoAction(formData: FormData) { - const photo = convertFormDataToPhoto(formData, true); + const photo = convertFormDataToPhotoDbInsert(formData, true); const updatedUrl = await convertUploadToPhoto(photo.url, photo.id); @@ -36,7 +43,7 @@ export async function createPhotoAction(formData: FormData) { } export async function updatePhotoAction(formData: FormData) { - const photo = convertFormDataToPhoto(formData); + const photo = convertFormDataToPhotoDbInsert(formData); await sqlUpdatePhoto(photo); @@ -84,7 +91,40 @@ export async function deleteBlobPhotoAction(formData: FormData) { if (formData.get('redirectToPhotos') === 'true') { redirect(PATH_ADMIN_PHOTOS); } -}; +} + +export async function getExifDataAction( + photoFormPrevious: Partial, +): Promise> { + const { url } = photoFormPrevious; + if (url) { + const { photoForm } = await extractFormDataFromUploadPath(url); + if (photoForm) { + return photoForm; + } + } + return {}; +} + +export async function syncPhotoExifDataAction(formData: FormData) { + const photoId = formData.get('photoId') as string; + if (photoId) { + const photo = await getPhoto(photoId); + if (photo) { + const { + photoForm: photoFormExif, + } = await extractFormDataFromUploadPath(photo.url); + if (photoFormExif) { + const photoFormDbInsert = convertPhotoFormDataToPhotoDbInsert({ + ...convertPhotoToFormData(photo), + ...photoFormExif, + }); + await sqlUpdatePhoto(photoFormDbInsert); + revalidatePhotosKey(); + } + } + } +} export async function syncCacheAction() { revalidateAllKeysAndPaths(); diff --git a/src/photo/form.ts b/src/photo/form.ts index 6c1ea947..860fd610 100644 --- a/src/photo/form.ts +++ b/src/photo/form.ts @@ -123,7 +123,7 @@ export const convertExifToFormData = ( : undefined, }); -export const convertFormDataToPhoto = ( +export const convertFormDataToPhotoDbInsert = ( formData: FormData, generateId?: boolean, ): PhotoDbInsert => { @@ -178,3 +178,20 @@ export const convertFormDataToPhoto = ( hidden: photoForm.hidden === 'true', }; }; + +const convertPhotoFormDataToFormData = ( + photoFormData: PhotoFormData, +) => { + const formData = new FormData(); + for (const key in photoFormData) { + formData.append(key, photoFormData[key as keyof PhotoFormData]); + } + return formData; +}; + +export const convertPhotoFormDataToPhotoDbInsert = ( + photoFormData: PhotoFormData, +) => { + const formData = convertPhotoFormDataToFormData(photoFormData); + return convertFormDataToPhotoDbInsert(formData); +}; From 0f87bd3b5ce8a8524bcb96baf7d41840844f7a99 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Wed, 1 Nov 2023 09:57:25 -0500 Subject: [PATCH 3/6] Simplify EXIF data form handling --- .../admin/uploads/[uploadPath]/page.tsx | 15 ++++++------ src/photo/actions.ts | 15 +++++------- src/photo/form.ts | 23 ++++-------------- src/photo/server.ts | 24 +++++++++---------- 4 files changed, 30 insertions(+), 47 deletions(-) diff --git a/src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx b/src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx index 8852e404..7c031239 100644 --- a/src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx +++ b/src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx @@ -1,7 +1,8 @@ import PhotoForm from '@/photo/PhotoForm'; import AdminChildPage from '@/components/AdminChildPage'; -import { PATH_ADMIN_UPLOADS } from '@/site/paths'; -import { extractFormDataFromUploadPath } from '@/photo/server'; +import { PATH_ADMIN, PATH_ADMIN_UPLOADS } from '@/site/paths'; +import { extractExifDataFromBlobPath } from '@/photo/server'; +import { redirect } from 'next/navigation'; interface Params { params: { uploadPath: string } @@ -10,8 +11,10 @@ interface Params { export default async function UploadPage({ params: { uploadPath } }: Params) { const { blobId, - photoForm, - } = await extractFormDataFromUploadPath(uploadPath); + photoFormExif, + } = await extractExifDataFromBlobPath(uploadPath); + + if (!photoFormExif) { redirect(PATH_ADMIN); } return ( - {photoForm - ? - : null} + ); }; diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 7223beaf..168da4ad 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -11,7 +11,6 @@ import { import { PhotoFormData, convertFormDataToPhotoDbInsert, - convertPhotoFormDataToPhotoDbInsert, convertPhotoToFormData, } from './form'; import { redirect } from 'next/navigation'; @@ -26,7 +25,7 @@ import { revalidatePhotosKey, } from '@/cache'; import { PATH_ADMIN_PHOTOS, PATH_ADMIN_TAGS } from '@/site/paths'; -import { extractFormDataFromUploadPath } from './server'; +import { extractExifDataFromBlobPath } from './server'; export async function createPhotoAction(formData: FormData) { const photo = convertFormDataToPhotoDbInsert(formData, true); @@ -98,9 +97,9 @@ export async function getExifDataAction( ): Promise> { const { url } = photoFormPrevious; if (url) { - const { photoForm } = await extractFormDataFromUploadPath(url); - if (photoForm) { - return photoForm; + const { photoFormExif } = await extractExifDataFromBlobPath(url); + if (photoFormExif) { + return photoFormExif; } } return {}; @@ -111,11 +110,9 @@ export async function syncPhotoExifDataAction(formData: FormData) { if (photoId) { const photo = await getPhoto(photoId); if (photo) { - const { - photoForm: photoFormExif, - } = await extractFormDataFromUploadPath(photo.url); + const { photoFormExif } = await extractExifDataFromBlobPath(photo.url); if (photoFormExif) { - const photoFormDbInsert = convertPhotoFormDataToPhotoDbInsert({ + const photoFormDbInsert = convertFormDataToPhotoDbInsert({ ...convertPhotoToFormData(photo), ...photoFormExif, }); diff --git a/src/photo/form.ts b/src/photo/form.ts index 860fd610..fed777d6 100644 --- a/src/photo/form.ts +++ b/src/photo/form.ts @@ -124,10 +124,12 @@ export const convertExifToFormData = ( }); export const convertFormDataToPhotoDbInsert = ( - formData: FormData, + formData: FormData | PhotoFormData, generateId?: boolean, ): PhotoDbInsert => { - const photoForm = Object.fromEntries(formData) as PhotoFormData; + const photoForm = formData instanceof FormData + ? Object.fromEntries(formData) as PhotoFormData + : formData; // Parse FormData: // - remove server action ID @@ -178,20 +180,3 @@ export const convertFormDataToPhotoDbInsert = ( hidden: photoForm.hidden === 'true', }; }; - -const convertPhotoFormDataToFormData = ( - photoFormData: PhotoFormData, -) => { - const formData = new FormData(); - for (const key in photoFormData) { - formData.append(key, photoFormData[key as keyof PhotoFormData]); - } - return formData; -}; - -export const convertPhotoFormDataToPhotoDbInsert = ( - photoFormData: PhotoFormData, -) => { - const formData = convertPhotoFormDataToFormData(photoFormData); - return convertFormDataToPhotoDbInsert(formData); -}; diff --git a/src/photo/server.ts b/src/photo/server.ts index cf8969e5..5b916114 100644 --- a/src/photo/server.ts +++ b/src/photo/server.ts @@ -8,24 +8,24 @@ import { import { ExifData, ExifParserFactory } from 'ts-exif-parser'; import { PhotoFormData } from './form'; -export const extractFormDataFromUploadPath = async ( - uploadPath: string +export const extractExifDataFromBlobPath = async ( + blobPath: string ): Promise<{ blobId?: string - photoForm?: Partial + photoFormExif?: Partial }> => { - const url = decodeURIComponent(uploadPath); + const url = decodeURIComponent(blobPath); const blobId = getIdFromBlobUrl(url); const extension = getExtensionFromBlobUrl(url); - const fileBytes = uploadPath + const fileBytes = blobPath ? await fetch(url) .then(res => res.arrayBuffer()) : undefined; - let exifDataForm: ExifData | undefined; + let exifData: ExifData | undefined; let filmSimulation: FujifilmSimulation | undefined; if (fileBytes) { @@ -33,10 +33,10 @@ export const extractFormDataFromUploadPath = async ( // Data for form parser.enableBinaryFields(false); - exifDataForm = parser.parse(); + exifData = parser.parse(); // Capture film simulation for Fujifilm cameras - if (isExifForFujifilm(exifDataForm)) { + if (isExifForFujifilm(exifData)) { // Parse exif data again with binary fields // in order to access MakerNote tag parser.enableBinaryFields(true); @@ -50,11 +50,11 @@ export const extractFormDataFromUploadPath = async ( return { blobId, - ...exifDataForm && { - photoForm: { - ...convertExifToFormData(exifDataForm, filmSimulation), + ...exifData && { + photoFormExif: { + ...convertExifToFormData(exifData, filmSimulation), extension, - url: decodeURIComponent(uploadPath), + url, }, }, }; From af693b91410d9432e5b30b42b24520ff9471e4d0 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Wed, 1 Nov 2023 23:20:46 -0500 Subject: [PATCH 4/6] Finalize exif syncing behaviors --- src/admin/AdminGrid.tsx | 2 +- src/admin/BlobUrls.tsx | 43 +++++++++------- src/admin/DeleteButton.tsx | 15 ++++-- src/app/(auth-state)/admin/photos/page.tsx | 51 +++++++++++++------ src/app/(auth-state)/admin/tags/page.tsx | 26 ++++++---- src/app/layout.tsx | 2 +- src/components/ShareModal.tsx | 8 +-- src/components/SubmitButtonWithStatus.tsx | 4 +- src/photo/PhotoEditPageClient.tsx | 25 +++++++-- src/photo/PhotoForm.tsx | 34 +++++++++++-- src/photo/actions.ts | 2 +- src/site/SiteChecklistClient.tsx | 11 ++-- src/site/globals.css | 2 +- src/tag/TagHeader.tsx | 22 +++++--- .../ToasterWithThemes.tsx | 0 src/toast/index.tsx | 26 ++++++++++ src/utility/object.ts | 13 +++++ 17 files changed, 199 insertions(+), 87 deletions(-) rename src/{components => toast}/ToasterWithThemes.tsx (100%) create mode 100644 src/toast/index.tsx create mode 100644 src/utility/object.ts diff --git a/src/admin/AdminGrid.tsx b/src/admin/AdminGrid.tsx index 16cf493c..bd97a5b6 100644 --- a/src/admin/AdminGrid.tsx +++ b/src/admin/AdminGrid.tsx @@ -16,7 +16,7 @@ export default function AdminGrid ({
{children} diff --git a/src/admin/BlobUrls.tsx b/src/admin/BlobUrls.tsx index fdeab93c..b00813e2 100644 --- a/src/admin/BlobUrls.tsx +++ b/src/admin/BlobUrls.tsx @@ -41,25 +41,30 @@ export default function BlobUrls({ > {pathForBlobUrl(url)} - - - - - - +
+ + + + + + +
;})} ); diff --git a/src/admin/DeleteButton.tsx b/src/admin/DeleteButton.tsx index 99307aea..3c9ec428 100644 --- a/src/admin/DeleteButton.tsx +++ b/src/admin/DeleteButton.tsx @@ -1,11 +1,16 @@ import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; -import { FaTimes } from 'react-icons/fa'; +import { cc } from '@/utility/css'; +import { BiTrash } from 'react-icons/bi'; export default function DeleteButton () { return } - > - Delete - ; + icon={} + className={cc( + 'text-red-500 dark:text-red-600', + 'active:!bg-red-100/50 active:dark:!bg-red-950/50', + '!border-red-200 hover:!border-red-300', + 'dark:!border-red-900/75 dark:hover:!border-red-900', + )} + />; } diff --git a/src/app/(auth-state)/admin/photos/page.tsx b/src/app/(auth-state)/admin/photos/page.tsx index 43409cb1..b419cded 100644 --- a/src/app/(auth-state)/admin/photos/page.tsx +++ b/src/app/(auth-state)/admin/photos/page.tsx @@ -5,7 +5,7 @@ import PhotoTiny from '@/photo/PhotoTiny'; import { cc } from '@/utility/css'; import FormWithConfirm from '@/components/FormWithConfirm'; import SiteGrid from '@/components/SiteGrid'; -import { deletePhotoAction } from '@/photo/actions'; +import { deletePhotoAction, syncPhotoExifDataAction } from '@/photo/actions'; import { pathForAdminPhotos, pathForPhoto, @@ -28,6 +28,8 @@ import DeleteButton from '@/admin/DeleteButton'; import EditButton from '@/admin/EditButton'; import BlobUrls from '@/admin/BlobUrls'; import { PRO_MODE_ENABLED } from '@/site/config'; +import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; +import { GrSync } from 'react-icons/gr'; export const runtime = 'edge'; @@ -76,11 +78,11 @@ export default async function AdminTagsPage({ )} photo={photo} /> -
+
}
{photo.takenAtNaive}
- - - - - - +
+ + + + } /> + + + + + + +
)} {showMorePhotos && diff --git a/src/app/(auth-state)/admin/tags/page.tsx b/src/app/(auth-state)/admin/tags/page.tsx index 4a11fb6e..0e29cfdb 100644 --- a/src/app/(auth-state)/admin/tags/page.tsx +++ b/src/app/(auth-state)/admin/tags/page.tsx @@ -10,6 +10,7 @@ import PhotoTag from '@/tag/PhotoTag'; import { formatTag } from '@/tag'; import EditButton from '@/admin/EditButton'; import { pathForAdminTagEdit } from '@/site/paths'; +import { cc } from '@/utility/css'; export const runtime = 'edge'; @@ -30,16 +31,21 @@ export default async function AdminPhotosPage() {
{photoQuantityText(count, false)}
- - - - - +
+ + + + + +
)}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fdbbcd7c..129dd900 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,7 +6,7 @@ import { BASE_URL, SITE_DESCRIPTION, SITE_TITLE } from '@/site/config'; import StateProvider from '@/state/AppStateProvider'; import ThemeProviderClient from '@/site/ThemeProviderClient'; import Nav from '@/site/Nav'; -import ToasterWithThemes from '@/components/ToasterWithThemes'; +import ToasterWithThemes from '@/toast/ToasterWithThemes'; import PhotoEscapeHandler from '@/photo/PhotoEscapeHandler'; import '../site/globals.css'; diff --git a/src/components/ShareModal.tsx b/src/components/ShareModal.tsx index 484124fb..727e40ac 100644 --- a/src/components/ShareModal.tsx +++ b/src/components/ShareModal.tsx @@ -4,10 +4,9 @@ import Modal from '@/components/Modal'; import { TbPhotoShare } from 'react-icons/tb'; import { cc } from '@/utility/css'; import { BiCopy } from 'react-icons/bi'; -import { toast } from 'sonner'; -import { FiCheckSquare } from 'react-icons/fi'; import { ReactNode } from 'react'; import { shortenUrl } from '@/utility/url'; +import { toastSuccess } from '@/toast'; export default function ShareModal({ title = 'Share', @@ -52,10 +51,7 @@ export default function ShareModal({ )} onClick={() => { navigator.clipboard.writeText(pathShare); - toast( - 'Link to photo copied', - { icon: }, - ); + toastSuccess('Link to photo copied'); }} > diff --git a/src/components/SubmitButtonWithStatus.tsx b/src/components/SubmitButtonWithStatus.tsx index 60026d46..49158630 100644 --- a/src/components/SubmitButtonWithStatus.tsx +++ b/src/components/SubmitButtonWithStatus.tsx @@ -46,11 +46,11 @@ export default function SubmitButtonWithStatus(props: Props) { ? : icon} } - {children} - + } ); }; diff --git a/src/photo/PhotoEditPageClient.tsx b/src/photo/PhotoEditPageClient.tsx index ffb9f157..fe5d87ee 100644 --- a/src/photo/PhotoEditPageClient.tsx +++ b/src/photo/PhotoEditPageClient.tsx @@ -4,22 +4,32 @@ import AdminChildPage from '@/components/AdminChildPage'; import { Photo } from '.'; import { PATH_ADMIN_PHOTOS } from '@/site/paths'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; -import { BiRefresh } from 'react-icons/bi'; import { PhotoFormData, convertPhotoToFormData } from './form'; import PhotoForm from './PhotoForm'; import { useFormState } from 'react-dom'; import { getExifDataAction } from './actions'; +import { GrSync } from 'react-icons/gr'; +import { areSimpleObjectsEqual } from '@/utility/object'; export default function PhotoEditPageClient({ photo, }: { photo: Photo }) { + const seedExifData = { url: photo.url }; + const [updatedExifData, action] = useFormState>( getExifDataAction, - { url: photo.url}, + seedExifData, ); + const hasExifDataBeenFound = !areSimpleObjectsEqual( + updatedExifData, + seedExifData, + ); + + console.log({ hasExifDataBeenFound }); + return ( } + icon={} > - Refresh EXIF + EXIF } > ); diff --git a/src/photo/PhotoForm.tsx b/src/photo/PhotoForm.tsx index 36816bbd..ec215456 100644 --- a/src/photo/PhotoForm.tsx +++ b/src/photo/PhotoForm.tsx @@ -17,6 +17,7 @@ import { generateLocalNaivePostgresString, generateLocalPostgresString, } from '@/utility/date'; +import { toastSuccess, toastWarning } from '@/toast'; const THUMBNAIL_WIDTH = 300; const THUMBNAIL_HEIGHT = 200; @@ -35,12 +36,35 @@ export default function PhotoForm({ const [formData, setFormData] = useState>(initialPhotoForm); + // Update form when EXIF data + // is refreshed by parent useEffect(() => { - // Update form when EXIF data is refreshed by parent - setFormData(currentForm => ({ - ...currentForm, - ...updatedExifData, - })); + if (Object.keys(updatedExifData ?? {}).length > 0) { + const changedKeys: string[] = []; + + setFormData(currentForm => { + Object.entries(updatedExifData ?? {}) + .forEach(([key, value]) => { + if (currentForm[key as keyof PhotoFormData] !== value) { + changedKeys.push(key); + } + }); + + return { + ...currentForm, + ...updatedExifData, + }; + }); + + if (changedKeys.length > 0) { + toastSuccess( + `Updated EXIF fields: ${changedKeys.join(', ')}`, + 8000, + ); + } else { + toastWarning('No new EXIF data found'); + } + } }, [updatedExifData]); // Generate local date strings when diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 168da4ad..6d30cdce 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -106,7 +106,7 @@ export async function getExifDataAction( } export async function syncPhotoExifDataAction(formData: FormData) { - const photoId = formData.get('photoId') as string; + const photoId = formData.get('id') as string; if (photoId) { const photo = await getPhoto(photoId); if (photo) { diff --git a/src/site/SiteChecklistClient.tsx b/src/site/SiteChecklistClient.tsx index ae2e9759..2b1b03c1 100644 --- a/src/site/SiteChecklistClient.tsx +++ b/src/site/SiteChecklistClient.tsx @@ -4,7 +4,7 @@ import { useTransition } from 'react'; import { useRouter } from 'next/navigation'; import { cc } from '@/utility/css'; import ChecklistRow from '../components/ChecklistRow'; -import { FiCheckSquare, FiExternalLink } from 'react-icons/fi'; +import { FiExternalLink } from 'react-icons/fi'; import { BiCog, BiCopy, @@ -14,9 +14,9 @@ import { BiRefresh, } from 'react-icons/bi'; import IconButton from '@/components/IconButton'; -import { toast } from 'sonner'; import InfoBlock from '@/components/InfoBlock'; import Checklist from '@/components/Checklist'; +import { toastSuccess } from '@/toast'; export default function SiteChecklistClient({ hasPostgres, @@ -84,12 +84,7 @@ export default function SiteChecklistClient({ className={cc(subtle && 'text-gray-300 dark:text-gray-700')} onClick={() => { navigator.clipboard.writeText(text); - toast( - `${label} copied to clipboard`, { - icon: , - duration: 4000, - }, - ); + toastSuccess(`${label} copied to clipboard`); }} />; diff --git a/src/site/globals.css b/src/site/globals.css index 7a080dc2..bd0db754 100644 --- a/src/site/globals.css +++ b/src/site/globals.css @@ -25,7 +25,7 @@ bg-white dark:bg-black border-gray-200 dark:border-gray-700 font-mono text-base leading-tight - min-h-[2.25rem] + min-h-[2.4rem] } input[type=text], input[type=email], input[type=password], select { @apply diff --git a/src/tag/TagHeader.tsx b/src/tag/TagHeader.tsx index 9f3b8d7e..541ee836 100644 --- a/src/tag/TagHeader.tsx +++ b/src/tag/TagHeader.tsx @@ -3,6 +3,7 @@ import PhotoTag from './PhotoTag'; import { descriptionForTaggedPhotos } from '.'; import { pathForTagShare } from '@/site/paths'; import PhotoHeader from '@/photo/PhotoHeader'; +import AnimateItems from '@/components/AnimateItems'; export default function TagHeader({ tag, @@ -16,14 +17,19 @@ export default function TagHeader({ count?: number }) { return ( - } - entityVerb="Tagged" - entityDescription={descriptionForTaggedPhotos(photos, undefined, count)} - photos={photos} - selectedPhoto={selectedPhoto} - sharePath={pathForTagShare(tag)} - count={count} + } + entityVerb="Tagged" + entityDescription={descriptionForTaggedPhotos(photos, undefined, count)} + photos={photos} + selectedPhoto={selectedPhoto} + sharePath={pathForTagShare(tag)} + count={count} + />]} /> ); } diff --git a/src/components/ToasterWithThemes.tsx b/src/toast/ToasterWithThemes.tsx similarity index 100% rename from src/components/ToasterWithThemes.tsx rename to src/toast/ToasterWithThemes.tsx diff --git a/src/toast/index.tsx b/src/toast/index.tsx new file mode 100644 index 00000000..19703b0c --- /dev/null +++ b/src/toast/index.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react'; +import { AiOutlineWarning } from 'react-icons/ai'; +import { FiCheckSquare } from 'react-icons/fi'; +import { toast } from 'sonner'; + +const DEFAULT_DURATION = 4000; + +export const toastSuccess = ( + message: ReactNode, + duration = DEFAULT_DURATION, +) => toast( + message, { + icon: , + duration, + }, +); + +export const toastWarning = ( + message: ReactNode, + duration = DEFAULT_DURATION, +) => toast( + message, { + icon: , + duration, + }, +); diff --git a/src/utility/object.ts b/src/utility/object.ts new file mode 100644 index 00000000..bc0d025e --- /dev/null +++ b/src/utility/object.ts @@ -0,0 +1,13 @@ +type SimpleObject = Record; + +export const areSimpleObjectsEqual = ( + obj1: SimpleObject, + obj2: SimpleObject, +): boolean => { + const obj1Keys = Object.keys(obj1); + const obj2Keys = Object.keys(obj2); + + return obj1Keys.length === obj2Keys.length + ? obj1Keys.every((key) => obj1[key] === obj2[key]) + : false; +}; From 9119a267f3bfe3dca8c8fd028778265d8c584de5 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 2 Nov 2023 09:23:14 -0500 Subject: [PATCH 5/6] Fix GrSync icon --- src/app/(auth-state)/admin/photos/page.tsx | 9 ++++---- src/photo/PhotoEditPageClient.tsx | 7 ++---- src/site/IconGrSync.tsx | 26 ++++++++++++++++++++++ 3 files changed, 32 insertions(+), 10 deletions(-) create mode 100644 src/site/IconGrSync.tsx diff --git a/src/app/(auth-state)/admin/photos/page.tsx b/src/app/(auth-state)/admin/photos/page.tsx index b419cded..4feb9165 100644 --- a/src/app/(auth-state)/admin/photos/page.tsx +++ b/src/app/(auth-state)/admin/photos/page.tsx @@ -29,7 +29,7 @@ import EditButton from '@/admin/EditButton'; import BlobUrls from '@/admin/BlobUrls'; import { PRO_MODE_ENABLED } from '@/site/config'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; -import { GrSync } from 'react-icons/gr'; +import IconGrSync from '@/site/IconGrSync'; export const runtime = 'edge'; @@ -125,10 +125,9 @@ export default async function AdminTagsPage({ } > - } /> + } + /> } + icon={} > EXIF diff --git a/src/site/IconGrSync.tsx b/src/site/IconGrSync.tsx new file mode 100644 index 00000000..348d2691 --- /dev/null +++ b/src/site/IconGrSync.tsx @@ -0,0 +1,26 @@ +export default function IconGrSync({ + className, +}: { + className?: string +}) { + return ( + + + + ); +} From 620e9756b9961122e7dcc0699c84094ee1955882 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 2 Nov 2023 13:14:04 -0500 Subject: [PATCH 6/6] Adjust GrSync SVG attributes --- src/site/IconGrSync.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/site/IconGrSync.tsx b/src/site/IconGrSync.tsx index 348d2691..c5f3acb2 100644 --- a/src/site/IconGrSync.tsx +++ b/src/site/IconGrSync.tsx @@ -8,7 +8,7 @@ export default function IconGrSync({ stroke="currentColor" fill="currentColor" className={className} - stroke-width="0" + strokeWidth="0" viewBox="0 0 24 24" height="15" width="15" @@ -17,7 +17,7 @@ export default function IconGrSync({