From d41c7f4617a1d74ceb7c6b75804e968f98e65b59 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 14 Oct 2023 12:21:09 -0500 Subject: [PATCH] Setup CopyExif with mixed results --- next.config.js | 2 +- src/app/(auth-state)/admin/photos/page.tsx | 7 +- src/components/ImageInput.tsx | 136 ++++++++++++++++++ src/lib/CopyExif.ts | 36 +++++ src/photo/PhotoForm.tsx | 14 +- src/photo/PhotoUpload.tsx | 77 ++++++++++ src/photo/PhotoUploadInput.tsx | 70 --------- src/photo/actions.ts | 14 +- .../image-response/PhotoImageResponse.tsx | 4 +- src/photo/image-response/index.ts | 4 +- src/site/globals.css | 34 ++++- src/utility/blob.ts | 15 ++ src/utility/image.ts | 8 +- 13 files changed, 312 insertions(+), 109 deletions(-) create mode 100644 src/components/ImageInput.tsx create mode 100644 src/lib/CopyExif.ts create mode 100644 src/photo/PhotoUpload.tsx delete mode 100644 src/photo/PhotoUploadInput.tsx create mode 100644 src/utility/blob.ts diff --git a/next.config.js b/next.config.js index 5fd31a4c..1f6a9f76 100644 --- a/next.config.js +++ b/next.config.js @@ -6,7 +6,7 @@ const STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match( const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', -}) +}); const nextConfig = { images: { diff --git a/src/app/(auth-state)/admin/photos/page.tsx b/src/app/(auth-state)/admin/photos/page.tsx index 8e8ac617..1c360682 100644 --- a/src/app/(auth-state)/admin/photos/page.tsx +++ b/src/app/(auth-state)/admin/photos/page.tsx @@ -1,5 +1,5 @@ import { Fragment } from 'react'; -import PhotoUploadInput from '@/photo/PhotoUploadInput'; +import PhotoUpload from '@/photo/PhotoUpload'; import Link from 'next/link'; import PhotoTiny from '@/photo/PhotoTiny'; import { cc } from '@/utility/css'; @@ -27,6 +27,7 @@ import AdminGrid from '@/admin/AdminGrid'; import DeleteButton from '@/admin/DeleteButton'; import EditButton from '@/admin/EditButton'; import BlobUrls from '@/admin/BlobUrls'; +import { PRO_MODE_ENABLED } from '@/site/config'; export const runtime = 'edge'; @@ -52,8 +53,8 @@ export default async function AdminTagsPage({ return ( - +
+ {blobPhotoUrls.length > 0 &&
void + maxSize?: number + quality?: number + loading?: boolean + debug?: boolean +}) { + const ref = useRef(null); + + const [fileName, setFileName] = useState(); + const [image, setImage] = useState(); + + return ( +
+
+ + {fileName && +
+ {fileName} +
} +
+ +
+ ); +} diff --git a/src/lib/CopyExif.ts b/src/lib/CopyExif.ts new file mode 100644 index 00000000..268759d1 --- /dev/null +++ b/src/lib/CopyExif.ts @@ -0,0 +1,36 @@ +export async function CopyExif( + src: Blob, + dest: Blob, + type = 'image/jpeg', +) { + const exif = await retrieveExif(src); + return new Blob([dest.slice(0, 2), exif, dest.slice(2)], { type }); +}; + +const SOS = 0xffda; +const APP1 = 0xffe1; +const EXIF = 0x45786966; + +const retrieveExif = (blob: Blob): any => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener('load', e => { + const buffer = e.target!.result as ArrayBuffer; + const view = new DataView(buffer); + let offset = 0; + if (view.getUint16(offset) !== 0xffd8) + return reject('not a valid jpeg'); + offset += 2; + + while (true) { + const marker = view.getUint16(offset); + if (marker === SOS) break; + const size = view.getUint16(offset + 2); + if (marker === APP1 && view.getUint32(offset + 4) === EXIF) + return resolve(blob.slice(offset, offset + 2 + size)); + offset += 2 + size; + } + return resolve(new Blob()); + }); + reader.readAsArrayBuffer(blob); + }); diff --git a/src/photo/PhotoForm.tsx b/src/photo/PhotoForm.tsx index 8e91c28f..e02dcde9 100644 --- a/src/photo/PhotoForm.tsx +++ b/src/photo/PhotoForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; import { FORM_METADATA_ENTRIES, PhotoFormData, @@ -31,11 +31,6 @@ export default function PhotoForm({ const url = formData.url ?? ''; - const [requestOrigin, setRequestOrigin] = useState(); - useEffect(() => { - setRequestOrigin(window.location.origin); - }, []); - const updateBlurData = useCallback((blurData: string) => { if (type === 'create') { setFormData(data => ({ @@ -102,13 +97,6 @@ export default function PhotoForm({ loading={loadingMessage && !formData[key] ? true : false} type={checkbox ? 'checkbox' : undefined} />)} - {type === 'create' && - }
{type === 'edit' && (); + const [debugDownload, setDebugDownload] = useState<{ + href: string + fileName: string + }>(); + + const router = useRouter(); + + return ( +
+
+
+ { + setIsUploading(true); + setUploadError(''); + if (debug) { + setDebugDownload({ + href: URL.createObjectURL(blob), + fileName: `debug.${extension}`, + }); + } else { + uploadPhotoFromClient( + blob, + extension, + ) + .then(({ url }) => { + // Refresh page to update upload list, + // relevant only when a photo isn't added + router.refresh(); + // Redirect to photo detail page + router.push(pathForAdminUploadUrl(url)); + }) + .catch(error => { + setIsUploading(false); + setUploadError(`Upload Error: ${error.message}`); + }); + } + }} + debug={debug} + /> + +
+ {debugDownload && + + Download + } + {uploadError && +
+ {uploadError} +
} +
+ ); +}; diff --git a/src/photo/PhotoUploadInput.tsx b/src/photo/PhotoUploadInput.tsx deleted file mode 100644 index a09dd022..00000000 --- a/src/photo/PhotoUploadInput.tsx +++ /dev/null @@ -1,70 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import Spinner from '@/components/Spinner'; -import { - ACCEPTED_PHOTO_FILE_TYPES, - uploadPhotoFromClient, -} from '@/services/blob'; -import { cc } from '@/utility/css'; -import { useRouter } from 'next/navigation'; -import { pathForAdminUploadUrl } from '@/site/paths'; - -export default function PhotoUploadInput() { - const [isUploading, setIsUploading] = useState(false); - const [uploadError, setUploadError] = useState(''); - - const router = useRouter(); - - return ( -
-
-
- { - const file = e.target.files?.[0]; - if (file) { - setIsUploading(true); - setUploadError(''); - const extension = file.name.split('.').pop(); - uploadPhotoFromClient( - file, - extension, - ) - .then(({ url }) => { - // Refresh page to update upload list, - // relevant only when a photo isn't added - router.refresh(); - // Redirect to photo detail page - router.push(pathForAdminUploadUrl(url)); - }) - .catch(error => { - setIsUploading(false); - setUploadError(`Upload Error: ${error.message}`); - }); - - } - }} - disabled={isUploading} - /> - {isUploading && -
- - Uploading... -
} -
-
- {uploadError && -
- {uploadError} -
} -
- ); -}; diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 87abaf5e..2f145266 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -18,24 +18,12 @@ import { revalidateBlobKey, revalidatePhotosKey, } from '@/cache'; -import { PRO_MODE_ENABLED } from '@/site/config'; -import { getNextImageUrlForRequest } from '@/utility/image'; import { PATH_ADMIN_PHOTOS, PATH_ADMIN_TAGS } from '@/site/paths'; export async function createPhotoAction(formData: FormData) { - const requestOrigin = formData.get('requestOrigin') as string | undefined; - formData.delete('requestOrigin'); - const photo = convertFormDataToPhoto(formData, true); - const updatedUrl = await convertUploadToPhoto( - photo.url, - photo.id, - !PRO_MODE_ENABLED - ? getNextImageUrlForRequest(photo.url, 3840, 90, requestOrigin) - : undefined, - !PRO_MODE_ENABLED ? 'webp' : undefined, - ); + const updatedUrl = await convertUploadToPhoto(photo.url, photo.id); if (updatedUrl) { photo.url = updatedUrl; } diff --git a/src/photo/image-response/PhotoImageResponse.tsx b/src/photo/image-response/PhotoImageResponse.tsx index 01ac40db..76892755 100644 --- a/src/photo/image-response/PhotoImageResponse.tsx +++ b/src/photo/image-response/PhotoImageResponse.tsx @@ -1,5 +1,5 @@ import { Photo } from '..'; -import { NextImageWidth } from '@/utility/image'; +import { NextImageSize } from '@/utility/image'; import { formatModelShort } from '@/utility/exif'; import { AiFillApple } from 'react-icons/ai'; import ImageCaption from './components/ImageCaption'; @@ -13,7 +13,7 @@ export default function PhotoImageResponse({ fontFamily, }: { photo: Photo - width: NextImageWidth + width: NextImageSize height: number fontFamily: string }) { diff --git a/src/photo/image-response/index.ts b/src/photo/image-response/index.ts index 4f231e34..cd6bb355 100644 --- a/src/photo/image-response/index.ts +++ b/src/photo/image-response/index.ts @@ -1,4 +1,4 @@ -import { NextImageWidth } from '@/utility/image'; +import { NextImageSize } from '@/utility/image'; export const MAX_PHOTOS_TO_SHOW_HOME = 12; export const MAX_PHOTOS_TO_SHOW_PER_TAG = 6; @@ -7,7 +7,7 @@ export const MAX_PHOTOS_TO_SHOW_TEMPLATE_TIGHT = 12; // 16:9 og image ratio const IMAGE_OG_RATIO = 16 / 9; -const IMAGE_OG_WIDTH: NextImageWidth = 1200; +const IMAGE_OG_WIDTH: NextImageSize = 1200; const IMAGE_OG_HEIGHT = IMAGE_OG_WIDTH * (1 / IMAGE_OG_RATIO); export const IMAGE_OG_SIZE = { width: IMAGE_OG_WIDTH, diff --git a/src/site/globals.css b/src/site/globals.css index a27992f7..a0db0332 100644 --- a/src/site/globals.css +++ b/src/site/globals.css @@ -6,9 +6,9 @@ /* Core */ body { @apply + text-main font-mono text-sm md:text-base bg-white dark:bg-black - text-gray-900 dark:text-gray-100 } /* Forms */ label { @@ -22,7 +22,7 @@ @apply px-2 py-1.5 border rounded-md - dark:bg-black + bg-white dark:bg-black border-gray-200 dark:border-gray-700 font-mono text-base leading-none min-h-[2.25rem] @@ -73,6 +73,20 @@ disabled:bg-transparent dark:disabled:bg-transparent disabled:border-gray-100 dark:disabled:border-gray-900 } + button.primary, .button.primary { + @apply + text-invert + bg-gray-900 dark:bg-gray-100 + disabled:bg-gray-900 disabled:dark:bg-gray-100 + 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 + shadow-none + } + button.primary.disabled, .button.primary.disabled { + @apply + text-extra-dim + } /* Toasts */ .toaster [data-sonner-toast] { @apply @@ -80,8 +94,24 @@ !border-gray-200 dark:!border-gray-800 } /* Common Utilities */ + .text-main { + @apply + text-gray-900 dark:text-gray-100 + } + .text-invert { + @apply + text-gray-100 dark:text-gray-900 + } .text-dim { @apply text-gray-400 dark:text-gray-500 } + .text-extra-dim { + @apply + text-gray-500 dark:text-gray-400 + } + .text-error { + @apply + text-red-500 dark:text-red-400 + } } diff --git a/src/utility/blob.ts b/src/utility/blob.ts new file mode 100644 index 00000000..7db47191 --- /dev/null +++ b/src/utility/blob.ts @@ -0,0 +1,15 @@ +export const blobToImage = (blob: Blob): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject('Error reading image'); + + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject('Error reading image'); + reader.onload = e => { + const result = (e.currentTarget as any).result as string; + image.src = result; + }; + + reader.readAsDataURL(blob); + }); diff --git a/src/utility/image.ts b/src/utility/image.ts index ec4c9986..5b57b5b2 100644 --- a/src/utility/image.ts +++ b/src/utility/image.ts @@ -4,18 +4,20 @@ import { BASE_URL } from '@/site/config'; type NextCustomSize = 200 | 400 | 1050; type NextImageDeviceSize = 640 | 750 | 828 | 1080 | 1200 | 1920 | 2048 | 3840; -export type NextImageWidth = NextCustomSize | NextImageDeviceSize; +export type NextImageSize = NextCustomSize | NextImageDeviceSize; + +export const MAX_IMAGE_SIZE: NextImageSize = 3840; export const getNextImageUrlForRequest = ( imageUrl: string, - width: NextImageWidth, + size: NextImageSize, quality = 75, baseUrl = BASE_URL, ) => { const url = new URL(`${baseUrl}/_next/image`); url.searchParams.append('url', imageUrl); - url.searchParams.append('w', width.toString()); + url.searchParams.append('w', size.toString()); url.searchParams.append('q', quality.toString()); return url.toString();