Merge pull request #14 from sambecker/bulk-upload

Accept multiple files when uploading
This commit is contained in:
Sam Becker 2023-11-10 16:59:04 -06:00 committed by GitHub
commit 75c73174e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 375 additions and 298 deletions

View File

@ -9,7 +9,7 @@
"analyze": "ANALYZE=true next build"
},
"dependencies": {
"@next/bundle-analyzer": "^14.0.2",
"@next/bundle-analyzer": "14.0.1",
"@tailwindcss/forms": "^0.5.6",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^14.1.0",
@ -26,12 +26,12 @@
"camelcase-keys": "^9.1.2",
"date-fns": "^2.30.0",
"eslint": "8.53.0",
"eslint-config-next": "14.0.2",
"eslint-config-next": "14.0.1",
"framer-motion": "^10.16.4",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"nanoid": "^5.0.3",
"next": "^14.0.2",
"next": "14.0.1",
"next-auth": "5.0.0-beta.3",
"next-themes": "^0.2.1",
"postcss": "8.4.31",

429
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

23
src/admin/AddButton.tsx Normal file
View File

@ -0,0 +1,23 @@
import Link from 'next/link';
import { BiImageAdd } from 'react-icons/bi';
export default function AddButton ({
href,
label = 'Add',
}: {
href: string,
label?: string,
}) {
return (
<Link
title={label}
href={href}
className="button"
>
<BiImageAdd size={18} className="translate-y-[1px]" />
<span className="hidden sm:inline-block">
{label}
</span>
</Link>
);
}

View File

@ -13,7 +13,8 @@ export default function AdminGrid ({
<div className="font-bold">
{title}
</div>}
<div className="min-w-[14rem] overflow-x-scroll">
{/* py-[1px] fixes Safari vertical scroll bug */}
<div className="min-w-[14rem] overflow-x-scroll py-[1px]">
<div className={cc(
'w-full',
'grid grid-cols-[auto_1fr_auto] ',

View File

@ -3,12 +3,12 @@ import AdminGrid from './AdminGrid';
import Link from 'next/link';
import ImageTiny from '@/components/ImageTiny';
import { pathForBlobUrl } from '@/services/blob';
import EditButton from './EditButton';
import FormWithConfirm from '@/components/FormWithConfirm';
import { deleteBlobPhotoAction } from '@/photo/actions';
import DeleteButton from './DeleteButton';
import { cc } from '@/utility/css';
import { pathForAdminUploadUrl } from '@/site/paths';
import AddButton from './AddButton';
export default function BlobUrls({
title,
@ -45,7 +45,7 @@ export default function BlobUrls({
'flex flex-nowrap',
'gap-2 sm:gap-3 items-center',
)}>
<EditButton href={href} label="Setup" />
<AddButton href={href} />
<FormWithConfirm
action={deleteBlobPhotoAction}
confirmText="Are you sure you want to delete this upload?"

View File

@ -6,6 +6,7 @@ export default function DeleteButton () {
return <SubmitButtonWithStatus
title="Delete"
icon={<BiTrash size={16} className="translate-y-[-1.5px]" />}
spinnerColor="text"
className={cc(
'text-red-500 dark:text-red-600',
'active:!bg-red-100/50 active:dark:!bg-red-950/50',

View File

@ -10,7 +10,7 @@ export default function EditButton ({
}) {
return (
<Link
title="Edit"
title={label}
href={href}
className="button"
>

View File

@ -1,6 +1,6 @@
import AdminNav from '@/admin/AdminNav';
import {
getBlobUploadUrlsCached,
getBlobUploadUrlsNoStore,
getPhotosCountIncludingHiddenCached,
getUniqueTagsCached,
} from '@/cache';
@ -21,7 +21,7 @@ export default async function AdminLayout({
countTags,
] = await Promise.all([
getPhotosCountIncludingHiddenCached(),
getBlobUploadUrlsCached().then(urls => urls.length),
getBlobUploadUrlsNoStore().then(urls => urls.length),
getUniqueTagsCached().then(tags => tags.length),
]);

View File

@ -1,9 +1,9 @@
import BlobUrls from '@/admin/BlobUrls';
import { getBlobUploadUrlsCached } from '@/cache';
import { getBlobUploadUrlsNoStore } from '@/cache';
import SiteGrid from '@/components/SiteGrid';
export default async function UploadsPage() {
const blobUrls = await getBlobUploadUrlsCached();
const blobUrls = await getBlobUploadUrlsNoStore();
return (
<SiteGrid
contentMain={<BlobUrls urls={blobUrls} />}

20
src/cache/index.ts vendored
View File

@ -1,4 +1,9 @@
import { revalidatePath, revalidateTag, unstable_cache } from 'next/cache';
import {
revalidatePath,
revalidateTag,
unstable_cache,
unstable_noStore,
} from 'next/cache';
import {
GetPhotosOptions,
getPhoto,
@ -253,16 +258,21 @@ export const getUniqueFilmSimulationsCached: typeof getUniqueFilmSimulations = (
export const getBlobUploadUrlsCached: typeof getBlobUploadUrls = (...args) =>
unstable_cache(
() => getBlobUploadUrls(...args),
[KEY_BLOB], {
tags: [KEY_BLOB],
[KEY_BLOB, 'uploads'], {
tags: [KEY_BLOB, 'uploads'],
}
)();
export const getBlobUploadUrlsNoStore: typeof getBlobUploadUrls = (...args) => {
unstable_noStore();
return getBlobUploadUrls(...args);
};
export const getBlobPhotoUrlsCached: typeof getBlobPhotoUrls = (...args) =>
unstable_cache(
() => getBlobPhotoUrls(...args),
[KEY_BLOB], {
tags: [KEY_BLOB],
[KEY_BLOB, 'photos'], {
tags: [KEY_BLOB, 'photos'],
}
)();

View File

@ -30,7 +30,7 @@ export default function IconButton({
className={cc(
'inline-flex items-center justify-center',
'p-0 border-none shadow-none',
'active:bg-transparent bg-transparent',
'active:bg-transparent bg-transparent dark:bg-transparent',
'translate-x-[-1px]',
onClick !== undefined && 'cursor-pointer',
'active:opacity-50',

View File

@ -19,7 +19,12 @@ export default function ImageInput({
debug,
}: {
onStart?: () => void
onBlobReady?: (blob: Blob, extension?: string) => void
onBlobReady?: (args: {
blob: Blob,
extension?: string,
hasMultipleUploads?: boolean,
isLastBlob?: boolean,
}) => Promise<any>
maxSize?: number
quality?: number
loading?: boolean
@ -27,8 +32,14 @@ export default function ImageInput({
}) {
const ref = useRef<HTMLCanvasElement>(null);
const [fileName, setFileName] = useState<string>();
const [image, setImage] = useState<HTMLImageElement>();
const [filesLength, setFilesLength] = useState(0);
const [fileUploadIndex, setFileUploadIndex] = useState(0);
const [fileUploadName, setFileUploadName] = useState('');
const uploadStatusText = filesLength > 1
? `${fileUploadIndex + 1} of ${filesLength}: ${fileUploadName}`
: fileUploadName;
return (
<div className="space-y-4 min-w-0">
@ -55,7 +66,9 @@ export default function ImageInput({
className="translate-y-[0.5px] shrink-0"
/>}
</span>
Upload Photo
{loading
? 'Uploading'
: 'Upload Photos'}
</span>
<input
id={INPUT_ID}
@ -63,62 +76,78 @@ export default function ImageInput({
className="!hidden"
accept={ACCEPTED_PHOTO_FILE_TYPES.join(',')}
disabled={loading}
multiple
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)) {
// No need to process
onBlobReady?.(file);
} else {
// 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,
);
const { files } = e.currentTarget;
if (files && files.length > 0) {
setFilesLength(files.length);
for (let i = 0; i < files.length; i++) {
const file = files[i];
setFileUploadIndex(i);
setFileUploadName(file.name);
const callbackArgs = {
extension: file.name.split('.').pop()?.toLowerCase(),
hasMultipleUploads: files.length > 1,
isLastBlob: i === files.length - 1,
};
const canvas = ref.current;
if (!(maxSize && canvas)) {
// No need to process
await onBlobReady?.({
...callbackArgs,
blob: file,
});
} else {
// 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);
await onBlobReady?.({
...callbackArgs,
blob: blobWithExif,
});
}
},
'image/jpeg',
quality,
);
}
}
}
}}
/>
</label>
{fileName &&
{filesLength > 0 &&
<div className="max-w-full truncate text-ellipsis">
{fileName}
{uploadStatusText}
</div>}
</div>
<canvas

View File

@ -2,18 +2,20 @@
import { HTMLProps } from 'react';
import { useFormStatus } from 'react-dom';
import Spinner from './Spinner';
import Spinner, { SpinnerColor } from './Spinner';
import { cc } from '@/utility/css';
interface Props extends HTMLProps<HTMLButtonElement> {
icon?: JSX.Element
styleAsLink?: boolean
spinnerColor?: SpinnerColor
}
export default function SubmitButtonWithStatus(props: Props) {
const {
icon,
styleAsLink,
spinnerColor,
children,
disabled,
className,
@ -43,7 +45,7 @@ export default function SubmitButtonWithStatus(props: Props) {
'translate-y-[1px]',
)}>
{pending
? <Spinner size={14} />
? <Spinner size={14} color={spinnerColor} />
: icon}
</span>}
{children && <span className={cc(

View File

@ -3,7 +3,7 @@
import { useState } from 'react';
import { uploadPhotoFromClient } from '@/services/blob';
import { useRouter } from 'next/navigation';
import { pathForAdminUploadUrl } from '@/site/paths';
import { PATH_ADMIN_UPLOADS, pathForAdminUploadUrl } from '@/site/paths';
import ImageInput from '../components/ImageInput';
import { MAX_IMAGE_SIZE } from '@/utility/image';
import { cc } from '@/utility/css';
@ -38,7 +38,12 @@ export default function PhotoUpload({
setIsUploading(true);
setUploadError('');
}}
onBlobReady={(blob, extension) => {
onBlobReady={async ({
blob,
extension,
hasMultipleUploads,
isLastBlob,
}) => {
if (debug) {
setDebugDownload({
href: URL.createObjectURL(blob),
@ -47,16 +52,23 @@ export default function PhotoUpload({
setIsUploading(false);
setUploadError('');
} else {
uploadPhotoFromClient(
return 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));
if (isLastBlob) {
// Refresh page to update upload list,
// relevant to upload count in nav
router.refresh();
if (hasMultipleUploads) {
// Redirect to view multiple uploads
router.push(PATH_ADMIN_UPLOADS);
} else {
// Redirect to photo detail page
router.push(pathForAdminUploadUrl(url));
}
}
})
.catch(error => {
setIsUploading(false);

View File

@ -1,4 +1,3 @@
/* eslint-disable max-len */
import { Photo } from '@/photo';
import { BASE_URL } from './config';
import {
@ -31,8 +30,7 @@ const PATH_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/:camera`;
export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`;
export const PATH_ADMIN_UPLOADS = `${PATH_ADMIN}/uploads`;
export const PATH_ADMIN_TAGS = `${PATH_ADMIN}/tags`;
export const PATH_ADMIN_UPLOAD = `${PATH_ADMIN}/uploads`;
export const PATH_ADMIN_UPLOAD_BLOB = `${PATH_ADMIN_UPLOAD}/blob`;
export const PATH_ADMIN_UPLOAD_BLOB = `${PATH_ADMIN_UPLOADS}/blob`;
export const PATH_ADMIN_CONFIGURATION = `${PATH_ADMIN}/configuration`;
// Modifiers
@ -45,7 +43,6 @@ export const PATHS_ADMIN = [
PATH_ADMIN_PHOTOS,
PATH_ADMIN_UPLOADS,
PATH_ADMIN_TAGS,
PATH_ADMIN_UPLOAD,
PATH_ADMIN_UPLOAD_BLOB,
PATH_ADMIN_CONFIGURATION,
];
@ -166,8 +163,9 @@ export const absolutePathForTagImage = (tag: string) =>
export const absolutePathForCameraImage= (camera: Camera) =>
`${absolutePathForCamera(camera)}/image`;
export const absolutePathForFilmSimulationImage = (simulation: FilmSimulation) =>
`${absolutePathForFilmSimulation(simulation)}/image`;
export const absolutePathForFilmSimulationImage =
(simulation: FilmSimulation) =>
`${absolutePathForFilmSimulation(simulation)}/image`;
// p/[photoId]
export const isPathPhoto = (pathname = '') =>