Merge pull request #4 from sambecker/client-resize
Resize images on client before upload
This commit is contained in:
commit
953bb8ebbe
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -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"
|
||||
],
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
135
src/components/ImageInput.tsx
Normal file
135
src/components/ImageInput.tsx
Normal 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
36
src/lib/CopyExif.ts
Normal 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);
|
||||
});
|
||||
@ -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
85
src/photo/PhotoUpload.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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; }
|
||||
|
||||
|
||||
@ -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
|
||||
}) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
15
src/utility/blob.ts
Normal 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);
|
||||
});
|
||||
@ -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
7
src/utility/promise.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export const sleep = async (delay = 1000) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve('Ready');
|
||||
}, delay);
|
||||
});
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user