Vercel/src/components/ImageInput.tsx
2026-02-10 22:46:44 -06:00

176 lines
5.3 KiB
TypeScript

'use client';
import { useRef, RefObject } from 'react';
import { pngToJpegWithExif, jpgToJpegWithExif } from '@/utility/exif-client';
import { clsx } from 'clsx/lite';
import { ACCEPTED_PHOTO_FILE_TYPES } from '@/photo';
import { FiUploadCloud } from 'react-icons/fi';
import { MAX_IMAGE_SIZE } from '@/platforms/next-image';
import ProgressButton from './primitives/ProgressButton';
import { useAppState } from '@/app/AppState';
import { useAppText } from '@/i18n/state/client';
export default function ImageInput({
ref: inputRefExternal,
id = 'file',
onStart,
onBlobReady,
multiple = true,
shouldResize,
maxSize = MAX_IMAGE_SIZE,
quality = 0.9,
showButton,
disabled: disabledProp,
debug: _debug,
}: {
ref?: RefObject<HTMLInputElement | null>
id?: string
onStart?: () => void
onBlobReady?: (args: {
blob: Blob,
extension?: string,
hasMultipleUploads?: boolean,
isLastBlob?: boolean,
}) => Promise<any>
multiple?: boolean
shouldResize?: boolean
maxSize?: number
quality?: number
showButton?: boolean
disabled?: boolean
debug?: boolean
}) {
const inputRefInternal = useRef<HTMLInputElement>(null);
const inputRef = inputRefExternal ?? inputRefInternal;
const {
uploadState: {
isUploading,
filesLength,
fileUploadIndex,
},
setUploadState,
resetUploadState,
} = useAppState();
const appText = useAppText();
const disabled = disabledProp || isUploading;
return (
<div className="flex flex-col gap-4 min-w-0">
<div className="flex items-center gap-2 sm:gap-4">
<label
htmlFor={id}
className={clsx(
'shrink-0 select-none text-main',
// Undo standard label styles since
// content is shown as button
'font-normal tracking-normal',
disabled && 'pointer-events-none cursor-not-allowed',
)}
>
{showButton &&
<ProgressButton
type="button"
isLoading={disabled}
progress={filesLength > 1
? (fileUploadIndex + 1) / filesLength * 0.95
: undefined}
icon={<FiUploadCloud
size={18}
className="translate-x-[-0.5px] translate-y-[0.5px]"
/>}
aria-disabled={disabled}
onClick={() => inputRef.current?.click()}
hideText="never"
primary
>
{isUploading
? filesLength > 1
? appText.utility.paginateAction(
fileUploadIndex + 1,
filesLength,
appText.admin.uploading,
)
: appText.admin.uploading
: appText.admin.uploadPhotos}
</ProgressButton>}
<input
ref={inputRef}
id={id}
type="file"
className="hidden!"
accept={ACCEPTED_PHOTO_FILE_TYPES.join(',')}
disabled={disabled}
multiple={multiple}
onChange={async e => {
onStart?.();
const { files } = e.currentTarget;
if (files && files.length > 0) {
setUploadState?.({ filesLength: files.length });
for (let i = 0; i < files.length; i++) {
const file = files[i];
setUploadState?.({
fileUploadIndex: i,
fileUploadName: file.name,
});
const inputExtension = file.name
.split('.')
.pop()?.toLowerCase();
const isInputPng = inputExtension === 'png';
const outputExtension = shouldResize
? 'jpeg'
: inputExtension;
const callbackArgs = {
extension: outputExtension,
hasMultipleUploads: files.length > 1,
isLastBlob: i === files.length - 1,
};
let blob: Blob | File = file;
if (shouldResize) {
if (isInputPng) {
// Use specialized PNG <> JPEG converter
// for EXIF preservation
blob = await pngToJpegWithExif(
file,
{ maxSize, quality },
).catch(() => file);
} else {
// Use specialized JPG <> JPEG converter
// for EXIF preservation
blob = await jpgToJpegWithExif(
file,
{ maxSize, quality },
).catch(() => file);
}
await onBlobReady?.({
...callbackArgs,
blob,
});
} else {
// No need to process
await onBlobReady?.({
...callbackArgs,
blob,
});
}
}
} else {
resetUploadState?.();
}
}}
/>
</label>
</div>
</div>
);
}