Merge pull request #4 from sambecker/client-resize

Resize images on client before upload
This commit is contained in:
Sam Becker 2023-10-14 17:08:03 -05:00 committed by GitHub
commit 953bb8ebbe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 337 additions and 119 deletions

View File

@ -1,15 +1,18 @@
{
"cSpell.words": [
"ABCDEFGHIJKLMNOP",
"ARROWLEFT",
"ARROWRIGHT",
"camelcase",
"exif",
"ghijklmnopqrstuv",
"hgetall",
"hset",
"Lightbox",
"nanoids",
"nextjs",
"qaub",
"QRSTUVWXYZ",
"skippable",
"sonner",
"thephotoblog",
@ -17,6 +20,7 @@
"unnest",
"UsKSGcbt",
"WRHGZC",
"wxyz",
"zadd",
"zrange"
],

View File

@ -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: {

View File

@ -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 (
<SiteGrid
contentMain={
<div className="space-y-6">
<PhotoUploadInput />
<div className="space-y-8">
<PhotoUpload shouldResize={!PRO_MODE_ENABLED} />
{blobPhotoUrls.length > 0 &&
<div className={cc(
'border-b pb-6',

View File

@ -1,8 +1,6 @@
import { revalidatePhotosAndBlobKeys } from '@/cache';
import {
ACCEPTED_PHOTO_FILE_TYPES,
isUploadPathnameValid,
} from '@/services/blob';
import { ACCEPTED_PHOTO_FILE_TYPES } from '@/photo';
import { isUploadPathnameValid } from '@/services/blob';
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client';
import { NextResponse } from 'next/server';

View File

@ -0,0 +1,135 @@
'use client';
import { blobToImage } from '@/utility/blob';
import { useRef, useState } from 'react';
import { CopyExif } from '@/lib/CopyExif';
import { cc } from '@/utility/css';
import { AiOutlineCloudUpload } from 'react-icons/ai';
import Spinner from './Spinner';
import { ACCEPTED_PHOTO_FILE_TYPES } from '@/photo';
const INPUT_ID = 'file';
export default function ImageInput({
onStart,
onBlobReady,
maxSize,
quality = 0.8,
loading,
debug,
}: {
onStart?: () => void
onBlobReady?: (blob: Blob, extension?: string) => void
maxSize?: number
quality?: number
loading?: boolean
debug?: boolean
}) {
const ref = useRef<HTMLCanvasElement>(null);
const [fileName, setFileName] = useState<string>();
const [image, setImage] = useState<HTMLImageElement>();
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<label
htmlFor={INPUT_ID}
className={cc(
'shrink-0 select-none text-main',
loading && 'pointer-events-none cursor-not-allowed',
)}
>
<span
className={cc(
'button primary normal-case',
loading && 'disabled'
)}
aria-disabled={loading}
>
<span className="w-4 inline-flex items-center">
{loading
? <Spinner color="text" />
: <AiOutlineCloudUpload
size={18}
className="translate-y-[0.5px]"
/>}
</span>
Upload Photo
</span>
<input
id={INPUT_ID}
type="file"
className="!hidden"
accept={ACCEPTED_PHOTO_FILE_TYPES.join(',')}
disabled={loading}
onChange={async e => {
onStart?.();
const file = e.currentTarget.files?.[0];
setFileName(file?.name);
const extension = file?.name.split('.').pop()?.toLowerCase();
const canvas = ref.current;
if (file) {
if (maxSize && canvas) {
// Process images that need resizing
const image = await blobToImage(file);
setImage(image);
const { naturalWidth, naturalHeight } = image;
const ratio = naturalWidth / naturalHeight;
const width =
Math.round(ratio >= 1 ? maxSize : maxSize * ratio);
const height =
Math.round(ratio >= 1 ? maxSize / ratio : maxSize);
canvas.width = width;
canvas.height = height;
// Specify wide gamut to avoid data loss while resizing
const ctx = canvas.getContext(
'2d',
{ colorSpace: 'display-p3' },
);
ctx?.drawImage(
image,
0,
0,
canvas.width,
canvas.height,
);
canvas.toBlob(
async blob => {
if (blob) {
const blobWithExif = await CopyExif(file, blob);
onBlobReady?.(blobWithExif, extension);
}
},
'image/jpeg',
quality,
);
} else {
// No need to process
onBlobReady?.(file);
}
}
}}
/>
</label>
{fileName &&
<div className="max-w-full truncate text-ellipsis">
{fileName}
</div>}
</div>
<canvas
ref={ref}
className={cc(
'bg-gray-50 dark:bg-gray-900/50 rounded-md',
'border border-gray-200 dark:border-gray-800',
'w-[400px]',
(!image || !debug) && 'hidden',
)}
/>
</div>
);
}

36
src/lib/CopyExif.ts Normal file
View File

@ -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): Promise<Blob> =>
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);
});

View File

@ -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<string>();
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' &&
<input
name="requestOrigin"
defaultValue={requestOrigin}
readOnly
hidden
/>}
<div className="flex gap-3">
{type === 'edit' &&
<Link

85
src/photo/PhotoUpload.tsx Normal file
View File

@ -0,0 +1,85 @@
'use client';
import { useState } from 'react';
import { uploadPhotoFromClient } from '@/services/blob';
import { useRouter } from 'next/navigation';
import { pathForAdminUploadUrl } from '@/site/paths';
import ImageInput from '../components/ImageInput';
import { MAX_IMAGE_SIZE } from '@/utility/image';
import { cc } from '@/utility/css';
export default function PhotoUpload({
shouldResize,
debug,
}: {
shouldResize?: boolean
debug?: boolean
}) {
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string>();
const [debugDownload, setDebugDownload] = useState<{
href: string
fileName: string
}>();
const router = useRouter();
return (
<div className={cc(
'space-y-4',
isUploading && 'cursor-not-allowed',
)}>
<div className="flex items-center gap-8">
<form className="flex items-center gap-3">
<ImageInput
maxSize={shouldResize ? MAX_IMAGE_SIZE : undefined}
loading={isUploading}
onStart={() => {
setIsUploading(true);
setUploadError('');
}}
onBlobReady={(blob, extension) => {
if (debug) {
setDebugDownload({
href: URL.createObjectURL(blob),
fileName: `debug.${extension}`,
});
setIsUploading(false);
setUploadError('');
} 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}
/>
</form>
</div>
{debug && debugDownload &&
<a
className="block"
href={debugDownload.href}
download={debugDownload.fileName}
>
Download
</a>}
{uploadError &&
<div className="text-error">
{uploadError}
</div>}
</div>
);
};

View File

@ -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 (
<div>
<div className="flex items-center gap-8">
<form className="flex items-center gap-3">
<input
type="file"
name="file"
accept={ACCEPTED_PHOTO_FILE_TYPES.join(',')}
onChange={e => {
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 &&
<div className={cc(
'flex items-center gap-2',
'flex-grow',
'select-none',
)}>
<Spinner size={14} />
Uploading...
</div>}
</form>
</div>
{uploadError &&
<div className="text-red-500">
{uploadError}
</div>}
</div>
);
};

View File

@ -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; }

View File

@ -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
}) {

View File

@ -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,

View File

@ -12,6 +12,11 @@ import type { Metadata } from 'next';
export const GRID_THUMBNAILS_TO_SHOW_MAX = 12;
export const ACCEPTED_PHOTO_FILE_TYPES = [
'image/jpg',
'image/jpeg',
];
// Core EXIF data
export interface PhotoExif {
aspectRatio: number

View File

@ -12,12 +12,6 @@ export const BLOB_BASE_URL =
const PREFIX_UPLOAD = 'upload';
const PREFIX_PHOTO = 'photo';
export const ACCEPTED_PHOTO_FILE_TYPES = [
'image/jpg',
'image/jpeg',
'image/png',
];
const REGEX_UPLOAD_PATH = new RegExp(
`(?:${PREFIX_UPLOAD})\.[a-z]{1,4}`,
'i',

View File

@ -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
}
}

15
src/utility/blob.ts Normal file
View File

@ -0,0 +1,15 @@
export const blobToImage = (blob: Blob): Promise<HTMLImageElement> =>
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);
});

View File

@ -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();

7
src/utility/promise.ts Normal file
View File

@ -0,0 +1,7 @@
export const sleep = async (delay = 1000) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve('Ready');
}, delay);
});
};