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": [
|
"cSpell.words": [
|
||||||
|
"ABCDEFGHIJKLMNOP",
|
||||||
"ARROWLEFT",
|
"ARROWLEFT",
|
||||||
"ARROWRIGHT",
|
"ARROWRIGHT",
|
||||||
"camelcase",
|
"camelcase",
|
||||||
"exif",
|
"exif",
|
||||||
|
"ghijklmnopqrstuv",
|
||||||
"hgetall",
|
"hgetall",
|
||||||
"hset",
|
"hset",
|
||||||
"Lightbox",
|
"Lightbox",
|
||||||
"nanoids",
|
"nanoids",
|
||||||
"nextjs",
|
"nextjs",
|
||||||
"qaub",
|
"qaub",
|
||||||
|
"QRSTUVWXYZ",
|
||||||
"skippable",
|
"skippable",
|
||||||
"sonner",
|
"sonner",
|
||||||
"thephotoblog",
|
"thephotoblog",
|
||||||
@ -17,6 +20,7 @@
|
|||||||
"unnest",
|
"unnest",
|
||||||
"UsKSGcbt",
|
"UsKSGcbt",
|
||||||
"WRHGZC",
|
"WRHGZC",
|
||||||
|
"wxyz",
|
||||||
"zadd",
|
"zadd",
|
||||||
"zrange"
|
"zrange"
|
||||||
],
|
],
|
||||||
|
|||||||
@ -6,7 +6,7 @@ const STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match(
|
|||||||
|
|
||||||
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||||
enabled: process.env.ANALYZE === 'true',
|
enabled: process.env.ANALYZE === 'true',
|
||||||
})
|
});
|
||||||
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
images: {
|
images: {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
import PhotoUploadInput from '@/photo/PhotoUploadInput';
|
import PhotoUpload from '@/photo/PhotoUpload';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import PhotoTiny from '@/photo/PhotoTiny';
|
import PhotoTiny from '@/photo/PhotoTiny';
|
||||||
import { cc } from '@/utility/css';
|
import { cc } from '@/utility/css';
|
||||||
@ -27,6 +27,7 @@ import AdminGrid from '@/admin/AdminGrid';
|
|||||||
import DeleteButton from '@/admin/DeleteButton';
|
import DeleteButton from '@/admin/DeleteButton';
|
||||||
import EditButton from '@/admin/EditButton';
|
import EditButton from '@/admin/EditButton';
|
||||||
import BlobUrls from '@/admin/BlobUrls';
|
import BlobUrls from '@/admin/BlobUrls';
|
||||||
|
import { PRO_MODE_ENABLED } from '@/site/config';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
|
||||||
@ -52,8 +53,8 @@ export default async function AdminTagsPage({
|
|||||||
return (
|
return (
|
||||||
<SiteGrid
|
<SiteGrid
|
||||||
contentMain={
|
contentMain={
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<PhotoUploadInput />
|
<PhotoUpload shouldResize={!PRO_MODE_ENABLED} />
|
||||||
{blobPhotoUrls.length > 0 &&
|
{blobPhotoUrls.length > 0 &&
|
||||||
<div className={cc(
|
<div className={cc(
|
||||||
'border-b pb-6',
|
'border-b pb-6',
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import { revalidatePhotosAndBlobKeys } from '@/cache';
|
import { revalidatePhotosAndBlobKeys } from '@/cache';
|
||||||
import {
|
import { ACCEPTED_PHOTO_FILE_TYPES } from '@/photo';
|
||||||
ACCEPTED_PHOTO_FILE_TYPES,
|
import { isUploadPathnameValid } from '@/services/blob';
|
||||||
isUploadPathnameValid,
|
|
||||||
} from '@/services/blob';
|
|
||||||
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client';
|
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client';
|
||||||
import { NextResponse } from 'next/server';
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
FORM_METADATA_ENTRIES,
|
FORM_METADATA_ENTRIES,
|
||||||
PhotoFormData,
|
PhotoFormData,
|
||||||
@ -31,11 +31,6 @@ export default function PhotoForm({
|
|||||||
|
|
||||||
const url = formData.url ?? '';
|
const url = formData.url ?? '';
|
||||||
|
|
||||||
const [requestOrigin, setRequestOrigin] = useState<string>();
|
|
||||||
useEffect(() => {
|
|
||||||
setRequestOrigin(window.location.origin);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateBlurData = useCallback((blurData: string) => {
|
const updateBlurData = useCallback((blurData: string) => {
|
||||||
if (type === 'create') {
|
if (type === 'create') {
|
||||||
setFormData(data => ({
|
setFormData(data => ({
|
||||||
@ -102,13 +97,6 @@ export default function PhotoForm({
|
|||||||
loading={loadingMessage && !formData[key] ? true : false}
|
loading={loadingMessage && !formData[key] ? true : false}
|
||||||
type={checkbox ? 'checkbox' : undefined}
|
type={checkbox ? 'checkbox' : undefined}
|
||||||
/>)}
|
/>)}
|
||||||
{type === 'create' &&
|
|
||||||
<input
|
|
||||||
name="requestOrigin"
|
|
||||||
defaultValue={requestOrigin}
|
|
||||||
readOnly
|
|
||||||
hidden
|
|
||||||
/>}
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{type === 'edit' &&
|
{type === 'edit' &&
|
||||||
<Link
|
<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,
|
revalidateBlobKey,
|
||||||
revalidatePhotosKey,
|
revalidatePhotosKey,
|
||||||
} from '@/cache';
|
} from '@/cache';
|
||||||
import { PRO_MODE_ENABLED } from '@/site/config';
|
|
||||||
import { getNextImageUrlForRequest } from '@/utility/image';
|
|
||||||
import { PATH_ADMIN_PHOTOS, PATH_ADMIN_TAGS } from '@/site/paths';
|
import { PATH_ADMIN_PHOTOS, PATH_ADMIN_TAGS } from '@/site/paths';
|
||||||
|
|
||||||
export async function createPhotoAction(formData: FormData) {
|
export async function createPhotoAction(formData: FormData) {
|
||||||
const requestOrigin = formData.get('requestOrigin') as string | undefined;
|
|
||||||
formData.delete('requestOrigin');
|
|
||||||
|
|
||||||
const photo = convertFormDataToPhoto(formData, true);
|
const photo = convertFormDataToPhoto(formData, true);
|
||||||
|
|
||||||
const updatedUrl = await convertUploadToPhoto(
|
const updatedUrl = await convertUploadToPhoto(photo.url, photo.id);
|
||||||
photo.url,
|
|
||||||
photo.id,
|
|
||||||
!PRO_MODE_ENABLED
|
|
||||||
? getNextImageUrlForRequest(photo.url, 3840, 90, requestOrigin)
|
|
||||||
: undefined,
|
|
||||||
!PRO_MODE_ENABLED ? 'webp' : undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (updatedUrl) { photo.url = updatedUrl; }
|
if (updatedUrl) { photo.url = updatedUrl; }
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Photo } from '..';
|
import { Photo } from '..';
|
||||||
import { NextImageWidth } from '@/utility/image';
|
import { NextImageSize } from '@/utility/image';
|
||||||
import { formatModelShort } from '@/utility/exif';
|
import { formatModelShort } from '@/utility/exif';
|
||||||
import { AiFillApple } from 'react-icons/ai';
|
import { AiFillApple } from 'react-icons/ai';
|
||||||
import ImageCaption from './components/ImageCaption';
|
import ImageCaption from './components/ImageCaption';
|
||||||
@ -13,7 +13,7 @@ export default function PhotoImageResponse({
|
|||||||
fontFamily,
|
fontFamily,
|
||||||
}: {
|
}: {
|
||||||
photo: Photo
|
photo: Photo
|
||||||
width: NextImageWidth
|
width: NextImageSize
|
||||||
height: number
|
height: number
|
||||||
fontFamily: string
|
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_HOME = 12;
|
||||||
export const MAX_PHOTOS_TO_SHOW_PER_TAG = 6;
|
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
|
// 16:9 og image ratio
|
||||||
const IMAGE_OG_RATIO = 16 / 9;
|
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);
|
const IMAGE_OG_HEIGHT = IMAGE_OG_WIDTH * (1 / IMAGE_OG_RATIO);
|
||||||
export const IMAGE_OG_SIZE = {
|
export const IMAGE_OG_SIZE = {
|
||||||
width: IMAGE_OG_WIDTH,
|
width: IMAGE_OG_WIDTH,
|
||||||
|
|||||||
@ -12,6 +12,11 @@ import type { Metadata } from 'next';
|
|||||||
|
|
||||||
export const GRID_THUMBNAILS_TO_SHOW_MAX = 12;
|
export const GRID_THUMBNAILS_TO_SHOW_MAX = 12;
|
||||||
|
|
||||||
|
export const ACCEPTED_PHOTO_FILE_TYPES = [
|
||||||
|
'image/jpg',
|
||||||
|
'image/jpeg',
|
||||||
|
];
|
||||||
|
|
||||||
// Core EXIF data
|
// Core EXIF data
|
||||||
export interface PhotoExif {
|
export interface PhotoExif {
|
||||||
aspectRatio: number
|
aspectRatio: number
|
||||||
|
|||||||
@ -12,12 +12,6 @@ export const BLOB_BASE_URL =
|
|||||||
const PREFIX_UPLOAD = 'upload';
|
const PREFIX_UPLOAD = 'upload';
|
||||||
const PREFIX_PHOTO = 'photo';
|
const PREFIX_PHOTO = 'photo';
|
||||||
|
|
||||||
export const ACCEPTED_PHOTO_FILE_TYPES = [
|
|
||||||
'image/jpg',
|
|
||||||
'image/jpeg',
|
|
||||||
'image/png',
|
|
||||||
];
|
|
||||||
|
|
||||||
const REGEX_UPLOAD_PATH = new RegExp(
|
const REGEX_UPLOAD_PATH = new RegExp(
|
||||||
`(?:${PREFIX_UPLOAD})\.[a-z]{1,4}`,
|
`(?:${PREFIX_UPLOAD})\.[a-z]{1,4}`,
|
||||||
'i',
|
'i',
|
||||||
|
|||||||
@ -6,9 +6,9 @@
|
|||||||
/* Core */
|
/* Core */
|
||||||
body {
|
body {
|
||||||
@apply
|
@apply
|
||||||
|
text-main
|
||||||
font-mono text-sm md:text-base
|
font-mono text-sm md:text-base
|
||||||
bg-white dark:bg-black
|
bg-white dark:bg-black
|
||||||
text-gray-900 dark:text-gray-100
|
|
||||||
}
|
}
|
||||||
/* Forms */
|
/* Forms */
|
||||||
label {
|
label {
|
||||||
@ -22,7 +22,7 @@
|
|||||||
@apply
|
@apply
|
||||||
px-2 py-1.5
|
px-2 py-1.5
|
||||||
border rounded-md
|
border rounded-md
|
||||||
dark:bg-black
|
bg-white dark:bg-black
|
||||||
border-gray-200 dark:border-gray-700
|
border-gray-200 dark:border-gray-700
|
||||||
font-mono text-base leading-none
|
font-mono text-base leading-none
|
||||||
min-h-[2.25rem]
|
min-h-[2.25rem]
|
||||||
@ -73,6 +73,20 @@
|
|||||||
disabled:bg-transparent dark:disabled:bg-transparent
|
disabled:bg-transparent dark:disabled:bg-transparent
|
||||||
disabled:border-gray-100 dark:disabled:border-gray-900
|
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 */
|
/* Toasts */
|
||||||
.toaster [data-sonner-toast] {
|
.toaster [data-sonner-toast] {
|
||||||
@apply
|
@apply
|
||||||
@ -80,8 +94,24 @@
|
|||||||
!border-gray-200 dark:!border-gray-800
|
!border-gray-200 dark:!border-gray-800
|
||||||
}
|
}
|
||||||
/* Common Utilities */
|
/* Common Utilities */
|
||||||
|
.text-main {
|
||||||
|
@apply
|
||||||
|
text-gray-900 dark:text-gray-100
|
||||||
|
}
|
||||||
|
.text-invert {
|
||||||
|
@apply
|
||||||
|
text-gray-100 dark:text-gray-900
|
||||||
|
}
|
||||||
.text-dim {
|
.text-dim {
|
||||||
@apply
|
@apply
|
||||||
text-gray-400 dark:text-gray-500
|
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 NextCustomSize = 200 | 400 | 1050;
|
||||||
type NextImageDeviceSize = 640 | 750 | 828 | 1080 | 1200 | 1920 | 2048 | 3840;
|
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 = (
|
export const getNextImageUrlForRequest = (
|
||||||
imageUrl: string,
|
imageUrl: string,
|
||||||
width: NextImageWidth,
|
size: NextImageSize,
|
||||||
quality = 75,
|
quality = 75,
|
||||||
baseUrl = BASE_URL,
|
baseUrl = BASE_URL,
|
||||||
) => {
|
) => {
|
||||||
const url = new URL(`${baseUrl}/_next/image`);
|
const url = new URL(`${baseUrl}/_next/image`);
|
||||||
|
|
||||||
url.searchParams.append('url', imageUrl);
|
url.searchParams.append('url', imageUrl);
|
||||||
url.searchParams.append('w', width.toString());
|
url.searchParams.append('w', size.toString());
|
||||||
url.searchParams.append('q', quality.toString());
|
url.searchParams.append('q', quality.toString());
|
||||||
|
|
||||||
return url.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