Merge pull request #14 from sambecker/bulk-upload
Accept multiple files when uploading
This commit is contained in:
commit
75c73174e9
@ -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
429
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
23
src/admin/AddButton.tsx
Normal file
23
src/admin/AddButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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] ',
|
||||
|
||||
@ -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?"
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -10,7 +10,7 @@ export default function EditButton ({
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
title="Edit"
|
||||
title={label}
|
||||
href={href}
|
||||
className="button"
|
||||
>
|
||||
|
||||
@ -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),
|
||||
]);
|
||||
|
||||
|
||||
@ -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
20
src/cache/index.ts
vendored
@ -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'],
|
||||
}
|
||||
)();
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 = '') =>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user