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/[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/photos/page.tsx b/src/app/(auth-state)/admin/photos/page.tsx index 43409cb1..4feb9165 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 IconGrSync from '@/site/IconGrSync'; 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/(auth-state)/admin/uploads/[uploadPath]/page.tsx b/src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx index a908701f..7c031239 100644 --- a/src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx +++ b/src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx @@ -1,67 +1,28 @@ 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 { PATH_ADMIN, PATH_ADMIN_UPLOADS } from '@/site/paths'; +import { extractExifDataFromBlobPath } from '@/photo/server'; +import { redirect } from 'next/navigation'; interface Params { params: { uploadPath: string } } export default async function UploadPage({ params: { uploadPath } }: Params) { - const url = decodeURIComponent(uploadPath); + const { + blobId, + photoFormExif, + } = await extractExifDataFromBlobPath(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); - } - } - } + if (!photoFormExif) { redirect(PATH_ADMIN); } return ( - {exifDataForm - ? - : null} + ); }; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 026a40e6..68a1d36d 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/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/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/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 bb9d661e..49158630 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'; @@ -47,11 +46,11 @@ export default function SubmitButtonWithStatus(props: Props) { ? : icon} } - {children} - + } ); }; diff --git a/src/photo/PhotoEditPageClient.tsx b/src/photo/PhotoEditPageClient.tsx new file mode 100644 index 00000000..225c4da8 --- /dev/null +++ b/src/photo/PhotoEditPageClient.tsx @@ -0,0 +1,57 @@ +'use client'; + +import AdminChildPage from '@/components/AdminChildPage'; +import { Photo } from '.'; +import { PATH_ADMIN_PHOTOS } from '@/site/paths'; +import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; +import { PhotoFormData, convertPhotoToFormData } from './form'; +import PhotoForm from './PhotoForm'; +import { useFormState } from 'react-dom'; +import { getExifDataAction } from './actions'; +import { areSimpleObjectsEqual } from '@/utility/object'; +import IconGrSync from '@/site/IconGrSync'; + +export default function PhotoEditPageClient({ + photo, +}: { + photo: Photo +}) { + const seedExifData = { url: photo.url }; + + const [updatedExifData, action] = useFormState>( + getExifDataAction, + seedExifData, + ); + + const hasExifDataBeenFound = !areSimpleObjectsEqual( + updatedExifData, + seedExifData, + ); + + console.log({ hasExifDataBeenFound }); + + return ( + + + } + > + EXIF + + } + > + + + ); +}; diff --git a/src/photo/PhotoForm.tsx b/src/photo/PhotoForm.tsx index 9f680de0..ec215456 100644 --- a/src/photo/PhotoForm.tsx +++ b/src/photo/PhotoForm.tsx @@ -12,27 +12,61 @@ 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, } from '@/utility/date'; +import { toastSuccess, toastWarning } from '@/toast'; const THUMBNAIL_WIDTH = 300; 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); + // Update form when EXIF data + // is refreshed by parent + useEffect(() => { + 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 // none can be harvested from EXIF useEffect(() => { @@ -126,13 +160,12 @@ export default function PhotoForm({ type={checkbox ? 'checkbox' : undefined} />)}
- {type === 'edit' && - - Cancel - } + + Cancel + diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 811be878..6d30cdce 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -6,8 +6,13 @@ import { sqlDeletePhotoTagGlobally, sqlUpdatePhoto, sqlRenamePhotoTagGlobally, + getPhoto, } from '@/services/postgres'; -import { convertFormDataToPhoto } from './form'; +import { + PhotoFormData, + convertFormDataToPhotoDbInsert, + convertPhotoToFormData, +} from './form'; import { redirect } from 'next/navigation'; import { convertUploadToPhoto, @@ -20,9 +25,10 @@ import { revalidatePhotosKey, } from '@/cache'; import { PATH_ADMIN_PHOTOS, PATH_ADMIN_TAGS } from '@/site/paths'; +import { extractExifDataFromBlobPath } 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 +42,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 +90,38 @@ 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 { photoFormExif } = await extractExifDataFromBlobPath(url); + if (photoFormExif) { + return photoFormExif; + } + } + return {}; +} + +export async function syncPhotoExifDataAction(formData: FormData) { + const photoId = formData.get('id') as string; + if (photoId) { + const photo = await getPhoto(photoId); + if (photo) { + const { photoFormExif } = await extractExifDataFromBlobPath(photo.url); + if (photoFormExif) { + const photoFormDbInsert = convertFormDataToPhotoDbInsert({ + ...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 37e98342..fed777d6 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 }, }; @@ -122,11 +123,13 @@ export const convertExifToFormData = ( : undefined, }); -export const convertFormDataToPhoto = ( - formData: FormData, +export const convertFormDataToPhotoDbInsert = ( + 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 diff --git a/src/photo/server.ts b/src/photo/server.ts new file mode 100644 index 00000000..5b916114 --- /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 extractExifDataFromBlobPath = async ( + blobPath: string +): Promise<{ + blobId?: string + photoFormExif?: Partial +}> => { + const url = decodeURIComponent(blobPath); + + const blobId = getIdFromBlobUrl(url); + + const extension = getExtensionFromBlobUrl(url); + + const fileBytes = blobPath + ? await fetch(url) + .then(res => res.arrayBuffer()) + : undefined; + + let exifData: ExifData | undefined; + let filmSimulation: FujifilmSimulation | undefined; + + if (fileBytes) { + const parser = ExifParserFactory.create(Buffer.from(fileBytes)); + + // Data for form + parser.enableBinaryFields(false); + exifData = parser.parse(); + + // Capture film simulation for Fujifilm cameras + if (isExifForFujifilm(exifData)) { + // 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, + ...exifData && { + photoFormExif: { + ...convertExifToFormData(exifData, filmSimulation), + extension, + url, + }, + }, + }; +}; diff --git a/src/site/IconGrSync.tsx b/src/site/IconGrSync.tsx new file mode 100644 index 00000000..c5f3acb2 --- /dev/null +++ b/src/site/IconGrSync.tsx @@ -0,0 +1,26 @@ +export default function IconGrSync({ + className, +}: { + className?: string +}) { + return ( + + + + ); +} 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; +}; 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; + } + } } };